# {{capProjectName}} {{capAppName}} — the SaaS dashboard

The frontend that closes the **signup → paywall → app** loop, consuming the
`api-saas-starter` api. Bilingual (en/de), auth-gated, live.

Scaffold both halves together:

```bash
voltro init my-saas --api=api-saas-starter --web=frontend-saas
voltro dev            # api on :4000, this app on its configured port
```

## The loop, as pages

| URL | What it does |
|---|---|
| `/` | Marketing landing (static, zero-JS) |
| `/login` | Sign-in / sign-up form → POST `/auth/*` (CSRF handled), sets the session cookie |
| `/dashboard` | **Gate** (SSR loader asks `session.me`, redirects anonymous → /login) + live projects list; creating the 4th trips the **paywall** |
| `/dashboard/billing` | Live `billing.subscription`; **upgrade** via `billing.startCheckout`, **add seats** via `billing.changeSeats` |
| `/dashboard/team` | Live `invites.list`; **invite** via `invites.create` (spends a seat) |

## How the pieces connect

- **Auth gate** — `dashboard/layout.tsx` exports a `loader`. Server-side it calls
  the api's `session.me` (forwarding the session cookie) and throws
  `RedirectError('/login')` for an anonymous caller. The pages under it are
  `renderMode: 'ssr'` so the gate runs per request.
- **Live reads** — `useSubscription('app', 'projects.list' | 'invites.list' |
  'billing.subscription')`. First paint is server-rendered; the WebSocket
  upgrades it live.
- **The paywall** — `projects.create` / `invites.create` are entitlement-gated
  on the api. When quota runs out the mutation rejects with a typed
  `EntitlementExceeded`; the page branches on `isEntitlementExceeded(err)` (see
  `src/lib/api.ts`) and shows the upgrade CTA — never a raw error string.
- **Checkout + seats** — `useAction('app', 'billing.startCheckout' |
  'billing.changeSeats')`. Under `voltro dev` the `mock` billing provider returns
  a dev checkout URL; swap the api to `provider: 'stripe'` for real Stripe.

## i18n

Cookie-driven (no `/<locale>` URL prefix): `<LocaleSwitcher>` writes `voltro:locale`
and reloads. Every string is a key in `src/locales/en.ts`, mirrored in `de.ts`
(`defineLocale<typeof en>()` fails the build on a missing key). Add a language
with a new code in `app.config.ts` + a `src/locales/<code>.ts`.

## Files

```
src/pages/
  (marketing)/            silent route group → `/` and `/login`
    page.tsx              landing (static)
    login/page.tsx        real auth form → /auth/*
  dashboard/
    layout.tsx            SSR auth gate (session.me) + sidebar shell
    page.tsx              projects list + create + PAYWALL branch
    billing/page.tsx      subscription + upgrade (checkout) + seats
    team/page.tsx         invites list + create
    error.tsx / not-found.tsx
src/lib/api.ts            local row types + isEntitlementExceeded()
src/locales/{en,de}.ts    bilingual catalogs
```

## Anti-patterns

- **String-matching the paywall.** Branch on the typed `_tag ===
  'EntitlementExceeded'` (`isEntitlementExceeded`), not on a message — messages
  are localized and change.
- **A `static` gated page.** The auth gate must run per request; a `static` page
  can't redirect per-visitor and the build rejects a `RedirectError` there. Keep
  the dashboard pages `renderMode: 'ssr'`.
- **Putting a secret in this app's env.** The browser bundle is public. Secrets
  live on the api.
