# Web surfaces

Three sibling **Next.js 16 (App Router)** apps deployed to **AWS Amplify** on their
own `drape.to` subdomains. The repos configure `output: "export"`, but Amplify
actually runs them on the **`WEB_COMPUTE`** platform (its Next.js server runtime) —
see [Infrastructure](./infrastructure.md) for the verified hosting facts. All three
are frontends only — data comes from the [Postgres backend](./backend-evolution.md)
at `api.drape.to`. The brand and admin portals share Drape's
[design tokens](./design-system.md); the marketing site has its own extended
editorial palette.

```mermaid
graph TB
    subgraph "Shared conventions"
        Conv["Next.js 16 · React 19 · Tailwind v4<br/>Amplify WEB_COMPUTE · JWT in localStorage<br/>client-side isTokenExpired guard"]
    end
    Mkt["Drape-Marketing<br/>www.drape.to"] --> Conv
    Brand["Drape-brand<br/>brand.drape.to + {slug}.drape.to"] --> Conv
    Admin["Drape-admin-v1<br/>admin.drape.to"] --> Conv
```

## Marketing — `www.drape.to`

`Drape-Marketing` is the public acquisition site — no auth, fully static. Its route
map:

| Route | Purpose |
|---|---|
| `/` | Landing: hero → product demo → trust → shopper value → download **or** waitlist (gated on `NEXT_PUBLIC_DOWNLOADS_ENABLED`) |
| `/download` | Store links or waitlist form depending on the same flag |
| `/for-brands` | Brand recruitment — features, plans, how-it-works, FAQ; CTAs deep-link to `brand.drape.to/apply` |
| `/join` | Referral landing — reads `?ref=CODE`, stores it in `localStorage` under `drape_referral_code` |
| `` `/product/[id]` `` | Client-side product share page — fetches `` `GET /api/products/{id}` ``, renders an "Open in Drape" deep link (`drape://product/{id}`) |

It calls only two backend endpoints (`` `GET /api/products/{id}` `` and
`POST /api/marketing/waitlist`) and never sends an auth header. The verbatim
product and plan copy it holds is documented under
[For brands](../product/for-brands.md).

## Brand portal — `brand.drape.to` and `{slug}.drape.to`

`Drape-brand` is the desktop counterpart to the mobile brand portal, and it is
**multi-tenant by subdomain**. A `proxy.ts` (Next.js 16's renamed middleware) reads
the host and sets a tenant slug header:

```mermaid
graph LR
    Reserved["brand.drape.to<br/>(reserved host)"] -->|apply / signup / login| Auth["Auth + onboarding"]
    Auth -->|"login handoff: #token=…"| Tenant["{slug}.drape.to<br/>/auth/handoff"]
    Tenant --> Dash["Authenticated dashboard"]
```

- **Reserved host** (`brand.drape.to`) serves the marketing landing, `/apply`
  onboarding wizard, `/signup`, `/login`, and `/verify-email`.
- **Tenant host** (`{slug}.drape.to`, e.g. `prk.drape.to`) serves the store's
  admin dashboard. Login on the reserved host performs a **cross-subdomain handoff**
  — it redirects to `{slug}.drape.to/auth/handoff#token=…&user=…`, which validates
  portal access and slug match, then lands on `/dashboard`.

:::note[The wildcard is live at DNS; Amplify verified=false is a quirk]
The `{slug}.drape.to` tenant model is built in code **and** wired at DNS:
Route 53 has a `*.drape.to` CNAME to the brand CloudFront hostname, and the
Amplify domain association for `Drape-brand` includes `brand` + `*`. Amplify
still reports **`verified=false`** on those names while `domainStatus` is
AVAILABLE — a metadata issue, not “wildcard unwired.” See
[Infrastructure](./infrastructure.md) and the
[AWS system map](./aws-system-map.md).
:::

The authenticated dashboard is large and **RBAC-gated** (the sidebar shows only
permitted sections): dashboard, products (+ new/edit, CSV import/export, QR),
orders, analytics (charts), content + comment moderation, go-live (streams),
engagement (drops / waitlists / UGC / price-drops), retail (in-store checkout +
QR scanner), locations (+ door QR + inventory), team (roles/invites), settings,
billing (Stripe Connect + subscription), integrations (webhooks), widget, and
agreement.

**Auth & access:** `brandFetch()` attaches the bearer token, pre-checks
`isTokenExpired()` (30s skew) and hard-redirects to `/login` on expiry or any 401.
The token lives in `localStorage` under `drape_brand_token`. The access gate is
`is_admin` **or** (`is_store_admin` + `store_id`) **or** (`store_id` + `role_id`).
A `TenantProvider` fetches `` `GET /api/tenant/{slug}` `` and applies the store's
`brand_colors` as CSS custom properties at runtime.

:::note[Brand pricing ladder]
Public plans: **Freemium** ($0 / 10%, 25-product cap, analytics + Connect),
**Basic** ($19.99 / 8%), **Premium** ($29.99 / 6%). Live prices come from
admin `brand_plan_catalog` via `GET /api/marketing/brand-plans`; client
fallbacks are in `drape-brand-v1/lib/billingPlans.ts` and
`drape-marketing/lib/brandMarketing.ts`. **Grandfathered** ($0 / 5%) is
admin-only with a required end date and is never listed for self-serve checkout.
:::

## Admin — `admin.drape.to`

`Drape-admin-v1` is the internal ops dashboard that replaced the old in-app
`app/admin/*` screens. Public `/login`, then an authenticated shell grouped as
Dashboard · Catalog (stores / products / featured / drops) · Onboarding
(submissions / brand-waitlist) · Community (moderation / UGC / referrals) · People
(users / inbox) · Platform (integrations / analytics / audit-logs / ops).

Notable: a full **TipTap** rich-email composer powers outbound campaigns and an
inbound mailbox (`/inbox`), and a shared bulk-action pattern (select rows →
confirm → bulk email / status change) spans stores, products, users, waitlist, and
submissions.

**Auth:** `adminFetch()` mirrors the brand client (bearer token, client-side
expiry pre-check, redirect on 401); token in `localStorage` under
`drape_admin_token`; every admin call requires `is_admin`.

:::warning[The frontend is not the authorization boundary]
The admin JWT is browser-only `localStorage`, accepted as "internal ops." The
admin README is explicit that the **frontend is not the authorization boundary** —
every write is enforced `is_admin` server-side. CORS must allow `admin.drape.to`
and `*.drape.to`. Do not treat the client-side gate as security.
:::

## Shared conventions

- **Stack:** Next.js 16.2.x, React 19.2, Tailwind v4, TypeScript 5, Inter font,
  ESLint flat config.
- **Auth:** JWT in `localStorage` with a client-side `isTokenExpired()` guard (30s
  skew) that redirects to `/login` on 401/expiry. Keys differ per app
  (`drape_brand_token` vs `drape_admin_token`).
- **Deploy:** AWS Amplify in us-east-1 on the **WEB_COMPUTE** platform (`main` =
  prod). Git connections are **`drape-ai-llc/*`** (org), not `KCB1099/*`.
  Verified app IDs and domains are on the
  [Infrastructure](./infrastructure.md) page.
- **Backend:** `NEXT_PUBLIC_BACKEND_URL` → `api.drape.to` (dev `dev-api.drape.to`).
  `staging.drape.to` is not in the live Route 53 zone map.
