Skip to content

06 — Public Submission Page (Screen 13, `/t/:token`)

Cursor-ready plan for the customer-facing testimonial capture flow: token validation, media upload, consent, and submission APIs.

Source: 02-Implementation-Blueprint.mdScreen 13 - Public Submission Page (/t/:token), with the minimum companion public routes from §6 (t.$token.jsx, api.testimonial-upload-url, api.testimonial-submit; optional api.testimonial-media-callback).

Product alignment: 01-Post-Purchase-Video-Testimonial-Collector-Plan.md§6 MVP (tokenized links, mobile-friendly upload/record, video + photo + optional text + rating, consent).

This document is a build spec only. No code changes are implied until an implementation task references this file.


A buyer opens a unique link (/t/:token) tied to one TestimonialRequest; the app validates the token, shows locked product context, lets them record or upload video/photo, optionally add text + rating, require consent, then create a TestimonialSubmission (+ TestimonialMediaAsset) in pending moderation state and mark the request as submitted.


1) Prerequisites (must exist before this screen works end-to-end)

Section titled “1) Prerequisites (must exist before this screen works end-to-end)”
DependencyWhy
TestimonialRequest with unique submissionToken (blueprint §5.5)Loader resolves :token → request row; binds shopifyProductId, shopId, optional requestId on submission.
TestimonialSubmission + TestimonialMediaAsset (§5.7 / §5.8)Final POST creates rows; media row tracks upload/processing.
Shop + testimonialConsentVersion (or equivalent on §5.1)Persist consent version string on submit.
Optional: Product mirror or Shopify Admin APITo show product title + image on the page without extra Shopify calls from an unauthenticated browser (prefer denormalized snapshot on TestimonialRequest or join Product by shopifyProductId).

Out of scope for this plan: creating requests from webhooks (§4), email templates (Screen 4), moderation UI (Screens 5–6). For local dev, seed a TestimonialRequest with a known token.


ArtifactResponsibility
app/routes/t.$token.jsxPublic page route. :token = TestimonialRequest.submissionToken (opaque string; use high-entropy tokens, e.g. UUID v4 or 32+ byte hex).
app/routes/api.testimonial-upload-url.jsxPOST (or GET if you prefer) — returns short-lived presigned upload target + storageKey / assetId the client must send on final submit.
app/routes/api.testimonial-submit.jsxPOST JSON — validates token again, creates TestimonialSubmission + links media, updates request status.

Do not nest under /app/* — page is customer-facing, no authenticate.admin.

React Router: ensure t.$token is excluded from embedded app layout if the boilerplate wraps /app only (follow existing _index vs app split).


3) Token validation rules (loader for t.$token.jsx)

Section titled “3) Token validation rules (loader for t.$token.jsx)”

On every load:

  1. Look up TestimonialRequest where submissionToken === params.token (constant-time comparison if comparing strings in application code).
  2. If not found → render a simple 404 / invalid link page (no stack traces, no hints).
  3. If status is already submitted → show “Already submitted” (idempotent messaging; optional link to store).
  4. If expired or failed (if you use these) → friendly message; no form.
  5. If scheduled but not yet sent (optional policy): either allow early access or show “Link not active yet” — pick one and document in env (recommend: allow only sent or clicked for MVP to match email timing).
  6. Resolve shop by shopId; if shop status not active / app uninstalled → invalid link.

Open decision (record in ticket): blueprint does not mandate tokenExpiresAt. Recommend adding optional tokenExpiresAt on TestimonialRequest for security; loader rejects if now > expires.

Analytics: set clickedAt on first successful page load (once per token) to avoid double-counting — use a small idempotent update (e.g. only if clickedAt is null).


Build a mobile-first layout (single column, large tap targets). No Shopify Polaris requirement on public page unless you reuse it; plain CSS or lightweight components is fine.

  • Merchant-configurable header + trust copy (source: Shop testimonial branding JSON, hardcoded v1 defaults, or static copy until Screen 8 ships — document source of truth).
  • Product title, thumbnail, optional variant title from request snapshot or Product join.
  • Short note: “Your testimonial will be linked to this product.” (blueprint Screen 13 product binding note).
  • Record videoMediaRecorder where supported; fallback message to upload if not.
  • Upload video — file input accept video types; max size from shop/moderation settings (Screen 9 — if not ready, use safe defaults, e.g. 100MB).
  • Upload photo — image accept; same size limits.

Client flow: user picks mode → file/blob → call upload-url APIPUT to storage → receive final playbackUrl / storageKey / assetId for submit payload.

  • Headline (optional), review text (optional) — maxlength enforced client + server.
  • Star rating — optional or required per Shop / Screen 9 Require minimum rating / global flag ratingRequired (add to shop testimonial settings if missing).
  • Checkbox required; label text must mention media usage; store consentAccepted, consentAcceptedAt, consentVersion on submission (§5.7).
  • Primary button disabled until: media uploaded (or recorded and uploaded), consent checked, rating valid if required.
  • Loading and error states; no full page reload required if using fetch.
  • Thank-you message; optional incentive copy from TestimonialCampaign / request (if incentiveEnabled — read-only display; fulfillment of discount is a later plan).

Return credentials for direct upload to object storage (S3-compatible, Supabase, Mux direct upload, etc.) without streaming large files through the app server.

  • Body: { "token": "<same as URL token>", "mimeType": "video/mp4", "fileName": "optional", "bytes": 12345678 }
    or query params if GET — POST preferred to avoid logging tokens.
  • Resolve request by token with same rules as §3 (must be eligible to submit).
  • Enforce max file size and allowed MIME list (video/*, image/* subset).
  • Rate limit per token + IP (prevent abuse).
  • { "uploadUrl": "https://...", "method": "PUT", "headers": { ... }, "assetId": "uuid", "storageKey": "..." }
    Shape must match what api.testimonial-submit expects to attach to TestimonialMediaAsset.
  • Presigned URL short TTL (e.g. 15 minutes).
  • Do not return shop accessToken or admin keys to the browser.

{
"token": "opaque-token",
"mediaType": "video",
"assetId": "uuid",
"storageKey": "shop/.../file.mp4",
"mimeType": "video/mp4",
"headline": "optional",
"reviewText": "optional",
"rating": 5,
"displayName": "optional public name",
"consentAccepted": true
}

6.2 Server steps (transactional where possible)

Section titled “6.2 Server steps (transactional where possible)”
  1. Re-validate token and eligibility (including no duplicate submission for same request — unique constraint or check submittedAt).
  2. Create TestimonialSubmission:
    • shopId, requestId, shopifyProductId / variant / customer from request (customer must not override product).
    • mediaType, status: pending, consentAccepted: true, timestamps, consentVersion from shop.
    • Optional displayName / displayEmail if collected (minimize PII; blueprint allows display name).
  3. Create TestimonialMediaAsset:
    • submissionId, storageProvider, storageKey, mimeType, processingStatus: uploaded (or processing if async pipeline).
    • playbackUrl / thumbnailUrl nullable until worker fills (§7 integration step 7).
  4. Update TestimonialRequest: status = submitted, submittedAt = now().
  5. Return { "ok": true, "submissionId": "..." }.
  • 400 validation, 404 invalid token, 409 already submitted, 413 payload too large (if checking server-side), 429 rate limit.

7) Media processing (boundary of this plan)

Section titled “7) Media processing (boundary of this plan)”

This plan ends at intake (uploaded / processing). Implementing transcoding, thumbnails, and processingStatus = ready belongs to a separate media-worker plan (blueprint §7 steps 6–7).

  • Submission must still appear in moderation inbox with a “processing” badge if video not ready.
  • Optional route api.testimonial-media-callback: provider webhook → update TestimonialMediaAssetout of scope here; stub if needed.

  • HTTPS only in production.
  • CORS: upload and submit APIs: allow storefront origins only if calls originate from theme; for /t/* on app domain, CORS may be same-origin — configure explicitly.
  • GDPR: submission stores customer-related data; ensure customers/redact compliance path (blueprint §8) later scrubs by email/customer id — document fields to redact.
  • Abuse: rate limits, max uploads per token, block oversized files.

9) Testing checklist (acceptance criteria)

Section titled “9) Testing checklist (acceptance criteria)”
  • Invalid token → generic error page.
  • Valid token → product context correct; cannot change product client-side (tamper POST shopifyProductId — server ignores body product id and uses request).
  • Upload URL returns presigned fields; client can complete PUT.
  • Submit creates TestimonialSubmission + TestimonialMediaAsset; request status=submitted.
  • Second submit for same token → 409 or “already submitted” UX.
  • Consent unchecked → 400.
  • Mobile: record and upload paths smoke-tested on iOS Safari + Chrome Android.

  1. Prisma models §5.5–5.8 (if not already migrated) + any tokenExpiresAt / product snapshot columns you add.
  2. t.$token.jsx loader + static layout + error states.
  3. api.testimonial-upload-url + storage provider wiring (env vars).
  4. Client-side upload/record integration on the page.
  5. api.testimonial-submit + DB transaction + request status update.
  6. Success page + basic logging for debugging (no PII in logs).

  • 02-Implementation-Blueprint.mdScreen 13, §5.5–5.8, §6 public routes, §7 steps 5–6, §8 security.
  • 01-Post-Purchase-Video-Testimonial-Collector-Plan.md§6 MVP Collection & Requests + Media Processing (intake only here).

05-storefront-widgets-and-read-api.md already covers Screen 8 / 8.1. This file is 06-… so plans stay ordered.