# Template Feature Completion — Design Spec

**Date:** 2026-06-03
**Workstream:** 2 of 2 (complete starter features). Workstream 1 (design neutralization) is shipped.
**Status:** Approved (Approach B + targeted read-back)

## Problem

The starter templates are well-architected and mostly wired, but every **terminal write action is simulated** — it fakes async with `setTimeout` and persists nothing:

| Template | File | Handler | Today |
| --- | --- | --- | --- |
| ecommerce | `src/pages/Checkout.tsx` | `handlePaymentSubmit` | `setTimeout(2000)` → toast "Order placed", clears cart. No order saved. |
| cms | `src/pages/PostEditor.tsx` | submit | `setTimeout(1000)` → toast. No post saved. |
| dashboard | `src/pages/Profile.tsx` | submit | `setTimeout(1000)` → toast. No profile saved. |
| landing | `src/pages/Contact.tsx` | `handleSubmit` | `setTimeout(800)` → toast "message sent". Nothing sent. |
| portfolio | `src/pages/Contact.tsx` | `handleSubmit` | `setTimeout(800)` → toast. Nothing sent. |

Reads, cart, auth, routing, and the Supabase data layer (`useSupabaseTable` = graceful CRUD, `useData` = sample fallback) are genuinely complete. Only the final submits are hollow. The `default` template has no terminal write and is untouched.

## Goal

Complete the five simulated flows so that, **when a Supabase backend is configured**, they persist via the existing data layer; **when no backend is configured**, they keep today's simulated-success behavior (demo mode). Make the two naturally-coupled round-trips visible (placed orders appear in Account; a published post appears in the list).

## Decisions (locked)

| Decision | Choice |
| --- | --- |
| Scope | Complete the 5 existing simulated flows to real persistence + graceful demo fallback. No new breadth. |
| Table provisioning | **Agent-driven.** App writes to well-known tables; each template's `KNOWLEDGE.md` instructs the agent to `defineTable` them when Supabase is enabled. No new build-time auto-define machinery. |
| Structure | **Approach B** — a small `submitOrSimulate` helper per template wrapping the existing write path, used by all handlers. |
| Read-backs | **Targeted only** — ecommerce Account lists real `orders`; cms list reflects new `posts`. Profile pre-load is out (YAGNI). |
| Fallback semantics | No backend → simulate success. Backend + write ok → real success. Backend + write fails → honest `toast.error`, never fake success. |

## Architecture

### Shared helper — `src/lib/submit.ts` (new, one per affected template)

```ts
import { isSupabaseConfigured } from './supabase'

export interface SubmitResult {
  /** True when the record was persisted, or simulated in demo mode. */
  ok: boolean
  /** True when no backend is configured and the write was simulated. */
  demo: boolean
  /** New row id when a real insert succeeded. */
  id?: string
}

/**
 * Run a real write when a Supabase backend is configured; otherwise simulate
 * (demo mode) so the starter flow still completes. Never throws.
 *
 * `write` should perform the actual persistence via the established data layer
 * (e.g. `() => table.add(row)` from useSupabaseTable, or a Supabase upsert),
 * returning the new id (or non-null) on success and null on failure.
 */
export async function submitOrSimulate(
  write: () => Promise<string | null>,
  opts: { simulateMs?: number } = {},
): Promise<SubmitResult> {
  if (!isSupabaseConfigured()) {
    await new Promise((r) => setTimeout(r, opts.simulateMs ?? 600))
    return { ok: true, demo: true }
  }
  const id = await write()
  return id ? { ok: true, demo: false, id } : { ok: false, demo: false }
}
```

This fits the existing `lib/` convention (`lib/format`, `lib/validation`, `lib/utils`). It reuses the tested `useSupabaseTable` write path rather than duplicating insert logic.

### Handler pattern (applied to all 5)

Call `useSupabaseTable('<table>')` (or get a Supabase client for upsert) at component top, then:

```ts
const orders = useSupabaseTable<Order>('orders')
// ...
const handlePaymentSubmit = async (e: React.FormEvent) => {
  e.preventDefault()
  setLoading(true)
  const res = await submitOrSimulate(
    () => orders.add({ items, subtotal, total, shipping, shipping_address, status: 'pending' }),
    { simulateMs: 1200 },
  )
  setLoading(false)
  if (!res.ok) { toast.error('Could not place your order. Please try again.'); return }
  setStep(3); clearCart(); toast.success('Order placed successfully!')
}
```

`profiles` is an upsert keyed to `user_id` (a row may not exist yet), so its `write` uses a Supabase `upsert` rather than `add`; the helper is identical otherwise.

## Per-template changes

All five: add `src/lib/submit.ts`, rewrite the handler, add a `KNOWLEDGE.md` "Starter persistence" section.

1. **ecommerce** — Checkout writes `orders`. **Account** reads the user's `orders` (read-back): ensure its order list uses the live `orders` table (via `useData<Order>('orders')` / `useSupabaseTable`) so placed orders appear.
2. **cms** — PostEditor writes/updates `posts` (the same table the list reads). Read-back: the posts list already reads `posts` via `useData`, so a published post appears on refresh/navigation — confirm the list points at `posts` and the editor writes the same shape.
3. **dashboard** — Profile upserts `profiles` keyed to `user_id`.
4. **landing** — Contact writes `contact_messages`.
5. **portfolio** — Contact writes `contact_messages`.

## Table specs (for the `KNOWLEDGE.md` defineTable guidance)

`defineTable` adds `id`/`created_at`/`user_id` + RLS automatically; specs list only domain columns:

- **`orders`** — `items` jsonb, `subtotal` numeric, `total` numeric, `shipping` numeric, `shipping_address` jsonb, `status` text
- **`contact_messages`** — `name` text, `email` text, `message` text
- **`profiles`** — `display_name` text, `bio` text, `avatar_url` text (one row per `user_id`)
- **`posts`** — match the existing read-side shape: `title` text, `content` text, `excerpt` text, `slug` text, `status` text

Each template's `KNOWLEDGE.md` gains:

```markdown
## Starter persistence
This template's <flow> writes to the `<table>` table via `useSupabaseTable`.
When a Supabase backend is enabled, create it with the `defineTable` tool
(columns below — id/created_at/user_id + RLS are added automatically); without a
backend the flow runs in demo mode (simulated success).
- `<table>`: <column: type, ...>
```

## Testing / acceptance

1. **Standalone build per affected template** — `npm install && npm run build` succeeds (the rewrites + `submit.ts` compile).
2. **No-backend smoke (builder build, Supabase disabled)** — each flow completes via the demo path: toast success, form/cart reset, no console error, no crash.
3. **Backend-enabled persistence (where feasible)** — a build with Supabase enabled where the agent `defineTable`s the starter table: submit the flow, confirm a row is written to the project schema and (for ecommerce/cms) the read-back shows it.
4. **Residue check** — grep all five handlers: no un-gated `setTimeout`-only terminal write remains; every terminal submit routes through `submitOrSimulate`.
5. **Helper presence** — each affected template zip contains `src/lib/submit.ts`; each `KNOWLEDGE.md` contains a "Starter persistence" section.

## Packaging

Re-bake the changed files into the five affected template zips (`default` excluded) via the **binary-safe** path established in WS1 (`*.zip` is `binary` in `.gitattributes`). A one-time script extracts each zip, writes the new/edited files, re-zips, and the result is committed. Verify HEAD zips extract to the new content and `git status` is clean (no binary round-trip drift).

## Risks & notes

- **Persistence is contingent on the agent defining the table.** If the agent skips `defineTable`, `useSupabaseTable.add` fails gracefully and the user sees an honest error (backend configured) — never a silent fake. Acceptable: the agent already owns table creation and the KNOWLEDGE.md guidance is explicit.
- **`profiles` upsert** needs the user signed in (RLS scopes by `user_id`); Profile is already `ProtectedRoute`-gated, so a user is present.
- Keep changes generic and removable — the agent must be able to prune a flow (e.g. no contact form) without leftover references. The helper is self-contained and unused-import-free if a page is deleted.
- `cms` PostEditor may be create-or-edit; if editing an existing post, use `update(id, row)` instead of `add` — the handler branches on whether an id is present, both routed through `submitOrSimulate` (the `write` thunk picks add vs update).
