# Reviews — widget, token wizard, photo rewards

Verified-purchase reviews. Reviews exist **only** via a
single-use, order-scoped, expiring **token** minted by the request scanner
and mailed as `<review_link_base>/<token>` — the token IS the auth for
every write; no login. All public reads serve `status='visible'` rows only
— hidden/pending/deleted never leak (RLS-enforced too).

The wizard is two-step: **submit** (rating + body, consumes the token) →
**photo** (attach media, mint the reward code). Resume rule: token consumed
+ `review.reward_code` null → resume at the photo step; `reward_code` set →
fully done, show the code.

> Public reads below run executably against the seeded `Linen Shirt`
> product (`prod_01tst00000000000000000001`, scripts/seed-fixtures.ts) —
> shape holds at any review count, including zero. Token-gated writes need
> a server-minted token, so their happy paths are pinned by
> `tests/store/reviews-store.test.ts` + `tests/contract/reviews-widget.contract.test.ts`;
> here the error contracts run executably.

## GET /api/store/reviews — list visible reviews

- **Purpose**: the paginated review list under the PDP widget.
- **Auth**: anon (`x-client-id`).
- **Request**: query `{product_id (required), sort? default|rating|date,
  order? asc|desc, limit? (≤50, default 10), offset?}`. `default` = with-
  media first, newest within.
- **Response**: `{reviews: [PublicReview…], count, has_more}`.
  `PublicReview` is EXACTLY these keys (leak guard — no email/order/IP/
  reward/status; media entries hidden by moderation are filtered out):

```jsonc
{
  "reviews": [
    {
      "id": "uuid",
      "customer_name": "Елена Г.",
      "rating": 5,
      "title": null,                  // always null — the form has no title
      "body": "Страхотна риза!",
      "media": [ { "type": "image", "url": "https://…", "thumb": "https://…", "w": 0, "h": 0, "bytes": 0 } ],
      "admin_response": null,
      "admin_response_at": null,
      "created_at": "ISO-8601"
    }
  ],
  "count": 1,
  "has_more": false
}
```

- **Errors**: `400 validation_failed` (missing product_id, bad sort/limit).
- **SDK**: `reviews.listReviews(client, query)`
- **Components**: review list / masonry grid (review widget family).

```bash
curl -sf "$BASE/api/store/reviews?product_id=prod_01tst00000000000000000001&sort=date" \
  -H "x-client-id: $CLIENT_ID" | grep -q '"has_more"'
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/store/reviews" \
  -H "x-client-id: $CLIENT_ID")
test "$STATUS" = 400
```

## GET /api/store/reviews/aggregate — star-badge stats

- **Purpose**: the PDP star rating + histogram. Visible only. Edge-cached
  60s (`Cache-Control: public, max-age=60, s-maxage=60`).
- **Auth**: anon (`x-client-id`).
- **Request**: query `{product_id}`.
- **Response**: `{product_id, count, avg_rating, distribution: {"1"…"5"}}`
  — `avg_rating` rounded to 1 decimal, 0 when no reviews.
- **Errors**: `400 validation_failed`.
- **SDK**: `reviews.getAggregate(client, productId)`
- **Components**: star badge (PDP + product cards).

```bash
curl -sf "$BASE/api/store/reviews/aggregate?product_id=prod_01tst00000000000000000001" \
  -H "x-client-id: $CLIENT_ID" | grep -q '"distribution"'
```

## GET /api/store/reviews/widget — one-call widget payload

- **Purpose**: aggregate + first page (sized/sorted per the store's
  Settings → Reviews display options) + display options in ONE call — what
  the product widget + star badge mount from. Edge-cached 60s.
- **Auth**: anon (`x-client-id`).
- **Request**: query `{product_id}`.
- **Response**:

```jsonc
{
  "product_id": "prod_…",
  "aggregate": { /* ReviewAggregate — shape above */ },
  "reviews": [ /* PublicReview[] — first page */ ],
  "count": 3,
  "has_more": false,
  "options": { "layout": "masonry", "page_size": 6, "photo_first": true }
}
```

- **Errors**: `400 validation_failed`.
- **SDK**: `reviews.getWidget(client, productId)`
- **Components**: the review widget (masonry/list) + star badge.
- **Settings**: `widget_layout`, `widget_page_size`, `widget_photo_first`
  (admin → Settings → Reviews).

```bash
BODY=$(curl -sf "$BASE/api/store/reviews/widget?product_id=prod_01tst00000000000000000001" \
  -H "x-client-id: $CLIENT_ID")
echo "$BODY" | grep -q '"aggregate"'
echo "$BODY" | grep -q '"options"'
echo "$BODY" | grep -q '"photo_first"'
```

## GET /api/store/reviews/token/:token — validate + form context

- **Purpose**: bootstrap the review form from the emailed link: validity,
  product card, greeting, and the RESUME state. **Never cached** — state
  changes on submit.
- **Auth**: anon (`x-client-id`); the token is the bearer secret.
- **Response** — ALWAYS 200, `TokenValidation`:

```jsonc
// invalid
{ "valid": false, "reason": "not_found" }   // or "expired" | "invalid" (malformed/too short)
// valid
{
  "valid": true,
  "already_submitted": false,
  "review": null,                  // {id, reward_code} once submitted
  "product": { "id": "prod_…", "handle": "linen-shirt", "title": "Linen Shirt", "thumbnail": "https://…" },
  "customer_name": "Елена",
  "expires_at": "ISO-8601"
}
```

- **Errors**: none over HTTP — failures are in-band (`valid: false`).
- **SDK**: `reviews.validateToken(client, token)`
- **Components**: review wizard entry route.

```bash
# Unknown (but well-formed) token → in-band not_found, HTTP 200.
curl -sf "$BASE/api/store/reviews/token/doc-not-a-real-token-$RUN" \
  -H "x-client-id: $CLIENT_ID" | grep -q '"reason":"not_found"'
# Malformed (too short) → "invalid".
curl -sf "$BASE/api/store/reviews/token/short" \
  -H "x-client-id: $CLIENT_ID" | grep -q '"reason":"invalid"'
```

## POST /api/store/reviews — submit (wizard step 1)

- **Purpose**: create the review from a token. Consumes the token;
  idempotent on the (order, product) unique — a race/retry returns the
  existing review and still consumes the token.
- **Auth**: anon (`x-client-id`); the token is the auth.
- **Request**: `{token, rating: 1–5 int, body (REQUIRED — a rating alone is
  not a review; HTML stripped, ≤2000 chars), media?}` — media ≤ 7 items
  (≤6 images + ≤1 video), URLs must be on the store's file host (from
  upload-url below). **No title field.**
- **Response**: `{id, success: true}`.
- **Errors**: `400 invalid_data` (shape / empty-after-sanitize body / media
  rule) · `404 not_found` (unknown token) · `409 conflict` (token consumed
  — replay) · `410 gone` (expired) · `429 rate_limited` (3/h per IP).
- **SDK**: `reviews.submitReview(client, input)`
- **Components**: review wizard, step 1.
- **Settings**: `moderation_mode: "hold"` lands the review as `pending`
  (not publicly visible until approved); `auto_publish` goes live at once.

```bash
STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/store/reviews" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"token": "doc-not-a-real-token-'$RUN'", "rating": 5, "body": "great"}')
test "$STATUS" = 404
```

## POST /api/store/reviews/:id/photo — attach media + reward (step 2)

- **Purpose**: attach media and mint the single-use reward code (a REAL
  promotion, percentage-off-order — 10% default). The reward is for the
  PHOTO, never the rating. Idempotent — retries return the same code; a
  promo-mint failure never loses the media. A CONSUMED token is accepted
  (step 1 consumed it).
- **Auth**: anon (`x-client-id`); the token must match the review's
  (order, product) pair.
- **Request**: `{token, media (1–7 items, ≤6 images + ≤1 video, file-host
  URLs only)}`.
- **Response**: `{code}` (fresh mint — also emails the `review-reward`
  template) | `{code, already_issued: true}` (retry) | `{code: null,
  message}` (mint failed; media saved).
- **Errors**: `400 invalid_data` · `403 forbidden` (token does not match
  this review) · `404 not_found` (review or token unknown).
- **SDK**: `reviews.attachReviewPhoto(client, reviewId, input)`
- **Components**: review wizard, step 2 (photo + reward reveal).
- **Settings**: `reward_enabled`, `reward_percentage`.

```bash
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
  -X POST "$BASE/api/store/reviews/00000000-0000-0000-0000-00000000dead/photo" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"token": "doc-not-a-real-token-'$RUN'", "media": [{"type": "image", "url": "https://example.com/x.jpg"}]}')
test "$STATUS" = 400
```

## POST /api/store/reviews/upload-url — signed media upload

- **Purpose**: get a signed R2 PUT for the photo step. Upload the raw file
  to `uploadUrl`, then reference `publicUrl` in the media array. A consumed
  token is accepted; expiry still applies.
- **Auth**: anon (`x-client-id`); the token is the auth.
- **Request**: `{token, name, type (image/jpeg|jpg|png|webp or
  video/mp4|quicktime), size?}` — caps: image ≤ 8MB, video ≤ 50MB.
- **Response**: `{uploadUrl, publicUrl, key, filename}`.
- **Errors**: `400 invalid_data` (unsupported MIME, too large) ·
  `404 not_found` (unknown token) · `410 gone` (expired token).
- **SDK**: `reviews.createUploadUrl(client, input)`
- **Components**: review wizard photo picker.

```bash
STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/store/reviews/upload-url" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"token": "doc-not-a-real-token-'$RUN'", "name": "x.jpg", "type": "image/jpeg"}')
test "$STATUS" = 404
```
