Spaces:
Running
Running
Deploy Grabby Voice mobile app
Browse files- .dockerignore +13 -0
- .eslintrc.json +3 -0
- .gitignore +43 -0
- Dockerfile +41 -0
- README.md +175 -4
- next.config.js +19 -0
- package-lock.json +0 -0
- package.json +36 -0
- postcss.config.mjs +9 -0
- src/app/admin/reviews/page.tsx +149 -0
- src/app/api/public/reviews/campaign/[slug]/route.ts +20 -0
- src/app/api/public/reviews/submission/[submissionId]/route.ts +35 -0
- src/app/api/public/reviews/submit/route.ts +60 -0
- src/app/api/public/reviews/video/[submissionId]/route.ts +109 -0
- src/app/c/[slug]/LandingClient.tsx +80 -0
- src/app/c/[slug]/page.tsx +23 -0
- src/app/c/[slug]/record/GuidedRecordingClient.tsx +241 -0
- src/app/c/[slug]/record/page.tsx +23 -0
- src/app/globals.css +51 -0
- src/app/layout.tsx +46 -0
- src/app/page.tsx +77 -0
- src/app/preview/page.tsx +298 -0
- src/app/reward/page.tsx +76 -0
- src/components/Button.tsx +36 -0
- src/components/Confetti.tsx +25 -0
- src/components/MatchaCircle.tsx +24 -0
- src/components/MobileShell.tsx +23 -0
- src/components/ProgressPips.tsx +38 -0
- src/components/PromptCard.tsx +25 -0
- src/components/RecordButton.tsx +32 -0
- src/components/RecordingBadge.tsx +20 -0
- src/components/RenderShimmer.tsx +12 -0
- src/components/RewardCard.tsx +25 -0
- src/hooks/useGuidedRecording.ts +205 -0
- src/hooks/useSubmissionPolling.ts +55 -0
- src/lib/ffmpeg.ts +206 -0
- src/lib/humeoApi.ts +92 -0
- src/lib/recordingStore.ts +109 -0
- src/lib/reviews/public.ts +123 -0
- src/lib/reviews/types.ts +114 -0
- src/lib/server/reviewStore.ts +746 -0
- src/lib/utils.ts +20 -0
- src/lib/voiceover.ts +162 -0
- src/middleware.ts +56 -0
- tailwind.config.ts +81 -0
- tsconfig.json +23 -0
.dockerignore
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.next
|
| 3 |
+
.vercel
|
| 4 |
+
.cursor
|
| 5 |
+
node_modules
|
| 6 |
+
.local-review-data
|
| 7 |
+
reference
|
| 8 |
+
reference-2
|
| 9 |
+
.env
|
| 10 |
+
.env.*
|
| 11 |
+
*.log
|
| 12 |
+
tsconfig.tsbuildinfo
|
| 13 |
+
matcha-moments-prototype_2.html
|
.eslintrc.json
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"extends": "next/core-web-vitals"
|
| 3 |
+
}
|
.gitignore
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# dependencies
|
| 2 |
+
node_modules/
|
| 3 |
+
.pnp/
|
| 4 |
+
.pnp.js
|
| 5 |
+
.yarn/install-state.gz
|
| 6 |
+
|
| 7 |
+
# Next.js
|
| 8 |
+
.next/
|
| 9 |
+
out/
|
| 10 |
+
build/
|
| 11 |
+
dist/
|
| 12 |
+
|
| 13 |
+
# Misc
|
| 14 |
+
.DS_Store
|
| 15 |
+
*.pem
|
| 16 |
+
|
| 17 |
+
# Debug
|
| 18 |
+
npm-debug.log*
|
| 19 |
+
yarn-debug.log*
|
| 20 |
+
yarn-error.log*
|
| 21 |
+
.pnpm-debug.log*
|
| 22 |
+
|
| 23 |
+
# Env files
|
| 24 |
+
.env
|
| 25 |
+
.env*.local
|
| 26 |
+
|
| 27 |
+
# IDE
|
| 28 |
+
.vscode/
|
| 29 |
+
.idea/
|
| 30 |
+
|
| 31 |
+
# Vercel
|
| 32 |
+
.vercel/
|
| 33 |
+
|
| 34 |
+
# TypeScript build info
|
| 35 |
+
*.tsbuildinfo
|
| 36 |
+
next-env.d.ts
|
| 37 |
+
|
| 38 |
+
# Reference Humeo codebase — read-only library, never push to matcha-moments remote
|
| 39 |
+
reference/
|
| 40 |
+
reference-2/
|
| 41 |
+
|
| 42 |
+
# Local office prototype uploads and submission metadata
|
| 43 |
+
.local-review-data/
|
Dockerfile
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:20-slim AS deps
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
ENV NEXT_TELEMETRY_DISABLED=1
|
| 6 |
+
|
| 7 |
+
COPY package.json package-lock.json ./
|
| 8 |
+
RUN npm ci
|
| 9 |
+
|
| 10 |
+
FROM node:20-slim AS builder
|
| 11 |
+
|
| 12 |
+
WORKDIR /app
|
| 13 |
+
|
| 14 |
+
ENV NEXT_TELEMETRY_DISABLED=1
|
| 15 |
+
|
| 16 |
+
COPY --from=deps /app/node_modules ./node_modules
|
| 17 |
+
COPY . .
|
| 18 |
+
RUN npm run build
|
| 19 |
+
|
| 20 |
+
FROM node:20-slim AS runner
|
| 21 |
+
|
| 22 |
+
WORKDIR /app
|
| 23 |
+
|
| 24 |
+
ENV NODE_ENV=production
|
| 25 |
+
ENV NEXT_TELEMETRY_DISABLED=1
|
| 26 |
+
ENV HOSTNAME=0.0.0.0
|
| 27 |
+
ENV PORT=7860
|
| 28 |
+
|
| 29 |
+
RUN groupadd --system --gid 1001 nodejs \
|
| 30 |
+
&& useradd --system --uid 1001 --gid nodejs nextjs \
|
| 31 |
+
&& mkdir -p /app/.local-review-data/uploads \
|
| 32 |
+
&& chown -R nextjs:nodejs /app
|
| 33 |
+
|
| 34 |
+
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
| 35 |
+
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
| 36 |
+
|
| 37 |
+
USER nextjs
|
| 38 |
+
|
| 39 |
+
EXPOSE 7860
|
| 40 |
+
|
| 41 |
+
CMD ["node", "server.js"]
|
README.md
CHANGED
|
@@ -1,10 +1,181 @@
|
|
| 1 |
---
|
| 2 |
title: Grabby Voice
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
colorTo: blue
|
| 6 |
sdk: docker
|
|
|
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Grabby Voice
|
| 3 |
+
colorFrom: green
|
| 4 |
+
colorTo: yellow
|
|
|
|
| 5 |
sdk: docker
|
| 6 |
+
app_port: 7860
|
| 7 |
+
fullWidth: true
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# Matcha Moments — frontend
|
| 12 |
+
|
| 13 |
+
Cafe-aesthetic, mobile-first PWA that walks a customer through a 5-clip guided
|
| 14 |
+
video review and, on submit, hands them a matcha redemption code.
|
| 15 |
+
|
| 16 |
+
This repo is a **standalone Next.js app**. It calls Humeo's deployed public
|
| 17 |
+
review APIs (`https://humeo.app/api/public/reviews/*`) — no backend changes
|
| 18 |
+
needed in the Humeo monorepo for v1.
|
| 19 |
+
|
| 20 |
+
The original Humeo codebase lives at `reference/` for type / pattern lookups.
|
| 21 |
+
It's gitignored, so it never ships with this repo.
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
## Tech stack
|
| 26 |
+
|
| 27 |
+
- **Next.js 14** (App Router) + TypeScript + Tailwind CSS
|
| 28 |
+
- **`@ffmpeg/ffmpeg`** (ffmpeg.wasm) — client-side concatenation of the 5 recorded clips into one video before upload
|
| 29 |
+
- **`getUserMedia` + `MediaRecorder`** — standard browser camera APIs (no native install required)
|
| 30 |
+
- **`zod`** — shared validation schemas, mirrors the ones in Humeo's `src/lib/reviews/types.ts`
|
| 31 |
+
|
| 32 |
+
Why a PWA over Expo: the customer-facing flow has to start in a tabletop QR scan
|
| 33 |
+
in a cafe. Asking the customer to install an app kills conversion. Browser-based
|
| 34 |
+
flow opens in 2 seconds, no install, works on iOS Safari and Android Chrome.
|
| 35 |
+
|
| 36 |
+
---
|
| 37 |
+
|
| 38 |
+
## What it does (5 screens)
|
| 39 |
+
|
| 40 |
+
1. `/` — QR landing context screen (dev-only; in prod, the cafe's QR deep-links straight to `/c/[slug]`)
|
| 41 |
+
2. `/c/[slug]` — Cafe landing: brand, big "Free matcha, on the house" headline, consent copy, primary CTA
|
| 42 |
+
3. `/c/[slug]/record` — Guided 5-clip recorder (video preview → prompt card → record button → auto-advance)
|
| 43 |
+
4. `/preview` — ffmpeg.wasm stitches the clips, uploads to Humeo, polls submission status, shows the rendered preview
|
| 44 |
+
5. `/reward` — Confetti, reward code in a dark card, "show this screen to your server"
|
| 45 |
+
|
| 46 |
+
The `[slug]` route is a real Next.js dynamic segment that fetches its campaign
|
| 47 |
+
from `${NEXT_PUBLIC_HUMEO_API_URL}/api/public/reviews/campaign/[slug]`, the same
|
| 48 |
+
endpoint Humeo's existing public review flow already uses
|
| 49 |
+
(`reference/src/app/api/public/reviews/campaign/[slug]/route.ts`).
|
| 50 |
+
|
| 51 |
+
---
|
| 52 |
+
|
| 53 |
+
## Getting started
|
| 54 |
+
|
| 55 |
+
```bash
|
| 56 |
+
cp .env.example .env.local # then edit NEXT_PUBLIC_HUMEO_API_URL if needed
|
| 57 |
+
npm install
|
| 58 |
+
npm run dev # http://localhost:3000
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
### Test on your phone (recommended)
|
| 62 |
+
|
| 63 |
+
`getUserMedia` only works on `https://` (or `http://localhost`). Easiest path:
|
| 64 |
+
|
| 65 |
+
```bash
|
| 66 |
+
npx ngrok http 3000
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
Then open the `https://*.ngrok-free.app` URL on your phone, scan the QR or load
|
| 70 |
+
`/c/sageandstone` directly. iOS Safari and Android Chrome will prompt for
|
| 71 |
+
camera and mic. Allow both.
|
| 72 |
+
|
| 73 |
+
---
|
| 74 |
+
|
| 75 |
+
## How it talks to Humeo
|
| 76 |
+
|
| 77 |
+
```
|
| 78 |
+
matcha-moments PWA Humeo backend (deployed)
|
| 79 |
+
----------------- ------------------------
|
| 80 |
+
GET /c/[slug] ──────► GET /api/public/reviews/campaign/[slug]
|
| 81 |
+
◄────── { id, slug, restaurantName, rulesConfig, ... }
|
| 82 |
+
|
| 83 |
+
stitch clips locally (ffmpeg.wasm)
|
| 84 |
+
|
| 85 |
+
POST /preview submit ──────► POST /api/public/reviews/submit
|
| 86 |
+
FormData: video, slug, consentAccepted,
|
| 87 |
+
deviceKey, durationSeconds, tableId
|
| 88 |
+
◄────── { submissionId, status, decision, reward }
|
| 89 |
+
|
| 90 |
+
poll every 6s ──────► GET /api/public/reviews/submission/[id]?slug=...
|
| 91 |
+
◄────── { status, decision, feedback, reward }
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
`src/lib/humeoApi.ts` is the only place that fetches from `humeo.app`. If
|
| 95 |
+
Humeo's `review_campaigns` row doesn't yet have `mode` / `prompts` / `theme`
|
| 96 |
+
columns, `getCampaign()` augments the response with a hardcoded fallback
|
| 97 |
+
prompts list — flagged with `TODO` so we drop it once the BE migration ships.
|
| 98 |
+
|
| 99 |
+
### Fields Humeo's BE will eventually need
|
| 100 |
+
|
| 101 |
+
To remove the fallback, Humeo's `review_campaigns` schema would add:
|
| 102 |
+
- `mode text` — `'single_take' | 'guided_clips'`
|
| 103 |
+
- `prompts jsonb` — array of `{ step, title, tip, camera, maxSeconds }`
|
| 104 |
+
- `theme text` — `'default' | 'cafe-cream'`
|
| 105 |
+
|
| 106 |
+
Until then the matcha-moments app silently injects the cafe defaults.
|
| 107 |
+
|
| 108 |
+
---
|
| 109 |
+
|
| 110 |
+
## Why client-side ffmpeg.wasm?
|
| 111 |
+
|
| 112 |
+
Humeo's `/api/public/reviews/submit` accepts a single video file. We want a
|
| 113 |
+
multi-clip guided UX without forking Humeo's submit flow. Stitching the 5
|
| 114 |
+
recordings in the browser solves that with zero backend changes.
|
| 115 |
+
|
| 116 |
+
Trade-offs:
|
| 117 |
+
- 8MB WASM download, lazy-loaded only after the customer finishes recording
|
| 118 |
+
- 3-6 seconds of stitch time on a modern phone for ~50s of total video
|
| 119 |
+
- `next.config.js` sets COOP/COEP headers (required for `SharedArrayBuffer`)
|
| 120 |
+
|
| 121 |
+
If cafe staff start hearing complaints about phone heat, swap to a
|
| 122 |
+
multi-clip upload + server-side ffmpeg endpoint. Humeo's worker
|
| 123 |
+
(`reference/src/lib/server/processInterview.ts`) already uses ffmpeg, so the
|
| 124 |
+
migration is mostly a new submit endpoint.
|
| 125 |
+
|
| 126 |
+
---
|
| 127 |
+
|
| 128 |
+
## Project layout
|
| 129 |
+
|
| 130 |
+
```
|
| 131 |
+
src/
|
| 132 |
+
app/
|
| 133 |
+
layout.tsx Fonts (Fraunces / DM Sans / DM Mono), global CSS
|
| 134 |
+
page.tsx QR landing (dev-only)
|
| 135 |
+
c/[slug]/
|
| 136 |
+
page.tsx Server component — fetches campaign
|
| 137 |
+
LandingClient.tsx Cafe landing screen
|
| 138 |
+
record/
|
| 139 |
+
page.tsx Server component — fetches campaign
|
| 140 |
+
GuidedRecordingClient.tsx 5-clip guided recorder
|
| 141 |
+
preview/page.tsx Stitch + upload + preview
|
| 142 |
+
reward/page.tsx Reward code reveal
|
| 143 |
+
globals.css
|
| 144 |
+
components/ Button, MatchaCircle, ProgressPips, PromptCard,
|
| 145 |
+
RecordButton, RecordingBadge, RenderShimmer,
|
| 146 |
+
Confetti, RewardCard
|
| 147 |
+
hooks/
|
| 148 |
+
useGuidedRecording.ts getUserMedia + MediaRecorder + hard cap
|
| 149 |
+
useSubmissionPolling.ts Mirrors Humeo's PublicReviewRecordingClient polling
|
| 150 |
+
lib/
|
| 151 |
+
humeoApi.ts Typed fetch wrapper for /api/public/reviews/*
|
| 152 |
+
ffmpeg.ts ffmpeg.wasm concatClips() helper
|
| 153 |
+
recordingStore.ts In-tab clip store, useRecordingStore() hook
|
| 154 |
+
utils.ts cn(), ensureDeviceKey()
|
| 155 |
+
reviews/
|
| 156 |
+
types.ts Zod schemas + types (mirrors Humeo's)
|
| 157 |
+
public.ts Display helpers (verbatim from Humeo)
|
| 158 |
+
reference/ Humeo's repo (gitignored, read-only library)
|
| 159 |
+
matcha-moments-prototype_2.html Original wireframe (visual spec)
|
| 160 |
+
```
|
| 161 |
+
|
| 162 |
+
---
|
| 163 |
+
|
| 164 |
+
## Out of scope (v1)
|
| 165 |
+
|
| 166 |
+
- Per-clip re-record (current "re-record" wipes all 5 — flagged as a known
|
| 167 |
+
product call in `matcha-moments-prototype_2.html` dev notes)
|
| 168 |
+
- Email-me-a-copy of the final video
|
| 169 |
+
- Staff-side redemption screen
|
| 170 |
+
- Reward expiry / redemption tracking
|
| 171 |
+
- Native iOS/Android wrapping (Humeo can ship this as a Capacitor or PWA-installed shortcut later)
|
| 172 |
+
|
| 173 |
+
---
|
| 174 |
+
|
| 175 |
+
## Deploying
|
| 176 |
+
|
| 177 |
+
Vercel — connect this repo, set `NEXT_PUBLIC_HUMEO_API_URL=https://humeo.app`,
|
| 178 |
+
done. The COOP/COEP headers in `next.config.js` carry over on Vercel.
|
| 179 |
+
|
| 180 |
+
CORS: confirm `humeo.app` allow-lists this app's deploy domain
|
| 181 |
+
(e.g. `matcha.humeo.app`) on the `/api/public/reviews/*` routes.
|
next.config.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('next').NextConfig} */
|
| 2 |
+
const nextConfig = {
|
| 3 |
+
reactStrictMode: true,
|
| 4 |
+
output: 'standalone',
|
| 5 |
+
// ffmpeg.wasm needs SharedArrayBuffer, which requires cross-origin isolation headers.
|
| 6 |
+
async headers() {
|
| 7 |
+
return [
|
| 8 |
+
{
|
| 9 |
+
source: '/(.*)',
|
| 10 |
+
headers: [
|
| 11 |
+
{ key: 'Cross-Origin-Opener-Policy', value: 'same-origin' },
|
| 12 |
+
{ key: 'Cross-Origin-Embedder-Policy', value: 'require-corp' },
|
| 13 |
+
],
|
| 14 |
+
},
|
| 15 |
+
];
|
| 16 |
+
},
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
module.exports = nextConfig;
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "matcha-moments-frontend",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"description": "Matcha Moments — guided cafe video review experience. Calls Humeo's deployed public review APIs.",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "next dev",
|
| 8 |
+
"build": "next build",
|
| 9 |
+
"start": "next start",
|
| 10 |
+
"lint": "next lint",
|
| 11 |
+
"type-check": "tsc --noEmit"
|
| 12 |
+
},
|
| 13 |
+
"dependencies": {
|
| 14 |
+
"@ffmpeg/ffmpeg": "^0.12.15",
|
| 15 |
+
"@ffmpeg/util": "^0.12.2",
|
| 16 |
+
"@supabase/supabase-js": "^2.105.1",
|
| 17 |
+
"clsx": "^2.1.1",
|
| 18 |
+
"lucide-react": "^0.469.0",
|
| 19 |
+
"next": "14.2.35",
|
| 20 |
+
"react": "^18.3.1",
|
| 21 |
+
"react-dom": "^18.3.1",
|
| 22 |
+
"tailwind-merge": "^2.5.5",
|
| 23 |
+
"zod": "^3.23.8"
|
| 24 |
+
},
|
| 25 |
+
"devDependencies": {
|
| 26 |
+
"@types/node": "^20.16.11",
|
| 27 |
+
"@types/react": "^18.3.12",
|
| 28 |
+
"@types/react-dom": "^18.3.1",
|
| 29 |
+
"autoprefixer": "^10.4.20",
|
| 30 |
+
"eslint": "^8.57.1",
|
| 31 |
+
"eslint-config-next": "14.2.35",
|
| 32 |
+
"postcss": "^8.4.47",
|
| 33 |
+
"tailwindcss": "^3.4.14",
|
| 34 |
+
"typescript": "^5.6.3"
|
| 35 |
+
}
|
| 36 |
+
}
|
postcss.config.mjs
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('postcss-load-config').Config} */
|
| 2 |
+
const config = {
|
| 3 |
+
plugins: {
|
| 4 |
+
tailwindcss: {},
|
| 5 |
+
autoprefixer: {},
|
| 6 |
+
},
|
| 7 |
+
};
|
| 8 |
+
|
| 9 |
+
export default config;
|
src/app/admin/reviews/page.tsx
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Link from 'next/link';
|
| 2 |
+
import { listAdminReviewSubmissions } from '@/lib/server/reviewStore';
|
| 3 |
+
|
| 4 |
+
export const dynamic = 'force-dynamic';
|
| 5 |
+
export const runtime = 'nodejs';
|
| 6 |
+
|
| 7 |
+
function formatBytes(bytes: number) {
|
| 8 |
+
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
| 9 |
+
const units = ['B', 'KB', 'MB', 'GB'];
|
| 10 |
+
let value = bytes;
|
| 11 |
+
let unit = 0;
|
| 12 |
+
while (value >= 1024 && unit < units.length - 1) {
|
| 13 |
+
value /= 1024;
|
| 14 |
+
unit += 1;
|
| 15 |
+
}
|
| 16 |
+
return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
function formatDate(iso: string) {
|
| 20 |
+
return new Intl.DateTimeFormat('en', {
|
| 21 |
+
month: 'short',
|
| 22 |
+
day: 'numeric',
|
| 23 |
+
hour: 'numeric',
|
| 24 |
+
minute: '2-digit',
|
| 25 |
+
}).format(new Date(iso));
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function statusClass(status: string) {
|
| 29 |
+
if (status === 'reward_issued') return 'border-emerald-300 bg-emerald-50 text-emerald-900';
|
| 30 |
+
if (status === 'processing_failed') return 'border-red-300 bg-red-50 text-red-900';
|
| 31 |
+
if (status === 'fail_and_retry') return 'border-amber-300 bg-amber-50 text-amber-900';
|
| 32 |
+
return 'border-sky-300 bg-sky-50 text-sky-900';
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
function PhoneVideoPreview({ src }: { src: string }) {
|
| 36 |
+
return (
|
| 37 |
+
<div className="mx-auto w-full max-w-[170px] overflow-hidden rounded-[18px] bg-black shadow-[0_18px_45px_rgba(0,0,0,0.28)]">
|
| 38 |
+
<video
|
| 39 |
+
controls
|
| 40 |
+
playsInline
|
| 41 |
+
preload="metadata"
|
| 42 |
+
src={src}
|
| 43 |
+
className="aspect-[9/16] w-full bg-black object-cover"
|
| 44 |
+
/>
|
| 45 |
+
</div>
|
| 46 |
+
);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
export default async function AdminReviewsPage() {
|
| 50 |
+
const submissions = await listAdminReviewSubmissions();
|
| 51 |
+
|
| 52 |
+
return (
|
| 53 |
+
<main className="min-h-dvh bg-[#171512] px-5 py-6 text-cream sm:px-8">
|
| 54 |
+
<div className="mx-auto flex max-w-6xl flex-col gap-5">
|
| 55 |
+
<header className="flex flex-col gap-3 border-b border-cream/10 pb-5 sm:flex-row sm:items-end sm:justify-between">
|
| 56 |
+
<div>
|
| 57 |
+
<div className="text-eyebrow mb-2 text-sage">office prototype</div>
|
| 58 |
+
<h1 className="text-display text-[34px] text-cream">Review submissions</h1>
|
| 59 |
+
</div>
|
| 60 |
+
<Link
|
| 61 |
+
href="/c/sageandstone"
|
| 62 |
+
className="inline-flex h-11 items-center justify-center rounded-full border border-cream/20 px-5 text-sm text-cream transition hover:bg-cream/10"
|
| 63 |
+
>
|
| 64 |
+
Open recorder
|
| 65 |
+
</Link>
|
| 66 |
+
</header>
|
| 67 |
+
|
| 68 |
+
{submissions.length === 0 ? (
|
| 69 |
+
<section className="rounded-lg border border-cream/10 bg-cream/[0.04] px-5 py-12 text-center">
|
| 70 |
+
<div className="text-display text-[26px] text-cream">No submissions yet.</div>
|
| 71 |
+
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-cream/60">
|
| 72 |
+
Record a test review from the customer flow and it will appear here with the saved video.
|
| 73 |
+
</p>
|
| 74 |
+
</section>
|
| 75 |
+
) : (
|
| 76 |
+
<section className="grid gap-4 lg:grid-cols-2">
|
| 77 |
+
{submissions.map((submission) => (
|
| 78 |
+
<article
|
| 79 |
+
key={submission.submissionId}
|
| 80 |
+
className="overflow-hidden rounded-lg border border-cream/10 bg-[#211d18]"
|
| 81 |
+
>
|
| 82 |
+
<div className="grid gap-4 p-4 sm:grid-cols-[180px_1fr]">
|
| 83 |
+
<PhoneVideoPreview src={submission.previewUrl} />
|
| 84 |
+
|
| 85 |
+
<div className="flex min-w-0 flex-col gap-3">
|
| 86 |
+
<div className="flex flex-wrap items-center gap-2">
|
| 87 |
+
<span
|
| 88 |
+
className={`rounded-full border px-3 py-1 font-mono text-[10px] uppercase tracking-[0.12em] ${statusClass(
|
| 89 |
+
submission.status,
|
| 90 |
+
)}`}
|
| 91 |
+
>
|
| 92 |
+
{submission.status.replace(/_/g, ' ')}
|
| 93 |
+
</span>
|
| 94 |
+
{submission.reward?.value ? (
|
| 95 |
+
<span className="rounded-full border border-sage/40 bg-sage/15 px-3 py-1 font-mono text-[10px] uppercase tracking-[0.12em] text-sage">
|
| 96 |
+
{submission.reward.value}
|
| 97 |
+
</span>
|
| 98 |
+
) : null}
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
<div>
|
| 102 |
+
<h2 className="truncate text-lg font-semibold text-cream">
|
| 103 |
+
{submission.restaurantName}
|
| 104 |
+
</h2>
|
| 105 |
+
<p className="mt-1 break-all font-mono text-[11px] text-cream/45">
|
| 106 |
+
{submission.submissionId}
|
| 107 |
+
</p>
|
| 108 |
+
</div>
|
| 109 |
+
|
| 110 |
+
<dl className="grid grid-cols-2 gap-2 text-sm">
|
| 111 |
+
<div className="rounded-md bg-cream/[0.04] px-3 py-2">
|
| 112 |
+
<dt className="font-mono text-[10px] uppercase tracking-[0.14em] text-cream/45">
|
| 113 |
+
Created
|
| 114 |
+
</dt>
|
| 115 |
+
<dd className="mt-1 text-cream/85">{formatDate(submission.createdAtIso)}</dd>
|
| 116 |
+
</div>
|
| 117 |
+
<div className="rounded-md bg-cream/[0.04] px-3 py-2">
|
| 118 |
+
<dt className="font-mono text-[10px] uppercase tracking-[0.14em] text-cream/45">
|
| 119 |
+
Duration
|
| 120 |
+
</dt>
|
| 121 |
+
<dd className="mt-1 text-cream/85">{submission.durationSeconds}s</dd>
|
| 122 |
+
</div>
|
| 123 |
+
<div className="rounded-md bg-cream/[0.04] px-3 py-2">
|
| 124 |
+
<dt className="font-mono text-[10px] uppercase tracking-[0.14em] text-cream/45">
|
| 125 |
+
Size
|
| 126 |
+
</dt>
|
| 127 |
+
<dd className="mt-1 text-cream/85">{formatBytes(submission.videoSize)}</dd>
|
| 128 |
+
</div>
|
| 129 |
+
<div className="rounded-md bg-cream/[0.04] px-3 py-2">
|
| 130 |
+
<dt className="font-mono text-[10px] uppercase tracking-[0.14em] text-cream/45">
|
| 131 |
+
Table
|
| 132 |
+
</dt>
|
| 133 |
+
<dd className="mt-1 text-cream/85">{submission.tableId ?? '-'}</dd>
|
| 134 |
+
</div>
|
| 135 |
+
</dl>
|
| 136 |
+
|
| 137 |
+
<div className="rounded-md bg-cream/[0.04] px-3 py-2 text-sm leading-5 text-cream/70">
|
| 138 |
+
{submission.feedback}
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
</article>
|
| 143 |
+
))}
|
| 144 |
+
</section>
|
| 145 |
+
)}
|
| 146 |
+
</div>
|
| 147 |
+
</main>
|
| 148 |
+
);
|
| 149 |
+
}
|
src/app/api/public/reviews/campaign/[slug]/route.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
import { getCampaignBySlug } from '@/lib/server/reviewStore';
|
| 3 |
+
|
| 4 |
+
export const dynamic = 'force-dynamic';
|
| 5 |
+
|
| 6 |
+
type Params = { params: { slug: string } };
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* GET /api/public/reviews/campaign/[slug]
|
| 10 |
+
*
|
| 11 |
+
* Mirrors reference/src/app/api/public/reviews/campaign/[slug]/route.ts
|
| 12 |
+
* Response shape: { campaign: PublicReviewCampaign }
|
| 13 |
+
*/
|
| 14 |
+
export async function GET(_req: NextRequest, { params }: Params) {
|
| 15 |
+
const campaign = getCampaignBySlug(params.slug);
|
| 16 |
+
if (!campaign) {
|
| 17 |
+
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 });
|
| 18 |
+
}
|
| 19 |
+
return NextResponse.json({ campaign });
|
| 20 |
+
}
|
src/app/api/public/reviews/submission/[submissionId]/route.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
import {
|
| 3 |
+
getSubmission,
|
| 4 |
+
toPublicSubmitResult,
|
| 5 |
+
} from '@/lib/server/reviewStore';
|
| 6 |
+
|
| 7 |
+
export const dynamic = 'force-dynamic';
|
| 8 |
+
export const runtime = 'nodejs';
|
| 9 |
+
|
| 10 |
+
type Params = { params: { submissionId: string } };
|
| 11 |
+
|
| 12 |
+
const sanitizeText = (value: string | null, max = 200) => {
|
| 13 |
+
if (typeof value !== 'string') return '';
|
| 14 |
+
return value.trim().slice(0, max);
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
/**
|
| 18 |
+
* GET /api/public/reviews/submission/[submissionId]?slug=…
|
| 19 |
+
*
|
| 20 |
+
* Mirrors reference/src/app/api/public/reviews/submission/[submissionId]/route.ts
|
| 21 |
+
* Response shape: { submission: PublicSubmitResult }
|
| 22 |
+
*/
|
| 23 |
+
export async function GET(req: NextRequest, { params }: Params) {
|
| 24 |
+
const slug = sanitizeText(req.nextUrl.searchParams.get('slug'), 120);
|
| 25 |
+
if (!slug) {
|
| 26 |
+
return NextResponse.json({ error: 'Missing campaign slug' }, { status: 400 });
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
const result = await getSubmission(params.submissionId, slug);
|
| 30 |
+
if (!result.ok) {
|
| 31 |
+
return NextResponse.json({ error: result.error }, { status: result.status });
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
return NextResponse.json({ submission: toPublicSubmitResult(result.submission) });
|
| 35 |
+
}
|
src/app/api/public/reviews/submit/route.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
import {
|
| 3 |
+
createSubmission,
|
| 4 |
+
toPublicSubmitResult,
|
| 5 |
+
} from '@/lib/server/reviewStore';
|
| 6 |
+
|
| 7 |
+
export const dynamic = 'force-dynamic';
|
| 8 |
+
export const runtime = 'nodejs';
|
| 9 |
+
|
| 10 |
+
const MAX_VIDEO_BYTES = 150 * 1024 * 1024;
|
| 11 |
+
|
| 12 |
+
const sanitizeText = (value: FormDataEntryValue | null, max = 200) => {
|
| 13 |
+
if (typeof value !== 'string') return '';
|
| 14 |
+
return value.trim().slice(0, max);
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
export async function POST(req: NextRequest) {
|
| 18 |
+
try {
|
| 19 |
+
const form = await req.formData();
|
| 20 |
+
const slug = sanitizeText(form.get('slug'), 120);
|
| 21 |
+
const consentAccepted = sanitizeText(form.get('consentAccepted'), 10) === 'true';
|
| 22 |
+
const socialHandle = sanitizeText(form.get('socialHandle'), 120) || null;
|
| 23 |
+
const deviceKey = sanitizeText(form.get('deviceKey'), 200) || null;
|
| 24 |
+
const tableId = sanitizeText(form.get('tableId'), 80) || null;
|
| 25 |
+
const durationSecondsRaw = Number(sanitizeText(form.get('durationSeconds'), 20));
|
| 26 |
+
const durationSeconds = Number.isFinite(durationSecondsRaw)
|
| 27 |
+
? Math.max(0, durationSecondsRaw)
|
| 28 |
+
: 0;
|
| 29 |
+
const video = form.get('video');
|
| 30 |
+
|
| 31 |
+
if (!(video instanceof File) || video.size <= 0) {
|
| 32 |
+
return NextResponse.json({ error: 'A video file is required' }, { status: 400 });
|
| 33 |
+
}
|
| 34 |
+
if (video.size > MAX_VIDEO_BYTES) {
|
| 35 |
+
return NextResponse.json({ error: 'Video file is too large' }, { status: 400 });
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
const result = await createSubmission({
|
| 39 |
+
slug,
|
| 40 |
+
consentAccepted,
|
| 41 |
+
socialHandle,
|
| 42 |
+
deviceKey,
|
| 43 |
+
tableId,
|
| 44 |
+
durationSeconds,
|
| 45 |
+
video,
|
| 46 |
+
});
|
| 47 |
+
|
| 48 |
+
if (!result.ok) {
|
| 49 |
+
return NextResponse.json({ error: result.error }, { status: result.status });
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
return NextResponse.json(toPublicSubmitResult(result.submission));
|
| 53 |
+
} catch (err) {
|
| 54 |
+
console.error('[matcha-moments/submit] failed', err);
|
| 55 |
+
return NextResponse.json(
|
| 56 |
+
{ error: err instanceof Error ? err.message : 'Submission failed' },
|
| 57 |
+
{ status: 500 },
|
| 58 |
+
);
|
| 59 |
+
}
|
| 60 |
+
}
|
src/app/api/public/reviews/video/[submissionId]/route.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createReadStream } from 'fs';
|
| 2 |
+
import { Readable } from 'stream';
|
| 3 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 4 |
+
import {
|
| 5 |
+
fetchSupabaseVideoObject,
|
| 6 |
+
getSubmissionVideo,
|
| 7 |
+
} from '@/lib/server/reviewStore';
|
| 8 |
+
|
| 9 |
+
export const dynamic = 'force-dynamic';
|
| 10 |
+
export const runtime = 'nodejs';
|
| 11 |
+
|
| 12 |
+
type Params = { params: { submissionId: string } };
|
| 13 |
+
|
| 14 |
+
function parseRange(rangeHeader: string | null, size: number) {
|
| 15 |
+
if (!rangeHeader) return null;
|
| 16 |
+
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader);
|
| 17 |
+
if (!match) return null;
|
| 18 |
+
|
| 19 |
+
const start = match[1] ? Number(match[1]) : 0;
|
| 20 |
+
const end = match[2] ? Number(match[2]) : size - 1;
|
| 21 |
+
|
| 22 |
+
if (
|
| 23 |
+
!Number.isInteger(start) ||
|
| 24 |
+
!Number.isInteger(end) ||
|
| 25 |
+
start < 0 ||
|
| 26 |
+
end < start ||
|
| 27 |
+
start >= size
|
| 28 |
+
) {
|
| 29 |
+
return { invalid: true as const };
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
return {
|
| 33 |
+
invalid: false as const,
|
| 34 |
+
start,
|
| 35 |
+
end: Math.min(end, size - 1),
|
| 36 |
+
};
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
export async function GET(req: NextRequest, { params }: Params) {
|
| 40 |
+
const video = await getSubmissionVideo(params.submissionId);
|
| 41 |
+
if (!video) {
|
| 42 |
+
return NextResponse.json({ error: 'Video not found' }, { status: 404 });
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
const range = parseRange(req.headers.get('range'), video.size);
|
| 46 |
+
|
| 47 |
+
if (range?.invalid) {
|
| 48 |
+
return new NextResponse(null, {
|
| 49 |
+
status: 416,
|
| 50 |
+
headers: {
|
| 51 |
+
'Content-Range': `bytes */${video.size}`,
|
| 52 |
+
},
|
| 53 |
+
});
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
if (video.kind === 'supabase') {
|
| 57 |
+
const upstreamRange =
|
| 58 |
+
range && !range.invalid ? `bytes=${range.start}-${range.end}` : null;
|
| 59 |
+
const upstream = await fetchSupabaseVideoObject(video, upstreamRange);
|
| 60 |
+
|
| 61 |
+
if (!upstream) {
|
| 62 |
+
return NextResponse.json({ error: 'Video not found' }, { status: 404 });
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
const headers = new Headers({
|
| 66 |
+
'Accept-Ranges': 'bytes',
|
| 67 |
+
'Cache-Control': 'no-store',
|
| 68 |
+
'Content-Type': upstream.headers.get('content-type') ?? video.contentType,
|
| 69 |
+
});
|
| 70 |
+
const contentLength = upstream.headers.get('content-length');
|
| 71 |
+
const contentRange = upstream.headers.get('content-range');
|
| 72 |
+
|
| 73 |
+
if (contentLength) headers.set('Content-Length', contentLength);
|
| 74 |
+
if (contentRange) headers.set('Content-Range', contentRange);
|
| 75 |
+
|
| 76 |
+
return new NextResponse(upstream.body, {
|
| 77 |
+
status: upstream.status,
|
| 78 |
+
headers,
|
| 79 |
+
});
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
if (range && !range.invalid) {
|
| 83 |
+
const stream = Readable.toWeb(
|
| 84 |
+
createReadStream(video.filePath, { start: range.start, end: range.end }),
|
| 85 |
+
) as ReadableStream;
|
| 86 |
+
const chunkSize = range.end - range.start + 1;
|
| 87 |
+
|
| 88 |
+
return new NextResponse(stream, {
|
| 89 |
+
status: 206,
|
| 90 |
+
headers: {
|
| 91 |
+
'Accept-Ranges': 'bytes',
|
| 92 |
+
'Cache-Control': 'no-store',
|
| 93 |
+
'Content-Length': String(chunkSize),
|
| 94 |
+
'Content-Range': `bytes ${range.start}-${range.end}/${video.size}`,
|
| 95 |
+
'Content-Type': video.contentType,
|
| 96 |
+
},
|
| 97 |
+
});
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
const stream = Readable.toWeb(createReadStream(video.filePath)) as ReadableStream;
|
| 101 |
+
return new NextResponse(stream, {
|
| 102 |
+
headers: {
|
| 103 |
+
'Accept-Ranges': 'bytes',
|
| 104 |
+
'Cache-Control': 'no-store',
|
| 105 |
+
'Content-Length': String(video.size),
|
| 106 |
+
'Content-Type': video.contentType,
|
| 107 |
+
},
|
| 108 |
+
});
|
| 109 |
+
}
|
src/app/c/[slug]/LandingClient.tsx
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useRouter } from 'next/navigation';
|
| 4 |
+
import { useEffect, useState } from 'react';
|
| 5 |
+
import { Button } from '@/components/Button';
|
| 6 |
+
import { MatchaCircle } from '@/components/MatchaCircle';
|
| 7 |
+
import { MobileShell } from '@/components/MobileShell';
|
| 8 |
+
import { recordingStore } from '@/lib/recordingStore';
|
| 9 |
+
import type { PublicReviewCampaign } from '@/lib/reviews/types';
|
| 10 |
+
|
| 11 |
+
type Props = {
|
| 12 |
+
slug: string;
|
| 13 |
+
tableId: string | null;
|
| 14 |
+
campaign: PublicReviewCampaign;
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
export function LandingClient({ slug, tableId, campaign }: Props) {
|
| 18 |
+
const router = useRouter();
|
| 19 |
+
const [consentAccepted, setConsentAccepted] = useState(false);
|
| 20 |
+
|
| 21 |
+
useEffect(() => {
|
| 22 |
+
recordingStore.setMeta({ slug, tableId });
|
| 23 |
+
}, [slug, tableId]);
|
| 24 |
+
|
| 25 |
+
const handleStart = () => {
|
| 26 |
+
if (!consentAccepted) return;
|
| 27 |
+
window.sessionStorage.setItem(`matcha-moments-consent:${slug}`, 'true');
|
| 28 |
+
const tableQuery = tableId ? `?t=${encodeURIComponent(tableId)}` : '';
|
| 29 |
+
router.push(`/c/${encodeURIComponent(slug)}/record${tableQuery}`);
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
return (
|
| 33 |
+
<MobileShell>
|
| 34 |
+
<main className="flex min-h-dvh flex-col bg-cream">
|
| 35 |
+
<section className="flex flex-1 flex-col items-center justify-center px-7 pt-6 text-center">
|
| 36 |
+
<div className="mb-7 flex items-center gap-2.5 font-serif italic text-sm text-muted">
|
| 37 |
+
<span className="h-px w-6 bg-muted/50" aria-hidden />
|
| 38 |
+
{campaign.restaurantName}
|
| 39 |
+
<span className="h-px w-6 bg-muted/50" aria-hidden />
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
<MatchaCircle />
|
| 43 |
+
|
| 44 |
+
<h1 className="text-display mt-4 text-[38px] text-ink">
|
| 45 |
+
Free matcha,
|
| 46 |
+
<br />
|
| 47 |
+
<em className="text-matcha">on the house</em>
|
| 48 |
+
</h1>
|
| 49 |
+
|
| 50 |
+
<p className="mt-4 max-w-[300px] text-[14px] leading-[1.5] text-ink/65">
|
| 51 |
+
We'll guide you through a few food shots and short voice notes — then edit the reel for you.
|
| 52 |
+
</p>
|
| 53 |
+
|
| 54 |
+
<label className="mt-6 flex max-w-[320px] cursor-pointer items-start gap-3 rounded-2xl border border-ink/10 bg-paper px-4 py-3 text-left">
|
| 55 |
+
<input
|
| 56 |
+
type="checkbox"
|
| 57 |
+
checked={consentAccepted}
|
| 58 |
+
onChange={(event) => setConsentAccepted(event.target.checked)}
|
| 59 |
+
className="mt-0.5 h-4 w-4 accent-matcha"
|
| 60 |
+
/>
|
| 61 |
+
<span className="text-[11px] leading-[1.5] text-ink/65">
|
| 62 |
+
I allow {campaign.restaurantName} to review, edit, and use my video and voice on social media.
|
| 63 |
+
</span>
|
| 64 |
+
</label>
|
| 65 |
+
</section>
|
| 66 |
+
|
| 67 |
+
<footer className="px-7 pb-6 pt-2">
|
| 68 |
+
<Button onClick={handleStart} disabled={!consentAccepted}>
|
| 69 |
+
Get my matcha →
|
| 70 |
+
</Button>
|
| 71 |
+
{tableId ? (
|
| 72 |
+
<p className="mt-3 text-center font-mono text-[10px] uppercase tracking-[0.15em] text-muted">
|
| 73 |
+
Table {tableId}
|
| 74 |
+
</p>
|
| 75 |
+
) : null}
|
| 76 |
+
</footer>
|
| 77 |
+
</main>
|
| 78 |
+
</MobileShell>
|
| 79 |
+
);
|
| 80 |
+
}
|
src/app/c/[slug]/page.tsx
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { notFound } from 'next/navigation';
|
| 2 |
+
import { LandingClient } from './LandingClient';
|
| 3 |
+
import { getCampaignBySlug } from '@/lib/server/reviewStore';
|
| 4 |
+
|
| 5 |
+
type PageProps = {
|
| 6 |
+
params: { slug: string };
|
| 7 |
+
searchParams: { t?: string };
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
export default function CafeLandingPage({ params, searchParams }: PageProps) {
|
| 11 |
+
const campaign = getCampaignBySlug(params.slug);
|
| 12 |
+
if (!campaign) {
|
| 13 |
+
notFound();
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
return (
|
| 17 |
+
<LandingClient
|
| 18 |
+
slug={params.slug}
|
| 19 |
+
tableId={searchParams.t ?? null}
|
| 20 |
+
campaign={campaign}
|
| 21 |
+
/>
|
| 22 |
+
);
|
| 23 |
+
}
|
src/app/c/[slug]/record/GuidedRecordingClient.tsx
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { ChevronLeft, Mic, SkipForward } from 'lucide-react';
|
| 4 |
+
import { useRouter } from 'next/navigation';
|
| 5 |
+
import { useCallback, useEffect, useState } from 'react';
|
| 6 |
+
import { ProgressPips } from '@/components/ProgressPips';
|
| 7 |
+
import { PromptCard } from '@/components/PromptCard';
|
| 8 |
+
import { RecordButton } from '@/components/RecordButton';
|
| 9 |
+
import { RecordingBadge } from '@/components/RecordingBadge';
|
| 10 |
+
import { MobileShell } from '@/components/MobileShell';
|
| 11 |
+
import { useGuidedRecording } from '@/hooks/useGuidedRecording';
|
| 12 |
+
import { recordingStore, useRecordingStore } from '@/lib/recordingStore';
|
| 13 |
+
import type { PublicReviewCampaign } from '@/lib/reviews/types';
|
| 14 |
+
|
| 15 |
+
type Props = {
|
| 16 |
+
slug: string;
|
| 17 |
+
tableId: string | null;
|
| 18 |
+
campaign: PublicReviewCampaign;
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
type ClipReadyInput = {
|
| 22 |
+
blob: Blob;
|
| 23 |
+
durationSeconds: number;
|
| 24 |
+
ext: 'webm' | 'mp4' | 'mov';
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
export function GuidedRecordingClient({ slug, tableId, campaign }: Props) {
|
| 28 |
+
const router = useRouter();
|
| 29 |
+
const store = useRecordingStore();
|
| 30 |
+
const totalSteps = campaign.prompts.length;
|
| 31 |
+
const [stepNum, setStepNum] = useState(1);
|
| 32 |
+
const [hasConsent, setHasConsent] = useState(false);
|
| 33 |
+
|
| 34 |
+
useEffect(() => {
|
| 35 |
+
recordingStore.setMeta({ slug, tableId });
|
| 36 |
+
if (window.sessionStorage.getItem(`matcha-moments-consent:${slug}`) === 'true') {
|
| 37 |
+
setHasConsent(true);
|
| 38 |
+
return;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
const tableQuery = tableId ? `?t=${encodeURIComponent(tableId)}` : '';
|
| 42 |
+
router.replace(`/c/${encodeURIComponent(slug)}${tableQuery}`);
|
| 43 |
+
}, [router, slug, tableId]);
|
| 44 |
+
|
| 45 |
+
const currentPrompt = campaign.prompts[stepNum - 1];
|
| 46 |
+
const goNext = useCallback(() => {
|
| 47 |
+
if (stepNum < totalSteps) {
|
| 48 |
+
window.setTimeout(() => setStepNum((s) => s + 1), 250);
|
| 49 |
+
} else {
|
| 50 |
+
window.setTimeout(() => router.push('/preview'), 250);
|
| 51 |
+
}
|
| 52 |
+
}, [router, stepNum, totalSteps]);
|
| 53 |
+
|
| 54 |
+
const handleClipReady = useCallback(
|
| 55 |
+
(clip: ClipReadyInput) => {
|
| 56 |
+
recordingStore.setClip({
|
| 57 |
+
step: stepNum,
|
| 58 |
+
mediaType: currentPrompt?.mediaType ?? 'video',
|
| 59 |
+
blob: clip.blob,
|
| 60 |
+
durationSeconds: clip.durationSeconds,
|
| 61 |
+
ext: clip.ext,
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
goNext();
|
| 65 |
+
},
|
| 66 |
+
[currentPrompt?.mediaType, goNext, stepNum],
|
| 67 |
+
);
|
| 68 |
+
|
| 69 |
+
const handleSkip = useCallback(() => {
|
| 70 |
+
if (!currentPrompt?.optional) return;
|
| 71 |
+
recordingStore.skipStep(stepNum);
|
| 72 |
+
goNext();
|
| 73 |
+
}, [currentPrompt?.optional, goNext, stepNum]);
|
| 74 |
+
|
| 75 |
+
if (!hasConsent || !currentPrompt) {
|
| 76 |
+
return null;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
return (
|
| 80 |
+
<RecorderInner
|
| 81 |
+
key={`${currentPrompt.mediaType ?? 'video'}-${currentPrompt.camera}`}
|
| 82 |
+
stepNum={stepNum}
|
| 83 |
+
totalSteps={totalSteps}
|
| 84 |
+
doneCount={
|
| 85 |
+
Object.keys(store.clipsByStep).filter((k) => Number(k) < stepNum).length +
|
| 86 |
+
Object.keys(store.skippedSteps).filter((k) => Number(k) < stepNum).length
|
| 87 |
+
}
|
| 88 |
+
prompt={currentPrompt}
|
| 89 |
+
onClipReady={handleClipReady}
|
| 90 |
+
onSkip={currentPrompt.optional ? handleSkip : undefined}
|
| 91 |
+
onBack={() => {
|
| 92 |
+
if (stepNum > 1) setStepNum((s) => s - 1);
|
| 93 |
+
else router.back();
|
| 94 |
+
}}
|
| 95 |
+
/>
|
| 96 |
+
);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
type InnerProps = {
|
| 100 |
+
stepNum: number;
|
| 101 |
+
totalSteps: number;
|
| 102 |
+
doneCount: number;
|
| 103 |
+
prompt: PublicReviewCampaign['prompts'][number];
|
| 104 |
+
onClipReady: (clip: ClipReadyInput) => void;
|
| 105 |
+
onSkip?: () => void;
|
| 106 |
+
onBack: () => void;
|
| 107 |
+
};
|
| 108 |
+
|
| 109 |
+
function RecorderInner({
|
| 110 |
+
stepNum,
|
| 111 |
+
totalSteps,
|
| 112 |
+
doneCount,
|
| 113 |
+
prompt,
|
| 114 |
+
onClipReady,
|
| 115 |
+
onSkip,
|
| 116 |
+
onBack,
|
| 117 |
+
}: InnerProps) {
|
| 118 |
+
const mediaType = prompt.mediaType ?? 'video';
|
| 119 |
+
const isAudio = mediaType === 'audio';
|
| 120 |
+
const {
|
| 121 |
+
state,
|
| 122 |
+
elapsedMs,
|
| 123 |
+
liveProgress,
|
| 124 |
+
error,
|
| 125 |
+
videoRef,
|
| 126 |
+
startRecording,
|
| 127 |
+
stopRecording,
|
| 128 |
+
requestPermissionAndPreview,
|
| 129 |
+
} = useGuidedRecording({ prompt, onClipReady });
|
| 130 |
+
|
| 131 |
+
const handleRecordPress = () => {
|
| 132 |
+
if (state === 'recording') {
|
| 133 |
+
stopRecording();
|
| 134 |
+
} else if (state === 'ready') {
|
| 135 |
+
startRecording();
|
| 136 |
+
} else if (state === 'idle') {
|
| 137 |
+
void requestPermissionAndPreview();
|
| 138 |
+
}
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
+
return (
|
| 142 |
+
<MobileShell tone="dark">
|
| 143 |
+
<main className="relative flex min-h-dvh flex-col bg-[#0e0d0b] text-white">
|
| 144 |
+
{isAudio ? (
|
| 145 |
+
<div
|
| 146 |
+
className="absolute inset-0 flex items-center justify-center bg-[radial-gradient(circle_at_50%_35%,rgba(184,201,168,0.28),transparent_34%),linear-gradient(160deg,#15130f,#272119_55%,#0e0d0b)]"
|
| 147 |
+
aria-hidden
|
| 148 |
+
>
|
| 149 |
+
<div className="flex h-40 w-40 items-center justify-center rounded-full border border-white/15 bg-white/10 shadow-[0_24px_80px_rgba(0,0,0,0.35)] backdrop-blur-md">
|
| 150 |
+
<Mic className="h-16 w-16 text-sage" />
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
) : (
|
| 154 |
+
<video
|
| 155 |
+
ref={videoRef}
|
| 156 |
+
autoPlay
|
| 157 |
+
playsInline
|
| 158 |
+
muted
|
| 159 |
+
className="absolute inset-0 h-full w-full object-cover"
|
| 160 |
+
/>
|
| 161 |
+
)}
|
| 162 |
+
|
| 163 |
+
<div className="absolute inset-0 bg-black/15" aria-hidden />
|
| 164 |
+
|
| 165 |
+
<div className="relative flex flex-1 flex-col">
|
| 166 |
+
<div className="pt-[max(env(safe-area-inset-top),16px)]">
|
| 167 |
+
<ProgressPips
|
| 168 |
+
total={totalSteps}
|
| 169 |
+
current={stepNum}
|
| 170 |
+
doneCount={doneCount}
|
| 171 |
+
liveProgress={state === 'recording' ? liveProgress : 0}
|
| 172 |
+
/>
|
| 173 |
+
</div>
|
| 174 |
+
|
| 175 |
+
<div className="px-4 pt-8">
|
| 176 |
+
<PromptCard
|
| 177 |
+
step={stepNum}
|
| 178 |
+
totalSteps={totalSteps}
|
| 179 |
+
title={prompt.title}
|
| 180 |
+
tip={prompt.tip}
|
| 181 |
+
label={isAudio ? 'Voice' : 'Shot'}
|
| 182 |
+
optional={prompt.optional}
|
| 183 |
+
/>
|
| 184 |
+
</div>
|
| 185 |
+
|
| 186 |
+
<div className="flex-1" />
|
| 187 |
+
|
| 188 |
+
<div className="flex flex-col items-center gap-4 pb-[max(env(safe-area-inset-bottom),24px)]">
|
| 189 |
+
<RecordingBadge elapsedMs={elapsedMs} visible={state === 'recording'} />
|
| 190 |
+
|
| 191 |
+
{error ? (
|
| 192 |
+
<div className="mx-4 rounded-2xl bg-red-500/90 px-4 py-2 text-sm">
|
| 193 |
+
{error}
|
| 194 |
+
</div>
|
| 195 |
+
) : null}
|
| 196 |
+
|
| 197 |
+
<div className="flex items-center justify-center gap-10">
|
| 198 |
+
<button
|
| 199 |
+
type="button"
|
| 200 |
+
onClick={onBack}
|
| 201 |
+
className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10 backdrop-blur"
|
| 202 |
+
aria-label="Previous"
|
| 203 |
+
>
|
| 204 |
+
<ChevronLeft className="h-5 w-5" />
|
| 205 |
+
</button>
|
| 206 |
+
|
| 207 |
+
<RecordButton
|
| 208 |
+
recording={state === 'recording'}
|
| 209 |
+
disabled={state === 'requesting_permission' || state === 'finalizing'}
|
| 210 |
+
onClick={handleRecordPress}
|
| 211 |
+
/>
|
| 212 |
+
|
| 213 |
+
{onSkip ? (
|
| 214 |
+
<button
|
| 215 |
+
type="button"
|
| 216 |
+
onClick={onSkip}
|
| 217 |
+
disabled={state === 'recording' || state === 'requesting_permission' || state === 'finalizing'}
|
| 218 |
+
className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10 backdrop-blur disabled:opacity-40"
|
| 219 |
+
aria-label="Skip optional reaction"
|
| 220 |
+
>
|
| 221 |
+
<SkipForward className="h-5 w-5" />
|
| 222 |
+
</button>
|
| 223 |
+
) : null}
|
| 224 |
+
</div>
|
| 225 |
+
|
| 226 |
+
{onSkip ? (
|
| 227 |
+
<button
|
| 228 |
+
type="button"
|
| 229 |
+
onClick={onSkip}
|
| 230 |
+
disabled={state === 'recording' || state === 'requesting_permission' || state === 'finalizing'}
|
| 231 |
+
className="rounded-full bg-white/10 px-4 py-2 text-xs font-medium text-white/80 backdrop-blur disabled:opacity-40"
|
| 232 |
+
>
|
| 233 |
+
Skip this shot
|
| 234 |
+
</button>
|
| 235 |
+
) : null}
|
| 236 |
+
</div>
|
| 237 |
+
</div>
|
| 238 |
+
</main>
|
| 239 |
+
</MobileShell>
|
| 240 |
+
);
|
| 241 |
+
}
|
src/app/c/[slug]/record/page.tsx
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { notFound } from 'next/navigation';
|
| 2 |
+
import { GuidedRecordingClient } from './GuidedRecordingClient';
|
| 3 |
+
import { getCampaignBySlug } from '@/lib/server/reviewStore';
|
| 4 |
+
|
| 5 |
+
type PageProps = {
|
| 6 |
+
params: { slug: string };
|
| 7 |
+
searchParams: { t?: string };
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
export default function RecordPage({ params, searchParams }: PageProps) {
|
| 11 |
+
const campaign = getCampaignBySlug(params.slug);
|
| 12 |
+
if (!campaign) {
|
| 13 |
+
notFound();
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
return (
|
| 17 |
+
<GuidedRecordingClient
|
| 18 |
+
slug={params.slug}
|
| 19 |
+
tableId={searchParams.t ?? null}
|
| 20 |
+
campaign={campaign}
|
| 21 |
+
/>
|
| 22 |
+
);
|
| 23 |
+
}
|
src/app/globals.css
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
:root {
|
| 6 |
+
--bg: #1c1814;
|
| 7 |
+
--line: rgba(42, 37, 32, 0.12);
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
html,
|
| 11 |
+
body {
|
| 12 |
+
background: #F5EFE2;
|
| 13 |
+
color: #2A2520;
|
| 14 |
+
-webkit-font-smoothing: antialiased;
|
| 15 |
+
-moz-osx-font-smoothing: grayscale;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
* {
|
| 19 |
+
box-sizing: border-box;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
@layer utilities {
|
| 23 |
+
.scrollbar-hide::-webkit-scrollbar {
|
| 24 |
+
display: none;
|
| 25 |
+
}
|
| 26 |
+
.scrollbar-hide {
|
| 27 |
+
-ms-overflow-style: none;
|
| 28 |
+
scrollbar-width: none;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
.text-display {
|
| 32 |
+
font-family: var(--font-fraunces), 'Fraunces', Georgia, serif;
|
| 33 |
+
font-weight: 300;
|
| 34 |
+
letter-spacing: -0.025em;
|
| 35 |
+
line-height: 1.05;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
.text-display em,
|
| 39 |
+
.text-display .em {
|
| 40 |
+
font-style: italic;
|
| 41 |
+
font-family: var(--font-fraunces), 'Fraunces', Georgia, serif;
|
| 42 |
+
font-weight: 300;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
.text-eyebrow {
|
| 46 |
+
font-family: var(--font-dm-mono), 'DM Mono', ui-monospace, monospace;
|
| 47 |
+
font-size: 10px;
|
| 48 |
+
letter-spacing: 0.2em;
|
| 49 |
+
text-transform: uppercase;
|
| 50 |
+
}
|
| 51 |
+
}
|
src/app/layout.tsx
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata, Viewport } from 'next';
|
| 2 |
+
import { Fraunces, DM_Sans, DM_Mono } from 'next/font/google';
|
| 3 |
+
import './globals.css';
|
| 4 |
+
|
| 5 |
+
const fraunces = Fraunces({
|
| 6 |
+
subsets: ['latin'],
|
| 7 |
+
weight: ['300', '400'],
|
| 8 |
+
style: ['normal', 'italic'],
|
| 9 |
+
variable: '--font-fraunces',
|
| 10 |
+
display: 'swap',
|
| 11 |
+
});
|
| 12 |
+
|
| 13 |
+
const dmSans = DM_Sans({
|
| 14 |
+
subsets: ['latin'],
|
| 15 |
+
weight: ['400', '500', '600', '700'],
|
| 16 |
+
variable: '--font-dm-sans',
|
| 17 |
+
display: 'swap',
|
| 18 |
+
});
|
| 19 |
+
|
| 20 |
+
const dmMono = DM_Mono({
|
| 21 |
+
subsets: ['latin'],
|
| 22 |
+
weight: ['400', '500'],
|
| 23 |
+
variable: '--font-dm-mono',
|
| 24 |
+
display: 'swap',
|
| 25 |
+
});
|
| 26 |
+
|
| 27 |
+
export const metadata: Metadata = {
|
| 28 |
+
title: 'Matcha Moments',
|
| 29 |
+
description: 'Free matcha for an honest review.',
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
export const viewport: Viewport = {
|
| 33 |
+
width: 'device-width',
|
| 34 |
+
initialScale: 1,
|
| 35 |
+
maximumScale: 1,
|
| 36 |
+
userScalable: false,
|
| 37 |
+
themeColor: '#F5EFE2',
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
| 41 |
+
return (
|
| 42 |
+
<html lang="en" className={`${fraunces.variable} ${dmSans.variable} ${dmMono.variable}`}>
|
| 43 |
+
<body className="font-sans bg-cream text-ink min-h-dvh">{children}</body>
|
| 44 |
+
</html>
|
| 45 |
+
);
|
| 46 |
+
}
|
src/app/page.tsx
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import Link from 'next/link';
|
| 4 |
+
import { useEffect, useState } from 'react';
|
| 5 |
+
import { Button } from '@/components/Button';
|
| 6 |
+
import { MobileShell } from '@/components/MobileShell';
|
| 7 |
+
|
| 8 |
+
const DEFAULT_SLUG = 'sageandstone';
|
| 9 |
+
|
| 10 |
+
export default function HomePage() {
|
| 11 |
+
const [scanY, setScanY] = useState(0);
|
| 12 |
+
|
| 13 |
+
useEffect(() => {
|
| 14 |
+
let raf = 0;
|
| 15 |
+
const start = performance.now();
|
| 16 |
+
const tick = (now: number) => {
|
| 17 |
+
const t = ((now - start) / 2000) % 1;
|
| 18 |
+
// up-down ping-pong
|
| 19 |
+
setScanY(Math.abs(Math.sin(t * Math.PI)));
|
| 20 |
+
raf = requestAnimationFrame(tick);
|
| 21 |
+
};
|
| 22 |
+
raf = requestAnimationFrame(tick);
|
| 23 |
+
return () => cancelAnimationFrame(raf);
|
| 24 |
+
}, []);
|
| 25 |
+
|
| 26 |
+
return (
|
| 27 |
+
<MobileShell>
|
| 28 |
+
<main className="flex min-h-dvh flex-col bg-cream">
|
| 29 |
+
<section className="flex flex-1 flex-col items-center justify-center px-7 text-center">
|
| 30 |
+
<div className="relative mb-5 h-40 w-40 overflow-hidden rounded-2xl border border-ink/10 bg-white p-3.5">
|
| 31 |
+
<div
|
| 32 |
+
className="h-full w-full rounded-md"
|
| 33 |
+
style={{
|
| 34 |
+
backgroundImage:
|
| 35 |
+
'linear-gradient(90deg, #2A2520 25%, transparent 25% 50%, #2A2520 50% 75%, transparent 75%), linear-gradient(#2A2520 25%, transparent 25% 50%, #2A2520 50% 75%, transparent 75%)',
|
| 36 |
+
backgroundSize: '8px 8px',
|
| 37 |
+
backgroundBlendMode: 'multiply',
|
| 38 |
+
}}
|
| 39 |
+
/>
|
| 40 |
+
<div className="absolute left-3.5 top-3.5 h-[30px] w-[30px] border-[6px] border-ink bg-white" />
|
| 41 |
+
<div className="absolute right-3.5 top-3.5 h-[30px] w-[30px] border-[6px] border-ink bg-white" />
|
| 42 |
+
<div className="absolute bottom-3.5 left-3.5 h-[30px] w-[30px] border-[6px] border-ink bg-white" />
|
| 43 |
+
<div
|
| 44 |
+
className="pointer-events-none absolute left-3.5 right-3.5 h-[2px]"
|
| 45 |
+
style={{
|
| 46 |
+
top: `${10 + scanY * 75}%`,
|
| 47 |
+
background:
|
| 48 |
+
'linear-gradient(90deg, transparent, #4A6B3D, transparent)',
|
| 49 |
+
}}
|
| 50 |
+
/>
|
| 51 |
+
</div>
|
| 52 |
+
|
| 53 |
+
<div className="text-eyebrow mb-3 text-matcha">Step 00 · context</div>
|
| 54 |
+
<h2 className="text-display max-w-[280px] text-[26px] text-ink">
|
| 55 |
+
She scans the QR <em className="text-matcha">on the table</em>
|
| 56 |
+
</h2>
|
| 57 |
+
<p className="mt-3 max-w-[300px] text-[14px] leading-[1.5] text-ink/65">
|
| 58 |
+
Tabletop card reads:{' '}
|
| 59 |
+
<em className="font-serif italic text-matcha">
|
| 60 |
+
“Free matcha for an honest review.”
|
| 61 |
+
</em>{' '}
|
| 62 |
+
She points her camera. Browser opens.
|
| 63 |
+
</p>
|
| 64 |
+
</section>
|
| 65 |
+
|
| 66 |
+
<footer className="px-7 pb-6 pt-4">
|
| 67 |
+
<Link href={`/c/${DEFAULT_SLUG}`} prefetch>
|
| 68 |
+
<Button>Tap to load the page →</Button>
|
| 69 |
+
</Link>
|
| 70 |
+
<p className="mt-3 text-center font-mono text-[10px] text-muted">
|
| 71 |
+
Dev note: in production, the QR camera scan deep-links to /c/[slug].
|
| 72 |
+
</p>
|
| 73 |
+
</footer>
|
| 74 |
+
</main>
|
| 75 |
+
</MobileShell>
|
| 76 |
+
);
|
| 77 |
+
}
|
src/app/preview/page.tsx
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { CheckCircle2, Loader2, RotateCcw } from 'lucide-react';
|
| 4 |
+
import { useRouter } from 'next/navigation';
|
| 5 |
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
| 6 |
+
import { Button } from '@/components/Button';
|
| 7 |
+
import { MobileShell } from '@/components/MobileShell';
|
| 8 |
+
import { RenderShimmer } from '@/components/RenderShimmer';
|
| 9 |
+
import { useSubmissionPolling } from '@/hooks/useSubmissionPolling';
|
| 10 |
+
import { concatClips } from '@/lib/ffmpeg';
|
| 11 |
+
import { submit } from '@/lib/humeoApi';
|
| 12 |
+
import { recordingStore, useRecordingStore } from '@/lib/recordingStore';
|
| 13 |
+
import type { PublicSubmitResult } from '@/lib/reviews/types';
|
| 14 |
+
import { ensureDeviceKey } from '@/lib/utils';
|
| 15 |
+
import { addVoiceoverToVideo } from '@/lib/voiceover';
|
| 16 |
+
|
| 17 |
+
type Phase =
|
| 18 |
+
| { kind: 'idle' }
|
| 19 |
+
| { kind: 'concatenating'; progress: number }
|
| 20 |
+
| { kind: 'mixing'; progress: number }
|
| 21 |
+
| { kind: 'uploading' }
|
| 22 |
+
| { kind: 'polling'; result: PublicSubmitResult }
|
| 23 |
+
| { kind: 'ready'; result: PublicSubmitResult }
|
| 24 |
+
| { kind: 'error'; message: string };
|
| 25 |
+
|
| 26 |
+
function pipelineErrorMessage(err: unknown) {
|
| 27 |
+
if (err instanceof Error && err.message) return err.message;
|
| 28 |
+
if (typeof err === 'string' && err.trim()) return err;
|
| 29 |
+
try {
|
| 30 |
+
return JSON.stringify(err);
|
| 31 |
+
} catch {
|
| 32 |
+
return 'Submit failed';
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
export default function PreviewPage() {
|
| 37 |
+
const router = useRouter();
|
| 38 |
+
const store = useRecordingStore();
|
| 39 |
+
const [phase, setPhase] = useState<Phase>({ kind: 'idle' });
|
| 40 |
+
const triggeredRef = useRef(false);
|
| 41 |
+
|
| 42 |
+
const submissionId =
|
| 43 |
+
phase.kind === 'polling' || phase.kind === 'ready' ? phase.result.submissionId : null;
|
| 44 |
+
|
| 45 |
+
const { result: pollResult } = useSubmissionPolling(submissionId, store.slug, 1500);
|
| 46 |
+
|
| 47 |
+
useEffect(() => {
|
| 48 |
+
if (!pollResult) return;
|
| 49 |
+
if (pollResult.status === 'reward_issued' || pollResult.decision === 'pass') {
|
| 50 |
+
setPhase({ kind: 'ready', result: pollResult });
|
| 51 |
+
} else if (
|
| 52 |
+
pollResult.status === 'processing_failed' ||
|
| 53 |
+
pollResult.decision === 'fail_and_retry'
|
| 54 |
+
) {
|
| 55 |
+
setPhase({
|
| 56 |
+
kind: 'error',
|
| 57 |
+
message: pollResult.feedback || 'Processing failed. Please try again.',
|
| 58 |
+
});
|
| 59 |
+
} else {
|
| 60 |
+
setPhase({ kind: 'polling', result: pollResult });
|
| 61 |
+
}
|
| 62 |
+
}, [pollResult]);
|
| 63 |
+
|
| 64 |
+
const runPipeline = useCallback(async () => {
|
| 65 |
+
const videoClips = store.orderedClips.filter((clip) => clip.mediaType !== 'audio');
|
| 66 |
+
const audioClips = store.orderedClips.filter((clip) => clip.mediaType === 'audio');
|
| 67 |
+
|
| 68 |
+
if (videoClips.length === 0) {
|
| 69 |
+
router.replace('/');
|
| 70 |
+
return;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
try {
|
| 74 |
+
setPhase({ kind: 'concatenating', progress: 0 });
|
| 75 |
+
|
| 76 |
+
const concat = await concatClips(
|
| 77 |
+
videoClips.map((clip) => ({ blob: clip.blob, ext: clip.ext })),
|
| 78 |
+
(progress) => setPhase({ kind: 'concatenating', progress }),
|
| 79 |
+
);
|
| 80 |
+
|
| 81 |
+
setPhase({ kind: 'mixing', progress: 0 });
|
| 82 |
+
const finalVideo = await addVoiceoverToVideo(
|
| 83 |
+
{ blob: concat.blob, filename: concat.filename },
|
| 84 |
+
audioClips.map((clip) => ({ blob: clip.blob, ext: clip.ext })),
|
| 85 |
+
(progress) => setPhase({ kind: 'mixing', progress }),
|
| 86 |
+
);
|
| 87 |
+
|
| 88 |
+
setPhase({ kind: 'uploading' });
|
| 89 |
+
|
| 90 |
+
const result = await submit({
|
| 91 |
+
slug: store.slug ?? 'sageandstone',
|
| 92 |
+
consentAccepted: true,
|
| 93 |
+
socialHandle: store.socialHandle || undefined,
|
| 94 |
+
deviceKey: ensureDeviceKey(),
|
| 95 |
+
tableId: store.tableId,
|
| 96 |
+
durationSeconds: finalVideo.durationSeconds,
|
| 97 |
+
video: finalVideo.blob,
|
| 98 |
+
videoFileName: finalVideo.filename,
|
| 99 |
+
});
|
| 100 |
+
|
| 101 |
+
setPhase({ kind: 'polling', result });
|
| 102 |
+
} catch (err) {
|
| 103 |
+
console.error('[matcha-moments/preview] pipeline failed', err);
|
| 104 |
+
setPhase({
|
| 105 |
+
kind: 'error',
|
| 106 |
+
message: pipelineErrorMessage(err) || 'Submit failed',
|
| 107 |
+
});
|
| 108 |
+
}
|
| 109 |
+
}, [router, store.orderedClips, store.slug, store.socialHandle, store.tableId]);
|
| 110 |
+
|
| 111 |
+
useEffect(() => {
|
| 112 |
+
if (triggeredRef.current) return;
|
| 113 |
+
triggeredRef.current = true;
|
| 114 |
+
void runPipeline();
|
| 115 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 116 |
+
}, []);
|
| 117 |
+
|
| 118 |
+
const handleRetry = () => {
|
| 119 |
+
void runPipeline();
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
+
const handleSubmitFinal = () => {
|
| 123 |
+
if (phase.kind === 'ready') {
|
| 124 |
+
router.push(`/reward?code=${encodeURIComponent(phase.result.reward?.value ?? '')}`);
|
| 125 |
+
}
|
| 126 |
+
};
|
| 127 |
+
|
| 128 |
+
const handleRerecord = () => {
|
| 129 |
+
recordingStore.reset();
|
| 130 |
+
router.replace(`/c/${encodeURIComponent(store.slug ?? 'sageandstone')}/record`);
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
+
const isReady = phase.kind === 'ready';
|
| 134 |
+
|
| 135 |
+
const renderSteps = useMemo(
|
| 136 |
+
() => [
|
| 137 |
+
{
|
| 138 |
+
label: 'Stitching recorded clips',
|
| 139 |
+
done: phase.kind !== 'concatenating',
|
| 140 |
+
current: phase.kind === 'concatenating',
|
| 141 |
+
},
|
| 142 |
+
{
|
| 143 |
+
label: 'Adding voiceover',
|
| 144 |
+
done: phase.kind === 'uploading' || phase.kind === 'polling' || phase.kind === 'ready',
|
| 145 |
+
current: phase.kind === 'mixing',
|
| 146 |
+
},
|
| 147 |
+
{
|
| 148 |
+
label: 'Saving the video',
|
| 149 |
+
done: phase.kind === 'polling' || phase.kind === 'ready',
|
| 150 |
+
current: phase.kind === 'uploading',
|
| 151 |
+
},
|
| 152 |
+
{
|
| 153 |
+
label: 'Checking the submission',
|
| 154 |
+
done: isReady,
|
| 155 |
+
current: phase.kind === 'polling',
|
| 156 |
+
},
|
| 157 |
+
{
|
| 158 |
+
label: 'Matcha code',
|
| 159 |
+
done: isReady,
|
| 160 |
+
current: false,
|
| 161 |
+
},
|
| 162 |
+
],
|
| 163 |
+
[isReady, phase.kind],
|
| 164 |
+
);
|
| 165 |
+
|
| 166 |
+
return (
|
| 167 |
+
<MobileShell>
|
| 168 |
+
<main className="flex min-h-dvh flex-col bg-cream">
|
| 169 |
+
<section className="flex flex-1 flex-col px-7 pt-8">
|
| 170 |
+
<div className="mb-6">
|
| 171 |
+
{isReady ? (
|
| 172 |
+
<>
|
| 173 |
+
<div className="text-eyebrow mb-3 text-matcha">approved</div>
|
| 174 |
+
<h1 className="text-display text-[32px] text-ink">
|
| 175 |
+
Your review is <em className="text-matcha">saved.</em>
|
| 176 |
+
</h1>
|
| 177 |
+
</>
|
| 178 |
+
) : (
|
| 179 |
+
<>
|
| 180 |
+
<div className="text-eyebrow mb-3 text-matcha">processing</div>
|
| 181 |
+
<h1 className="text-display text-[32px] text-ink">
|
| 182 |
+
Saving your
|
| 183 |
+
<br />
|
| 184 |
+
<em className="text-matcha">matcha moment.</em>
|
| 185 |
+
</h1>
|
| 186 |
+
</>
|
| 187 |
+
)}
|
| 188 |
+
</div>
|
| 189 |
+
|
| 190 |
+
<div className="rounded-[22px] border border-ink/10 bg-paper p-5 shadow-[0_18px_50px_rgba(42,37,32,0.12)]">
|
| 191 |
+
<div className="relative mb-5 flex aspect-[9/12] max-h-[420px] items-center justify-center overflow-hidden rounded-[18px] bg-[#15120f]">
|
| 192 |
+
{!isReady ? (
|
| 193 |
+
<div className="flex flex-col items-center px-5 text-center">
|
| 194 |
+
<RenderShimmer />
|
| 195 |
+
<p className="relative z-10 mt-4 max-w-[240px] text-sm leading-6 text-cream/75">
|
| 196 |
+
The office prototype is saving your stitched recording and preparing the reward.
|
| 197 |
+
</p>
|
| 198 |
+
</div>
|
| 199 |
+
) : (
|
| 200 |
+
<div className="flex flex-col items-center px-5 text-center">
|
| 201 |
+
<CheckCircle2 className="h-16 w-16 text-sage" />
|
| 202 |
+
<p className="mt-5 max-w-[240px] text-sm leading-6 text-cream/80">
|
| 203 |
+
Saved locally for the team to review in the internal dashboard.
|
| 204 |
+
</p>
|
| 205 |
+
</div>
|
| 206 |
+
)}
|
| 207 |
+
</div>
|
| 208 |
+
|
| 209 |
+
<ul className="flex flex-col gap-2.5">
|
| 210 |
+
{renderSteps.map((step) => (
|
| 211 |
+
<li
|
| 212 |
+
key={step.label}
|
| 213 |
+
className="flex items-center gap-3 rounded-xl border border-ink/10 bg-cream px-3.5 py-3 text-[13px] text-ink/75"
|
| 214 |
+
>
|
| 215 |
+
<span
|
| 216 |
+
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold ${
|
| 217 |
+
step.done
|
| 218 |
+
? 'bg-matcha text-cream'
|
| 219 |
+
: step.current
|
| 220 |
+
? 'animate-spin border-2 border-matcha border-t-transparent'
|
| 221 |
+
: 'bg-cream-deep text-muted'
|
| 222 |
+
}`}
|
| 223 |
+
>
|
| 224 |
+
{step.done ? 'OK' : step.current ? '' : '-'}
|
| 225 |
+
</span>
|
| 226 |
+
<span>{step.label}</span>
|
| 227 |
+
</li>
|
| 228 |
+
))}
|
| 229 |
+
</ul>
|
| 230 |
+
|
| 231 |
+
{phase.kind === 'concatenating' ? (
|
| 232 |
+
<p className="mt-4 text-center font-mono text-[10px] uppercase tracking-[0.15em] text-muted">
|
| 233 |
+
Stitching clips - {Math.round(phase.progress * 100)}%
|
| 234 |
+
</p>
|
| 235 |
+
) : null}
|
| 236 |
+
|
| 237 |
+
{phase.kind === 'mixing' ? (
|
| 238 |
+
<p className="mt-4 text-center font-mono text-[10px] uppercase tracking-[0.15em] text-muted">
|
| 239 |
+
Adding voiceover - {Math.round(phase.progress * 100)}%
|
| 240 |
+
</p>
|
| 241 |
+
) : null}
|
| 242 |
+
|
| 243 |
+
{phase.kind === 'uploading' ? (
|
| 244 |
+
<p className="mt-4 flex items-center justify-center gap-2 text-center font-mono text-[10px] uppercase tracking-[0.15em] text-muted">
|
| 245 |
+
<Loader2 className="h-3 w-3 animate-spin" /> Saving recording
|
| 246 |
+
</p>
|
| 247 |
+
) : null}
|
| 248 |
+
|
| 249 |
+
{phase.kind === 'error' ? (
|
| 250 |
+
<div className="mt-4 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900">
|
| 251 |
+
{phase.message}
|
| 252 |
+
</div>
|
| 253 |
+
) : null}
|
| 254 |
+
</div>
|
| 255 |
+
|
| 256 |
+
{isReady ? (
|
| 257 |
+
<div className="mt-4 flex flex-wrap gap-2">
|
| 258 |
+
<span className="rounded-full border border-ink/10 bg-paper px-3 py-1.5 font-mono text-[11px] uppercase tracking-wide text-ink">
|
| 259 |
+
{store.orderedClips.filter((clip) => clip.mediaType !== 'audio').length} shots
|
| 260 |
+
</span>
|
| 261 |
+
<span className="rounded-full border border-ink/10 bg-paper px-3 py-1.5 font-mono text-[11px] uppercase tracking-wide text-ink">
|
| 262 |
+
{store.orderedClips.filter((clip) => clip.mediaType === 'audio').length} voice notes
|
| 263 |
+
</span>
|
| 264 |
+
<span className="rounded-full border border-ink/10 bg-paper px-3 py-1.5 font-mono text-[11px] uppercase tracking-wide text-ink">
|
| 265 |
+
saved locally
|
| 266 |
+
</span>
|
| 267 |
+
<span className="rounded-full border border-ink/10 bg-paper px-3 py-1.5 font-mono text-[11px] uppercase tracking-wide text-ink">
|
| 268 |
+
reward ready
|
| 269 |
+
</span>
|
| 270 |
+
</div>
|
| 271 |
+
) : null}
|
| 272 |
+
</section>
|
| 273 |
+
|
| 274 |
+
<footer className="flex flex-col gap-2.5 px-7 pb-6 pt-4">
|
| 275 |
+
{isReady ? (
|
| 276 |
+
<>
|
| 277 |
+
<Button onClick={handleSubmitFinal}>Reveal matcha code</Button>
|
| 278 |
+
<Button variant="secondary" onClick={handleRerecord}>
|
| 279 |
+
<RotateCcw className="h-4 w-4" />
|
| 280 |
+
Re-record clips
|
| 281 |
+
</Button>
|
| 282 |
+
</>
|
| 283 |
+
) : phase.kind === 'error' ? (
|
| 284 |
+
<>
|
| 285 |
+
<Button onClick={handleRetry}>Try again</Button>
|
| 286 |
+
<Button variant="secondary" onClick={handleRerecord}>
|
| 287 |
+
<RotateCcw className="h-4 w-4" />
|
| 288 |
+
Re-record clips
|
| 289 |
+
</Button>
|
| 290 |
+
</>
|
| 291 |
+
) : (
|
| 292 |
+
<Button disabled>Processing...</Button>
|
| 293 |
+
)}
|
| 294 |
+
</footer>
|
| 295 |
+
</main>
|
| 296 |
+
</MobileShell>
|
| 297 |
+
);
|
| 298 |
+
}
|
src/app/reward/page.tsx
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { Hand } from 'lucide-react';
|
| 4 |
+
import { useRouter, useSearchParams } from 'next/navigation';
|
| 5 |
+
import { Suspense } from 'react';
|
| 6 |
+
import { Button } from '@/components/Button';
|
| 7 |
+
import { Confetti } from '@/components/Confetti';
|
| 8 |
+
import { MobileShell } from '@/components/MobileShell';
|
| 9 |
+
import { RewardCard } from '@/components/RewardCard';
|
| 10 |
+
import { recordingStore } from '@/lib/recordingStore';
|
| 11 |
+
|
| 12 |
+
export default function RewardPage() {
|
| 13 |
+
return (
|
| 14 |
+
<Suspense fallback={null}>
|
| 15 |
+
<RewardScreen />
|
| 16 |
+
</Suspense>
|
| 17 |
+
);
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
function RewardScreen() {
|
| 21 |
+
const router = useRouter();
|
| 22 |
+
const params = useSearchParams();
|
| 23 |
+
const code = params.get('code') || 'MATCHA-7K2Q';
|
| 24 |
+
|
| 25 |
+
const handleRestart = () => {
|
| 26 |
+
recordingStore.reset();
|
| 27 |
+
router.replace('/');
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
return (
|
| 31 |
+
<MobileShell>
|
| 32 |
+
<main
|
| 33 |
+
className="relative flex min-h-dvh flex-col overflow-hidden bg-cream"
|
| 34 |
+
style={{
|
| 35 |
+
backgroundImage:
|
| 36 |
+
'radial-gradient(ellipse at 50% 0%, rgba(184,201,168,0.5), transparent 60%)',
|
| 37 |
+
}}
|
| 38 |
+
>
|
| 39 |
+
<Confetti />
|
| 40 |
+
|
| 41 |
+
<section className="relative z-10 flex flex-1 flex-col items-center px-7 pt-10 text-center">
|
| 42 |
+
<div className="mb-4 animate-bounce text-[64px]">🎉</div>
|
| 43 |
+
<div className="text-eyebrow mb-2 text-matcha">submitted · thank you</div>
|
| 44 |
+
<h1 className="text-display text-[38px] text-ink">
|
| 45 |
+
You're a <em className="text-matcha">star.</em>
|
| 46 |
+
</h1>
|
| 47 |
+
<p className="mt-2.5 max-w-[300px] text-[14px] leading-[1.5] text-ink/65">
|
| 48 |
+
Your video is on its way to the team's social. Now — about that matcha.
|
| 49 |
+
</p>
|
| 50 |
+
|
| 51 |
+
<div className="my-7 w-full">
|
| 52 |
+
<RewardCard code={code} />
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<div className="mb-4 flex items-center gap-2.5 rounded-2xl bg-sage px-5 py-3.5 text-[13px] font-semibold text-matcha-deep">
|
| 56 |
+
<Hand className="h-[18px] w-[18px]" />
|
| 57 |
+
Show this screen to your server
|
| 58 |
+
</div>
|
| 59 |
+
|
| 60 |
+
<p className="max-w-[300px] text-[11px] leading-[1.5] text-muted">
|
| 61 |
+
Want a copy of your video?{' '}
|
| 62 |
+
<span className="cursor-pointer text-matcha underline">
|
| 63 |
+
Send it to my email →
|
| 64 |
+
</span>
|
| 65 |
+
</p>
|
| 66 |
+
</section>
|
| 67 |
+
|
| 68 |
+
<footer className="relative z-10 px-7 pb-6 pt-2">
|
| 69 |
+
<Button variant="secondary" onClick={handleRestart}>
|
| 70 |
+
↺ Restart prototype
|
| 71 |
+
</Button>
|
| 72 |
+
</footer>
|
| 73 |
+
</main>
|
| 74 |
+
</MobileShell>
|
| 75 |
+
);
|
| 76 |
+
}
|
src/components/Button.tsx
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { forwardRef } from 'react';
|
| 2 |
+
import { cn } from '@/lib/utils';
|
| 3 |
+
|
| 4 |
+
type Variant = 'primary' | 'secondary' | 'ghost';
|
| 5 |
+
|
| 6 |
+
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
| 7 |
+
variant?: Variant;
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
const variantClasses: Record<Variant, string> = {
|
| 11 |
+
primary: 'bg-matcha text-cream hover:bg-matcha-deep active:bg-matcha-deep',
|
| 12 |
+
secondary: 'bg-transparent text-ink border border-ink/80 hover:bg-ink/5',
|
| 13 |
+
ghost: 'bg-transparent text-ink/60',
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
export const Button = forwardRef<HTMLButtonElement, Props>(function Button(
|
| 17 |
+
{ variant = 'primary', className, children, disabled, ...rest },
|
| 18 |
+
ref,
|
| 19 |
+
) {
|
| 20 |
+
return (
|
| 21 |
+
<button
|
| 22 |
+
ref={ref}
|
| 23 |
+
disabled={disabled}
|
| 24 |
+
className={cn(
|
| 25 |
+
'flex w-full items-center justify-center gap-2 rounded-full px-6 py-[18px]',
|
| 26 |
+
'text-[15px] font-medium font-sans transition-all duration-200',
|
| 27 |
+
'disabled:opacity-40 disabled:cursor-not-allowed',
|
| 28 |
+
variantClasses[variant],
|
| 29 |
+
className,
|
| 30 |
+
)}
|
| 31 |
+
{...rest}
|
| 32 |
+
>
|
| 33 |
+
{children}
|
| 34 |
+
</button>
|
| 35 |
+
);
|
| 36 |
+
});
|
src/components/Confetti.tsx
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const PIECES = [
|
| 2 |
+
{ emoji: '🍵', delay: '0s', x: '5%' },
|
| 3 |
+
{ emoji: '✨', delay: '0.4s', x: '18%' },
|
| 4 |
+
{ emoji: '🥬', delay: '1s', x: '32%' },
|
| 5 |
+
{ emoji: '🍵', delay: '0.2s', x: '48%' },
|
| 6 |
+
{ emoji: '✨', delay: '1.5s', x: '62%' },
|
| 7 |
+
{ emoji: '🥬', delay: '0.7s', x: '78%' },
|
| 8 |
+
{ emoji: '🍵', delay: '1.8s', x: '90%' },
|
| 9 |
+
];
|
| 10 |
+
|
| 11 |
+
export function Confetti() {
|
| 12 |
+
return (
|
| 13 |
+
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
| 14 |
+
{PIECES.map((p, i) => (
|
| 15 |
+
<span
|
| 16 |
+
key={i}
|
| 17 |
+
className="absolute top-0 animate-confetti text-lg"
|
| 18 |
+
style={{ left: p.x, animationDelay: p.delay }}
|
| 19 |
+
>
|
| 20 |
+
{p.emoji}
|
| 21 |
+
</span>
|
| 22 |
+
))}
|
| 23 |
+
</div>
|
| 24 |
+
);
|
| 25 |
+
}
|
src/components/MatchaCircle.tsx
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function MatchaCircle() {
|
| 2 |
+
return (
|
| 3 |
+
<div className="relative mx-auto my-4 h-[180px] w-[180px]">
|
| 4 |
+
<div
|
| 5 |
+
className="absolute inset-0 rounded-full shadow-[0_30px_60px_rgba(74,107,61,0.25)]"
|
| 6 |
+
style={{
|
| 7 |
+
background:
|
| 8 |
+
'radial-gradient(circle at 35% 30%, #95B485, #4A6B3D 60%, #324A2A 100%)',
|
| 9 |
+
}}
|
| 10 |
+
/>
|
| 11 |
+
<div
|
| 12 |
+
className="absolute left-[18%] top-[14%] h-[18%] w-[35%] rounded-full"
|
| 13 |
+
style={{
|
| 14 |
+
background:
|
| 15 |
+
'radial-gradient(ellipse, rgba(255,255,255,0.5), transparent 70%)',
|
| 16 |
+
filter: 'blur(4px)',
|
| 17 |
+
}}
|
| 18 |
+
/>
|
| 19 |
+
<span className="absolute inset-0 flex items-start justify-center pt-[28%] font-serif italic text-[22px] text-white/95">
|
| 20 |
+
~
|
| 21 |
+
</span>
|
| 22 |
+
</div>
|
| 23 |
+
);
|
| 24 |
+
}
|
src/components/MobileShell.tsx
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ReactNode } from 'react';
|
| 2 |
+
|
| 3 |
+
type Props = {
|
| 4 |
+
children: ReactNode;
|
| 5 |
+
tone?: 'cream' | 'dark';
|
| 6 |
+
};
|
| 7 |
+
|
| 8 |
+
export function MobileShell({ children, tone = 'cream' }: Props) {
|
| 9 |
+
const isDark = tone === 'dark';
|
| 10 |
+
|
| 11 |
+
return (
|
| 12 |
+
<div className={isDark ? 'min-h-dvh bg-[#0e0d0b]' : 'min-h-dvh bg-cream-deep'}>
|
| 13 |
+
<div
|
| 14 |
+
className={[
|
| 15 |
+
'mx-auto min-h-dvh w-full max-w-[430px] overflow-hidden',
|
| 16 |
+
isDark ? 'bg-[#0e0d0b]' : 'bg-cream',
|
| 17 |
+
].join(' ')}
|
| 18 |
+
>
|
| 19 |
+
{children}
|
| 20 |
+
</div>
|
| 21 |
+
</div>
|
| 22 |
+
);
|
| 23 |
+
}
|
src/components/ProgressPips.tsx
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { cn } from '@/lib/utils';
|
| 2 |
+
|
| 3 |
+
type Props = {
|
| 4 |
+
total: number;
|
| 5 |
+
current: number;
|
| 6 |
+
doneCount: number;
|
| 7 |
+
liveProgress?: number;
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
export function ProgressPips({ total, current, doneCount, liveProgress = 0 }: Props) {
|
| 11 |
+
return (
|
| 12 |
+
<div className="flex gap-1 px-4">
|
| 13 |
+
{Array.from({ length: total }, (_, i) => {
|
| 14 |
+
const idx = i + 1;
|
| 15 |
+
const isDone = idx <= doneCount;
|
| 16 |
+
const isCurrent = idx === current && !isDone;
|
| 17 |
+
return (
|
| 18 |
+
<div
|
| 19 |
+
key={idx}
|
| 20 |
+
className={cn(
|
| 21 |
+
'h-[3px] flex-1 overflow-hidden rounded-full',
|
| 22 |
+
isDone ? 'bg-sage' : 'bg-white/20',
|
| 23 |
+
)}
|
| 24 |
+
>
|
| 25 |
+
{isCurrent ? (
|
| 26 |
+
<div
|
| 27 |
+
className="h-full bg-sage transition-[width] duration-100 ease-linear"
|
| 28 |
+
style={{
|
| 29 |
+
width: `${Math.max(8, Math.min(100, liveProgress * 100))}%`,
|
| 30 |
+
}}
|
| 31 |
+
/>
|
| 32 |
+
) : null}
|
| 33 |
+
</div>
|
| 34 |
+
);
|
| 35 |
+
})}
|
| 36 |
+
</div>
|
| 37 |
+
);
|
| 38 |
+
}
|
src/components/PromptCard.tsx
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
type Props = {
|
| 2 |
+
step: number;
|
| 3 |
+
totalSteps: number;
|
| 4 |
+
title: string;
|
| 5 |
+
tip: string;
|
| 6 |
+
label?: string;
|
| 7 |
+
optional?: boolean;
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
export function PromptCard({ step, totalSteps, title, tip, label = 'Clip', optional }: Props) {
|
| 11 |
+
return (
|
| 12 |
+
<div className="rounded-[22px] border border-white/10 bg-[rgba(20,18,14,0.78)] px-5 py-[18px] backdrop-blur-md">
|
| 13 |
+
<div className="text-eyebrow mb-1.5 text-sage">
|
| 14 |
+
{label} {String(step).padStart(2, '0')} of {String(totalSteps).padStart(2, '0')}
|
| 15 |
+
{optional ? ' - optional' : ''}
|
| 16 |
+
</div>
|
| 17 |
+
<div className="font-serif text-[22px] leading-[1.2] text-white">
|
| 18 |
+
{title}
|
| 19 |
+
</div>
|
| 20 |
+
{tip ? (
|
| 21 |
+
<div className="mt-1.5 text-xs leading-[1.4] text-white/60">{tip}</div>
|
| 22 |
+
) : null}
|
| 23 |
+
</div>
|
| 24 |
+
);
|
| 25 |
+
}
|
src/components/RecordButton.tsx
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { cn } from '@/lib/utils';
|
| 4 |
+
|
| 5 |
+
type Props = {
|
| 6 |
+
recording: boolean;
|
| 7 |
+
onClick: () => void;
|
| 8 |
+
disabled?: boolean;
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
export function RecordButton({ recording, onClick, disabled }: Props) {
|
| 12 |
+
return (
|
| 13 |
+
<button
|
| 14 |
+
type="button"
|
| 15 |
+
disabled={disabled}
|
| 16 |
+
onClick={onClick}
|
| 17 |
+
className={cn(
|
| 18 |
+
'flex h-[76px] w-[76px] items-center justify-center rounded-full',
|
| 19 |
+
'border-[4px] border-white bg-transparent transition-transform duration-150',
|
| 20 |
+
'active:scale-95 disabled:opacity-40',
|
| 21 |
+
)}
|
| 22 |
+
aria-label={recording ? 'Stop recording' : 'Start recording'}
|
| 23 |
+
>
|
| 24 |
+
<span
|
| 25 |
+
className={cn(
|
| 26 |
+
'block bg-[#EE4040] transition-[width,height,border-radius] duration-200 ease-out',
|
| 27 |
+
recording ? 'h-[26px] w-[26px] rounded-md' : 'h-[56px] w-[56px] rounded-full',
|
| 28 |
+
)}
|
| 29 |
+
/>
|
| 30 |
+
</button>
|
| 31 |
+
);
|
| 32 |
+
}
|
src/components/RecordingBadge.tsx
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
type Props = {
|
| 2 |
+
elapsedMs: number;
|
| 3 |
+
visible: boolean;
|
| 4 |
+
};
|
| 5 |
+
|
| 6 |
+
export function RecordingBadge({ elapsedMs, visible }: Props) {
|
| 7 |
+
if (!visible) return null;
|
| 8 |
+
|
| 9 |
+
const totalSeconds = Math.floor(elapsedMs / 1000);
|
| 10 |
+
const min = Math.floor(totalSeconds / 60);
|
| 11 |
+
const sec = totalSeconds % 60;
|
| 12 |
+
const time = `${min}:${String(sec).padStart(2, '0')}`;
|
| 13 |
+
|
| 14 |
+
return (
|
| 15 |
+
<div className="flex items-center gap-2 rounded-full bg-[rgba(238,64,64,0.95)] px-3.5 py-2 font-mono text-xs text-white">
|
| 16 |
+
<span className="block h-2 w-2 animate-blink rounded-full bg-white" />
|
| 17 |
+
<span>REC {time}</span>
|
| 18 |
+
</div>
|
| 19 |
+
);
|
| 20 |
+
}
|
src/components/RenderShimmer.tsx
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function RenderShimmer() {
|
| 2 |
+
return (
|
| 3 |
+
<div
|
| 4 |
+
className="pointer-events-none absolute inset-0 animate-shimmer"
|
| 5 |
+
style={{
|
| 6 |
+
backgroundImage:
|
| 7 |
+
'linear-gradient(120deg, transparent 0%, rgba(184,201,168,0.15) 40%, rgba(184,201,168,0.3) 50%, rgba(184,201,168,0.15) 60%, transparent 100%)',
|
| 8 |
+
backgroundSize: '200% 100%',
|
| 9 |
+
}}
|
| 10 |
+
/>
|
| 11 |
+
);
|
| 12 |
+
}
|
src/components/RewardCard.tsx
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
type Props = {
|
| 2 |
+
code: string;
|
| 3 |
+
hint?: string;
|
| 4 |
+
};
|
| 5 |
+
|
| 6 |
+
export function RewardCard({ code, hint = 'single-use · save it for the bar' }: Props) {
|
| 7 |
+
return (
|
| 8 |
+
<div className="relative w-full overflow-hidden rounded-[24px] bg-ink px-6 py-7 text-center text-cream shadow-[0_20px_50px_rgba(42,37,32,0.25)]">
|
| 9 |
+
<div
|
| 10 |
+
className="pointer-events-none absolute -inset-[50%] animate-rotateBg"
|
| 11 |
+
style={{
|
| 12 |
+
background:
|
| 13 |
+
'conic-gradient(from 0deg, transparent, rgba(184,201,168,0.15), transparent 25%)',
|
| 14 |
+
}}
|
| 15 |
+
/>
|
| 16 |
+
<div className="relative">
|
| 17 |
+
<div className="text-eyebrow mb-3 text-sage">Your code</div>
|
| 18 |
+
<div className="font-serif text-[42px] font-light tracking-[0.04em]">
|
| 19 |
+
{code}
|
| 20 |
+
</div>
|
| 21 |
+
<div className="mt-2 text-xs opacity-60">{hint}</div>
|
| 22 |
+
</div>
|
| 23 |
+
</div>
|
| 24 |
+
);
|
| 25 |
+
}
|
src/hooks/useGuidedRecording.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
| 4 |
+
import type { ClipPrompt } from '@/lib/reviews/types';
|
| 5 |
+
|
| 6 |
+
export type RecordingState =
|
| 7 |
+
| 'idle'
|
| 8 |
+
| 'requesting_permission'
|
| 9 |
+
| 'ready'
|
| 10 |
+
| 'recording'
|
| 11 |
+
| 'finalizing';
|
| 12 |
+
|
| 13 |
+
const recorderMimePriority = [
|
| 14 |
+
'video/webm;codecs=vp9,opus',
|
| 15 |
+
'video/webm;codecs=vp8,opus',
|
| 16 |
+
'video/webm',
|
| 17 |
+
'video/mp4',
|
| 18 |
+
];
|
| 19 |
+
|
| 20 |
+
const audioRecorderMimePriority = [
|
| 21 |
+
'audio/webm;codecs=opus',
|
| 22 |
+
'audio/webm',
|
| 23 |
+
'audio/mp4',
|
| 24 |
+
];
|
| 25 |
+
|
| 26 |
+
function pickRecorderMime(mediaType: 'video' | 'audio') {
|
| 27 |
+
if (typeof MediaRecorder === 'undefined') return undefined;
|
| 28 |
+
const mimes = mediaType === 'audio' ? audioRecorderMimePriority : recorderMimePriority;
|
| 29 |
+
for (const mime of mimes) {
|
| 30 |
+
if (MediaRecorder.isTypeSupported(mime)) return mime;
|
| 31 |
+
}
|
| 32 |
+
return undefined;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
function extFromMime(mime: string | undefined): 'webm' | 'mp4' | 'mov' {
|
| 36 |
+
if (!mime) return 'webm';
|
| 37 |
+
if (mime.includes('mp4')) return 'mp4';
|
| 38 |
+
if (mime.includes('quicktime')) return 'mov';
|
| 39 |
+
return 'webm';
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export type UseGuidedRecordingOptions = {
|
| 43 |
+
prompt: ClipPrompt;
|
| 44 |
+
/** Called once a recording finishes (either user-stopped or auto-stopped on hard cap). */
|
| 45 |
+
onClipReady: (clip: { blob: Blob; durationSeconds: number; ext: 'webm' | 'mp4' | 'mov' }) => void;
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
export function useGuidedRecording({ prompt, onClipReady }: UseGuidedRecordingOptions) {
|
| 49 |
+
const mediaType = prompt.mediaType ?? 'video';
|
| 50 |
+
const [state, setState] = useState<RecordingState>('idle');
|
| 51 |
+
const [elapsedMs, setElapsedMs] = useState(0);
|
| 52 |
+
const [error, setError] = useState<string | null>(null);
|
| 53 |
+
|
| 54 |
+
const videoRef = useRef<HTMLVideoElement | null>(null);
|
| 55 |
+
const streamRef = useRef<MediaStream | null>(null);
|
| 56 |
+
const recorderRef = useRef<MediaRecorder | null>(null);
|
| 57 |
+
const chunksRef = useRef<BlobPart[]>([]);
|
| 58 |
+
const startedAtRef = useRef<number>(0);
|
| 59 |
+
const tickRef = useRef<number | null>(null);
|
| 60 |
+
const autoStopRef = useRef<number | null>(null);
|
| 61 |
+
|
| 62 |
+
const stopTicking = useCallback(() => {
|
| 63 |
+
if (tickRef.current !== null) {
|
| 64 |
+
window.clearInterval(tickRef.current);
|
| 65 |
+
tickRef.current = null;
|
| 66 |
+
}
|
| 67 |
+
}, []);
|
| 68 |
+
|
| 69 |
+
const stopAutoStop = useCallback(() => {
|
| 70 |
+
if (autoStopRef.current !== null) {
|
| 71 |
+
window.clearTimeout(autoStopRef.current);
|
| 72 |
+
autoStopRef.current = null;
|
| 73 |
+
}
|
| 74 |
+
}, []);
|
| 75 |
+
|
| 76 |
+
const stopStream = useCallback(() => {
|
| 77 |
+
streamRef.current?.getTracks().forEach((t) => t.stop());
|
| 78 |
+
streamRef.current = null;
|
| 79 |
+
}, []);
|
| 80 |
+
|
| 81 |
+
const requestPermissionAndPreview = useCallback(async () => {
|
| 82 |
+
setError(null);
|
| 83 |
+
setState('requesting_permission');
|
| 84 |
+
try {
|
| 85 |
+
const stream = await navigator.mediaDevices.getUserMedia(
|
| 86 |
+
mediaType === 'audio'
|
| 87 |
+
? { audio: true }
|
| 88 |
+
: {
|
| 89 |
+
video: {
|
| 90 |
+
facingMode: prompt.camera === 'rear' ? { ideal: 'environment' } : { ideal: 'user' },
|
| 91 |
+
width: { ideal: 1080 },
|
| 92 |
+
height: { ideal: 1920 },
|
| 93 |
+
},
|
| 94 |
+
audio: true,
|
| 95 |
+
},
|
| 96 |
+
);
|
| 97 |
+
streamRef.current = stream;
|
| 98 |
+
if (mediaType === 'video' && videoRef.current) {
|
| 99 |
+
videoRef.current.srcObject = stream;
|
| 100 |
+
videoRef.current.muted = true;
|
| 101 |
+
await videoRef.current.play().catch(() => undefined);
|
| 102 |
+
}
|
| 103 |
+
setState('ready');
|
| 104 |
+
} catch (err) {
|
| 105 |
+
setError(err instanceof Error ? err.message : 'Camera access was blocked.');
|
| 106 |
+
setState('idle');
|
| 107 |
+
}
|
| 108 |
+
}, [mediaType, prompt.camera]);
|
| 109 |
+
|
| 110 |
+
// Re-acquire the stream only when the capture source changes. Adjacent food
|
| 111 |
+
// shots should keep the same camera warm instead of tearing it down.
|
| 112 |
+
useEffect(() => {
|
| 113 |
+
void requestPermissionAndPreview();
|
| 114 |
+
return () => {
|
| 115 |
+
stopTicking();
|
| 116 |
+
stopAutoStop();
|
| 117 |
+
try {
|
| 118 |
+
recorderRef.current?.stop();
|
| 119 |
+
} catch {
|
| 120 |
+
/* ignore */
|
| 121 |
+
}
|
| 122 |
+
stopStream();
|
| 123 |
+
};
|
| 124 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 125 |
+
}, [prompt.camera, mediaType]);
|
| 126 |
+
|
| 127 |
+
const startRecording = useCallback(() => {
|
| 128 |
+
if (!streamRef.current) return;
|
| 129 |
+
const mime = pickRecorderMime(mediaType);
|
| 130 |
+
let recorder: MediaRecorder;
|
| 131 |
+
try {
|
| 132 |
+
recorder = mime
|
| 133 |
+
? new MediaRecorder(streamRef.current, { mimeType: mime })
|
| 134 |
+
: new MediaRecorder(streamRef.current);
|
| 135 |
+
} catch (err) {
|
| 136 |
+
setError(err instanceof Error ? err.message : 'Recording is not supported on this device.');
|
| 137 |
+
return;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
recorderRef.current = recorder;
|
| 141 |
+
chunksRef.current = [];
|
| 142 |
+
|
| 143 |
+
recorder.ondataavailable = (e) => {
|
| 144 |
+
if (e.data && e.data.size > 0) chunksRef.current.push(e.data);
|
| 145 |
+
};
|
| 146 |
+
|
| 147 |
+
recorder.onstop = () => {
|
| 148 |
+
stopTicking();
|
| 149 |
+
stopAutoStop();
|
| 150 |
+
const elapsedSeconds = Math.max(
|
| 151 |
+
0,
|
| 152 |
+
Math.round((Date.now() - startedAtRef.current) / 1000),
|
| 153 |
+
);
|
| 154 |
+
const blobMime = mime ?? 'video/webm';
|
| 155 |
+
const blob = new Blob(chunksRef.current, { type: blobMime });
|
| 156 |
+
chunksRef.current = [];
|
| 157 |
+
setState('finalizing');
|
| 158 |
+
onClipReady({
|
| 159 |
+
blob,
|
| 160 |
+
durationSeconds: elapsedSeconds,
|
| 161 |
+
ext: extFromMime(mime),
|
| 162 |
+
});
|
| 163 |
+
// Brief finalize state for UX, then return to ready (parent may unmount).
|
| 164 |
+
window.setTimeout(() => setState('ready'), 200);
|
| 165 |
+
};
|
| 166 |
+
|
| 167 |
+
startedAtRef.current = Date.now();
|
| 168 |
+
setElapsedMs(0);
|
| 169 |
+
setState('recording');
|
| 170 |
+
recorder.start();
|
| 171 |
+
|
| 172 |
+
tickRef.current = window.setInterval(() => {
|
| 173 |
+
setElapsedMs(Date.now() - startedAtRef.current);
|
| 174 |
+
}, 100);
|
| 175 |
+
|
| 176 |
+
autoStopRef.current = window.setTimeout(() => {
|
| 177 |
+
try {
|
| 178 |
+
recorderRef.current?.stop();
|
| 179 |
+
} catch {
|
| 180 |
+
/* ignore */
|
| 181 |
+
}
|
| 182 |
+
}, prompt.maxSeconds * 1000);
|
| 183 |
+
}, [mediaType, onClipReady, prompt.maxSeconds, stopAutoStop, stopTicking]);
|
| 184 |
+
|
| 185 |
+
const stopRecording = useCallback(() => {
|
| 186 |
+
try {
|
| 187 |
+
recorderRef.current?.stop();
|
| 188 |
+
} catch {
|
| 189 |
+
/* ignore */
|
| 190 |
+
}
|
| 191 |
+
}, []);
|
| 192 |
+
|
| 193 |
+
const liveProgress = Math.min(1, elapsedMs / (prompt.maxSeconds * 1000));
|
| 194 |
+
|
| 195 |
+
return {
|
| 196 |
+
state,
|
| 197 |
+
elapsedMs,
|
| 198 |
+
liveProgress,
|
| 199 |
+
error,
|
| 200 |
+
videoRef,
|
| 201 |
+
startRecording,
|
| 202 |
+
stopRecording,
|
| 203 |
+
requestPermissionAndPreview,
|
| 204 |
+
};
|
| 205 |
+
}
|
src/hooks/useSubmissionPolling.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useEffect, useRef, useState } from 'react';
|
| 4 |
+
import { getSubmission, POLLING_SUBMISSION_STATUSES } from '@/lib/humeoApi';
|
| 5 |
+
import type { PublicSubmitResult } from '@/lib/reviews/types';
|
| 6 |
+
|
| 7 |
+
/**
|
| 8 |
+
* Mirrors the polling pattern in
|
| 9 |
+
* reference/src/app/components/reviews/PublicReviewRecordingClient.tsx
|
| 10 |
+
* (POLLING_SUBMISSION_STATUSES + setInterval).
|
| 11 |
+
*/
|
| 12 |
+
export function useSubmissionPolling(
|
| 13 |
+
submissionId: string | null,
|
| 14 |
+
slug: string | null,
|
| 15 |
+
intervalMs = 6000,
|
| 16 |
+
) {
|
| 17 |
+
const [result, setResult] = useState<PublicSubmitResult | null>(null);
|
| 18 |
+
const [error, setError] = useState<string | null>(null);
|
| 19 |
+
const stopRef = useRef<number | null>(null);
|
| 20 |
+
|
| 21 |
+
useEffect(() => {
|
| 22 |
+
if (!submissionId || !slug) return;
|
| 23 |
+
let cancelled = false;
|
| 24 |
+
|
| 25 |
+
const tick = async () => {
|
| 26 |
+
try {
|
| 27 |
+
const next = await getSubmission(submissionId, slug);
|
| 28 |
+
if (cancelled) return;
|
| 29 |
+
setResult(next);
|
| 30 |
+
if (!POLLING_SUBMISSION_STATUSES.has(next.status)) {
|
| 31 |
+
if (stopRef.current !== null) {
|
| 32 |
+
window.clearInterval(stopRef.current);
|
| 33 |
+
stopRef.current = null;
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
} catch (err) {
|
| 37 |
+
if (cancelled) return;
|
| 38 |
+
setError(err instanceof Error ? err.message : 'Status poll failed');
|
| 39 |
+
}
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
void tick();
|
| 43 |
+
stopRef.current = window.setInterval(tick, intervalMs);
|
| 44 |
+
|
| 45 |
+
return () => {
|
| 46 |
+
cancelled = true;
|
| 47 |
+
if (stopRef.current !== null) {
|
| 48 |
+
window.clearInterval(stopRef.current);
|
| 49 |
+
stopRef.current = null;
|
| 50 |
+
}
|
| 51 |
+
};
|
| 52 |
+
}, [submissionId, slug, intervalMs]);
|
| 53 |
+
|
| 54 |
+
return { result, error };
|
| 55 |
+
}
|
src/lib/ffmpeg.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Client-side video concatenation using ffmpeg.wasm.
|
| 3 |
+
*
|
| 4 |
+
* Why client-side: Humeo's existing /api/public/reviews/submit endpoint accepts
|
| 5 |
+
* a single video file. To avoid backend changes for v1, we stitch the 5 clips
|
| 6 |
+
* in the browser before upload.
|
| 7 |
+
*
|
| 8 |
+
* Cost: ~8MB of WASM lazy-loaded after the customer finishes recording.
|
| 9 |
+
* Performance: ~50 seconds of total video concatenates in 5-10s on a modern phone.
|
| 10 |
+
*
|
| 11 |
+
* Future: if cafes complain about phone heat or battery, swap to multi-clip
|
| 12 |
+
* upload + ffmpeg-on-server. The Humeo BE worker (processInterview.ts) already
|
| 13 |
+
* uses ffmpeg.
|
| 14 |
+
*/
|
| 15 |
+
|
| 16 |
+
import { FFmpeg } from '@ffmpeg/ffmpeg';
|
| 17 |
+
import { fetchFile } from '@ffmpeg/util';
|
| 18 |
+
|
| 19 |
+
async function createFFmpeg(): Promise<FFmpeg> {
|
| 20 |
+
const ffmpeg = new FFmpeg();
|
| 21 |
+
// ffmpeg.wasm fetches its core from a CDN by default; for SharedArrayBuffer
|
| 22 |
+
// support we need the COOP/COEP headers set in next.config.js.
|
| 23 |
+
await ffmpeg.load();
|
| 24 |
+
return ffmpeg;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
export type ConcatInput = {
|
| 28 |
+
blob: Blob;
|
| 29 |
+
/** Hint for input file extension; .webm fallback otherwise. */
|
| 30 |
+
ext?: 'webm' | 'mp4' | 'mov';
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
export type ConcatResult = {
|
| 34 |
+
blob: Blob;
|
| 35 |
+
durationSeconds: number;
|
| 36 |
+
filename: string;
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
/**
|
| 40 |
+
* Concatenate an ordered list of recorded video Blobs into a single output blob.
|
| 41 |
+
*
|
| 42 |
+
* Normalize each clip independently, reset audio/video PTS per clip, then use
|
| 43 |
+
* ffmpeg's concat filter. The concat demuxer path can preserve timestamp gaps
|
| 44 |
+
* between MediaRecorder clips, which shows up as frozen video at clip joins.
|
| 45 |
+
*/
|
| 46 |
+
export async function concatClips(
|
| 47 |
+
inputs: ConcatInput[],
|
| 48 |
+
onProgress?: (progress: number) => void,
|
| 49 |
+
): Promise<ConcatResult> {
|
| 50 |
+
if (inputs.length === 0) {
|
| 51 |
+
throw new Error('No clips to concatenate');
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
const ffmpeg = await createFFmpeg();
|
| 55 |
+
const logs: string[] = [];
|
| 56 |
+
|
| 57 |
+
const logHandler = ({ message }: { message: string }) => {
|
| 58 |
+
const trimmed = message.trim();
|
| 59 |
+
if (trimmed) logs.push(trimmed);
|
| 60 |
+
if (logs.length > 80) logs.splice(0, logs.length - 80);
|
| 61 |
+
};
|
| 62 |
+
const progressHandler = ({ progress }: { progress: number }) => {
|
| 63 |
+
onProgress?.(Math.max(0, Math.min(1, progress)));
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
ffmpeg.on('log', logHandler);
|
| 67 |
+
ffmpeg.on('progress', progressHandler);
|
| 68 |
+
|
| 69 |
+
const runId = Math.random().toString(36).slice(2, 8);
|
| 70 |
+
const inputFiles: string[] = [];
|
| 71 |
+
for (let i = 0; i < inputs.length; i++) {
|
| 72 |
+
const input = inputs[i]!;
|
| 73 |
+
const ext = input.ext ?? 'webm';
|
| 74 |
+
const filename = `input_${runId}_${i}.${ext}`;
|
| 75 |
+
await ffmpeg.writeFile(filename, await fetchFile(input.blob));
|
| 76 |
+
inputFiles.push(filename);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
const outputName = `matcha-moments-${runId}.webm`;
|
| 80 |
+
|
| 81 |
+
async function runOrThrow(args: string[], label: string) {
|
| 82 |
+
logs.length = 0;
|
| 83 |
+
const exitCode = await ffmpeg.exec(args);
|
| 84 |
+
if (exitCode !== 0) {
|
| 85 |
+
const detail = logs.slice(-12).join(' | ');
|
| 86 |
+
throw new Error(
|
| 87 |
+
detail
|
| 88 |
+
? `${label} failed with ffmpeg exit code ${exitCode}: ${detail}`
|
| 89 |
+
: `${label} failed with ffmpeg exit code ${exitCode}`,
|
| 90 |
+
);
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
async function removeOutput() {
|
| 95 |
+
try {
|
| 96 |
+
await ffmpeg.deleteFile(outputName);
|
| 97 |
+
} catch {
|
| 98 |
+
/* ignore */
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
async function runNormalizedConcat() {
|
| 103 |
+
const inputArgs = inputFiles.flatMap((name) => ['-i', name]);
|
| 104 |
+
const normalizedStreams = inputFiles
|
| 105 |
+
.map((_, i) => {
|
| 106 |
+
const video =
|
| 107 |
+
`[${i}:v]scale=720:1280:force_original_aspect_ratio=decrease,` +
|
| 108 |
+
'pad=720:1280:(ow-iw)/2:(oh-ih)/2,' +
|
| 109 |
+
`setsar=1,fps=30,setpts=PTS-STARTPTS[v${i}]`;
|
| 110 |
+
const audio =
|
| 111 |
+
`[${i}:a]aresample=async=1:first_pts=0,asetpts=PTS-STARTPTS[a${i}]`;
|
| 112 |
+
return `${video};${audio}`;
|
| 113 |
+
})
|
| 114 |
+
.join(';');
|
| 115 |
+
const concatInputs = inputFiles.map((_, i) => `[v${i}][a${i}]`).join('');
|
| 116 |
+
const filterComplex = `${normalizedStreams};${concatInputs}concat=n=${inputFiles.length}:v=1:a=1[v][a]`;
|
| 117 |
+
|
| 118 |
+
await runOrThrow([
|
| 119 |
+
'-y',
|
| 120 |
+
...inputArgs,
|
| 121 |
+
'-filter_complex',
|
| 122 |
+
filterComplex,
|
| 123 |
+
'-map',
|
| 124 |
+
'[v]',
|
| 125 |
+
'-map',
|
| 126 |
+
'[a]',
|
| 127 |
+
'-c:v',
|
| 128 |
+
'libvpx',
|
| 129 |
+
'-b:v',
|
| 130 |
+
'1.8M',
|
| 131 |
+
'-deadline',
|
| 132 |
+
'realtime',
|
| 133 |
+
'-cpu-used',
|
| 134 |
+
'5',
|
| 135 |
+
'-c:a',
|
| 136 |
+
'libopus',
|
| 137 |
+
'-b:a',
|
| 138 |
+
'96k',
|
| 139 |
+
'-shortest',
|
| 140 |
+
'-avoid_negative_ts',
|
| 141 |
+
'make_zero',
|
| 142 |
+
outputName,
|
| 143 |
+
], 'Normalized concat');
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
try {
|
| 147 |
+
await runNormalizedConcat();
|
| 148 |
+
} catch (err) {
|
| 149 |
+
await removeOutput();
|
| 150 |
+
throw err instanceof Error ? err : new Error(String(err));
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
const data = await ffmpeg.readFile(outputName);
|
| 154 |
+
// ffmpeg.readFile returns string | Uint8Array; for a binary file it's Uint8Array.
|
| 155 |
+
// Copy into a fresh ArrayBuffer-backed view so TS / Blob APIs are happy
|
| 156 |
+
// even if the underlying buffer was a SharedArrayBuffer.
|
| 157 |
+
const bytes =
|
| 158 |
+
typeof data === 'string' ? new TextEncoder().encode(data) : new Uint8Array(data);
|
| 159 |
+
const buf = new ArrayBuffer(bytes.byteLength);
|
| 160 |
+
new Uint8Array(buf).set(bytes);
|
| 161 |
+
const blob = new Blob([buf], { type: 'video/webm' });
|
| 162 |
+
|
| 163 |
+
// Cleanup
|
| 164 |
+
for (const name of inputFiles) {
|
| 165 |
+
try {
|
| 166 |
+
await ffmpeg.deleteFile(name);
|
| 167 |
+
} catch {
|
| 168 |
+
/* ignore */
|
| 169 |
+
}
|
| 170 |
+
}
|
| 171 |
+
try {
|
| 172 |
+
await ffmpeg.deleteFile(outputName);
|
| 173 |
+
} catch {
|
| 174 |
+
/* ignore */
|
| 175 |
+
}
|
| 176 |
+
ffmpeg.off('log', logHandler);
|
| 177 |
+
ffmpeg.off('progress', progressHandler);
|
| 178 |
+
ffmpeg.terminate();
|
| 179 |
+
|
| 180 |
+
// Probe duration via the same helper used elsewhere.
|
| 181 |
+
const duration = await probeDuration(blob);
|
| 182 |
+
|
| 183 |
+
return {
|
| 184 |
+
blob,
|
| 185 |
+
durationSeconds: duration,
|
| 186 |
+
filename: outputName,
|
| 187 |
+
};
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
async function probeDuration(blob: Blob): Promise<number> {
|
| 191 |
+
return await new Promise<number>((resolve) => {
|
| 192 |
+
const url = URL.createObjectURL(blob);
|
| 193 |
+
const video = document.createElement('video');
|
| 194 |
+
video.preload = 'metadata';
|
| 195 |
+
video.muted = true;
|
| 196 |
+
video.onloadedmetadata = () => {
|
| 197 |
+
resolve(Number.isFinite(video.duration) ? Math.max(0, video.duration) : 0);
|
| 198 |
+
URL.revokeObjectURL(url);
|
| 199 |
+
};
|
| 200 |
+
video.onerror = () => {
|
| 201 |
+
resolve(0);
|
| 202 |
+
URL.revokeObjectURL(url);
|
| 203 |
+
};
|
| 204 |
+
video.src = url;
|
| 205 |
+
});
|
| 206 |
+
}
|
src/lib/humeoApi.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Client-side API wrapper for the public review endpoints.
|
| 3 |
+
*
|
| 4 |
+
* Routes live in this app at:
|
| 5 |
+
* GET /api/public/reviews/campaign/[slug]
|
| 6 |
+
* POST /api/public/reviews/submit
|
| 7 |
+
* GET /api/public/reviews/submission/[submissionId]?slug=...
|
| 8 |
+
*
|
| 9 |
+
* Shapes mirror reference/src/app/api/public/reviews/* exactly. The day Humeo
|
| 10 |
+
* deploys these routes publicly, set NEXT_PUBLIC_HUMEO_API_URL=https://humeo.app
|
| 11 |
+
* to redirect all traffic at the deployed backend with no code change.
|
| 12 |
+
*
|
| 13 |
+
* Default base URL: same-origin (empty string → relative URLs), so this app
|
| 14 |
+
* is fully self-contained out of the box.
|
| 15 |
+
*/
|
| 16 |
+
|
| 17 |
+
import type {
|
| 18 |
+
PublicReviewCampaign,
|
| 19 |
+
PublicSubmitResult,
|
| 20 |
+
} from '@/lib/reviews/types';
|
| 21 |
+
import { POLLING_SUBMISSION_STATUSES } from '@/lib/reviews/types';
|
| 22 |
+
|
| 23 |
+
const BASE_URL = process.env.NEXT_PUBLIC_HUMEO_API_URL ?? '';
|
| 24 |
+
|
| 25 |
+
function endpoint(path: string) {
|
| 26 |
+
return `${BASE_URL.replace(/\/$/, '')}${path}`;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export async function getCampaign(slug: string): Promise<PublicReviewCampaign> {
|
| 30 |
+
const res = await fetch(
|
| 31 |
+
endpoint(`/api/public/reviews/campaign/${encodeURIComponent(slug)}`),
|
| 32 |
+
{ cache: 'no-store' },
|
| 33 |
+
);
|
| 34 |
+
if (!res.ok) {
|
| 35 |
+
throw new Error(`Failed to load campaign (${res.status})`);
|
| 36 |
+
}
|
| 37 |
+
const body = (await res.json()) as { campaign: PublicReviewCampaign };
|
| 38 |
+
return body.campaign;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
export type SubmitInput = {
|
| 42 |
+
slug: string;
|
| 43 |
+
consentAccepted: boolean;
|
| 44 |
+
socialHandle?: string;
|
| 45 |
+
deviceKey: string;
|
| 46 |
+
tableId?: string | null;
|
| 47 |
+
durationSeconds: number;
|
| 48 |
+
video: Blob;
|
| 49 |
+
videoFileName?: string;
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
export async function submit(input: SubmitInput): Promise<PublicSubmitResult> {
|
| 53 |
+
const form = new FormData();
|
| 54 |
+
form.append('slug', input.slug);
|
| 55 |
+
form.append('consentAccepted', input.consentAccepted ? 'true' : 'false');
|
| 56 |
+
form.append('socialHandle', input.socialHandle ?? '');
|
| 57 |
+
form.append('deviceKey', input.deviceKey);
|
| 58 |
+
form.append('durationSeconds', String(Math.round(input.durationSeconds)));
|
| 59 |
+
if (input.tableId) form.append('tableId', input.tableId);
|
| 60 |
+
const filename = input.videoFileName ?? 'matcha-moments.webm';
|
| 61 |
+
form.append('video', input.video, filename);
|
| 62 |
+
|
| 63 |
+
const res = await fetch(endpoint('/api/public/reviews/submit'), {
|
| 64 |
+
method: 'POST',
|
| 65 |
+
body: form,
|
| 66 |
+
});
|
| 67 |
+
|
| 68 |
+
const body = await res.json().catch(() => ({}));
|
| 69 |
+
if (!res.ok) {
|
| 70 |
+
throw new Error((body as { error?: string }).error || `Submit failed (${res.status})`);
|
| 71 |
+
}
|
| 72 |
+
return body as PublicSubmitResult;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
export async function getSubmission(
|
| 76 |
+
submissionId: string,
|
| 77 |
+
slug: string,
|
| 78 |
+
): Promise<PublicSubmitResult> {
|
| 79 |
+
const res = await fetch(
|
| 80 |
+
endpoint(
|
| 81 |
+
`/api/public/reviews/submission/${encodeURIComponent(submissionId)}?slug=${encodeURIComponent(slug)}`,
|
| 82 |
+
),
|
| 83 |
+
{ cache: 'no-store' },
|
| 84 |
+
);
|
| 85 |
+
if (!res.ok) {
|
| 86 |
+
throw new Error(`Failed to load submission (${res.status})`);
|
| 87 |
+
}
|
| 88 |
+
const body = (await res.json()) as { submission: PublicSubmitResult };
|
| 89 |
+
return body.submission;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
export { POLLING_SUBMISSION_STATUSES };
|
src/lib/recordingStore.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Module-level store for the customer's session of recorded clips.
|
| 3 |
+
*
|
| 4 |
+
* Lives in memory only — survives across route navigations within a single tab,
|
| 5 |
+
* doesn't survive a hard reload (and shouldn't, because the customer would have
|
| 6 |
+
* to re-grant camera permission anyway).
|
| 7 |
+
*
|
| 8 |
+
* Browser-only. Importing on the server is a no-op.
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
import { useEffect, useState } from 'react';
|
| 12 |
+
|
| 13 |
+
export type RecordedClip = {
|
| 14 |
+
step: number;
|
| 15 |
+
mediaType: 'video' | 'audio';
|
| 16 |
+
blob: Blob;
|
| 17 |
+
durationSeconds: number;
|
| 18 |
+
ext: 'webm' | 'mp4' | 'mov';
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
type Listener = () => void;
|
| 22 |
+
|
| 23 |
+
type Snapshot = {
|
| 24 |
+
clipsByStep: Record<number, RecordedClip>;
|
| 25 |
+
orderedClips: RecordedClip[];
|
| 26 |
+
skippedSteps: Record<number, true>;
|
| 27 |
+
socialHandle: string;
|
| 28 |
+
tableId: string | null;
|
| 29 |
+
slug: string | null;
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
let clipsByStep: Record<number, RecordedClip> = {};
|
| 33 |
+
let skippedSteps: Record<number, true> = {};
|
| 34 |
+
let socialHandle = '';
|
| 35 |
+
let tableId: string | null = null;
|
| 36 |
+
let slug: string | null = null;
|
| 37 |
+
const listeners = new Set<Listener>();
|
| 38 |
+
|
| 39 |
+
function emit() {
|
| 40 |
+
for (const l of listeners) l();
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
function buildSnapshot(): Snapshot {
|
| 44 |
+
return {
|
| 45 |
+
clipsByStep,
|
| 46 |
+
orderedClips: Object.values(clipsByStep).sort((a, b) => a.step - b.step),
|
| 47 |
+
skippedSteps,
|
| 48 |
+
socialHandle,
|
| 49 |
+
tableId,
|
| 50 |
+
slug,
|
| 51 |
+
};
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
export const recordingStore = {
|
| 55 |
+
setClip(clip: RecordedClip) {
|
| 56 |
+
clipsByStep = { ...clipsByStep, [clip.step]: clip };
|
| 57 |
+
const nextSkipped = { ...skippedSteps };
|
| 58 |
+
delete nextSkipped[clip.step];
|
| 59 |
+
skippedSteps = nextSkipped;
|
| 60 |
+
emit();
|
| 61 |
+
},
|
| 62 |
+
removeClip(step: number) {
|
| 63 |
+
const next = { ...clipsByStep };
|
| 64 |
+
delete next[step];
|
| 65 |
+
clipsByStep = next;
|
| 66 |
+
emit();
|
| 67 |
+
},
|
| 68 |
+
skipStep(step: number) {
|
| 69 |
+
const nextClips = { ...clipsByStep };
|
| 70 |
+
delete nextClips[step];
|
| 71 |
+
clipsByStep = nextClips;
|
| 72 |
+
skippedSteps = { ...skippedSteps, [step]: true };
|
| 73 |
+
emit();
|
| 74 |
+
},
|
| 75 |
+
setMeta(meta: { slug?: string; tableId?: string | null; socialHandle?: string }) {
|
| 76 |
+
if (meta.slug !== undefined) slug = meta.slug;
|
| 77 |
+
if (meta.tableId !== undefined) tableId = meta.tableId;
|
| 78 |
+
if (meta.socialHandle !== undefined) socialHandle = meta.socialHandle;
|
| 79 |
+
emit();
|
| 80 |
+
},
|
| 81 |
+
reset() {
|
| 82 |
+
clipsByStep = {};
|
| 83 |
+
skippedSteps = {};
|
| 84 |
+
socialHandle = '';
|
| 85 |
+
tableId = null;
|
| 86 |
+
slug = null;
|
| 87 |
+
emit();
|
| 88 |
+
},
|
| 89 |
+
snapshot(): Snapshot {
|
| 90 |
+
return buildSnapshot();
|
| 91 |
+
},
|
| 92 |
+
subscribe(listener: Listener) {
|
| 93 |
+
listeners.add(listener);
|
| 94 |
+
return () => {
|
| 95 |
+
listeners.delete(listener);
|
| 96 |
+
};
|
| 97 |
+
},
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
export function useRecordingStore(): Snapshot {
|
| 101 |
+
const [snap, setSnap] = useState<Snapshot>(() => buildSnapshot());
|
| 102 |
+
useEffect(() => {
|
| 103 |
+
const unsubscribe = recordingStore.subscribe(() => setSnap(buildSnapshot()));
|
| 104 |
+
return () => {
|
| 105 |
+
unsubscribe();
|
| 106 |
+
};
|
| 107 |
+
}, []);
|
| 108 |
+
return snap;
|
| 109 |
+
}
|
src/lib/reviews/public.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Display string helpers — copied verbatim from reference/src/lib/reviews/public.ts
|
| 3 |
+
* (Humeo's source of truth). Keep in sync if Humeo's copy diverges.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import type { PublicReviewCampaign } from '@/lib/reviews/types';
|
| 7 |
+
|
| 8 |
+
export type PublicReviewSubmitReward = {
|
| 9 |
+
type?: string;
|
| 10 |
+
value?: string;
|
| 11 |
+
} | null | undefined;
|
| 12 |
+
|
| 13 |
+
export type PublicRewardDisplay = {
|
| 14 |
+
title: string;
|
| 15 |
+
value: string;
|
| 16 |
+
detail: string | null;
|
| 17 |
+
actionHref: string | null;
|
| 18 |
+
actionLabel: string | null;
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
const FAILURE_REASON_COPY: Record<string, string> = {
|
| 22 |
+
daily_limit_reached: 'This device has already claimed a reward for this campaign today.',
|
| 23 |
+
missing_consent: 'Accept the rights-to-use consent before submitting.',
|
| 24 |
+
empty_transcript: 'Speak clearly so the review can be transcribed.',
|
| 25 |
+
too_short: 'Make the review long enough before submitting again.',
|
| 26 |
+
too_long: 'Keep the review shorter and more focused.',
|
| 27 |
+
too_few_words: 'Say a bit more so the review has enough spoken detail.',
|
| 28 |
+
restaurant_not_mentioned: 'Mention the restaurant clearly in the review.',
|
| 29 |
+
blocked_term_detected: 'Avoid profanity or abusive language.',
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
const isHttpUrl = (value: string) => {
|
| 33 |
+
try {
|
| 34 |
+
const url = new URL(value);
|
| 35 |
+
return url.protocol === 'http:' || url.protocol === 'https:';
|
| 36 |
+
} catch {
|
| 37 |
+
return false;
|
| 38 |
+
}
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
export async function getVideoDuration(file: File | Blob) {
|
| 42 |
+
return await new Promise<number>((resolve) => {
|
| 43 |
+
const url = URL.createObjectURL(file);
|
| 44 |
+
const video = document.createElement('video');
|
| 45 |
+
video.preload = 'metadata';
|
| 46 |
+
video.onloadedmetadata = () => {
|
| 47 |
+
resolve(Number.isFinite(video.duration) ? Math.max(0, video.duration) : 0);
|
| 48 |
+
URL.revokeObjectURL(url);
|
| 49 |
+
};
|
| 50 |
+
video.onerror = () => {
|
| 51 |
+
resolve(0);
|
| 52 |
+
URL.revokeObjectURL(url);
|
| 53 |
+
};
|
| 54 |
+
video.src = url;
|
| 55 |
+
});
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
export function buildPublicReviewIntro(campaign: PublicReviewCampaign) {
|
| 59 |
+
return `Share a short video review for ${campaign.restaurantName}.`;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
export function buildPublicReviewChecklist(campaign: PublicReviewCampaign) {
|
| 63 |
+
const checklist = [
|
| 64 |
+
`Keep it between ${campaign.rulesConfig.minDurationSeconds} and ${campaign.rulesConfig.maxDurationSeconds} seconds.`,
|
| 65 |
+
`Speak clearly and say at least ${campaign.rulesConfig.minWordCount} words.`,
|
| 66 |
+
];
|
| 67 |
+
|
| 68 |
+
if (campaign.rulesConfig.requireRestaurantMention) {
|
| 69 |
+
checklist.push(`Mention ${campaign.restaurantName} by name in the review.`);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
checklist.push('Choose a quiet, well-lit spot and keep the camera steady.');
|
| 73 |
+
|
| 74 |
+
return checklist;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
export function buildReviewFailureGuidance(reasons: string[] | undefined) {
|
| 78 |
+
const seen = new Set<string>();
|
| 79 |
+
const guidance: string[] = [];
|
| 80 |
+
|
| 81 |
+
for (const reason of reasons ?? []) {
|
| 82 |
+
const copy = FAILURE_REASON_COPY[reason];
|
| 83 |
+
if (!copy || seen.has(copy)) continue;
|
| 84 |
+
seen.add(copy);
|
| 85 |
+
guidance.push(copy);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
return guidance;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
export function buildRewardDisplay(reward: PublicReviewSubmitReward): PublicRewardDisplay {
|
| 92 |
+
const type = reward?.type ?? '';
|
| 93 |
+
const value = (reward?.value ?? '').trim();
|
| 94 |
+
|
| 95 |
+
if (type === 'external_redirect') {
|
| 96 |
+
const actionHref = isHttpUrl(value) ? value : null;
|
| 97 |
+
return {
|
| 98 |
+
title: 'Reward unlocked',
|
| 99 |
+
value: actionHref ? 'Open your reward link' : value || 'Reward link ready',
|
| 100 |
+
detail: actionHref ? 'Use the link below to claim the offer.' : null,
|
| 101 |
+
actionHref,
|
| 102 |
+
actionLabel: actionHref ? 'Open reward' : null,
|
| 103 |
+
};
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
if (type === 'message_only') {
|
| 107 |
+
return {
|
| 108 |
+
title: 'Reward unlocked',
|
| 109 |
+
value: value || 'Reward issued',
|
| 110 |
+
detail: 'Show this message to the team if redemption instructions are needed.',
|
| 111 |
+
actionHref: null,
|
| 112 |
+
actionLabel: null,
|
| 113 |
+
};
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
return {
|
| 117 |
+
title: 'Coupon unlocked',
|
| 118 |
+
value: value || 'Reward issued',
|
| 119 |
+
detail: 'Keep this code handy for redemption.',
|
| 120 |
+
actionHref: null,
|
| 121 |
+
actionLabel: null,
|
| 122 |
+
};
|
| 123 |
+
}
|
src/lib/reviews/types.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Review types — copied (and extended) from reference/src/lib/reviews/types.ts
|
| 3 |
+
* (Humeo's source of truth). Stays aligned with whatever
|
| 4 |
+
* ${NEXT_PUBLIC_HUMEO_API_URL}/api/public/reviews/* returns.
|
| 5 |
+
*
|
| 6 |
+
* Extensions for matcha-moments (cafe pilot):
|
| 7 |
+
* - CampaignMode: 'single_take' | 'guided_clips'
|
| 8 |
+
* - ClipPrompt: per-clip config (camera direction, hard-cap)
|
| 9 |
+
* - mode / prompts / theme fields on PublicReviewCampaign
|
| 10 |
+
*
|
| 11 |
+
* If Humeo's review_campaigns table doesn't yet have these columns,
|
| 12 |
+
* humeoApi.getCampaign() falls back to a hardcoded prompts list.
|
| 13 |
+
* See src/lib/humeoApi.ts for that fallback.
|
| 14 |
+
*/
|
| 15 |
+
|
| 16 |
+
import { z } from 'zod';
|
| 17 |
+
|
| 18 |
+
export const REVIEW_CAMPAIGN_STATUSES = ['draft', 'active', 'paused', 'archived'] as const;
|
| 19 |
+
export const REVIEW_REWARD_TYPES = ['static_code', 'message_only', 'external_redirect'] as const;
|
| 20 |
+
export const REVIEW_SUBMISSION_STATUSES = [
|
| 21 |
+
'opened',
|
| 22 |
+
'uploading',
|
| 23 |
+
'processing_interview',
|
| 24 |
+
'evaluating_rules',
|
| 25 |
+
'pass',
|
| 26 |
+
'fail_and_retry',
|
| 27 |
+
'reward_issued',
|
| 28 |
+
'processing_failed',
|
| 29 |
+
] as const;
|
| 30 |
+
|
| 31 |
+
export type ReviewCampaignStatus = (typeof REVIEW_CAMPAIGN_STATUSES)[number];
|
| 32 |
+
export type ReviewRewardType = (typeof REVIEW_REWARD_TYPES)[number];
|
| 33 |
+
export type ReviewSubmissionStatus = (typeof REVIEW_SUBMISSION_STATUSES)[number];
|
| 34 |
+
|
| 35 |
+
export const ReviewRulesConfigSchema = z.object({
|
| 36 |
+
minDurationSeconds: z.number().int().min(0).max(600).default(8),
|
| 37 |
+
maxDurationSeconds: z.number().int().min(1).max(1200).default(90),
|
| 38 |
+
minWordCount: z.number().int().min(1).max(500).default(8),
|
| 39 |
+
requireRestaurantMention: z.boolean().default(false),
|
| 40 |
+
blockedTerms: z.array(z.string().trim().min(1).max(80)).max(100).default([]),
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
export const ReviewCampaignSettingsSchema = z.object({
|
| 44 |
+
dailyRewardLimitPerDevice: z.number().int().min(1).max(10).default(1),
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
export type ReviewRulesConfig = z.infer<typeof ReviewRulesConfigSchema>;
|
| 48 |
+
export type ReviewCampaignSettings = z.infer<typeof ReviewCampaignSettingsSchema>;
|
| 49 |
+
|
| 50 |
+
// === matcha-moments extensions ===
|
| 51 |
+
|
| 52 |
+
export const CAMPAIGN_MODES = ['single_take', 'guided_clips'] as const;
|
| 53 |
+
export type CampaignMode = (typeof CAMPAIGN_MODES)[number];
|
| 54 |
+
|
| 55 |
+
export const CAMPAIGN_THEMES = ['default', 'cafe-cream'] as const;
|
| 56 |
+
export type CampaignTheme = (typeof CAMPAIGN_THEMES)[number];
|
| 57 |
+
|
| 58 |
+
export const ClipPromptSchema = z.object({
|
| 59 |
+
step: z.number().int().min(1).max(20),
|
| 60 |
+
title: z.string().trim().min(1).max(160),
|
| 61 |
+
tip: z.string().trim().max(280).default(''),
|
| 62 |
+
mediaType: z.enum(['video', 'audio']).default('video'),
|
| 63 |
+
camera: z.enum(['front', 'rear']).default('front'),
|
| 64 |
+
maxSeconds: z.number().int().min(1).max(60).default(10),
|
| 65 |
+
optional: z.boolean().default(false),
|
| 66 |
+
});
|
| 67 |
+
export type ClipPrompt = z.infer<typeof ClipPromptSchema>;
|
| 68 |
+
|
| 69 |
+
// === Public API contract ===
|
| 70 |
+
|
| 71 |
+
export type PublicReviewCampaign = {
|
| 72 |
+
id: string;
|
| 73 |
+
slug: string;
|
| 74 |
+
restaurantName: string;
|
| 75 |
+
status: ReviewCampaignStatus;
|
| 76 |
+
rulesConfig: ReviewRulesConfig;
|
| 77 |
+
settings: ReviewCampaignSettings;
|
| 78 |
+
|
| 79 |
+
// matcha-moments extensions; safe defaults if Humeo BE doesn't return them yet.
|
| 80 |
+
mode: CampaignMode;
|
| 81 |
+
prompts: ClipPrompt[];
|
| 82 |
+
rewardType: ReviewRewardType;
|
| 83 |
+
rewardValue: string | null;
|
| 84 |
+
theme: CampaignTheme;
|
| 85 |
+
accentColor?: string;
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
export type PublicSubmitResult = {
|
| 89 |
+
submissionId: string;
|
| 90 |
+
interviewId?: string;
|
| 91 |
+
status: ReviewSubmissionStatus;
|
| 92 |
+
decision: 'pass' | 'fail_and_retry' | null;
|
| 93 |
+
feedback: string;
|
| 94 |
+
reward?: { type?: ReviewRewardType; value?: string } | null;
|
| 95 |
+
reasons?: string[];
|
| 96 |
+
previewUrl?: string;
|
| 97 |
+
updatedAt: string;
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
export const POLLING_SUBMISSION_STATUSES = new Set<ReviewSubmissionStatus>([
|
| 101 |
+
'opened',
|
| 102 |
+
'uploading',
|
| 103 |
+
'processing_interview',
|
| 104 |
+
'evaluating_rules',
|
| 105 |
+
'fail_and_retry',
|
| 106 |
+
]);
|
| 107 |
+
|
| 108 |
+
export function normalizeReviewRulesConfig(value: unknown): ReviewRulesConfig {
|
| 109 |
+
return ReviewRulesConfigSchema.parse(value ?? {});
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
export function normalizeReviewCampaignSettings(value: unknown): ReviewCampaignSettings {
|
| 113 |
+
return ReviewCampaignSettingsSchema.parse(value ?? {});
|
| 114 |
+
}
|
src/lib/server/reviewStore.ts
ADDED
|
@@ -0,0 +1,746 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
| 2 |
+
import { stat, writeFile } from 'fs/promises';
|
| 3 |
+
import path from 'path';
|
| 4 |
+
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
|
| 5 |
+
import type {
|
| 6 |
+
PublicReviewCampaign,
|
| 7 |
+
PublicSubmitResult,
|
| 8 |
+
ReviewRewardType,
|
| 9 |
+
ReviewSubmissionStatus,
|
| 10 |
+
} from '@/lib/reviews/types';
|
| 11 |
+
|
| 12 |
+
/**
|
| 13 |
+
* Review store for the standalone prototype.
|
| 14 |
+
*
|
| 15 |
+
* Preferred mode: Supabase Storage
|
| 16 |
+
* review-videos/videos/<submissionId>.<ext>
|
| 17 |
+
* review-videos/submissions/<submissionId>.json
|
| 18 |
+
*
|
| 19 |
+
* Local file fallback remains available when Supabase env vars are absent.
|
| 20 |
+
*/
|
| 21 |
+
|
| 22 |
+
export type LocalSubmission = {
|
| 23 |
+
submissionId: string;
|
| 24 |
+
interviewId: string;
|
| 25 |
+
campaignSlug: string;
|
| 26 |
+
status: ReviewSubmissionStatus;
|
| 27 |
+
decision: 'pass' | 'fail_and_retry' | null;
|
| 28 |
+
feedback: string;
|
| 29 |
+
reward: { type: ReviewRewardType; value: string } | null;
|
| 30 |
+
reasons: string[];
|
| 31 |
+
consentAccepted: boolean;
|
| 32 |
+
socialHandle: string | null;
|
| 33 |
+
deviceKey: string | null;
|
| 34 |
+
tableId: string | null;
|
| 35 |
+
durationSeconds: number;
|
| 36 |
+
videoSize: number;
|
| 37 |
+
videoMime: string;
|
| 38 |
+
videoFileName: string;
|
| 39 |
+
storagePath: string;
|
| 40 |
+
storageBackend: 'local' | 'supabase';
|
| 41 |
+
createdAt: number;
|
| 42 |
+
updatedAt: string;
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
export type AdminReviewSubmission = LocalSubmission & {
|
| 46 |
+
restaurantName: string;
|
| 47 |
+
previewUrl: string;
|
| 48 |
+
createdAtIso: string;
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
type StoreState = {
|
| 52 |
+
campaigns: Map<string, PublicReviewCampaign>;
|
| 53 |
+
submissions: Map<string, LocalSubmission>;
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
type LocalVideoResult = {
|
| 57 |
+
kind: 'local';
|
| 58 |
+
submission: LocalSubmission;
|
| 59 |
+
filePath: string;
|
| 60 |
+
size: number;
|
| 61 |
+
contentType: string;
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
type SupabaseVideoResult = {
|
| 65 |
+
kind: 'supabase';
|
| 66 |
+
submission: LocalSubmission;
|
| 67 |
+
storagePath: string;
|
| 68 |
+
size: number;
|
| 69 |
+
contentType: string;
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
export type SubmissionVideoResult = LocalVideoResult | SupabaseVideoResult;
|
| 73 |
+
|
| 74 |
+
const LOCAL_DATA_DIR = path.join(process.cwd(), '.local-review-data');
|
| 75 |
+
const UPLOADS_DIR = path.join(LOCAL_DATA_DIR, 'uploads');
|
| 76 |
+
const SUBMISSIONS_FILE = path.join(LOCAL_DATA_DIR, 'submissions.json');
|
| 77 |
+
const ENV_FILE = path.join(process.cwd(), '.env.local');
|
| 78 |
+
const DEFAULT_BUCKET = 'review-videos';
|
| 79 |
+
|
| 80 |
+
const SAGE_AND_STONE: PublicReviewCampaign = {
|
| 81 |
+
id: 'sage-and-stone-cafe',
|
| 82 |
+
slug: 'sageandstone',
|
| 83 |
+
restaurantName: 'Sage & Stone Cafe',
|
| 84 |
+
status: 'active',
|
| 85 |
+
rulesConfig: {
|
| 86 |
+
minDurationSeconds: 8,
|
| 87 |
+
maxDurationSeconds: 90,
|
| 88 |
+
minWordCount: 8,
|
| 89 |
+
requireRestaurantMention: false,
|
| 90 |
+
blockedTerms: [],
|
| 91 |
+
},
|
| 92 |
+
settings: { dailyRewardLimitPerDevice: 1 },
|
| 93 |
+
mode: 'guided_clips',
|
| 94 |
+
prompts: [
|
| 95 |
+
{
|
| 96 |
+
step: 1,
|
| 97 |
+
title: 'Close-up pan of the food.',
|
| 98 |
+
tip: 'Move slowly across the texture, sauce, steam, and toppings.',
|
| 99 |
+
mediaType: 'video',
|
| 100 |
+
camera: 'rear',
|
| 101 |
+
maxSeconds: 10,
|
| 102 |
+
optional: false,
|
| 103 |
+
},
|
| 104 |
+
{
|
| 105 |
+
step: 2,
|
| 106 |
+
title: 'Wide shot of the table.',
|
| 107 |
+
tip: 'Show the full plate, drink, table setup, and a little cafe vibe.',
|
| 108 |
+
mediaType: 'video',
|
| 109 |
+
camera: 'rear',
|
| 110 |
+
maxSeconds: 10,
|
| 111 |
+
optional: false,
|
| 112 |
+
},
|
| 113 |
+
{
|
| 114 |
+
step: 3,
|
| 115 |
+
title: 'Action detail of the food.',
|
| 116 |
+
tip: 'Cut, scoop, pour, lift, or show the best bite without focusing on your face.',
|
| 117 |
+
mediaType: 'video',
|
| 118 |
+
camera: 'rear',
|
| 119 |
+
maxSeconds: 10,
|
| 120 |
+
optional: false,
|
| 121 |
+
},
|
| 122 |
+
{
|
| 123 |
+
step: 4,
|
| 124 |
+
title: 'What did you order?',
|
| 125 |
+
tip: 'Voice only. Say the dish name and describe what is on the plate.',
|
| 126 |
+
mediaType: 'audio',
|
| 127 |
+
camera: 'front',
|
| 128 |
+
maxSeconds: 8,
|
| 129 |
+
optional: false,
|
| 130 |
+
},
|
| 131 |
+
{
|
| 132 |
+
step: 5,
|
| 133 |
+
title: 'What did you like about it?',
|
| 134 |
+
tip: 'Voice only. Mention the flavor, texture, portion, or what stood out.',
|
| 135 |
+
mediaType: 'audio',
|
| 136 |
+
camera: 'front',
|
| 137 |
+
maxSeconds: 12,
|
| 138 |
+
optional: false,
|
| 139 |
+
},
|
| 140 |
+
{
|
| 141 |
+
step: 6,
|
| 142 |
+
title: 'Optional: quick reaction shot.',
|
| 143 |
+
tip: 'Take one bite or sip and react naturally. Skip if you would rather keep it food-only.',
|
| 144 |
+
mediaType: 'video',
|
| 145 |
+
camera: 'front',
|
| 146 |
+
maxSeconds: 4,
|
| 147 |
+
optional: true,
|
| 148 |
+
},
|
| 149 |
+
],
|
| 150 |
+
rewardType: 'static_code',
|
| 151 |
+
rewardValue: null,
|
| 152 |
+
theme: 'cafe-cream',
|
| 153 |
+
};
|
| 154 |
+
|
| 155 |
+
let supabaseAdmin: SupabaseClient | null = null;
|
| 156 |
+
let bucketReady = false;
|
| 157 |
+
let envFileCache: Record<string, string> | null = null;
|
| 158 |
+
|
| 159 |
+
function readEnvFile() {
|
| 160 |
+
if (envFileCache) return envFileCache;
|
| 161 |
+
envFileCache = {};
|
| 162 |
+
|
| 163 |
+
if (!existsSync(ENV_FILE)) return envFileCache;
|
| 164 |
+
|
| 165 |
+
try {
|
| 166 |
+
const raw = readFileSync(ENV_FILE, 'utf8');
|
| 167 |
+
for (const line of raw.split(/\r?\n/)) {
|
| 168 |
+
const trimmed = line.trim();
|
| 169 |
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
| 170 |
+
|
| 171 |
+
const splitAt = trimmed.indexOf('=');
|
| 172 |
+
if (splitAt <= 0) continue;
|
| 173 |
+
|
| 174 |
+
const key = trimmed.slice(0, splitAt).trim();
|
| 175 |
+
const value = trimmed.slice(splitAt + 1).trim();
|
| 176 |
+
envFileCache[key] = value.replace(/^['"]|['"]$/g, '');
|
| 177 |
+
}
|
| 178 |
+
} catch {
|
| 179 |
+
envFileCache = {};
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
return envFileCache;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
function serverEnv(name: string) {
|
| 186 |
+
return process.env[name] || readEnvFile()[name] || '';
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
function hasSupabaseConfig() {
|
| 190 |
+
return Boolean(serverEnv('SUPABASE_URL') && serverEnv('SUPABASE_SERVICE_ROLE_KEY'));
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
function getBucketName() {
|
| 194 |
+
return serverEnv('SUPABASE_REVIEW_VIDEO_BUCKET') || DEFAULT_BUCKET;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
function getSupabaseAdmin() {
|
| 198 |
+
if (!hasSupabaseConfig()) return null;
|
| 199 |
+
if (!supabaseAdmin) {
|
| 200 |
+
supabaseAdmin = createClient(serverEnv('SUPABASE_URL'), serverEnv('SUPABASE_SERVICE_ROLE_KEY'), {
|
| 201 |
+
auth: {
|
| 202 |
+
autoRefreshToken: false,
|
| 203 |
+
persistSession: false,
|
| 204 |
+
},
|
| 205 |
+
global: {
|
| 206 |
+
fetch: (input, init) => fetch(input, { ...init, cache: 'no-store' }),
|
| 207 |
+
},
|
| 208 |
+
});
|
| 209 |
+
}
|
| 210 |
+
return supabaseAdmin;
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
function supabaseObjectUrl(storagePath: string) {
|
| 214 |
+
const baseUrl = serverEnv('SUPABASE_URL').replace(/\/$/, '');
|
| 215 |
+
const encodedBucket = encodeURIComponent(getBucketName());
|
| 216 |
+
const encodedPath = storagePath.split('/').map(encodeURIComponent).join('/');
|
| 217 |
+
return `${baseUrl}/storage/v1/object/${encodedBucket}/${encodedPath}`;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
async function ensureSupabaseBucket(client: SupabaseClient) {
|
| 221 |
+
if (bucketReady) return;
|
| 222 |
+
const bucket = getBucketName();
|
| 223 |
+
const { data } = await client.storage.getBucket(bucket);
|
| 224 |
+
if (!data) {
|
| 225 |
+
const { error } = await client.storage.createBucket(bucket, {
|
| 226 |
+
public: false,
|
| 227 |
+
});
|
| 228 |
+
if (error && !error.message.toLowerCase().includes('already exists')) {
|
| 229 |
+
throw error;
|
| 230 |
+
}
|
| 231 |
+
}
|
| 232 |
+
bucketReady = true;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
function ensureLocalDirs() {
|
| 236 |
+
mkdirSync(UPLOADS_DIR, { recursive: true });
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
function readPersistedSubmissions() {
|
| 240 |
+
if (!existsSync(SUBMISSIONS_FILE)) return [];
|
| 241 |
+
|
| 242 |
+
try {
|
| 243 |
+
const raw = readFileSync(SUBMISSIONS_FILE, 'utf8');
|
| 244 |
+
const parsed = JSON.parse(raw) as unknown;
|
| 245 |
+
if (!Array.isArray(parsed)) return [];
|
| 246 |
+
return parsed.filter(isLocalSubmission);
|
| 247 |
+
} catch {
|
| 248 |
+
return [];
|
| 249 |
+
}
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
function isLocalSubmission(value: unknown): value is LocalSubmission {
|
| 253 |
+
if (!value || typeof value !== 'object') return false;
|
| 254 |
+
const maybe = value as Partial<LocalSubmission>;
|
| 255 |
+
return (
|
| 256 |
+
typeof maybe.submissionId === 'string' &&
|
| 257 |
+
typeof maybe.interviewId === 'string' &&
|
| 258 |
+
typeof maybe.campaignSlug === 'string' &&
|
| 259 |
+
typeof maybe.status === 'string' &&
|
| 260 |
+
typeof maybe.storagePath === 'string' &&
|
| 261 |
+
typeof maybe.createdAt === 'number'
|
| 262 |
+
);
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
function normalizeSubmission(value: LocalSubmission): LocalSubmission {
|
| 266 |
+
return {
|
| 267 |
+
...value,
|
| 268 |
+
storageBackend: value.storageBackend ?? 'local',
|
| 269 |
+
};
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
function createState(): StoreState {
|
| 273 |
+
const campaigns = new Map<string, PublicReviewCampaign>();
|
| 274 |
+
campaigns.set(SAGE_AND_STONE.slug, SAGE_AND_STONE);
|
| 275 |
+
|
| 276 |
+
const submissions = new Map<string, LocalSubmission>();
|
| 277 |
+
for (const submission of readPersistedSubmissions()) {
|
| 278 |
+
const normalized = normalizeSubmission(submission);
|
| 279 |
+
submissions.set(normalized.submissionId, normalized);
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
return { campaigns, submissions };
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
const globalForStore = globalThis as unknown as {
|
| 286 |
+
__matchaReviewStore?: StoreState;
|
| 287 |
+
};
|
| 288 |
+
|
| 289 |
+
const state = globalForStore.__matchaReviewStore ?? createState();
|
| 290 |
+
state.campaigns.set(SAGE_AND_STONE.slug, SAGE_AND_STONE);
|
| 291 |
+
for (const [id, submission] of state.submissions.entries()) {
|
| 292 |
+
if (!isLocalSubmission(submission)) {
|
| 293 |
+
state.submissions.delete(id);
|
| 294 |
+
} else {
|
| 295 |
+
state.submissions.set(id, normalizeSubmission(submission));
|
| 296 |
+
}
|
| 297 |
+
}
|
| 298 |
+
if (process.env.NODE_ENV !== 'production') {
|
| 299 |
+
globalForStore.__matchaReviewStore = state;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
function persistLocalSubmissions() {
|
| 303 |
+
ensureLocalDirs();
|
| 304 |
+
const submissions = [...state.submissions.values()]
|
| 305 |
+
.filter((submission) => submission.storageBackend === 'local')
|
| 306 |
+
.sort((a, b) => a.createdAt - b.createdAt);
|
| 307 |
+
writeFileSync(SUBMISSIONS_FILE, `${JSON.stringify(submissions, null, 2)}\n`, 'utf8');
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
export function getCampaignBySlug(slug: string): PublicReviewCampaign | null {
|
| 311 |
+
return state.campaigns.get(slug) ?? null;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
function newId(prefix: string) {
|
| 315 |
+
const rand = Math.random().toString(36).slice(2, 10);
|
| 316 |
+
return `${prefix}_${Date.now().toString(36)}_${rand}`;
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
function rewardCode() {
|
| 320 |
+
return `MATCHA-${Math.random().toString(36).slice(2, 6).toUpperCase()}`;
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
function resolveVideoExt(fileName: string, mime: string) {
|
| 324 |
+
const lowerName = fileName.toLowerCase();
|
| 325 |
+
const lowerMime = mime.toLowerCase();
|
| 326 |
+
if (lowerName.endsWith('.mp4') || lowerMime.includes('mp4')) return 'mp4';
|
| 327 |
+
if (lowerName.endsWith('.mov') || lowerMime.includes('quicktime')) return 'mov';
|
| 328 |
+
if (lowerName.endsWith('.webm') || lowerMime.includes('webm')) return 'webm';
|
| 329 |
+
return 'webm';
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
function buildPreviewUrl(submissionId: string) {
|
| 333 |
+
return `/api/public/reviews/video/${encodeURIComponent(submissionId)}`;
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
function localStoragePathFor(submissionId: string, ext: string) {
|
| 337 |
+
return `uploads/${submissionId}.${ext}`;
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
function supabaseVideoPathFor(submissionId: string, ext: string) {
|
| 341 |
+
return `videos/${submissionId}.${ext}`;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
function metadataPathFor(submissionId: string) {
|
| 345 |
+
return `submissions/${submissionId}.json`;
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
function absoluteStoragePath(storagePath: string) {
|
| 349 |
+
const absolute = path.resolve(LOCAL_DATA_DIR, storagePath);
|
| 350 |
+
const localRoot = path.resolve(LOCAL_DATA_DIR);
|
| 351 |
+
if (absolute !== localRoot && !absolute.startsWith(`${localRoot}${path.sep}`)) {
|
| 352 |
+
return null;
|
| 353 |
+
}
|
| 354 |
+
return absolute;
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
async function textFromDownloadedData(data: unknown) {
|
| 358 |
+
if (typeof data === 'string') return data;
|
| 359 |
+
|
| 360 |
+
if (data && typeof data === 'object') {
|
| 361 |
+
const maybeBlob = data as {
|
| 362 |
+
text?: () => Promise<string>;
|
| 363 |
+
arrayBuffer?: () => Promise<ArrayBuffer>;
|
| 364 |
+
};
|
| 365 |
+
|
| 366 |
+
if (typeof maybeBlob.text === 'function') {
|
| 367 |
+
return maybeBlob.text();
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
if (typeof maybeBlob.arrayBuffer === 'function') {
|
| 371 |
+
return Buffer.from(await maybeBlob.arrayBuffer()).toString('utf8');
|
| 372 |
+
}
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8');
|
| 376 |
+
if (Buffer.isBuffer(data)) return data.toString('utf8');
|
| 377 |
+
throw new Error('Unsupported downloaded data type');
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
async function persistSupabaseSubmission(client: SupabaseClient, submission: LocalSubmission) {
|
| 381 |
+
await ensureSupabaseBucket(client);
|
| 382 |
+
const body = JSON.stringify(submission, null, 2);
|
| 383 |
+
const { error } = await client.storage
|
| 384 |
+
.from(getBucketName())
|
| 385 |
+
.upload(metadataPathFor(submission.submissionId), body, {
|
| 386 |
+
contentType: 'application/json',
|
| 387 |
+
upsert: true,
|
| 388 |
+
});
|
| 389 |
+
if (error) throw error;
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
async function loadSupabaseSubmission(
|
| 393 |
+
client: SupabaseClient,
|
| 394 |
+
submissionId: string,
|
| 395 |
+
): Promise<LocalSubmission | null> {
|
| 396 |
+
await ensureSupabaseBucket(client);
|
| 397 |
+
const { data, error } = await client.storage
|
| 398 |
+
.from(getBucketName())
|
| 399 |
+
.download(metadataPathFor(submissionId));
|
| 400 |
+
if (error || !data) {
|
| 401 |
+
if (error) {
|
| 402 |
+
console.warn('[matcha-moments/review-store] failed to download Supabase metadata', {
|
| 403 |
+
submissionId,
|
| 404 |
+
message: error.message,
|
| 405 |
+
});
|
| 406 |
+
}
|
| 407 |
+
return null;
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
try {
|
| 411 |
+
const text = await textFromDownloadedData(data);
|
| 412 |
+
const parsed = JSON.parse(text) as unknown;
|
| 413 |
+
if (!isLocalSubmission(parsed)) {
|
| 414 |
+
console.warn('[matcha-moments/review-store] invalid Supabase metadata', {
|
| 415 |
+
submissionId,
|
| 416 |
+
});
|
| 417 |
+
return null;
|
| 418 |
+
}
|
| 419 |
+
return normalizeSubmission(parsed);
|
| 420 |
+
} catch (err) {
|
| 421 |
+
console.warn('[matcha-moments/review-store] failed to parse Supabase metadata', {
|
| 422 |
+
submissionId,
|
| 423 |
+
message: err instanceof Error ? err.message : 'Unknown error',
|
| 424 |
+
});
|
| 425 |
+
return null;
|
| 426 |
+
}
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
async function listSupabaseSubmissions(client: SupabaseClient) {
|
| 430 |
+
await ensureSupabaseBucket(client);
|
| 431 |
+
const { data, error } = await client.storage
|
| 432 |
+
.from(getBucketName())
|
| 433 |
+
.list('submissions', {
|
| 434 |
+
limit: 200,
|
| 435 |
+
sortBy: { column: 'created_at', order: 'desc' },
|
| 436 |
+
});
|
| 437 |
+
if (error || !data) {
|
| 438 |
+
if (error) {
|
| 439 |
+
console.warn('[matcha-moments/review-store] failed to list Supabase submissions', error);
|
| 440 |
+
}
|
| 441 |
+
return [];
|
| 442 |
+
}
|
| 443 |
+
const submissions = await Promise.all(
|
| 444 |
+
data
|
| 445 |
+
.filter((item) => item.name.endsWith('.json'))
|
| 446 |
+
.map((item) => loadSupabaseSubmission(client, item.name.replace(/\.json$/, ''))),
|
| 447 |
+
);
|
| 448 |
+
|
| 449 |
+
return submissions.filter((submission): submission is LocalSubmission => Boolean(submission));
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
function mergeSubmissions(...groups: LocalSubmission[][]) {
|
| 453 |
+
const merged = new Map<string, LocalSubmission>();
|
| 454 |
+
for (const group of groups) {
|
| 455 |
+
for (const submission of group) {
|
| 456 |
+
merged.set(submission.submissionId, normalizeSubmission(submission));
|
| 457 |
+
}
|
| 458 |
+
}
|
| 459 |
+
return [...merged.values()];
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
async function persistSubmission(submission: LocalSubmission) {
|
| 463 |
+
state.submissions.set(submission.submissionId, submission);
|
| 464 |
+
|
| 465 |
+
if (submission.storageBackend === 'supabase') {
|
| 466 |
+
const client = getSupabaseAdmin();
|
| 467 |
+
if (!client) throw new Error('Supabase is not configured');
|
| 468 |
+
await persistSupabaseSubmission(client, submission);
|
| 469 |
+
return;
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
persistLocalSubmissions();
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
function advanceSubmission(submission: LocalSubmission) {
|
| 476 |
+
if (
|
| 477 |
+
submission.status === 'reward_issued' ||
|
| 478 |
+
submission.status === 'processing_failed' ||
|
| 479 |
+
submission.status === 'fail_and_retry'
|
| 480 |
+
) {
|
| 481 |
+
return false;
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
const campaign = getCampaignBySlug(submission.campaignSlug);
|
| 485 |
+
if (!campaign) return false;
|
| 486 |
+
|
| 487 |
+
const elapsed = Date.now() - submission.createdAt;
|
| 488 |
+
let changed = false;
|
| 489 |
+
|
| 490 |
+
if (submission.status === 'opened' && elapsed >= 1000) {
|
| 491 |
+
submission.status = 'processing_interview';
|
| 492 |
+
changed = true;
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
if (submission.status === 'processing_interview' && elapsed >= 2500) {
|
| 496 |
+
submission.status = 'evaluating_rules';
|
| 497 |
+
changed = true;
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
if (submission.status === 'evaluating_rules' && elapsed >= 4000) {
|
| 501 |
+
submission.status = 'reward_issued';
|
| 502 |
+
submission.decision = 'pass';
|
| 503 |
+
submission.feedback = 'Office prototype approved. Show the matcha code to staff.';
|
| 504 |
+
submission.reward = {
|
| 505 |
+
type: campaign.rewardType,
|
| 506 |
+
value: campaign.rewardValue ?? rewardCode(),
|
| 507 |
+
};
|
| 508 |
+
changed = true;
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
if (changed) {
|
| 512 |
+
submission.updatedAt = new Date().toISOString();
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
return changed;
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
export type CreateSubmissionInput = {
|
| 519 |
+
slug: string;
|
| 520 |
+
consentAccepted: boolean;
|
| 521 |
+
socialHandle?: string | null;
|
| 522 |
+
deviceKey?: string | null;
|
| 523 |
+
tableId?: string | null;
|
| 524 |
+
durationSeconds: number;
|
| 525 |
+
video: File;
|
| 526 |
+
};
|
| 527 |
+
|
| 528 |
+
export async function createSubmission(input: CreateSubmissionInput):
|
| 529 |
+
Promise<
|
| 530 |
+
| { ok: true; submission: LocalSubmission }
|
| 531 |
+
| { ok: false; status: number; error: string }
|
| 532 |
+
> {
|
| 533 |
+
if (!input.slug) {
|
| 534 |
+
return { ok: false, status: 400, error: 'Missing campaign slug' };
|
| 535 |
+
}
|
| 536 |
+
if (!input.consentAccepted) {
|
| 537 |
+
return { ok: false, status: 400, error: 'Consent is required' };
|
| 538 |
+
}
|
| 539 |
+
if (!(input.video instanceof File) || input.video.size <= 0) {
|
| 540 |
+
return { ok: false, status: 400, error: 'A video file is required' };
|
| 541 |
+
}
|
| 542 |
+
if (!input.video.type.startsWith('video/')) {
|
| 543 |
+
return { ok: false, status: 400, error: 'Only video uploads are supported' };
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
const campaign = getCampaignBySlug(input.slug);
|
| 547 |
+
if (!campaign) {
|
| 548 |
+
return { ok: false, status: 404, error: 'Campaign not found' };
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
const submissionId = newId('sub');
|
| 552 |
+
const interviewId = newId('iv');
|
| 553 |
+
const videoMime = input.video.type || 'video/webm';
|
| 554 |
+
const videoFileName = input.video.name || 'matcha-moments.webm';
|
| 555 |
+
const ext = resolveVideoExt(videoFileName, videoMime);
|
| 556 |
+
const bytes = Buffer.from(await input.video.arrayBuffer());
|
| 557 |
+
const storageBackend = hasSupabaseConfig() ? 'supabase' : 'local';
|
| 558 |
+
const storagePath =
|
| 559 |
+
storageBackend === 'supabase'
|
| 560 |
+
? supabaseVideoPathFor(submissionId, ext)
|
| 561 |
+
: localStoragePathFor(submissionId, ext);
|
| 562 |
+
|
| 563 |
+
if (storageBackend === 'supabase') {
|
| 564 |
+
const client = getSupabaseAdmin();
|
| 565 |
+
if (!client) {
|
| 566 |
+
return { ok: false, status: 500, error: 'Supabase is not configured' };
|
| 567 |
+
}
|
| 568 |
+
await ensureSupabaseBucket(client);
|
| 569 |
+
const { error } = await client.storage.from(getBucketName()).upload(storagePath, bytes, {
|
| 570 |
+
contentType: videoMime,
|
| 571 |
+
upsert: false,
|
| 572 |
+
});
|
| 573 |
+
if (error) {
|
| 574 |
+
return { ok: false, status: 500, error: `Video upload failed: ${error.message}` };
|
| 575 |
+
}
|
| 576 |
+
} else {
|
| 577 |
+
const absolutePath = absoluteStoragePath(storagePath);
|
| 578 |
+
if (!absolutePath) {
|
| 579 |
+
return { ok: false, status: 500, error: 'Invalid local storage path' };
|
| 580 |
+
}
|
| 581 |
+
ensureLocalDirs();
|
| 582 |
+
await writeFile(absolutePath, bytes);
|
| 583 |
+
}
|
| 584 |
+
|
| 585 |
+
const now = new Date().toISOString();
|
| 586 |
+
const submission: LocalSubmission = {
|
| 587 |
+
submissionId,
|
| 588 |
+
interviewId,
|
| 589 |
+
campaignSlug: input.slug,
|
| 590 |
+
status: 'opened',
|
| 591 |
+
decision: null,
|
| 592 |
+
feedback: 'Your review is being processed.',
|
| 593 |
+
reward: null,
|
| 594 |
+
reasons: [],
|
| 595 |
+
consentAccepted: input.consentAccepted,
|
| 596 |
+
socialHandle: input.socialHandle?.trim() || null,
|
| 597 |
+
deviceKey: input.deviceKey?.trim() || null,
|
| 598 |
+
tableId: input.tableId?.trim() || null,
|
| 599 |
+
durationSeconds: Math.max(0, Math.round(input.durationSeconds)),
|
| 600 |
+
videoSize: input.video.size,
|
| 601 |
+
videoMime,
|
| 602 |
+
videoFileName,
|
| 603 |
+
storagePath,
|
| 604 |
+
storageBackend,
|
| 605 |
+
createdAt: Date.now(),
|
| 606 |
+
updatedAt: now,
|
| 607 |
+
};
|
| 608 |
+
|
| 609 |
+
await persistSubmission(submission);
|
| 610 |
+
|
| 611 |
+
return { ok: true, submission };
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
export async function getSubmission(
|
| 615 |
+
submissionId: string,
|
| 616 |
+
slug: string,
|
| 617 |
+
): Promise<
|
| 618 |
+
| { ok: true; submission: LocalSubmission }
|
| 619 |
+
| { ok: false; status: number; error: string }
|
| 620 |
+
> {
|
| 621 |
+
const campaign = getCampaignBySlug(slug);
|
| 622 |
+
if (!campaign) return { ok: false, status: 404, error: 'Campaign not found' };
|
| 623 |
+
|
| 624 |
+
const client = getSupabaseAdmin();
|
| 625 |
+
const submission =
|
| 626 |
+
(client ? await loadSupabaseSubmission(client, submissionId) : null) ??
|
| 627 |
+
state.submissions.get(submissionId);
|
| 628 |
+
|
| 629 |
+
if (!submission || submission.campaignSlug !== slug) {
|
| 630 |
+
return { ok: false, status: 404, error: 'Submission not found' };
|
| 631 |
+
}
|
| 632 |
+
|
| 633 |
+
if (advanceSubmission(submission)) {
|
| 634 |
+
await persistSubmission(submission);
|
| 635 |
+
} else {
|
| 636 |
+
state.submissions.set(submission.submissionId, submission);
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
return { ok: true, submission };
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
export async function listAdminReviewSubmissions(): Promise<AdminReviewSubmission[]> {
|
| 643 |
+
const client = getSupabaseAdmin();
|
| 644 |
+
const supabaseSubmissions = client ? await listSupabaseSubmissions(client) : [];
|
| 645 |
+
const sourceSubmissions = mergeSubmissions(
|
| 646 |
+
[...state.submissions.values()],
|
| 647 |
+
supabaseSubmissions,
|
| 648 |
+
);
|
| 649 |
+
|
| 650 |
+
const submissions: LocalSubmission[] = [];
|
| 651 |
+
for (const submission of sourceSubmissions) {
|
| 652 |
+
if (advanceSubmission(submission)) {
|
| 653 |
+
await persistSubmission(submission);
|
| 654 |
+
} else {
|
| 655 |
+
state.submissions.set(submission.submissionId, submission);
|
| 656 |
+
}
|
| 657 |
+
submissions.push(submission);
|
| 658 |
+
}
|
| 659 |
+
|
| 660 |
+
return submissions
|
| 661 |
+
.sort((a, b) => b.createdAt - a.createdAt)
|
| 662 |
+
.map((submission) => ({
|
| 663 |
+
...submission,
|
| 664 |
+
restaurantName: getCampaignBySlug(submission.campaignSlug)?.restaurantName ?? submission.campaignSlug,
|
| 665 |
+
previewUrl: buildPreviewUrl(submission.submissionId),
|
| 666 |
+
createdAtIso: new Date(submission.createdAt).toISOString(),
|
| 667 |
+
}));
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
+
export async function getSubmissionVideo(
|
| 671 |
+
submissionId: string,
|
| 672 |
+
): Promise<SubmissionVideoResult | null> {
|
| 673 |
+
const client = getSupabaseAdmin();
|
| 674 |
+
const submission =
|
| 675 |
+
(client ? await loadSupabaseSubmission(client, submissionId) : null) ??
|
| 676 |
+
state.submissions.get(submissionId);
|
| 677 |
+
|
| 678 |
+
if (!submission) return null;
|
| 679 |
+
|
| 680 |
+
if (submission.storageBackend === 'supabase') {
|
| 681 |
+
if (!client) return null;
|
| 682 |
+
return {
|
| 683 |
+
kind: 'supabase',
|
| 684 |
+
submission,
|
| 685 |
+
storagePath: submission.storagePath,
|
| 686 |
+
size: submission.videoSize,
|
| 687 |
+
contentType: submission.videoMime || 'video/webm',
|
| 688 |
+
};
|
| 689 |
+
}
|
| 690 |
+
|
| 691 |
+
const filePath = absoluteStoragePath(submission.storagePath);
|
| 692 |
+
if (!filePath) return null;
|
| 693 |
+
|
| 694 |
+
try {
|
| 695 |
+
const fileStat = await stat(filePath);
|
| 696 |
+
if (!fileStat.isFile()) return null;
|
| 697 |
+
return {
|
| 698 |
+
kind: 'local',
|
| 699 |
+
submission,
|
| 700 |
+
filePath,
|
| 701 |
+
size: fileStat.size,
|
| 702 |
+
contentType: submission.videoMime || 'video/webm',
|
| 703 |
+
};
|
| 704 |
+
} catch {
|
| 705 |
+
return null;
|
| 706 |
+
}
|
| 707 |
+
}
|
| 708 |
+
|
| 709 |
+
export async function fetchSupabaseVideoObject(
|
| 710 |
+
video: Extract<SubmissionVideoResult, { kind: 'supabase' }>,
|
| 711 |
+
rangeHeader: string | null,
|
| 712 |
+
) {
|
| 713 |
+
const serviceRoleKey = serverEnv('SUPABASE_SERVICE_ROLE_KEY');
|
| 714 |
+
if (!serviceRoleKey) return null;
|
| 715 |
+
|
| 716 |
+
const headers: HeadersInit = {
|
| 717 |
+
apikey: serviceRoleKey,
|
| 718 |
+
Authorization: `Bearer ${serviceRoleKey}`,
|
| 719 |
+
};
|
| 720 |
+
|
| 721 |
+
if (rangeHeader) {
|
| 722 |
+
headers.Range = rangeHeader;
|
| 723 |
+
}
|
| 724 |
+
|
| 725 |
+
const response = await fetch(supabaseObjectUrl(video.storagePath), {
|
| 726 |
+
headers,
|
| 727 |
+
cache: 'no-store',
|
| 728 |
+
});
|
| 729 |
+
|
| 730 |
+
if (!response.ok || !response.body) return null;
|
| 731 |
+
return response;
|
| 732 |
+
}
|
| 733 |
+
|
| 734 |
+
export function toPublicSubmitResult(submission: LocalSubmission): PublicSubmitResult {
|
| 735 |
+
return {
|
| 736 |
+
submissionId: submission.submissionId,
|
| 737 |
+
interviewId: submission.interviewId,
|
| 738 |
+
status: submission.status,
|
| 739 |
+
decision: submission.decision,
|
| 740 |
+
feedback: submission.feedback,
|
| 741 |
+
reward: submission.reward,
|
| 742 |
+
reasons: submission.reasons,
|
| 743 |
+
previewUrl: buildPreviewUrl(submission.submissionId),
|
| 744 |
+
updatedAt: submission.updatedAt,
|
| 745 |
+
};
|
| 746 |
+
}
|
src/lib/utils.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { type ClassValue, clsx } from 'clsx';
|
| 2 |
+
import { twMerge } from 'tailwind-merge';
|
| 3 |
+
|
| 4 |
+
export function cn(...inputs: ClassValue[]) {
|
| 5 |
+
return twMerge(clsx(inputs));
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
const DEVICE_KEY_STORAGE_KEY = 'matcha-moments-device-key';
|
| 9 |
+
|
| 10 |
+
export function ensureDeviceKey(): string {
|
| 11 |
+
if (typeof window === 'undefined') return '';
|
| 12 |
+
const existing = window.localStorage.getItem(DEVICE_KEY_STORAGE_KEY);
|
| 13 |
+
if (existing) return existing;
|
| 14 |
+
const next =
|
| 15 |
+
typeof window.crypto?.randomUUID === 'function'
|
| 16 |
+
? window.crypto.randomUUID()
|
| 17 |
+
: `device-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
| 18 |
+
window.localStorage.setItem(DEVICE_KEY_STORAGE_KEY, next);
|
| 19 |
+
return next;
|
| 20 |
+
}
|
src/lib/voiceover.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { FFmpeg } from '@ffmpeg/ffmpeg';
|
| 2 |
+
import { fetchFile, toBlobURL } from '@ffmpeg/util';
|
| 3 |
+
|
| 4 |
+
export type AudioInput = {
|
| 5 |
+
blob: Blob;
|
| 6 |
+
ext?: 'webm' | 'mp4' | 'mov';
|
| 7 |
+
};
|
| 8 |
+
|
| 9 |
+
export type VoiceoverResult = {
|
| 10 |
+
blob: Blob;
|
| 11 |
+
durationSeconds: number;
|
| 12 |
+
filename: string;
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
async function createFFmpeg(): Promise<FFmpeg> {
|
| 16 |
+
const ffmpeg = new FFmpeg();
|
| 17 |
+
const coreBaseUrl = 'https://unpkg.com/@ffmpeg/core@0.12.9/dist/umd';
|
| 18 |
+
await ffmpeg.load({
|
| 19 |
+
coreURL: await toBlobURL(`${coreBaseUrl}/ffmpeg-core.js`, 'text/javascript'),
|
| 20 |
+
wasmURL: await toBlobURL(`${coreBaseUrl}/ffmpeg-core.wasm`, 'application/wasm'),
|
| 21 |
+
});
|
| 22 |
+
return ffmpeg;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export async function addVoiceoverToVideo(
|
| 26 |
+
video: { blob: Blob; filename?: string },
|
| 27 |
+
audioInputs: AudioInput[],
|
| 28 |
+
onProgress?: (progress: number) => void,
|
| 29 |
+
): Promise<VoiceoverResult> {
|
| 30 |
+
if (audioInputs.length === 0) {
|
| 31 |
+
return {
|
| 32 |
+
blob: video.blob,
|
| 33 |
+
durationSeconds: await probeDuration(video.blob),
|
| 34 |
+
filename: video.filename ?? 'matcha-moments.webm',
|
| 35 |
+
};
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
const ffmpeg = await createFFmpeg();
|
| 39 |
+
const logs: string[] = [];
|
| 40 |
+
|
| 41 |
+
const logHandler = ({ message }: { message: string }) => {
|
| 42 |
+
const trimmed = message.trim();
|
| 43 |
+
if (trimmed) logs.push(trimmed);
|
| 44 |
+
if (logs.length > 80) logs.splice(0, logs.length - 80);
|
| 45 |
+
};
|
| 46 |
+
const progressHandler = ({ progress }: { progress: number }) => {
|
| 47 |
+
onProgress?.(Math.max(0, Math.min(1, progress)));
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
ffmpeg.on('log', logHandler);
|
| 51 |
+
ffmpeg.on('progress', progressHandler);
|
| 52 |
+
|
| 53 |
+
const runId = Math.random().toString(36).slice(2, 8);
|
| 54 |
+
const videoName = `food-video-${runId}.webm`;
|
| 55 |
+
const audioFiles: string[] = [];
|
| 56 |
+
const voiceName = `voiceover-${runId}.webm`;
|
| 57 |
+
const outputName = `matcha-voiceover-${runId}.webm`;
|
| 58 |
+
|
| 59 |
+
async function runOrThrow(args: string[], label: string) {
|
| 60 |
+
logs.length = 0;
|
| 61 |
+
const exitCode = await ffmpeg.exec(args);
|
| 62 |
+
if (exitCode !== 0) {
|
| 63 |
+
const detail = logs.slice(-12).join(' | ');
|
| 64 |
+
throw new Error(
|
| 65 |
+
detail
|
| 66 |
+
? `${label} failed with ffmpeg exit code ${exitCode}: ${detail}`
|
| 67 |
+
: `${label} failed with ffmpeg exit code ${exitCode}`,
|
| 68 |
+
);
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
try {
|
| 73 |
+
await ffmpeg.writeFile(videoName, await fetchFile(video.blob));
|
| 74 |
+
|
| 75 |
+
for (let i = 0; i < audioInputs.length; i++) {
|
| 76 |
+
const input = audioInputs[i]!;
|
| 77 |
+
const filename = `voice-${runId}-${i}.${input.ext ?? 'webm'}`;
|
| 78 |
+
await ffmpeg.writeFile(filename, await fetchFile(input.blob));
|
| 79 |
+
audioFiles.push(filename);
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
const inputArgs = audioFiles.flatMap((name) => ['-i', name]);
|
| 83 |
+
const normalizedAudio = audioFiles
|
| 84 |
+
.map((_, i) => `[${i}:a]aresample=48000,asetpts=PTS-STARTPTS[a${i}]`)
|
| 85 |
+
.join(';');
|
| 86 |
+
const concatInputs = audioFiles.map((_, i) => `[a${i}]`).join('');
|
| 87 |
+
const filterComplex = `${normalizedAudio};${concatInputs}concat=n=${audioFiles.length}:v=0:a=1[a]`;
|
| 88 |
+
|
| 89 |
+
await runOrThrow([
|
| 90 |
+
'-y',
|
| 91 |
+
...inputArgs,
|
| 92 |
+
'-filter_complex',
|
| 93 |
+
filterComplex,
|
| 94 |
+
'-map',
|
| 95 |
+
'[a]',
|
| 96 |
+
'-c:a',
|
| 97 |
+
'libopus',
|
| 98 |
+
'-b:a',
|
| 99 |
+
'96k',
|
| 100 |
+
voiceName,
|
| 101 |
+
], 'Voiceover concat');
|
| 102 |
+
|
| 103 |
+
await runOrThrow([
|
| 104 |
+
'-y',
|
| 105 |
+
'-i',
|
| 106 |
+
videoName,
|
| 107 |
+
'-i',
|
| 108 |
+
voiceName,
|
| 109 |
+
'-map',
|
| 110 |
+
'0:v:0',
|
| 111 |
+
'-map',
|
| 112 |
+
'1:a:0',
|
| 113 |
+
'-c:v',
|
| 114 |
+
'copy',
|
| 115 |
+
'-c:a',
|
| 116 |
+
'copy',
|
| 117 |
+
outputName,
|
| 118 |
+
], 'Voiceover mux');
|
| 119 |
+
|
| 120 |
+
const data = await ffmpeg.readFile(outputName);
|
| 121 |
+
const bytes =
|
| 122 |
+
typeof data === 'string' ? new TextEncoder().encode(data) : new Uint8Array(data);
|
| 123 |
+
const buf = new ArrayBuffer(bytes.byteLength);
|
| 124 |
+
new Uint8Array(buf).set(bytes);
|
| 125 |
+
const blob = new Blob([buf], { type: 'video/webm' });
|
| 126 |
+
|
| 127 |
+
return {
|
| 128 |
+
blob,
|
| 129 |
+
durationSeconds: await probeDuration(blob),
|
| 130 |
+
filename: outputName,
|
| 131 |
+
};
|
| 132 |
+
} finally {
|
| 133 |
+
for (const name of [videoName, voiceName, outputName, ...audioFiles]) {
|
| 134 |
+
try {
|
| 135 |
+
await ffmpeg.deleteFile(name);
|
| 136 |
+
} catch {
|
| 137 |
+
/* ignore */
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
ffmpeg.off('log', logHandler);
|
| 141 |
+
ffmpeg.off('progress', progressHandler);
|
| 142 |
+
ffmpeg.terminate();
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
async function probeDuration(blob: Blob): Promise<number> {
|
| 147 |
+
return await new Promise<number>((resolve) => {
|
| 148 |
+
const url = URL.createObjectURL(blob);
|
| 149 |
+
const video = document.createElement('video');
|
| 150 |
+
video.preload = 'metadata';
|
| 151 |
+
video.muted = true;
|
| 152 |
+
video.onloadedmetadata = () => {
|
| 153 |
+
resolve(Number.isFinite(video.duration) ? Math.max(0, video.duration) : 0);
|
| 154 |
+
URL.revokeObjectURL(url);
|
| 155 |
+
};
|
| 156 |
+
video.onerror = () => {
|
| 157 |
+
resolve(0);
|
| 158 |
+
URL.revokeObjectURL(url);
|
| 159 |
+
};
|
| 160 |
+
video.src = url;
|
| 161 |
+
});
|
| 162 |
+
}
|
src/middleware.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
|
| 3 |
+
const REALM = 'Matcha Moments Admin';
|
| 4 |
+
|
| 5 |
+
function unauthorized() {
|
| 6 |
+
return new NextResponse('Authentication required', {
|
| 7 |
+
status: 401,
|
| 8 |
+
headers: {
|
| 9 |
+
'WWW-Authenticate': `Basic realm="${REALM}", charset="UTF-8"`,
|
| 10 |
+
},
|
| 11 |
+
});
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
function parseBasicAuth(header: string | null) {
|
| 15 |
+
if (!header?.startsWith('Basic ')) return null;
|
| 16 |
+
|
| 17 |
+
try {
|
| 18 |
+
const decoded = atob(header.slice('Basic '.length));
|
| 19 |
+
const splitAt = decoded.indexOf(':');
|
| 20 |
+
if (splitAt < 0) return null;
|
| 21 |
+
|
| 22 |
+
return {
|
| 23 |
+
username: decoded.slice(0, splitAt),
|
| 24 |
+
password: decoded.slice(splitAt + 1),
|
| 25 |
+
};
|
| 26 |
+
} catch {
|
| 27 |
+
return null;
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export function middleware(req: NextRequest) {
|
| 32 |
+
if (process.env.NODE_ENV !== 'production') {
|
| 33 |
+
return NextResponse.next();
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
const expectedUsername = process.env.ADMIN_USERNAME;
|
| 37 |
+
const expectedPassword = process.env.ADMIN_PASSWORD;
|
| 38 |
+
|
| 39 |
+
if (!expectedUsername || !expectedPassword) {
|
| 40 |
+
return unauthorized();
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const credentials = parseBasicAuth(req.headers.get('authorization'));
|
| 44 |
+
if (
|
| 45 |
+
credentials?.username !== expectedUsername ||
|
| 46 |
+
credentials.password !== expectedPassword
|
| 47 |
+
) {
|
| 48 |
+
return unauthorized();
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
return NextResponse.next();
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
export const config = {
|
| 55 |
+
matcher: ['/admin/:path*'],
|
| 56 |
+
};
|
tailwind.config.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Config } from 'tailwindcss';
|
| 2 |
+
|
| 3 |
+
const config: Config = {
|
| 4 |
+
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
|
| 5 |
+
theme: {
|
| 6 |
+
extend: {
|
| 7 |
+
colors: {
|
| 8 |
+
cream: {
|
| 9 |
+
DEFAULT: '#F5EFE2',
|
| 10 |
+
deep: '#EDE3D0',
|
| 11 |
+
},
|
| 12 |
+
paper: '#FFFCF6',
|
| 13 |
+
matcha: {
|
| 14 |
+
DEFAULT: '#4A6B3D',
|
| 15 |
+
deep: '#324A2A',
|
| 16 |
+
},
|
| 17 |
+
sage: '#B8C9A8',
|
| 18 |
+
ink: {
|
| 19 |
+
DEFAULT: '#2A2520',
|
| 20 |
+
soft: 'rgba(42, 37, 32, 0.7)',
|
| 21 |
+
faint: 'rgba(42, 37, 32, 0.6)',
|
| 22 |
+
},
|
| 23 |
+
muted: '#8B7E6E',
|
| 24 |
+
warm: '#D89A7A',
|
| 25 |
+
},
|
| 26 |
+
fontFamily: {
|
| 27 |
+
serif: ['var(--font-fraunces)', 'Fraunces', 'Georgia', 'serif'],
|
| 28 |
+
sans: ['var(--font-dm-sans)', 'DM Sans', 'system-ui', 'sans-serif'],
|
| 29 |
+
mono: ['var(--font-dm-mono)', 'DM Mono', 'ui-monospace', 'monospace'],
|
| 30 |
+
},
|
| 31 |
+
borderRadius: {
|
| 32 |
+
card: '32px',
|
| 33 |
+
},
|
| 34 |
+
keyframes: {
|
| 35 |
+
scan: {
|
| 36 |
+
'0%, 100%': { top: '0%' },
|
| 37 |
+
'50%': { top: '100%' },
|
| 38 |
+
},
|
| 39 |
+
shimmer: {
|
| 40 |
+
'0%': { backgroundPosition: '200% 0' },
|
| 41 |
+
'100%': { backgroundPosition: '-100% 0' },
|
| 42 |
+
},
|
| 43 |
+
confetti: {
|
| 44 |
+
'0%': { transform: 'translateY(-100px) rotate(0deg)', opacity: '0' },
|
| 45 |
+
'10%': { opacity: '1' },
|
| 46 |
+
'100%': { transform: 'translateY(800px) rotate(720deg)', opacity: '0' },
|
| 47 |
+
},
|
| 48 |
+
bounce: {
|
| 49 |
+
'0%, 100%': { transform: 'translateY(0)' },
|
| 50 |
+
'50%': { transform: 'translateY(-12px)' },
|
| 51 |
+
},
|
| 52 |
+
spin: {
|
| 53 |
+
to: { transform: 'rotate(360deg)' },
|
| 54 |
+
},
|
| 55 |
+
rotateBg: {
|
| 56 |
+
to: { transform: 'rotate(360deg)' },
|
| 57 |
+
},
|
| 58 |
+
blink: {
|
| 59 |
+
'50%': { opacity: '0.3' },
|
| 60 |
+
},
|
| 61 |
+
pulse: {
|
| 62 |
+
'0%, 100%': { boxShadow: '0 0 0 0 rgba(74, 107, 61, 0.35)' },
|
| 63 |
+
'50%': { boxShadow: '0 0 0 24px rgba(74, 107, 61, 0)' },
|
| 64 |
+
},
|
| 65 |
+
},
|
| 66 |
+
animation: {
|
| 67 |
+
scan: 'scan 2s ease-in-out infinite',
|
| 68 |
+
shimmer: 'shimmer 2.4s ease-in-out infinite',
|
| 69 |
+
confetti: 'confetti 4s linear infinite',
|
| 70 |
+
bounce: 'bounce 1.4s ease-in-out infinite',
|
| 71 |
+
spin: 'spin 0.8s linear infinite',
|
| 72 |
+
rotateBg: 'rotateBg 8s linear infinite',
|
| 73 |
+
blink: 'blink 1s ease infinite',
|
| 74 |
+
pulse: 'pulse 2.4s ease-in-out infinite',
|
| 75 |
+
},
|
| 76 |
+
},
|
| 77 |
+
},
|
| 78 |
+
plugins: [],
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
+
export default config;
|
tsconfig.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2017",
|
| 4 |
+
"lib": ["dom", "dom.iterable", "esnext"],
|
| 5 |
+
"allowJs": true,
|
| 6 |
+
"skipLibCheck": true,
|
| 7 |
+
"strict": true,
|
| 8 |
+
"noEmit": true,
|
| 9 |
+
"esModuleInterop": true,
|
| 10 |
+
"module": "esnext",
|
| 11 |
+
"moduleResolution": "bundler",
|
| 12 |
+
"resolveJsonModule": true,
|
| 13 |
+
"isolatedModules": true,
|
| 14 |
+
"jsx": "preserve",
|
| 15 |
+
"incremental": true,
|
| 16 |
+
"plugins": [{ "name": "next" }],
|
| 17 |
+
"paths": {
|
| 18 |
+
"@/*": ["./src/*"]
|
| 19 |
+
}
|
| 20 |
+
},
|
| 21 |
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
| 22 |
+
"exclude": ["node_modules", "reference", "reference-2"]
|
| 23 |
+
}
|