Skip to content

Video Testimonial Collector - Implementation Blueprint

Screen-by-screen fields, workflows, and database design for building a photo + video testimonial app using this boilerplate.

Video Testimonial Collector - Implementation Blueprint

Section titled “Video Testimonial Collector - Implementation Blueprint”

This is a build-focused blueprint for implementing the Post-Purchase Photo + Video Testimonial Collector using this repository as boilerplate.

It is designed to match patterns already used in this codebase:

  • Shop-scoped multi-tenant data model (Shop FK on tenant tables)
  • Prisma + Postgres with @map("snake_case") columns
  • React Router loaders/actions for admin screens
  • Theme extension for storefront rendering
  • Webhook-driven automation for post-purchase workflows

Use a video-first, photo-supported strategy.

  • Primary CTA to customer: “Record a quick video testimonial”
  • Secondary CTA: “Upload a photo + short text”
  • Same moderation and publication workflow for both media types

This balances differentiation (video) and submission volume (photos).


2.1 Existing project conventions to follow

Section titled “2.1 Existing project conventions to follow”
  • Keep merchant-level config in Shop where possible.
  • New app entities should include:
    • id String @id @default(uuid())
    • shopId String @map("shop_id")
    • createdAt DateTime @default(now()) @map("created_at")
    • updatedAt DateTime @updatedAt @map("updated_at")
    • relation shop Shop @relation(... onDelete: Cascade)
  • Add route files as app/routes/app.<screen>.jsx.
  • Use authenticate.admin(request) in loaders/actions.
  • Use webhook routes for automation (app/routes/webhooks.*.jsx).
  1. Campaign engine (who gets request + when)
  2. Request delivery (email/SMS)
  3. Public submission page (secure token URL)
  4. Media ingestion/processing
  5. Moderation queue
  6. Storefront widget publication
  7. Analytics/billing

Per-product collection rule (required):

  • Create and send one testimonial request per delivered product line item.
  • Example: one order with 3 delivered products -> 3 request records and 3 testimonial request emails/SMS messages.
  • Each request token must map to exactly one Shopify product ID.
  • Each approved testimonial must remain linked to that same product and render automatically on that product detail page (PDP).

Recommended v1: 13 screens (12 admin + 1 public submission page).

Purpose: At-a-glance performance.

Fields/components:

  • Date range filter (last 7/30/90 days)
  • KPI cards:
    • Requests sent
    • Request click rate
    • Submission rate
    • Approval rate
    • Published testimonials
  • Top products by testimonial count
  • Pending moderation count CTA
  • Recent submissions list (thumbnail, type, status)

Screen 2 - Campaigns List (/app/testimonial-campaigns)

Section titled “Screen 2 - Campaigns List (/app/testimonial-campaigns)”

Purpose: Manage automation campaigns.

Fields/components:

  • Campaign table:
    • Name
    • Trigger event (order_paid, order_fulfilled)
    • Delay days
    • Audience scope (all products / collection / product list)
    • Channel (email, sms, both)
    • Status (active, paused)
    • Last sent at
  • Actions: create, pause, duplicate, archive

Screen 3 - Campaign Create/Edit (/app/testimonial-campaigns/:id)

Section titled “Screen 3 - Campaign Create/Edit (/app/testimonial-campaigns/:id)”

Purpose: Configure one post-purchase campaign.

Fields:

  • Campaign name (text)
  • Active toggle (boolean)
  • Trigger event (select)
  • Delay days (integer, min 0, max 60)
  • Audience:
    • All products toggle
    • Collection selector (multi)
    • Product selector (multi)
  • Channel (select: email/sms/both)
  • Reminder rules:
    • Enable reminder (boolean)
    • Reminder delay days (int)
    • Max reminders (int)
  • Incentive:
    • Enabled (boolean)
    • Incentive type (discount_code, gift_card, none)
    • Incentive value/code

Screen 4 - Templates (/app/testimonial-templates)

Section titled “Screen 4 - Templates (/app/testimonial-templates)”

Purpose: Edit request messages.

Fields:

  • Template type (initial_request, reminder_1, reminder_2)
  • Subject (email only)
  • Body (rich/plain text)
  • Variables help panel:
    • {{customer_first_name}}
    • {{shop_name}}
    • {{product_name}}
    • {{submission_link}}
    • {{incentive_text}}
  • Preview pane
  • Send test button

Screen 5 - Submissions Inbox (/app/testimonials)

Section titled “Screen 5 - Submissions Inbox (/app/testimonials)”

Purpose: Review incoming content quickly.

Fields/components:

  • Tabs: pending, approved, rejected, archived
  • Filters:
    • Media type (video, photo, both)
    • Product
    • Rating
    • Date range
  • Row cards:
    • Thumbnail
    • Customer display name
    • Product title
    • Media type
    • Submitted at
    • Current status
  • Bulk actions: approve/reject/archive

Screen 6 - Submission Detail (/app/testimonials/:id)

Section titled “Screen 6 - Submission Detail (/app/testimonials/:id)”

Purpose: Deep moderation + edit before publish.

Fields:

  • Media preview player/image
  • Submission metadata:
    • Duration (video)
    • Resolution
    • File size
    • Source (upload, record)
  • Customer info:
    • Name
    • Email (masked in UI if desired)
    • Order number
  • Testimonial content:
    • Star rating
    • Headline
    • Quote/review text
  • Consent:
    • Accepted checkbox state
    • Accepted timestamp
    • Consent version
  • Moderation actions:
    • Approve
    • Reject with reason
    • Flag
    • Publish toggle
  • Merchandising controls:
    • Assign products (multi)
    • Featured toggle
    • Sort priority

Screen 7 - Published Content (/app/testimonials/published)

Section titled “Screen 7 - Published Content (/app/testimonials/published)”

Purpose: Manage what is live.

Fields:

  • List/grid toggle
  • Product assignment column
  • Visibility by placement:
    • PDP
    • Home carousel
    • Collection page
  • Feature/pin
  • Display order drag handle
  • Unpublish action

Screen 8 - Widget Settings (/app/testimonials/widget)

Section titled “Screen 8 - Widget Settings (/app/testimonials/widget)”

Purpose: Storefront look and behavior.

Fields:

  • Enable widget (boolean)
  • Widget title text (string)
    • Default: Loved by customers
  • Widget subtitle/trust copy (string)
    • Default: Real reviews from verified buyers
  • Show store logo (boolean)
  • Store logo source (enum: shop_logo, custom_upload)
  • Custom logo image upload (file, png/svg/webp)
  • Logo position (enum: left_of_title, above_title, hidden_on_mobile)
  • Trust badge row toggle (boolean)
  • Trust badges (multi-select):
    • verified_purchase
    • secure_checkout
    • money_back_guarantee
    • fast_shipping
  • Header alignment (enum: left, center)
  • Header-to-grid spacing (int px)

Layout and card behavior:

  • Layout style (carousel, grid, masonry)
  • Card style (minimal, detailed)
  • Desktop columns (int: 2-5)
  • Mobile cards per view (int: 1-2)
  • Card corner radius (int px)
  • Card shadow style (enum: none, soft, medium)
  • Show rating (boolean)
  • Show customer first name (boolean)
  • Show customer location (boolean)
  • Show verified purchase badge on card (boolean)
  • Show product name on card (boolean)
  • Show submission date (boolean)
  • Show captions/transcript snippet (boolean)
  • Transcript snippet max length (int chars)
  • Autoplay video previews (boolean)
  • Mute autoplay videos by default (boolean)
  • Loop preview video (boolean)
  • Max testimonials per section (int)

Customer-facing copy controls:

  • Primary CTA label (string)
    • Default: Watch customer stories
  • Secondary CTA label (string)
    • Default: Read more reviews
  • Empty-state headline (string)
    • Example: Be the first to share your experience
  • Empty-state helper text (string)
    • Example: Customer stories will appear here after the first submission is approved.

Theme/brand styling:

  • Primary color
  • Background color
  • Text color
  • Accent color (play icon, chips, CTA links)
  • Star color
  • Trust badge background color
  • Trust badge text color
  • Light/dark adaptation toggle (boolean)

Placement options (legacy / global toggles — see Screen 8.1 for the required v1 split):

  • Product page position (enum: below_price, below_description, below_recommendations, manual_block)
  • Home page section toggle (boolean) — superseded by Home widget block settings in 8.1; keep for backward compatibility if a single “master” toggle is desired
  • Collection page section toggle (boolean)
  • Product-page-only filter (show only testimonials linked to current product) — always on for the PDP widget defined in 8.1
  • Fallback behavior if product has no testimonials (PDP widget only):
    • show_global_featured
    • show_recent
    • hide_widget

Home widget (store index) — admin defaults (pair with Theme block “Home”):

  • Home widget enabled (boolean)
  • Home max items (int, default 5)
  • Home media filter (enum: video_only, video_and_photo) — default video_only for the “top video reviews” strip
  • Home ranking (enum: featured_then_newest, newest, manual_sort_only) — default featured_then_newest
  • Show product title on home cards (boolean, default true) so shoppers know which product each clip refers to

PDP widget (product template) — admin defaults (pair with Theme block “PDP”):

  • PDP widget enabled (boolean)
  • PDP max items (int, nullable = no cap beyond performance limit, or default e.g. 12)
  • PDP media filter (enum: video_only, video_and_photo, all_published) — default can match merchant preference; product binding is always enforced
  • PDP fallback when this product has zero published testimonials: same enum as above (show_global_featured | show_recent | hide_widget)

Preview and QA controls:

  • Live preview panel with device toggle (desktop, mobile)
  • Preview sample density (3, 6, 12 cards)
  • Preview language selector (for localized storefronts)
  • Reset to defaults button
  • Publish changes button

Screen 8.1 — Two storefront widgets (v1 requirement)

Section titled “Screen 8.1 — Two storefront widgets (v1 requirement)”

Merchants must be able to show two different widgets on the storefront. Treat this subsection as the canonical spec when you split work into separate .md sub-plans (suggested sub-plan ids below).

Sub-plan idScopeOne-line outcome
SP-TW-01Home widgetRenders up to 5 published video testimonials store-wide (not tied to one product).
SP-TW-02PDP widgetRenders only testimonials linked to the current product (shopifyProductId matches Liquid product.id).
SP-TW-03Widget API + app proxyOne or two HTTP endpoints; query rules for placement=home vs placement=pdp + optional product_id.
SP-TW-04Theme app extensionLiquid block(s) + shared JS; passes shop, placement, product id (PDP only), limits from block settings or app defaults.

Widget A — Home page: “Top video reviews”

Section titled “Widget A — Home page: “Top video reviews””
  • Where it lives: Home page (index) template via Theme app extension block (merchant adds block where they want the strip).
  • What it shows: Published testimonials with mediaType = video, playback ready (e.g. asset processingStatus = ready when applicable), not filtered by current product.
  • How many: 5 items maximum by default (Home max items); merchant may raise/lower in Widget Settings within a sane cap (e.g. max 12) for future flexibility.
  • Sort order: Featured first, then by publishedAt / sortOrder per admin Home ranking setting.
  • Empty state: Use Widget Settings empty-state copy; no fallback to “random product” reviews unless explicitly added later.

Widget B — Product detail page (PDP): “Reviews for this product only”

Section titled “Widget B — Product detail page (PDP): “Reviews for this product only””
  • Where it lives: Product template via Theme app extension block.
  • What it shows: Only rows where TestimonialSubmission.shopifyProductId equals the current product’s Shopify GID or numeric id (match whatever the storefront passes consistently — document in SP-TW-04).
  • Media: Default can be video-first or mixed per PDP media filter; binding to product is non-negotiable.
  • Fallback (no testimonials for this product): Use PDP fallback enum — show_global_featured, show_recent, or hide_widget — so merchants control whether the section disappears or shows broader social proof.

Theme extension: one block vs two blocks (implementation choice)

Section titled “Theme extension: one block vs two blocks (implementation choice)”

Document this decision in SP-TW-04 when you implement.

  • Option A (recommended for sub-plan clarity): Two app blocks in the theme extension:
    • blocks/testimonial-widget-home.liquid — only home; embeds attributes: shop domain, placement=home, limit (default 5), media=video.
    • blocks/testimonial-widget-pdp.liquid — only PDP; embeds placement=pdp, product_id={{ product.id }}, optional limit, fallback behavior key if overridden per block.
    • Shared asset: assets/testimonial-widget.js (fetch, render, carousel/grid behavior).
  • Option B: Single block blocks/testimonial-widget.liquid with a schema setting widget_mode: home_top_videos | product_reviews. Same JS; mode selects API query. Slightly fewer files; slightly more merchant confusion if labels are unclear.

Define a single public read endpoint (App Proxy or approved pattern used elsewhere in this app), for example:

  • GET .../testimonials?shop={shop}&placement=home&limit=5&media=video
  • GET .../testimonials?shop={shop}&placement=pdp&product_id={id}&limit={n}&media={...}

Rules:

  • Only return rows that are published (and any other merchant visibility flags from Published Content screen).
  • Home placement: ignore product_id; apply global ranking and video filter per query params / shop defaults.
  • PDP placement: require product_id; filter strictly to that product unless fallback mode instructs otherwise (fallback may return a second “featured” payload — specify in implementation sub-plan).
  • Add both blocks in Theme Editor: one on Home, one on Product.
  • Widget Settings screen supplies defaults; individual blocks may allow overrides (limit, title override) via Liquid schema if desired in a later sub-plan.

Screen 9 - Moderation Settings (/app/testimonials/moderation)

Section titled “Screen 9 - Moderation Settings (/app/testimonials/moderation)”

Purpose: Reduce manual effort and keep brand safety.

Fields:

  • Auto-approve photos only (boolean)
  • Require minimum rating (int)
  • Blocklist words (textarea list)
  • Minimum video length (seconds)
  • Maximum video length (seconds)
  • Reject blurry videos/photos (future AI toggle placeholder)
  • Team notification emails

Screen 10 - Requests Log (/app/testimonials/requests)

Section titled “Screen 10 - Requests Log (/app/testimonials/requests)”

Purpose: Delivery observability and troubleshooting.

Fields:

  • Request ID
  • Shopify product ID
  • Product title
  • Customer
  • Order
  • Campaign
  • Channel
  • Status (queued, sent, delivered, opened, clicked, failed)
  • Failure reason
  • Sent timestamp
  • Last event timestamp

Screen 11 - Analytics (/app/testimonials/analytics)

Section titled “Screen 11 - Analytics (/app/testimonials/analytics)”

Purpose: Measure ROI.

Fields/charts:

  • Funnel chart:
    • Sent -> Opened -> Clicked -> Submitted -> Approved -> Published
  • Product-level testimonial coverage
  • Conversion comparison:
    • Products with testimonials vs without
  • Media type performance split:
    • Video submit rate
    • Photo submit rate
  • Optional UTM/source analysis

Screen 12 - Billing & Usage (/app/testimonials/billing)

Section titled “Screen 12 - Billing & Usage (/app/testimonials/billing)”

Purpose: Plan limits and overages.

Fields:

  • Current plan
  • Period usage:
    • Requests used
    • Storage used (GB)
    • Processed video minutes
  • Overage settings/toggles
  • Upgrade CTA

Screen 13 - Public Submission Page (/t/:token)

Section titled “Screen 13 - Public Submission Page (/t/:token)”

Purpose: Customer uploads testimonial.

Fields:

  • Header text and trust copy
  • Product context header (locked from token):
    • Product title
    • Product image thumbnail
    • Optional variant title
  • Media mode choice:
    • Record video
    • Upload video
    • Upload photo
  • Text fields:
    • Headline (optional)
    • Review text (optional)
  • Star rating selector (optional/required based on settings)
  • Consent checkbox (required)
  • Submit button
  • Success state with optional reward message
  • Product binding note:
    • Submission is automatically linked to the token’s product.
    • Customer cannot switch product on this page.

4) Email/SMS Workflow (Operational Detail)

Section titled “4) Email/SMS Workflow (Operational Detail)”
  1. Order event webhook received.
  2. Eligibility evaluated against campaign rules.
  3. Split eligible delivered order items into product-level request jobs.
  4. Create one testimonial_request row per delivered product with its own token and scheduled send time.
  5. Worker/cron sends one message per request row via provider.
  6. Track delivery/open/click events into request-event table.
  7. If no submission by reminder window, send reminder message.
  8. Stop reminders when submission is completed.

The following tables are designed to match this repo conventions.

Add testimonial config fields in Shop:

  • testimonialEnabled Boolean @default(true) @map("testimonial_enabled")
  • testimonialWidgetLayout String? @default("carousel") @map("testimonial_widget_layout")
  • testimonialPrimaryColor String? @map("testimonial_primary_color")
  • testimonialAutoApprovePhotos Boolean @default(false) @map("testimonial_auto_approve_photos")
  • testimonialConsentVersion String? @map("testimonial_consent_version")
  • id String @id @default(uuid())
  • shopId String @map("shop_id")
  • name String
  • status String @default("active") // active | paused | archived
  • triggerEvent String @map("trigger_event") // order_paid | order_fulfilled
  • delayDays Int @default(7) @map("delay_days")
  • channel String @default("email") // email | sms | both
  • allProducts Boolean @default(true) @map("all_products")
  • reminderEnabled Boolean @default(true) @map("reminder_enabled")
  • reminderDelayDays Int @default(3) @map("reminder_delay_days")
  • maxReminders Int @default(1) @map("max_reminders")
  • incentiveEnabled Boolean @default(false) @map("incentive_enabled")
  • incentiveType String? @map("incentive_type") // discount_code | gift_card
  • incentiveValue String? @map("incentive_value")
  • createdAt DateTime @default(now()) @map("created_at")
  • updatedAt DateTime @updatedAt @map("updated_at")

Indexes:

  • @@index([shopId, status])

Mapping table for product-targeted campaigns.

  • id String @id @default(uuid())
  • campaignId String @map("campaign_id")
  • shopId String @map("shop_id")
  • shopifyProductId String @map("shopify_product_id")
  • createdAt DateTime @default(now()) @map("created_at")

Unique:

  • @@unique([campaignId, shopifyProductId])
  • id String @id @default(uuid())
  • shopId String @map("shop_id")
  • campaignId String? @map("campaign_id")
  • channel String // email | sms
  • templateType String @map("template_type") // initial_request | reminder_1 | reminder_2
  • subject String?
  • body String @db.Text
  • isDefault Boolean @default(false) @map("is_default")
  • createdAt DateTime @default(now()) @map("created_at")
  • updatedAt DateTime @updatedAt @map("updated_at")

Unique:

  • @@unique([shopId, campaignId, channel, templateType])

One row per outreach attempt for a single product (customer/order/product/campaign).

  • id String @id @default(uuid())
  • shopId String @map("shop_id")
  • campaignId String @map("campaign_id")
  • shopifyOrderId String @map("shopify_order_id")
  • shopifyProductId String @map("shopify_product_id")
  • shopifyVariantId String? @map("shopify_variant_id")
  • shopifyCustomerId String? @map("shopify_customer_id")
  • customerEmail String? @map("customer_email")
  • customerPhone String? @map("customer_phone")
  • submissionToken String @unique @map("submission_token")
  • submissionUrl String @map("submission_url")
  • status String @default("scheduled") // scheduled | sent | clicked | submitted | expired | failed
  • scheduledFor DateTime @map("scheduled_for")
  • sentAt DateTime? @map("sent_at")
  • clickedAt DateTime? @map("clicked_at")
  • submittedAt DateTime? @map("submitted_at")
  • reminderCount Int @default(0) @map("reminder_count")
  • lastError String? @map("last_error") @db.Text
  • createdAt DateTime @default(now()) @map("created_at")
  • updatedAt DateTime @updatedAt @map("updated_at")

Indexes:

  • @@index([shopId, status])
  • @@index([shopId, scheduledFor])
  • @@index([campaignId])
  • @@index([shopId, shopifyProductId])

Event stream for delivery analytics.

  • id String @id @default(uuid())
  • shopId String @map("shop_id")
  • requestId String @map("request_id")
  • eventType String @map("event_type") // queued | sent | delivered | opened | clicked | failed
  • eventAt DateTime @default(now()) @map("event_at")
  • providerMessageId String? @map("provider_message_id")
  • payload Json?

Indexes:

  • @@index([requestId, eventAt])

Main UGC submission entity.

  • id String @id @default(uuid())
  • shopId String @map("shop_id")
  • requestId String? @map("request_id")
  • shopifyOrderId String? @map("shopify_order_id")
  • shopifyProductId String @map("shopify_product_id")
  • shopifyVariantId String? @map("shopify_variant_id")
  • shopifyCustomerId String? @map("shopify_customer_id")
  • displayName String? @map("display_name")
  • displayEmail String? @map("display_email")
  • mediaType String @map("media_type") // video | photo
  • headline String?
  • reviewText String? @map("review_text") @db.Text
  • rating Int?
  • status String @default("pending") // pending | approved | rejected | archived
  • consentAccepted Boolean @default(false) @map("consent_accepted")
  • consentAcceptedAt DateTime? @map("consent_accepted_at")
  • consentVersion String? @map("consent_version")
  • published Boolean @default(false)
  • publishedAt DateTime? @map("published_at")
  • featured Boolean @default(false)
  • sortOrder Int? @map("sort_order")
  • submittedAt DateTime @default(now()) @map("submitted_at")
  • createdAt DateTime @default(now()) @map("created_at")
  • updatedAt DateTime @updatedAt @map("updated_at")

Indexes:

  • @@index([shopId, status])
  • @@index([shopId, published])
  • @@index([requestId])
  • @@index([shopId, shopifyProductId, published])

Media processing and hosting details.

  • id String @id @default(uuid())
  • shopId String @map("shop_id")
  • submissionId String @map("submission_id")
  • storageProvider String @map("storage_provider") // stream | mux | s3
  • storageKey String @map("storage_key")
  • playbackUrl String? @map("playback_url") @db.Text
  • thumbnailUrl String? @map("thumbnail_url") @db.Text
  • mimeType String? @map("mime_type")
  • durationSec Int? @map("duration_sec")
  • width Int?
  • height Int?
  • fileSizeBytes BigInt? @map("file_size_bytes")
  • processingStatus String @default("uploaded") @map("processing_status") // uploaded | processing | ready | failed
  • processingError String? @map("processing_error") @db.Text
  • transcriptText String? @map("transcript_text") @db.Text
  • createdAt DateTime @default(now()) @map("created_at")
  • updatedAt DateTime @updatedAt @map("updated_at")

Indexes:

  • @@index([submissionId])
  • @@index([shopId, processingStatus])

Optional extension for secondary merchandising links. Primary product linkage should come from TestimonialSubmission.shopifyProductId.

  • id String @id @default(uuid())
  • shopId String @map("shop_id")
  • submissionId String @map("submission_id")
  • shopifyProductId String @map("shopify_product_id")
  • createdAt DateTime @default(now()) @map("created_at")

Unique:

  • @@unique([submissionId, shopifyProductId])

Audit trail for moderation decisions.

  • id String @id @default(uuid())
  • shopId String @map("shop_id")
  • submissionId String @map("submission_id")
  • action String // approve | reject | archive | unpublish | feature
  • reason String? @db.Text
  • actorType String @map("actor_type") // merchant | system
  • actorEmail String? @map("actor_email")
  • createdAt DateTime @default(now()) @map("created_at")

Indexes:

  • @@index([submissionId, createdAt])

Pre-aggregated daily metrics for dashboard speed.

  • id String @id @default(uuid())
  • shopId String @map("shop_id")
  • date DateTime @map("date") // UTC day bucket
  • requestsSent Int @default(0) @map("requests_sent")
  • requestsClicked Int @default(0) @map("requests_clicked")
  • submissionsCount Int @default(0) @map("submissions_count")
  • approvedCount Int @default(0) @map("approved_count")
  • publishedCount Int @default(0) @map("published_count")
  • videoSubmissionsCount Int @default(0) @map("video_submissions_count")
  • photoSubmissionsCount Int @default(0) @map("photo_submissions_count")
  • createdAt DateTime @default(now()) @map("created_at")
  • updatedAt DateTime @updatedAt @map("updated_at")

Unique:

  • @@unique([shopId, date])

6) Suggested Route/File Map (Boilerplate-Aligned)

Section titled “6) Suggested Route/File Map (Boilerplate-Aligned)”

Admin routes:

  • app/routes/app.testimonial-dashboard.jsx
  • app/routes/app.testimonial-campaigns.jsx
  • app/routes/app.testimonial-campaign.$id.jsx
  • app/routes/app.testimonial-templates.jsx
  • app/routes/app.testimonial-submissions.jsx
  • app/routes/app.testimonial-submission.$id.jsx
  • app/routes/app.testimonial-published.jsx
  • app/routes/app.testimonial-widget.jsx
  • app/routes/app.testimonial-moderation.jsx
  • app/routes/app.testimonial-requests.jsx
  • app/routes/app.testimonial-analytics.jsx
  • app/routes/app.testimonial-billing.jsx

Public/customer routes:

  • app/routes/t.$token.jsx (public submission page)
  • app/routes/api.testimonial-upload-url.jsx
  • app/routes/api.testimonial-submit.jsx
  • app/routes/api.testimonial-media-callback.jsx (optional if provider webhook)

Webhook routes:

  • app/routes/webhooks.orders.paid.jsx
  • app/routes/webhooks.orders.fulfilled.jsx

Theme extension (align with Screen 8.1):

  • Recommended (two blocks):
    • extensions/<testimonial-extension>/blocks/testimonial-widget-home.liquid — home: top video reviews (default limit 5)
    • extensions/<testimonial-extension>/blocks/testimonial-widget-pdp.liquid — PDP: this product only + fallback behavior
  • Alternative (single block):
    • extensions/<testimonial-extension>/blocks/testimonial-widget.liquid — schema widget_mode: home_top_videos | product_reviews
  • Shared:
    • extensions/<testimonial-extension>/assets/testimonial-widget.js

  1. On order webhook -> evaluate active campaigns.
  2. Expand delivered line items into product-level requests (one request per product).
  3. Create and schedule request rows with a unique per-product token.
  4. Scheduler job sends messages from due requests.
  5. Public page validates token and captures submission.
  6. Upload media via signed URL.
  7. Worker updates processing status and derived metadata.
  8. Submission appears in moderation queue.
  9. On approve, testimonial is automatically bound to its product PDP via stored shopifyProductId.
  10. Approved submissions are exposed by a storefront read API consumed by the theme extension:
    • Home widget requests placement=home, limit=5 (default), media=video (default).
    • PDP widget requests placement=pdp with product_id matching the current product; applies PDP fallback rules when the result set is empty.

  • Tokenized submission URLs with expiry.
  • Rate limit public submission endpoints.
  • Store consent acceptance and consent version per submission.
  • Keep moderation log immutable for audit.
  • Handle GDPR webhooks similarly to existing compliance route:
    • On customer redact, remove testimonial rows by customer email/id.
    • On shop redact, delete shop-scoped testimonial data via cascade.

  • DB schema + migrations
  • Campaign CRUD
  • Request creation from order webhook
  • Public token page
  • Media upload + storage
  • Moderation inbox and detail page
  • Theme widget rendering (two placements: home top-5 video strip + PDP product-scoped widget per Screen 8.1)
  • Widget storefront API (SP-TW-03) + theme blocks (SP-TW-04)
  • Analytics + billing limits
  • App Store readiness and QA

  • Reuse current nav pattern in app/routes/app.jsx and add testimonial sections.
  • Reuse page composition style from app.settings.jsx and app.appearance.jsx.
  • Keep actions idempotent where webhook retries can happen.
  • Use UTC date buckets for analytics (same convention already present in dashboard logic).
  • Keep optional fields nullable in Prisma to avoid migration friction during phased rollout.