Skip to content

09 — Submissions Inbox (Screen 5, `/app/testimonials`)

Cursor-ready plan: admin list view for testimonial submissions with tabs, filters, row cards, and bulk moderation actions.

Source: 02-Implementation-Blueprint.mdScreen 5 - Submissions Inbox (/app/testimonials).

Product alignment: 01-Post-Purchase-Video-Testimonial-Collector-Plan.md§6 MVP → Moderation & Publishing (pending queue, quick review).

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

Out of scope here: Screen 6 (submission detail, deep edit, full media metadata) — link to that route from each row; implement in a separate plan if needed.

Related: 08-security-compliance-and-privacy.md (audit log on actions), 06-public-submission-page-screen-13.md (data lands as pending).


Merchants open /app/testimonials in the embedded app, switch tabs by workflow state, filter the list, see row cards with thumbnail + key fields, open a detail page for one item, and run bulk approve / reject / archive on selected rows.


Blueprint §6 suggestionUse this or equivalent
app/routes/app.testimonial-submissions.jsxRecommended — file name matches “submissions” language.
URL path/app/testimonials (align with Screen 5 title; set path in route config if using folder-based routes).

Auth: every loader and action must call authenticate.admin(request) and scope all Prisma queries by shopId resolved from the session (same pattern as app.support.jsx / app.knowledge.jsx in the boilerplate).

Navigation: add an entry in the embedded app nav (e.g. app/routes/app.jsx s-link) when the testimonial section ships — exact label: “Submissions” or “Inbox”; link href="/app/testimonials".


  • TestimonialSubmission (blueprint §5.7): status (pending | approved | rejected | archived), mediaType, submittedAt, rating, displayName, shopifyProductId, requestId, etc.
  • Product title: join Product on (shopId, shopifyProductId) when a local mirror exists; if missing, show “Unknown product” or fetch title via Admin API in the loader (cache per request — avoid N+1 GraphQL if listing many rows; prefer denormalized productTitleSnapshot on submission in a later migration if needed).

  • Thumbnail: from TestimonialMediaAsset.thumbnailUrl (§5.8); fallback placeholder image if null while processing.

Ensure queries use indexed fields: (shopId, status), (shopId, submittedAt). Add @@index([shopId, status, submittedAt]) in Prisma if not present.


Use search params so the view is bookmarkable and shareable:

ParamValuesDefault
tabpending, approved, rejected, archivedpending
mediaall, video, photoall
productIdinternal uuid or shopifyProductId stringempty
ratingMin / ratingMaxinteger 1–5empty
from / toISO date (UTC, date-only)empty
pageinteger1
pageSizeinteger (e.g. 20, max 100)20
sortsubmittedAt_desc (v1 only; expand later)submittedAt_desc

Loader parses new URL(request.url).searchParams and builds a single where clause for Prisma.


  • Page with title="Submissions" (or “Testimonial submissions”).
  • Primary action (optional v1): “Refresh” or rely on React Router revalidation after actions.
  • Tabs component or ButtonGroup mapping to tab param — counts badge per tab (see §5).
  • Media typeSelect / button group: All, Video, Photo.
  • ProductAutocomplete or Select populated from distinct products that have submissions for this shop (query TestimonialSubmission groupBy / distinct shopifyProductId + join titles).
  • Rating — range or min select (only if rating is non-null in data).
  • Date range — two date fields or DatePicker range (use date-fns + shop § date format from env if already in app).
  • Clear filters — resets params except tab.

For each submission in the current page:

  • Thumbnail (left)
  • Stack: customer display name (or “Anonymous” if empty), product title, media type badge, submitted at (formatted with date-format helper)
  • Status badge matching tab or raw status
  • Kebab / secondary actions (optional on row): “Open”/app/testimonials/${id} (Screen 6)

Layout: use ResourceList with ResourceItem or IndexTable with selection checkboxes for bulk actions. IndexTable is better for bulk select.

  • No rows: EmptyState with copy like “No pending submissions” + link to help doc or campaign setup (optional).
  • Pagination control at bottom; count() + skip/take or cursor-based (offset is fine for v1 under ~10k rows per shop).

Run an efficient aggregate in the loader (one query per shop, not per tab N+1):

  • groupBy on status where shopId = X, or four count queries in Promise.all.

Pass counts to the tab UI: pending: 12, approved: 40, etc.


  • Select all on page checkbox in IndexTable.
  • Bulk action bar appears when selectedIds.length > 0.
  • Approve — set status to approved (and optionally published / visibility per product rules — if Screen 7 owns “published”, v1 can only approve here and leave published=false until merchant publishes from Screen 7 — pick one product rule and document it).

    Recommended v1: Approve = status=approved + published=false; merchant uses Screen 7 to go live. Alternative: Approve + auto-publish to PDP — simpler for merchants but couples screens; state the choice in the action handler comment.

  • Reject — modal with required reason string; set status=rejected, store reason on submission or in TestimonialModerationLog only (08 — prefer log + optional rejectionReason on submission for admin list).

  • Archive — set status=archived (or soft-delete pattern if you use archived boolean — align with §5.7 status enum).

  • POST action on the same route (Remix/React Router action export) with intent: "bulk_approve" | "bulk_reject" | "bulk_archive" and ids: string[].
  • Validate every id belongs to shopId before update.
  • Transaction: $transaction updating many rows + inserting TestimonialModerationLog rows per id (08).
  • Return toast success + revalidate.
  • Cap bulk size (e.g. max 50 ids per request) to avoid timeout.

  • Row click or “View” navigates to /app/testimonials/:id (Screen 6 — not implemented in this plan file).

  • Default page size 20; max 100.
  • Avoid loading full reviewText or large JSON in list query — select only fields needed for cards.
  • For video thumbnail, use thumbnailUrl only; do not load playbackUrl for every row if unnecessary.

9) Testing checklist (acceptance criteria)

Section titled “9) Testing checklist (acceptance criteria)”
  • Non-admin / wrong shop cannot access another shop’s submissions (verify session shop filter).
  • Each tab shows only rows matching status.
  • Filters combine correctly (media + product + date + rating).
  • Pagination changes page param and preserves filters.
  • Bulk approve updates all selected rows + creates moderation log entries (08).
  • Bulk reject requires reason; rejected rows appear under Rejected tab.
  • Empty states show for each tab when count is zero.
  • Deep link with query params restores the same view after reload.

  1. Prisma: confirm TestimonialSubmission + TestimonialMediaAsset exist; add list indexes if missing.
  2. app.testimonial-submissions.jsx (or chosen filename): loader with filters + pagination + tab counts.
  3. Polaris layout: tabs, filters, IndexTable, pagination.
  4. action: bulk approve / reject / archive + moderation log + toast.
  5. Wire nav link in app.jsx.
  6. Stub link to detail route /app/testimonials/:id (Screen 6 — separate task).

  • 02-Implementation-Blueprint.mdScreen 5, §5.7–5.8, §5.10, §6 admin routes list.
  • 01-Post-Purchase-Video-Testimonial-Collector-Plan.md§6 Moderation & Publishing.

Existing plans through 08-security-compliance-and-privacy.md. This file is 09-….