# Pure Design Projects — v3 + Ecom Platform · Design Spec

**Status:** Implementation in progress · 2026-05-16
**Hosted on:** `yesjo.au/puredesignprojects/v3/`
**Source-of-truth file** — all builder agents read from this document.

---

## 0. Inventory at a glance

| Surface | Path | Status |
|---|---|---|
| v1 site (live) | `/puredesignprojects/` | live, untouched |
| v2 "Quarry Floor" | `/puredesignprojects/v2/` | live |
| **v3 "Adele-inspired"** | `/puredesignprojects/v3/` | this build |
| Versions gallery | `/puredesignprojects/versions.html` | live, will be updated to include v3 |
| Admin panel | `/puredesignprojects/admin/` | this build |
| API (Lambda) | `api.puredesignprojects.com` (TBD) | spec only this build |

---

## 1. v3 Visual Direction — "Adele-inspired"

Reference: `https://www.justadele.com.au/`

### Mood
Bespoke / collectible-furniture boutique. Sophisticated, minimal, material-focused. **Not** a transactional Shopify store — a gallery. Most products show "Enquire" rather than a hard price; some show price with surcharge handling.

### Palette
| Token | Value | Use |
|---|---|---|
| `--paper` | `#FAF8F4` | page background |
| `--paper-soft` | `#F2EFE8` | section variant |
| `--ink` | `#1B1A18` | primary text |
| `--ink-soft` | `#5A554E` | body / muted text |
| `--rule` | `#E3DED2` | hairlines |
| `--accent` | `#8C6A48` | warm walnut — a single chromatic note, used sparingly |
| `--accent-soft` | `#C9A87B` | hover / highlight |

### Typography
- Display + body: **Söhne** (or fallback to **Inter Tight** at 300/400 — geometric, modest)
- Editorial italics: **Editorial New** italic (or fallback **Cormorant Garamond** italic)
- Pairings: large display headlines in 300 weight, body in 400, micro-labels in 500 uppercase with 0.2em letter-spacing.

### Signature visual moments
- Full-bleed dual hero (two product photos side-by-side, no overlay text)
- Oversized but light-weight typography
- Product cards: no border, no shadow, just product photo + tiny caption (name, material, price-or-"enquire")
- "Journal" section like an editorial
- "Visit by appointment" treatment
- No badges, no urgency timers, no "X bought this"
- Surcharge note appears only at checkout

### Page inventory (single-page navigation, anchor-based)
1. Hero (dual photo)
2. **Products** — grid of all categories; clicking a category opens a lightbox carousel (auto-play, ◀ ▶ controls) of up to 5 images per category
3. **About** — studio narrative
4. **Custom Order** — request form (no payment, sends enquiry)
5. **Visualise it** — short blurb "Let's talk", booking CTA
6. **Gift Card** — short blurb "Let's talk", contact CTA
7. **Bulk Orders** — short blurb "Let's talk", contact CTA
8. **Shipping** — policy + lead times
9. **Instagram** — feed embed (via simple iframe or static grid)
10. **Refer a friend** — email-link sharing UI; rewards documented
11. **Newsletter** — single email-input subscribe form
12. **Contact us** — address, phone, hours, form
13. **Cart** — slide-out drawer, opens from sticky cart-icon

### v3 Image carousel behaviour (homepage product category)
- Image gallery component shows max 5 images per category
- Autoplay 4s interval
- Pauses on hover
- User can ◀ ▶ navigate manually
- Indicator dots at the bottom
- Driven by data from `GET /categories` → each category has `images: [url]`
- Frontend gracefully degrades when category has fewer than 5

### Justadele-mimicking moments to replicate
- Dual hero photo grid
- Editorial-italic accent on the brand name
- Product names always italic, paired with sans-serif material label
- Tight 12-column grid for product layout

---

## 2. Admin Panel — `/puredesignprojects/admin/`

Auth-protected (Cognito or simple JWT; out of scope for v0 — use a passphrase guard server-side).

### Tabs
1. **Dashboard** — KPI strip (today's sales, cart abandonment %, avg order value, active customers); 30-day sales chart; quick links.
2. **Catalog** *(rebuilt 2026-05-16 — easier, search-first, images inline)*
   - **Page header explainer** under "Catalog" title: "Manage your product categories and individual products. Toggle products active/inactive to control website visibility. Pricing and surcharge mode are applied at checkout. Each product can have up to 5 images shown in the website gallery."
   - **Search bar** above categories — filters across category name, product name, material/description; clearing search restores the full tree.
   - **Categories** are collapsible cards. Each card header:
     - Editable name (click to edit)
     - Active toggle (when off, the entire category — and all its products — disappear from the v3 site)
     - "Products: N · Active: M" count line
     - Add Product / Delete Category buttons
   - **Each product row** (inside the expanded category) shows:
     - Editable name + material/description (two short fields)
     - Editable base price in dollars (rendered as `$1,250.00`, persisted in cents)
     - **Surcharge mode dropdown** with a help-tip:
       - `customer_pays` — added as a separate line in Stripe Checkout
       - `studio_absorbs` — gross-up internally, no surcharge line shown
       - `split` — 50/50, surcharge line shows the customer's half
     - **Active toggle** (when off, this product is hidden from the v3 site but still in admin)
     - **Image strip** showing up to 5 image thumbnails inline; drag-drop to upload, drag to reorder, × on hover to delete; virus-scan badge per image (Pending / Clean / Failed). When a product has 0 images, show an "Add images →" empty-state tile.
     - Delete Product button
   - All edits autosave on blur; toast confirms.
3. **Media** *(repurposed 2026-05-16 — now manages WEBSITE imagery, not category galleries)*
   - **Page header explainer** under "Website Media" title: "Manage the images shown in specific sections of the website. Replacing an image here updates the live site once virus-scan passes."
   - **Site sections** listed as cards, in the order they appear on the v3 site:
     - Hero · Left photo
     - Hero · Right photo
     - About · Studio photo
     - Instagram tile 1–8 (eight slots)
     - Contact · Section photo
   - Each slot card shows: current image preview (or empty-state), Replace button (file picker), virus-scan badge, "Last updated: …", an "open on site →" link that anchors to the section on `/v3/`.
   - Removing an image leaves the slot empty until replaced — v3 site renders a quiet "Image coming soon" placeholder in that case.
   - Product/category images stay under the **Catalog** tab, not here.
4. **Orders**
   - Table: order #, customer, total, Stripe Payment Intent ID, status (`pending`, `paid`, `fulfilled`, `refunded`, `chargeback`), date
   - Click row → drawer with line items, customer details, Stripe invoice link, timeline
5. **Customers**
   - Buyers list with order count, total spend, email, last order, **gift-cards-linked count chip**
   - Search bar (name, email, phone) — added 2026-05-16
   - Row click opens a **customer drawer** with KPIs + a "Gift cards (N)" section listing every card where the customer is buyer or recipient (status, balance, counterpart, message, "Open in Gift Cards →" jump)
6. **Gift Cards** *(new — 2026-05-16)*
   - KPI strip: cards issued, total face value, outstanding balance (active cards), redeemed
   - Search bar (code, buyer, recipient) + status pills (all / active / pending_delivery / redeemed / void)
   - Table: code · original · balance · status · buyer · recipient name · recipient email · deliver date · created
   - Row click opens a **gift-card drawer**: all attributes + message + actions (`Copy code`, `Resend email`, `Void card` — void prompts for a reason and updates DDB)
   - Backed by the `GiftCards` DDB table (§7) and admin endpoints `GET /admin/gift-cards`, `POST /admin/gift-cards/{code}/void`, `POST /admin/gift-cards/{code}/resend` — to be added to §8 when the API is wired
6. **Stats**
   - Sales (today / 7d / 30d / YTD)
   - Abandoned carts (count + total $)
   - Top categories / top products
   - Conversion rate
7. **Refunds & Chargebacks** — see §6

### Admin frontend interactions
- Image upload posts to `POST /admin/categories/{id}/images` (multipart); response includes virus-scan job ID; UI polls `GET /admin/images/{id}` until scan complete
- Removing an image or category triggers `DELETE` which removes from DDB; v3 site refreshes its category data via `GET /categories?since=…`

---

## 3. Page contents (copy direction)

| Page | Copy direction |
|---|---|
| Products | Just the grid, no marketing copy |
| **About** *(expanded 2026-05-16)* | Three paragraphs: (1) "Twenty years of stone." Origin — founded 2004 in Tottenham by Ivan, growing from a two-person bench to a twenty-eight-person crew. (2) "What we make." Range — kitchens, bathrooms, fireplaces, facades, bespoke furniture; mention the Tottenham 1,200 m² workshop. (3) "Why people come back." Values — vein-matching, in-house install crew, no subcontractors, repair-and-restore on past projects. Followed by the studio-numbers grid (Years, Benchtops, Reach, Fabrication). One large team photo (sourced from `Site Media → About · Studio photo`). |
| Custom order | Form: name, email, phone, room type, size, materials of interest, budget range, message; sends email to studio. |
| Visualise it | "Let's talk." subtitle. CTA: "Book a virtual mock-up call → +61 3 9318 9235" |
| **Gift card** *(expanded 2026-05-16 — buy-for-someone)* | Full purchase flow described in §4.1. Replaces the prior "Let's talk." stub. |
| Bulk orders | "Let's talk." subtitle. CTA: "Email for trade pricing & MOQ." |
| Shipping | Policy: Australia-wide white-glove install · lead time 4-8 weeks · interstate freight on request |
| Refer-a-friend | "Send a friend your referral link. When they purchase, you get 10% off your next order." Generate unique link client-side (using customer email hash) |
| Newsletter | One-line subscribe form; stores email → DDB `Newsletter` table |
| Contact us | Studio address, phone, email, hours, embedded map |

---

## 3.1 Newsletter & Specials *(new — 2026-05-16)*

The Newsletter section is **no longer** a manual broadcast list. It is an
auto-notify subscriber registry. Subscribers receive emails when one of two
events happens:

1. **Product change** — a product is created, activated, or has a material
   field changed (`name`, `description`, `base_price_cents`,
   `primary_image_url`, `status`). Fan-out is **digest-batched**: at most one
   email per active+confirmed subscriber per 24h. All product changes in
   that window collapse into a single digest email.
2. **New Special published** — when an admin creates or transitions a
   Special to `status=published`, every active+confirmed subscriber gets a
   "Now showing — {title}" email. Per-subscriber-per-special dedupe so
   re-publishes or stream replays don't double-send.

### Architecture

```
            ┌──────────────────┐               ┌───────────────────┐
 admin →    │  Products table  │ ──stream──▶ │                   │
            │  (NEW_AND_OLD)   │             │                   │
            └──────────────────┘             │                   │
                                             │ pdp-newsletter-   │
            ┌──────────────────┐             │   broadcast       │
 admin →    │  Specials table  │ ──stream──▶ │ (producer+        │
            │  (NEW_AND_OLD)   │             │  consumer in one) │──▶ SES
            └──────────────────┘             │                   │
                                             │                   │
            ┌──────────────────┐             │                   │
            │ EventBridge every│ ────────────▶                   │
            │ 30 min (cron)    │             │                   │
            └──────────────────┘             └─────────┬─────────┘
                                                       │
                                                       ▼
                                             ┌───────────────────┐
                                             │ NewsletterQueue   │
                                             │ (debounced jobs)  │
                                             └───────────────────┘
                                             ┌───────────────────┐
                                             │ NewsletterSends   │
                                             │ (per-sub dedupe)  │
                                             └───────────────────┘
                                             ┌───────────────────┐
                                             │ Newsletter        │
                                             │ last_emailed_at   │
                                             │ (24h digest cap)  │
                                             └───────────────────┘
```

### Lambda surface

| Lambda | Trigger | Role |
|---|---|---|
| `pdp-newsletter-subscribe` | `POST /newsletter/subscribe` | Idempotent subscribe; issues `confirmation_token` + `unsub_token`; emails confirmation link; rate-limited at 10/IP/hour. |
| `pdp-newsletter-confirm` | `GET /newsletter/confirm/{token}` | Flips `confirmed=true`. Single-use token. Renders HTML thanks page. |
| `pdp-newsletter-unsubscribe` | `GET /newsletter/unsubscribe/{token}` | One-click unsubscribe (footer link in every email). Idempotent. |
| `pdp-admin-newsletter-list` | `GET /admin/newsletter` | Admin subscriber list with status + `last_emailed_at`. |
| `pdp-newsletter-broadcast` | `Products` stream, `Specials` stream, EventBridge cron (30 min) | Producer: diff stream events → enqueue in `NewsletterQueue` (30-min debounce for products, immediate for specials). Consumer: drain due rows, send batched emails. |

### Constants

| Value | Default | Override |
|---|---|---|
| Product change debounce window | 30 minutes | `PRODUCT_DEBOUNCE_MIN` env |
| Per-subscriber digest cap | 24 hours (max 1 product-digest per subscriber) | `PER_SUBSCRIBER_DIGEST_HOURS` env |
| Subscribe rate limit | 10 attempts per IP per hour | hard-coded |
| Queue drain batch | 100 rows per scheduled run | `DRAIN_BATCH_SIZE` env |
| Queue dead-letter threshold | 5 attempts | hard-coded |

### Specials surface

Specials are admin-curated promotional items independent of products. CRUD
lives under `/admin/specials*` (admin/staff role required). The public
`GET /specials` returns only `status=published` and in-date items. Schema in
§7 (`Specials` table).

---

## 4. Ecommerce / Cart

### Cart drawer
- Slide-out from right
- Line items with thumbnail, name, qty stepper, line total
- Subtotal, surcharge breakdown, total
- "Checkout" button → opens Stripe Checkout in a redirect

### 4.1 Gift Card — buy for someone

The Gift Card section on the v3 site is a real purchase, not an enquiry.

**Buyer flow:**
1. Buyer picks an amount: `$50`, `$100`, `$250`, `$500`, or **Custom** (min $20, max $10,000)
2. Enters **recipient name**, **recipient email**, optional **personal message** (≤ 280 chars), optional **delivery date** (default = today)
3. Clicks **Add to cart** — gift card becomes a cart line item shaped like a product:
   ```
   { type: "gift_card", amount_cents, recipient_name, recipient_email, message, deliver_at, qty: 1 }
   ```
4. At checkout, Stripe Checkout charges the buyer normally
5. On `checkout.session.completed` webhook, backend:
   a. Generates a unique **gift card code** (12-char base32, e.g. `GC-7K2H-9PXN-MQ4F`)
   b. Writes a `GiftCards` DDB record (PK = code; attrs: original_amount_cents, balance_cents, buyer_email, recipient_email, message, deliver_at, status=`active`)
   c. Schedules an email to the recipient on `deliver_at` (DDB stream → EventBridge → SES) — for v0, send immediately if `deliver_at <= now`
   d. Sends a receipt to the buyer with the code visible (so they can hand-print it if the recipient email bounces)

**Redemption flow:**
1. At checkout, cart drawer shows a `Have a gift card?` field
2. User enters code; frontend POSTs `/cart/giftcard/apply` with `{ code }`
3. Backend validates: status=`active`, balance > 0, not expired
4. Returns `{ balance_cents }`; cart applies it as a discount line up to (order total − $0.50 Stripe minimum)
5. After successful checkout, backend decrements the gift card's balance by the redeemed amount; if balance reaches 0, status → `redeemed`
6. Gift card codes are non-transferable; one code per order; partial-redemption supported.

**Edge cases:**
- Gift cards never expire (per Australian consumer law since 1 Nov 2019, must be valid ≥ 3 years; we set "no expiry" to stay safe)
- If a buyer refunds an order paid for partly by gift card, refund the cash portion to Stripe and credit the gift card portion back to the gift card balance
- Gift cards are listed in the admin **Orders** tab with type=`gift_card` and have their own row in `GiftCards` table

**DDB addition** — see §7 for the `GiftCards` table.

### Checkout flow
1. User clicks `Checkout` in cart drawer
2. Frontend `POST /cart/checkout` with `{ items, customer_email }`
3. Backend creates Stripe Checkout Session (mode: `payment`); applies surcharge per product
4. Backend writes `Order` record to DDB with status `pending`
5. Returns `session.url`; frontend redirects
6. Stripe collects payment (we hold no card data)
7. Stripe → webhook `POST /stripe/webhook` fires on `checkout.session.completed`
8. Backend updates order to `paid`, fires `order.paid` event (email confirmation, etc.)

### Surcharge handling
- Per-product field `surcharge_mode` set in admin
- `customer_pays` → surcharge is added as a separate line item in Stripe Checkout
- `studio_absorbs` → no surcharge line; gross-up the base price internally only
- `split` → 50% added as line item, 50% absorbed
- Computed in backend, never trusted from frontend

### No PCI scope
- All card data lives in Stripe
- We persist only: payment_intent_id, charge_id, last4 (Stripe-provided), brand, customer_email

---

## 5. Media + Virus Scan Pipeline

```
admin uploads → POST /admin/categories/{id}/images
       │
       ▼
   S3 bucket: puredesignprojects-uploads/raw/{img_id}
       │
       ▼  (S3 ObjectCreated event)
   Lambda: scan-image
       │  ├─ ClamAV (via lambda layer or Mountpoint EFS)
       │  └─ image-format validation (jpg/png/webp, max 8MB, max 4000×4000)
       │
       ├─ Clean → S3 puredesignprojects-cdn/categories/{cat}/{img_id}.jpg
       │           DDB Images: status=clean, cdn_url=…
       │
       └─ Failed → DDB Images: status=failed, reason=…
                   S3 raw object deleted
```

DDB tagging:
```
Images table
  PK: image_id (uuid)
  GSI1 PK: category_id  →  list-by-category
  attrs: { category_id, position (0-4), status, cdn_url, uploaded_by, uploaded_at, scan_result, content_hash }
```

Removal:
- `DELETE /admin/images/{id}` → marks DDB row `deleted`, fires S3 delete
- `DELETE /admin/categories/{id}` → cascade: marks all child images deleted, marks category deleted, products in category re-assigned to `Uncategorised` or deleted per admin choice

---

## 6. Refunds & Chargebacks

### 6.1 How chargebacks are created (you don't create them)

Chargebacks originate **at the customer's bank**, not at us and not at Stripe. We only **receive and respond to them**.

```
Customer disputes a charge with their bank
       ↓
Bank submits the dispute to the card network (Visa / Mastercard / Amex)
       ↓
Card network notifies Stripe
       ↓
Stripe creates a `dispute` object and freezes the disputed amount
       ↓
Stripe fires webhooks to /stripe/webhook  →  our backend reacts
       ↓
Admin reviews + submits evidence in admin panel  →  stripe.disputes.update(...)
       ↓
Bank decides: won (funds restored) or lost (we keep the loss + dispute fee)
```

**Webhook events we handle:**
- `charge.dispute.created` — write Disputes DDB row; order.status → `chargeback`; email admin
- `charge.dispute.funds_withdrawn` — log the balance impact in AuditLog
- `charge.dispute.updated` — refresh DDB row (status, evidence_due_by changes)
- `charge.dispute.closed` — final outcome (won/lost); if lost, order stays `chargeback` and refund is implicit
- `charge.dispute.funds_reinstated` — fired when we win; log + reconcile

**Evidence we attach when submitting (via admin → `POST /admin/disputes/{id}/evidence`):**
- `receipt` — Stripe receipt URL
- `service_documentation` — install/delivery photos + signed delivery docket (uploaded by admin)
- `customer_communication` — full email thread (admin pastes/attaches)
- `shipping_documentation` — courier tracking or in-house install report
- `product_description` — link to the v3 product page snapshot
- `refund_policy` — link to /shipping page (our T&Cs)
- `uncategorized_text` — free-text rebuttal

### 6.2 Admin-initiated refund flow

1. Admin opens an order in **Orders** tab → drawer
2. Click `Refund` → modal: full / partial amount + reason dropdown (`requested_by_customer` | `duplicate` | `fraudulent` | `goodwill`)
3. Frontend `POST /admin/orders/{id}/refund` with `{ amount_cents, reason, note }`
4. Backend:
   a. Validates order is `paid` or `partially_refunded`, total refunded < order total
   b. Generates idempotency key `rf_{order_id}_v{n}` where n = existing refund count + 1
   c. Calls `stripe.refunds.create({ payment_intent, amount, reason, metadata })` with that idempotency key
   d. Writes Refunds DDB row (status = `pending`)
   e. Updates order: `partially_refunded` or `refunded` based on remaining balance
   f. Writes AuditLog row
   g. Emails customer: "Your refund of $X is on its way (3-10 business days)"
5. Stripe webhook `charge.refunded` reconciles status `pending` → `succeeded` (or `failed` if Stripe declined)
6. If the order included gift-card-applied amounts: the gift-card portion is **credited back to the gift card balance**, only the cash portion goes to Stripe

### 6.3 Customer-initiated refund REQUEST flow *(new — Option C, 2026-05-16)*

Customers don't trigger refunds directly — they **request** them. Admin approves.

**Entry points:**
- Order confirmation email → `Request a refund →` link (always present)
- Order success page (`/v3/checkout-success.html?session_id=…`) → "Need to request a refund?" link in fine print
- (Optional later) "My orders" lookup page if we add accounts

**Refund-request page** (`/v3/refund-request.html`):
- Form fields:
  - Order ID (text — they got it in confirmation email; format `PDP-XXXXXX`)
  - Email used at checkout (for verification)
  - Reason (dropdown: changed my mind / damaged on arrival / not as described / installation issue / other)
  - Free-text note (≤ 1000 chars)
  - Optional file upload (photo of damage etc) — uploaded to private S3 bucket via presigned URL
- Submit → `POST /orders/{order_id}/refund-request` with `{ email, reason, note, attachment_keys }`
- Backend:
  - Validates `(order_id, email)` matches a real order in DDB
  - Rate-limits to 3 requests per order per day (DDB conditional write on a count attribute with TTL)
  - Writes `RefundRequests` DDB row with `status = "open"`
  - Emails the studio + creates an in-admin notification
  - Returns confirmation page: "We've received your request. We'll respond within 2 business days."

**Admin Orders tab** gets a new badge **"N refund requests pending"** at the top, and each order row shows a small "⚠ Refund request" tag when one is open. Clicking opens the order drawer where the admin sees the request details and can either:
- **Approve & issue refund** — opens the existing admin-refund modal pre-filled with the request's amount and reason
- **Approve partial** — same modal, amount editable
- **Deny** — modal asks for a denial reason; sends email to customer; `RefundRequest.status = "denied"`

**RefundRequests DDB table** — added to §7.

### 6.4 PrettyFare port

See `api/refund-port-plan.md` for the reverse-engineered patterns and which utilities we reuse verbatim (webhook signature verifier, Stripe error sanitiser, idempotency middleware, webhook-event-locking, Stripe reason/dispute-status mappers, raw-body Buffer guard).

### Records
```
Refunds table
  PK: refund_id
  GSI1 PK: order_id  →  list-refunds-for-order
  attrs: { order_id, amount, stripe_refund_id, reason, status, created_at, created_by }

Disputes table
  PK: dispute_id (Stripe's du_…)
  attrs: { order_id, amount, reason, status, evidence_due_by, evidence_submitted_at }
```

---

## 7. DDB Schema (authoritative)

| Table | PK | GSI1 PK | Notes |
|---|---|---|---|
| `Categories` | `category_id` | `slug` | name, slug, position, status, image_count |
| `Products` | `product_id` | `category_id` | name, description, base_price_cents, surcharge_mode, status |
| `Images` | `image_id` | `category_id` | category_id, position 0-4, status, cdn_url, scan_result |
| `Orders` | `order_id` | `customer_email` | status, total_cents, surcharge_cents, stripe_session_id, stripe_payment_intent_id |
| `OrderItems` | `order_id` (PK) + `line_id` (SK) | — | product_id, qty, unit_price_cents |
| `Customers` | `email` | `stripe_customer_id` | name, phone, address, first_order_at, total_spend_cents |
| `Refunds` | `refund_id` | `order_id` | amount, stripe_refund_id, reason, status |
| `Disputes` | `dispute_id` | `order_id` | amount, reason, status |
| `Newsletter` *(reshaped 2026-05-16)* | `email` | `status`, `confirmation_token`, `unsub_token` (three GSIs) | status (`active`/`unsubscribed`/`bounced`), confirmed, confirmation_token, unsub_token, source, subscribed_at, confirmed_at, last_emailed_at |
| `NewsletterQueue` *(new)* | `queue_id` | `status` | Work queue for `pdp-newsletter-broadcast`. trigger_kind (`product_change`/`special`), product_id/special_id, payload_kind, not_before, status (`pending`/`sent`/`dead`), attempts, TTL 7d |
| `NewsletterSends` *(new)* | `send_key` (`${email}#${trigger_kind}#${id}`) | — | Per-subscriber-per-special dedupe; sent_at, TTL 365d |
| `Specials` *(new)* | `special_id` | `status` | title, body, image_url, linked_product_ids, discount_kind (`percent_off`/`fixed_amount_off`/`none`), discount_value, starts_at, ends_at, status (`draft`/`published`/`expired`/`archived`), audience_segment, created_by |
| `Referrals` | `referrer_email` (PK) + `referee_email` (SK) | — | discount_used, claimed_at |
| `AbandonedCarts` | `cart_id` | `email` | items, last_updated, customer_email |
| `AuditLog` | `event_id` | `entity_id` | actor, action, before, after, ip, ts |
| `GiftCards` *(new)* | `code` (e.g. `GC-7K2H-9PXN-MQ4F`) | `recipient_email`, `buyer_email` | original_amount_cents, balance_cents, status (`pending_delivery` / `active` / `redeemed` / `void`), message, deliver_at, redeemed_at, last_used_order_id |
| `SiteMedia` *(new)* | `slot_id` (e.g. `hero.left`, `about.studio`, `instagram.1`) | — | current image_id, history (array of past image_ids), updated_at, updated_by |
| `RefundRequests` *(new)* | `request_id` | `order_id`, `status` | order_id, customer_email, reason, note, attachment_keys, status (`open` / `approved` / `denied`), created_at, decided_at, decided_by, decision_note, linked_refund_id |

All tables use `pay_per_request` billing.

---

## 8. API Surface (REST, JSON)

**Public** (called from v3 site):
- `GET /categories` — list with images
- `GET /categories/{slug}`
- `GET /site-media` — current image per slot (consumed by v3 hero, about, instagram, contact)
- `POST /cart/checkout` — create Stripe session
- `POST /cart/giftcard/apply` — validate a gift card code, return balance
- `POST /newsletter/subscribe` — public; rate-limited; idempotent; issues confirmation token, emails confirmation link
- `GET /newsletter/confirm/{token}` — public; flips `confirmed=true`; renders HTML thanks page
- `GET /newsletter/unsubscribe/{token}` — public; one-click unsubscribe from email footer
- `GET /specials` — public; returns active (published + in-date) specials
- `POST /custom-order/request` — sends email enquiry
- `POST /contact/message`
- `GET /referrals/code?email=…` — returns a referral code
- `POST /referrals/redeem`
- `POST /orders/{order_id}/refund-request` — customer-initiated refund request (rate-limited, requires order_id + email match)
- `GET /orders/{order_id}/refund-request/{request_id}` — customer can check the status of their request

**Webhook**:
- `POST /stripe/webhook`

**Admin** (JWT + admin role):
- `GET /admin/dashboard`
- `GET|POST|PUT|DELETE /admin/categories[/{id}]`
- `GET|POST|PUT|DELETE /admin/products[/{id}]`
- `POST /admin/categories/{id}/images` (multipart)
- `DELETE /admin/images/{id}`
- `GET /admin/orders` (filterable)
- `GET /admin/orders/{id}`
- `POST /admin/orders/{id}/refund`
- `GET /admin/customers`
- `GET /admin/stats`
- `GET /admin/disputes`
- `POST /admin/disputes/{id}/evidence`
- `GET /admin/refund-requests` — list open requests
- `POST /admin/refund-requests/{id}/approve` — approve + issue refund (calls the admin refund flow internally)
- `POST /admin/refund-requests/{id}/deny` — deny with a reason; emails customer
- `GET /admin/gift-cards` — list with filter (status), search (code/buyer/recipient)
- `GET /admin/gift-cards/{code}` — detail + balance history
- `POST /admin/gift-cards/{code}/void` — body `{ reason }`; sets status=`void`, emits AuditLog, emails buyer
- `POST /admin/gift-cards/{code}/resend` — re-sends the recipient delivery email; useful when bounced
- `GET /admin/specials` — list all specials (all statuses); `?status=` filter optional
- `POST /admin/specials` — create a Special; `status=published` triggers a newsletter broadcast via the Specials DDB stream
- `PUT /admin/specials/{id}` — partial update; transitions to `published` also enqueue a broadcast
- `DELETE /admin/specials/{id}` — soft-delete (status → `archived`)
- `GET /admin/newsletter` — subscriber list with status + `last_emailed_at`; supports `?status=`, `?confirmed=`, cursor pagination

**Internal** (no public route — invoked by AWS event sources):
- `pdp-newsletter-broadcast` — wired to: `Products` DDB stream, `Specials` DDB stream, EventBridge cron (every 30 min). Producer enqueues debounced work into `NewsletterQueue`; consumer drains due rows and sends batched emails via SES.

OpenAPI spec lives at `api/openapi.yaml` (generated by backend-spec agent).

---

## 9. Deployment

| Layer | Where | How |
|---|---|---|
| v3 static files | S3 `yesjo.au` + CloudFront | `scripts/deploy-website-au.ps1` (existing) |
| Admin static files | S3 `yesjo.au/puredesignprojects/admin/` | same script |
| API (Lambda) | AWS Lambda + API Gateway | new `scripts/deploy-pdp-api.sh` |
| Images CDN | S3 `puredesignprojects-cdn` + CloudFront | new bucket |
| Uploads (raw) | S3 `puredesignprojects-uploads` (private) | new bucket |

---

## 10. Build assignment for concurrent agents

| Agent | Scope | Output paths |
|---|---|---|
| **A · v3 frontend** | HTML/CSS/JS mimicking justadele; all 13 pages/sections; cart drawer; image carousel | `v3/index.html`, `v3/styles.css`, `v3/cart.js`, `v3/carousel.js` |
| **B · admin frontend** | Admin SPA (single HTML + JS) with 7 tabs; mock data initially | `admin/index.html`, `admin/admin.css`, `admin/admin.js`, `admin/mock-data.js` |
| **C · backend spec** | OpenAPI + DDB schema + Lambda stubs documented | `api/openapi.yaml`, `api/ddb-schema.md`, `api/lambdas/README.md` |
| **D · PrettyFare port** | Locate refund/chargeback in PrettyFare and write port plan | `api/refund-port-plan.md` |

The four agents work concurrently. Coordination: each reads `design.md` (this file) as the contract. None edits another agent's files.

---

## 11. Done = deployable demo

- v3 fully usable, deployed at `yesjo.au/puredesignprojects/v3/`
- Admin fully clickable with mock data, deployed at `yesjo.au/puredesignprojects/admin/`
- `versions.html` updated to include v3 alongside v1 + v2
- `design.md` (this file) deployed alongside as the implementation contract
- Backend: spec only, ready for next-phase Lambda implementation
