moonlantern1 commited on
Commit
a733514
·
verified ·
1 Parent(s): 8da7616

Deploy Grabby Voice mobile app

Browse files
Files changed (46) hide show
  1. .dockerignore +13 -0
  2. .eslintrc.json +3 -0
  3. .gitignore +43 -0
  4. Dockerfile +41 -0
  5. README.md +175 -4
  6. next.config.js +19 -0
  7. package-lock.json +0 -0
  8. package.json +36 -0
  9. postcss.config.mjs +9 -0
  10. src/app/admin/reviews/page.tsx +149 -0
  11. src/app/api/public/reviews/campaign/[slug]/route.ts +20 -0
  12. src/app/api/public/reviews/submission/[submissionId]/route.ts +35 -0
  13. src/app/api/public/reviews/submit/route.ts +60 -0
  14. src/app/api/public/reviews/video/[submissionId]/route.ts +109 -0
  15. src/app/c/[slug]/LandingClient.tsx +80 -0
  16. src/app/c/[slug]/page.tsx +23 -0
  17. src/app/c/[slug]/record/GuidedRecordingClient.tsx +241 -0
  18. src/app/c/[slug]/record/page.tsx +23 -0
  19. src/app/globals.css +51 -0
  20. src/app/layout.tsx +46 -0
  21. src/app/page.tsx +77 -0
  22. src/app/preview/page.tsx +298 -0
  23. src/app/reward/page.tsx +76 -0
  24. src/components/Button.tsx +36 -0
  25. src/components/Confetti.tsx +25 -0
  26. src/components/MatchaCircle.tsx +24 -0
  27. src/components/MobileShell.tsx +23 -0
  28. src/components/ProgressPips.tsx +38 -0
  29. src/components/PromptCard.tsx +25 -0
  30. src/components/RecordButton.tsx +32 -0
  31. src/components/RecordingBadge.tsx +20 -0
  32. src/components/RenderShimmer.tsx +12 -0
  33. src/components/RewardCard.tsx +25 -0
  34. src/hooks/useGuidedRecording.ts +205 -0
  35. src/hooks/useSubmissionPolling.ts +55 -0
  36. src/lib/ffmpeg.ts +206 -0
  37. src/lib/humeoApi.ts +92 -0
  38. src/lib/recordingStore.ts +109 -0
  39. src/lib/reviews/public.ts +123 -0
  40. src/lib/reviews/types.ts +114 -0
  41. src/lib/server/reviewStore.ts +746 -0
  42. src/lib/utils.ts +20 -0
  43. src/lib/voiceover.ts +162 -0
  44. src/middleware.ts +56 -0
  45. tailwind.config.ts +81 -0
  46. 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
- emoji: 📈
4
- colorFrom: blue
5
- colorTo: blue
6
  sdk: docker
 
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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&apos;ll guide you through a few food shots and short voice notes &mdash; 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
+ &ldquo;Free matcha for an honest review.&rdquo;
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&apos;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&apos;s social. Now &mdash; 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
+ }