moonlantern1 commited on
Commit
1905abd
·
1 Parent(s): 9399dcd

Add Foodstar Rasa Rasa campaign

Browse files
package-lock.json CHANGED
The diff for this file is too large to render. See raw diff
 
package.json CHANGED
@@ -1,36 +1,38 @@
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
- }
 
 
 
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
+ "qrcode": "^1.5.4",
21
+ "react": "^18.3.1",
22
+ "react-dom": "^18.3.1",
23
+ "tailwind-merge": "^2.5.5",
24
+ "zod": "^3.23.8"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^20.16.11",
28
+ "@types/qrcode": "^1.5.6",
29
+ "@types/react": "^18.3.12",
30
+ "@types/react-dom": "^18.3.1",
31
+ "autoprefixer": "^10.4.20",
32
+ "eslint": "^8.57.1",
33
+ "eslint-config-next": "14.2.35",
34
+ "postcss": "^8.4.47",
35
+ "tailwindcss": "^3.4.14",
36
+ "typescript": "^5.6.3"
37
+ }
38
+ }
src/app/admin/reviews/page.tsx CHANGED
@@ -3,6 +3,7 @@ import {
3
  listAdminReviewSubmissions,
4
  type AdminReviewSubmission,
5
  } from '@/lib/server/reviewStore';
 
6
 
7
  export const dynamic = 'force-dynamic';
8
  export const runtime = 'nodejs';
@@ -53,12 +54,12 @@ function remoteRecorderHref() {
53
  if (explicit) return explicit;
54
 
55
  const remote = remoteAdminConfig();
56
- if (!remote) return '/c/sageandstone';
57
 
58
  try {
59
- return new URL('/c/sageandstone', remote.url).toString();
60
  } catch {
61
- return '/c/sageandstone';
62
  }
63
  }
64
 
@@ -210,6 +211,22 @@ export default async function AdminReviewsPage() {
210
  </dt>
211
  <dd className="mt-1 text-cream/85">{submission.tableId ?? '-'}</dd>
212
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  </dl>
214
 
215
  <div className="rounded-md bg-cream/[0.04] px-3 py-2 text-sm leading-5 text-cream/70">
 
3
  listAdminReviewSubmissions,
4
  type AdminReviewSubmission,
5
  } from '@/lib/server/reviewStore';
6
+ import { RASA_RASA_SLUG } from '@/lib/foodstar';
7
 
8
  export const dynamic = 'force-dynamic';
9
  export const runtime = 'nodejs';
 
54
  if (explicit) return explicit;
55
 
56
  const remote = remoteAdminConfig();
57
+ if (!remote) return `/c/${RASA_RASA_SLUG}`;
58
 
59
  try {
60
+ return new URL(`/c/${RASA_RASA_SLUG}`, remote.url).toString();
61
  } catch {
62
+ return `/c/${RASA_RASA_SLUG}`;
63
  }
64
  }
65
 
 
211
  </dt>
212
  <dd className="mt-1 text-cream/85">{submission.tableId ?? '-'}</dd>
213
  </div>
214
+ <div className="rounded-md bg-cream/[0.04] px-3 py-2">
215
+ <dt className="font-mono text-[10px] uppercase tracking-[0.14em] text-cream/45">
216
+ Instagram
217
+ </dt>
218
+ <dd className="mt-1 truncate text-cream/85">
219
+ {submission.instagramHandle ?? '-'}
220
+ </dd>
221
+ </div>
222
+ <div className="rounded-md bg-cream/[0.04] px-3 py-2">
223
+ <dt className="font-mono text-[10px] uppercase tracking-[0.14em] text-cream/45">
224
+ TikTok
225
+ </dt>
226
+ <dd className="mt-1 truncate text-cream/85">
227
+ {submission.tiktokHandle ?? '-'}
228
+ </dd>
229
+ </div>
230
  </dl>
231
 
232
  <div className="rounded-md bg-cream/[0.04] px-3 py-2 text-sm leading-5 text-cream/70">
src/app/api/public/reviews/submit-clips/route.ts CHANGED
@@ -40,6 +40,8 @@ export async function POST(req: NextRequest) {
40
  const slug = sanitizeText(form.get('slug'), 120);
41
  const consentAccepted = sanitizeText(form.get('consentAccepted'), 10) === 'true';
42
  const socialHandle = sanitizeText(form.get('socialHandle'), 120) || null;
 
 
43
  const deviceKey = sanitizeText(form.get('deviceKey'), 200) || null;
44
  const tableId = sanitizeText(form.get('tableId'), 80) || null;
45
 
@@ -71,6 +73,8 @@ export async function POST(req: NextRequest) {
71
  slug,
72
  consentAccepted,
73
  socialHandle,
 
 
74
  deviceKey,
75
  tableId,
76
  durationSeconds: rendered.durationSeconds,
 
40
  const slug = sanitizeText(form.get('slug'), 120);
41
  const consentAccepted = sanitizeText(form.get('consentAccepted'), 10) === 'true';
42
  const socialHandle = sanitizeText(form.get('socialHandle'), 120) || null;
43
+ const instagramHandle = sanitizeText(form.get('instagramHandle'), 80) || null;
44
+ const tiktokHandle = sanitizeText(form.get('tiktokHandle'), 80) || null;
45
  const deviceKey = sanitizeText(form.get('deviceKey'), 200) || null;
46
  const tableId = sanitizeText(form.get('tableId'), 80) || null;
47
 
 
73
  slug,
74
  consentAccepted,
75
  socialHandle,
76
+ instagramHandle,
77
+ tiktokHandle,
78
  deviceKey,
79
  tableId,
80
  durationSeconds: rendered.durationSeconds,
src/app/api/public/reviews/submit-session/route.ts CHANGED
@@ -35,6 +35,8 @@ export async function POST(req: NextRequest) {
35
  const slug = sanitizeText(form.get('slug'), 120);
36
  const consentAccepted = sanitizeText(form.get('consentAccepted'), 10) === 'true';
37
  const socialHandle = sanitizeText(form.get('socialHandle'), 120) || null;
 
 
38
  const deviceKey = sanitizeText(form.get('deviceKey'), 200) || null;
39
  const tableId = sanitizeText(form.get('tableId'), 80) || null;
40
  const sessionId = sanitizeText(form.get('sessionId'), 100);
@@ -68,6 +70,8 @@ export async function POST(req: NextRequest) {
68
  slug,
69
  consentAccepted,
70
  socialHandle,
 
 
71
  deviceKey,
72
  tableId,
73
  durationSeconds: rendered.durationSeconds,
 
35
  const slug = sanitizeText(form.get('slug'), 120);
36
  const consentAccepted = sanitizeText(form.get('consentAccepted'), 10) === 'true';
37
  const socialHandle = sanitizeText(form.get('socialHandle'), 120) || null;
38
+ const instagramHandle = sanitizeText(form.get('instagramHandle'), 80) || null;
39
+ const tiktokHandle = sanitizeText(form.get('tiktokHandle'), 80) || null;
40
  const deviceKey = sanitizeText(form.get('deviceKey'), 200) || null;
41
  const tableId = sanitizeText(form.get('tableId'), 80) || null;
42
  const sessionId = sanitizeText(form.get('sessionId'), 100);
 
70
  slug,
71
  consentAccepted,
72
  socialHandle,
73
+ instagramHandle,
74
+ tiktokHandle,
75
  deviceKey,
76
  tableId,
77
  durationSeconds: rendered.durationSeconds,
src/app/api/public/reviews/submit/route.ts CHANGED
@@ -20,6 +20,8 @@ export async function POST(req: NextRequest) {
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));
@@ -39,6 +41,8 @@ export async function POST(req: NextRequest) {
39
  slug,
40
  consentAccepted,
41
  socialHandle,
 
 
42
  deviceKey,
43
  tableId,
44
  durationSeconds,
 
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 instagramHandle = sanitizeText(form.get('instagramHandle'), 80) || null;
24
+ const tiktokHandle = sanitizeText(form.get('tiktokHandle'), 80) || null;
25
  const deviceKey = sanitizeText(form.get('deviceKey'), 200) || null;
26
  const tableId = sanitizeText(form.get('tableId'), 80) || null;
27
  const durationSecondsRaw = Number(sanitizeText(form.get('durationSeconds'), 20));
 
41
  slug,
42
  consentAccepted,
43
  socialHandle,
44
+ instagramHandle,
45
+ tiktokHandle,
46
  deviceKey,
47
  tableId,
48
  durationSeconds,
src/app/c/[slug]/LandingClient.tsx CHANGED
@@ -1,34 +1,141 @@
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="relative flex h-dvh max-h-dvh flex-col overflow-hidden bg-[#EDE5D2] text-[#1B1A14]">
@@ -94,3 +201,9 @@ export function LandingClient({ slug, tableId, campaign }: Props) {
94
  </MobileShell>
95
  );
96
  }
 
 
 
 
 
 
 
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 { socialHandleSummary } from '@/lib/foodstar';
10
  import type { PublicReviewCampaign } from '@/lib/reviews/types';
11
+
12
+ type Props = {
13
+ slug: string;
14
+ tableId: string | null;
15
+ campaign: PublicReviewCampaign;
16
+ };
17
+
18
  export function LandingClient({ slug, tableId, campaign }: Props) {
19
  const router = useRouter();
20
  const [consentAccepted, setConsentAccepted] = useState(false);
21
+ const [instagramHandle, setInstagramHandle] = useState('');
22
+ const [tiktokHandle, setTiktokHandle] = useState('');
23
+ const isFoodstar = campaign.theme === 'foodstar-humeo';
24
+
25
+ useEffect(() => {
26
+ recordingStore.setMeta({ slug, tableId });
27
+ }, [slug, tableId]);
28
+
29
  const handleStart = () => {
30
  if (!consentAccepted) return;
31
+ const instagram = normalizeHandle(instagramHandle);
32
+ const tiktok = normalizeHandle(tiktokHandle);
33
+ recordingStore.setMeta({
34
+ slug,
35
+ tableId,
36
+ instagramHandle: instagram,
37
+ tiktokHandle: tiktok,
38
+ socialHandle: socialHandleSummary(instagram, tiktok),
39
+ });
40
  window.sessionStorage.setItem(`matcha-moments-consent:${slug}`, 'true');
41
  const tableQuery = tableId ? `?t=${encodeURIComponent(tableId)}` : '';
42
  router.push(`/c/${encodeURIComponent(slug)}/record${tableQuery}`);
43
  };
44
+
45
+ if (isFoodstar) {
46
+ return (
47
+ <MobileShell tone="dark">
48
+ <main className="relative flex min-h-dvh flex-col overflow-hidden bg-[#0D0E0C] text-[#F8F3E7]">
49
+ <div
50
+ className="pointer-events-none absolute inset-0"
51
+ style={{
52
+ background:
53
+ 'radial-gradient(circle at 50% 12%, rgba(184,201,168,0.24), transparent 34%), radial-gradient(circle at 82% 78%, rgba(239,92,54,0.16), transparent 34%), linear-gradient(160deg, #10110F 0%, #231F18 58%, #0D0E0C 100%)',
54
+ }}
55
+ />
56
+
57
+ <section className="relative flex flex-1 flex-col px-7 pt-8 [@media(max-height:740px)]:pt-5">
58
+ <div className="flex items-center justify-between font-mono text-[10px] uppercase tracking-[0.18em] text-[#B8C9A8]">
59
+ <span>foodstar.co</span>
60
+ <span>{campaign.restaurantName}</span>
61
+ </div>
62
+
63
+ <div className="mt-10 [@media(max-height:740px)]:mt-6">
64
+ <div className="mb-3 font-mono text-[10px] uppercase tracking-[0.22em] text-[#F0C7A6]">
65
+ Review food for {campaign.restaurantName}
66
+ </div>
67
+ <h1 className="font-serif text-[42px] font-light leading-[1.02] text-[#F8F3E7] [@media(max-height:740px)]:text-[36px]">
68
+ Get a free piece
69
+ <br />
70
+ of chicken.
71
+ </h1>
72
+ <p className="mt-4 max-w-[320px] text-[15px] leading-[1.55] text-[#DAD2C4]/78 [@media(max-height:740px)]:mt-3">
73
+ Record a quick food review for Rasa Rasa: what you ordered, what you loved, and three food shots.
74
+ </p>
75
+ </div>
76
+
77
+ <div className="mt-6 rounded-[8px] border border-[#B8C9A8]/18 bg-[#F8F3E7]/7 p-4 [@media(max-height:740px)]:mt-4 [@media(max-height:740px)]:p-3">
78
+ <div className="font-mono text-[10px] uppercase tracking-[0.18em] text-[#B8C9A8]">
79
+ Tag me
80
+ </div>
81
+ <div className="mt-3 grid gap-2.5">
82
+ <input
83
+ value={instagramHandle}
84
+ onChange={(event) => setInstagramHandle(event.target.value)}
85
+ placeholder="Instagram handle"
86
+ className="h-12 rounded-full border border-[#F8F3E7]/12 bg-black/22 px-4 text-[14px] text-[#F8F3E7] outline-none placeholder:text-[#F8F3E7]/42 focus:border-[#B8C9A8]/55"
87
+ autoCapitalize="none"
88
+ autoCorrect="off"
89
+ inputMode="text"
90
+ />
91
+ <input
92
+ value={tiktokHandle}
93
+ onChange={(event) => setTiktokHandle(event.target.value)}
94
+ placeholder="TikTok handle"
95
+ className="h-12 rounded-full border border-[#F8F3E7]/12 bg-black/22 px-4 text-[14px] text-[#F8F3E7] outline-none placeholder:text-[#F8F3E7]/42 focus:border-[#B8C9A8]/55"
96
+ autoCapitalize="none"
97
+ autoCorrect="off"
98
+ inputMode="text"
99
+ />
100
+ </div>
101
+ </div>
102
+
103
+ <label className="mt-4 flex cursor-pointer items-start gap-3 rounded-[8px] border border-[#F8F3E7]/12 bg-black/18 px-4 py-3 text-left">
104
+ <input
105
+ type="checkbox"
106
+ checked={consentAccepted}
107
+ onChange={(event) => setConsentAccepted(event.target.checked)}
108
+ className="mt-0.5 h-4 w-4 accent-[#B8C9A8]"
109
+ />
110
+ <span className="text-[12.5px] leading-[1.45] text-[#DAD2C4]/76">
111
+ I allow {campaign.restaurantName} and Foodstar to review, edit, and use my video and voice on social media.
112
+ </span>
113
+ </label>
114
+ </section>
115
+
116
+ <footer className="relative px-7 pb-6 pt-2">
117
+ <Button
118
+ onClick={handleStart}
119
+ disabled={!consentAccepted}
120
+ className={
121
+ consentAccepted
122
+ ? 'bg-[#B8C9A8] text-[#151711] hover:bg-[#DDE8C8] active:bg-[#DDE8C8]'
123
+ : 'bg-[#5D6556] text-[#F8F3E7] disabled:cursor-not-allowed disabled:opacity-80'
124
+ }
125
+ >
126
+ {campaign.startCta ?? 'Start food review'} -&gt;
127
+ </Button>
128
+ {tableId ? (
129
+ <p className="mt-3 text-center font-mono text-[10px] uppercase tracking-[0.15em] text-[#F8F3E7]/38">
130
+ Table {tableId}
131
+ </p>
132
+ ) : null}
133
+ </footer>
134
+ </main>
135
+ </MobileShell>
136
+ );
137
+ }
138
+
139
  return (
140
  <MobileShell>
141
  <main className="relative flex h-dvh max-h-dvh flex-col overflow-hidden bg-[#EDE5D2] text-[#1B1A14]">
 
201
  </MobileShell>
202
  );
203
  }
204
+
205
+ function normalizeHandle(value: string) {
206
+ const trimmed = value.trim();
207
+ if (!trimmed) return '';
208
+ return trimmed.startsWith('@') ? trimmed : `@${trimmed}`;
209
+ }
src/app/layout.tsx CHANGED
@@ -1,46 +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
- }
 
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: 'Foodstar',
29
+ description: 'Scan the QR code, leave a food review, and claim your reward.',
30
+ };
31
+
32
+ export const viewport: Viewport = {
33
+ width: 'device-width',
34
+ initialScale: 1,
35
+ maximumScale: 1,
36
+ userScalable: false,
37
+ themeColor: '#0D0E0C',
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 CHANGED
@@ -1,77 +1,5 @@
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
  }
 
1
+ import { FoodstarTvScreen } from '@/components/FoodstarTvScreen';
 
 
 
 
 
 
 
2
 
3
  export default function HomePage() {
4
+ return <FoodstarTvScreen />;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  }
src/app/preview/page.tsx CHANGED
@@ -10,6 +10,7 @@ import { useSubmissionPolling } from '@/hooks/useSubmissionPolling';
10
  import { concatClips } from '@/lib/ffmpeg';
11
  import { submit, submitSession, uploadClip } 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';
@@ -40,6 +41,9 @@ export default function PreviewPage() {
40
  const store = useRecordingStore();
41
  const [phase, setPhase] = useState<Phase>({ kind: 'idle' });
42
  const triggeredRef = useRef(false);
 
 
 
43
 
44
  const submissionId =
45
  phase.kind === 'polling' || phase.kind === 'ready' ? phase.result.submissionId : null;
@@ -116,9 +120,11 @@ export default function PreviewPage() {
116
 
117
  setPhase({ kind: 'server_processing' });
118
  const result = await submitSession({
119
- slug: store.slug ?? 'sageandstone',
120
  consentAccepted: true,
121
  socialHandle: store.socialHandle || undefined,
 
 
122
  deviceKey: ensureDeviceKey(),
123
  tableId: store.tableId,
124
  sessionId: store.sessionId,
@@ -150,9 +156,11 @@ export default function PreviewPage() {
150
  setPhase({ kind: 'uploading' });
151
 
152
  const result = await submit({
153
- slug: store.slug ?? 'sageandstone',
154
  consentAccepted: true,
155
  socialHandle: store.socialHandle || undefined,
 
 
156
  deviceKey: ensureDeviceKey(),
157
  tableId: store.tableId,
158
  durationSeconds: finalVideo.durationSeconds,
@@ -169,12 +177,14 @@ export default function PreviewPage() {
169
  });
170
  }
171
  }, [
 
172
  router,
 
173
  store.orderedClips,
174
  store.sessionId,
175
- store.slug,
176
  store.socialHandle,
177
  store.tableId,
 
178
  ]);
179
 
180
  useEffect(() => {
@@ -190,13 +200,17 @@ export default function PreviewPage() {
190
 
191
  const handleApproveFinal = () => {
192
  if (phase.kind === 'ready') {
193
- router.push(`/reward?code=${encodeURIComponent(phase.result.reward?.value ?? '')}`);
 
 
 
 
194
  }
195
  };
196
 
197
  const handleRerecord = () => {
198
  recordingStore.reset();
199
- router.replace(`/c/${encodeURIComponent(store.slug ?? 'sageandstone')}/record`);
200
  };
201
 
202
  const isReady = phase.kind === 'ready';
@@ -261,31 +275,39 @@ export default function PreviewPage() {
261
 
262
  return (
263
  <MobileShell>
264
- <main className="flex min-h-dvh flex-col bg-cream">
 
 
 
 
265
  <section className="flex flex-1 flex-col px-7 pt-8">
266
  <div className="mb-6">
267
  {isReady ? (
268
  <>
269
- <div className="text-eyebrow mb-3 text-matcha">preview ready</div>
270
- <h1 className="text-display text-[32px] text-ink">
271
  Watch your
272
  <br />
273
- <em className="text-matcha">finished review.</em>
274
  </h1>
275
  </>
276
  ) : (
277
  <>
278
- <div className="text-eyebrow mb-3 text-matcha">processing</div>
279
- <h1 className="text-display text-[32px] text-ink">
280
  Saving your
281
  <br />
282
- <em className="text-matcha">video review.</em>
283
  </h1>
284
  </>
285
  )}
286
  </div>
287
 
288
- <div className="rounded-[22px] border border-ink/10 bg-paper p-5 shadow-[0_18px_50px_rgba(42,37,32,0.12)]">
 
 
 
 
289
  <div className="relative mx-auto mb-5 flex aspect-[9/16] w-full max-w-[260px] items-center justify-center overflow-hidden rounded-[18px] bg-[#15120f]">
290
  {!isReady ? (
291
  <div className="flex flex-col items-center px-5 text-center">
@@ -319,10 +341,19 @@ export default function PreviewPage() {
319
  ) : null}
320
 
321
  {isReady ? (
322
- <div className="mb-5 rounded-2xl bg-cream px-4 py-4 text-sm leading-6 text-ink/70">
323
- <p>Watch the edit, then approve it for Sage & Stone Cafe to review and use.</p>
 
 
 
 
324
  <div className="mt-3">
325
- <Button onClick={handleApproveFinal}>Approve for Sage & Stone</Button>
 
 
 
 
 
326
  </div>
327
  </div>
328
  ) : null}
@@ -331,7 +362,11 @@ export default function PreviewPage() {
331
  {renderSteps.map((step) => (
332
  <li
333
  key={step.label}
334
- className="flex items-center gap-3 rounded-xl border border-ink/10 bg-cream px-3.5 py-3 text-[13px] text-ink/75"
 
 
 
 
335
  >
336
  <span
337
  className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold ${
 
10
  import { concatClips } from '@/lib/ffmpeg';
11
  import { submit, submitSession, uploadClip } from '@/lib/humeoApi';
12
  import { recordingStore, useRecordingStore } from '@/lib/recordingStore';
13
+ import { RASA_RASA_SLUG } from '@/lib/foodstar';
14
  import type { PublicSubmitResult } from '@/lib/reviews/types';
15
  import { ensureDeviceKey } from '@/lib/utils';
16
  import { addVoiceoverToVideo } from '@/lib/voiceover';
 
41
  const store = useRecordingStore();
42
  const [phase, setPhase] = useState<Phase>({ kind: 'idle' });
43
  const triggeredRef = useRef(false);
44
+ const activeSlug = store.slug ?? RASA_RASA_SLUG;
45
+ const isFoodstar = activeSlug === RASA_RASA_SLUG;
46
+ const restaurantName = isFoodstar ? 'Rasa Rasa' : 'Sage & Stone Cafe';
47
 
48
  const submissionId =
49
  phase.kind === 'polling' || phase.kind === 'ready' ? phase.result.submissionId : null;
 
120
 
121
  setPhase({ kind: 'server_processing' });
122
  const result = await submitSession({
123
+ slug: activeSlug,
124
  consentAccepted: true,
125
  socialHandle: store.socialHandle || undefined,
126
+ instagramHandle: store.instagramHandle || undefined,
127
+ tiktokHandle: store.tiktokHandle || undefined,
128
  deviceKey: ensureDeviceKey(),
129
  tableId: store.tableId,
130
  sessionId: store.sessionId,
 
156
  setPhase({ kind: 'uploading' });
157
 
158
  const result = await submit({
159
+ slug: activeSlug,
160
  consentAccepted: true,
161
  socialHandle: store.socialHandle || undefined,
162
+ instagramHandle: store.instagramHandle || undefined,
163
+ tiktokHandle: store.tiktokHandle || undefined,
164
  deviceKey: ensureDeviceKey(),
165
  tableId: store.tableId,
166
  durationSeconds: finalVideo.durationSeconds,
 
177
  });
178
  }
179
  }, [
180
+ activeSlug,
181
  router,
182
+ store.instagramHandle,
183
  store.orderedClips,
184
  store.sessionId,
 
185
  store.socialHandle,
186
  store.tableId,
187
+ store.tiktokHandle,
188
  ]);
189
 
190
  useEffect(() => {
 
200
 
201
  const handleApproveFinal = () => {
202
  if (phase.kind === 'ready') {
203
+ router.push(
204
+ `/reward?code=${encodeURIComponent(phase.result.reward?.value ?? '')}&slug=${encodeURIComponent(
205
+ activeSlug,
206
+ )}`,
207
+ );
208
  }
209
  };
210
 
211
  const handleRerecord = () => {
212
  recordingStore.reset();
213
+ router.replace(`/c/${encodeURIComponent(activeSlug)}/record`);
214
  };
215
 
216
  const isReady = phase.kind === 'ready';
 
275
 
276
  return (
277
  <MobileShell>
278
+ <main
279
+ className={`flex min-h-dvh flex-col ${
280
+ isFoodstar ? 'bg-[#0D0E0C] text-[#F8F3E7]' : 'bg-cream'
281
+ }`}
282
+ >
283
  <section className="flex flex-1 flex-col px-7 pt-8">
284
  <div className="mb-6">
285
  {isReady ? (
286
  <>
287
+ <div className={`text-eyebrow mb-3 ${isFoodstar ? 'text-[#B8C9A8]' : 'text-matcha'}`}>preview ready</div>
288
+ <h1 className={`text-display text-[32px] ${isFoodstar ? 'text-[#F8F3E7]' : 'text-ink'}`}>
289
  Watch your
290
  <br />
291
+ <em className={isFoodstar ? 'text-[#F0C7A6]' : 'text-matcha'}>finished review.</em>
292
  </h1>
293
  </>
294
  ) : (
295
  <>
296
+ <div className={`text-eyebrow mb-3 ${isFoodstar ? 'text-[#B8C9A8]' : 'text-matcha'}`}>processing</div>
297
+ <h1 className={`text-display text-[32px] ${isFoodstar ? 'text-[#F8F3E7]' : 'text-ink'}`}>
298
  Saving your
299
  <br />
300
+ <em className={isFoodstar ? 'text-[#F0C7A6]' : 'text-matcha'}>video review.</em>
301
  </h1>
302
  </>
303
  )}
304
  </div>
305
 
306
+ <div
307
+ className={`rounded-[8px] border p-5 shadow-[0_18px_50px_rgba(42,37,32,0.12)] ${
308
+ isFoodstar ? 'border-[#F8F3E7]/10 bg-[#191713]' : 'border-ink/10 bg-paper'
309
+ }`}
310
+ >
311
  <div className="relative mx-auto mb-5 flex aspect-[9/16] w-full max-w-[260px] items-center justify-center overflow-hidden rounded-[18px] bg-[#15120f]">
312
  {!isReady ? (
313
  <div className="flex flex-col items-center px-5 text-center">
 
341
  ) : null}
342
 
343
  {isReady ? (
344
+ <div
345
+ className={`mb-5 rounded-[8px] px-4 py-4 text-sm leading-6 ${
346
+ isFoodstar ? 'bg-[#F8F3E7]/8 text-[#DAD2C4]/78' : 'bg-cream text-ink/70'
347
+ }`}
348
+ >
349
+ <p>Watch the edit, then approve it for {restaurantName} to review and use.</p>
350
  <div className="mt-3">
351
+ <Button
352
+ onClick={handleApproveFinal}
353
+ className={isFoodstar ? 'bg-[#B8C9A8] text-[#151711] hover:bg-[#DDE8C8]' : undefined}
354
+ >
355
+ Approve for {restaurantName}
356
+ </Button>
357
  </div>
358
  </div>
359
  ) : null}
 
362
  {renderSteps.map((step) => (
363
  <li
364
  key={step.label}
365
+ className={`flex items-center gap-3 rounded-[8px] border px-3.5 py-3 text-[13px] ${
366
+ isFoodstar
367
+ ? 'border-[#F8F3E7]/10 bg-[#F8F3E7]/7 text-[#DAD2C4]/78'
368
+ : 'border-ink/10 bg-cream text-ink/75'
369
+ }`}
370
  >
371
  <span
372
  className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold ${
src/app/reward/page.tsx CHANGED
@@ -1,75 +1,105 @@
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
  );
 
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 { RASA_RASA_SLUG } from '@/lib/foodstar';
11
+ import { recordingStore } from '@/lib/recordingStore';
12
+
13
+ export default function RewardPage() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  return (
15
+ <Suspense fallback={null}>
16
+ <RewardScreen />
17
+ </Suspense>
18
+ );
19
+ }
20
+
21
+ function RewardScreen() {
22
+ const router = useRouter();
23
+ const params = useSearchParams();
24
+ const slug = params.get('slug') || RASA_RASA_SLUG;
25
+ const isFoodstar = slug === RASA_RASA_SLUG;
26
+ const code = params.get('code') || (isFoodstar ? 'FREE-CHICKEN' : 'MATCHA-7K2Q');
27
+
28
+ const handleRestart = () => {
29
+ recordingStore.reset();
30
+ router.replace(isFoodstar ? '/' : `/c/${encodeURIComponent(slug)}`);
31
+ };
32
+
33
+ return (
34
+ <MobileShell tone={isFoodstar ? 'dark' : 'cream'}>
35
  <main
36
+ className={`relative flex min-h-dvh flex-col overflow-hidden ${
37
+ isFoodstar ? 'bg-[#0D0E0C] text-[#F8F3E7]' : 'bg-cream'
38
+ }`}
39
+ style={{
40
+ backgroundImage: isFoodstar
41
+ ? 'radial-gradient(ellipse at 50% 0%, rgba(184,201,168,0.24), transparent 62%), radial-gradient(circle at 85% 78%, rgba(239,92,54,0.14), transparent 34%)'
42
+ : 'radial-gradient(ellipse at 50% 0%, rgba(184,201,168,0.5), transparent 60%)',
43
+ }}
44
+ >
45
+ <Confetti />
46
+
47
+ <section className="relative z-10 flex flex-1 flex-col items-center px-7 pt-10 text-center">
48
+ <div className={`text-eyebrow mb-2 ${isFoodstar ? 'text-[#B8C9A8]' : 'text-matcha'}`}>
49
+ submitted / thank you
50
+ </div>
51
+ <h1
52
+ className={`text-display text-[38px] ${
53
+ isFoodstar ? 'text-[#F8F3E7]' : 'text-ink'
54
+ }`}
55
+ >
56
+ You&apos;re a{' '}
57
+ <em className={isFoodstar ? 'text-[#F0C7A6]' : 'text-matcha'}>star.</em>
58
+ </h1>
59
+ <p
60
+ className={`mt-2.5 max-w-[300px] text-[14px] leading-[1.5] ${
61
+ isFoodstar ? 'text-[#DAD2C4]/72' : 'text-ink/65'
62
+ }`}
63
+ >
64
+ {isFoodstar
65
+ ? 'Your video is on its way to Rasa Rasa. Show this screen for your chicken reward.'
66
+ : 'Your video is on its way to the team. Now, about that matcha.'}
67
+ </p>
68
+
69
+ <div className="my-7 w-full">
70
+ <RewardCard code={code} />
71
+ </div>
72
+
73
+ <div
74
+ className={`mb-4 flex items-center gap-2.5 rounded-[8px] px-5 py-3.5 text-[13px] font-semibold ${
75
+ isFoodstar ? 'bg-[#B8C9A8] text-[#151711]' : 'bg-sage text-matcha-deep'
76
+ }`}
77
+ >
78
+ <Hand className="h-[18px] w-[18px]" />
79
+ Show this screen to your server
80
+ </div>
81
+
82
+ {isFoodstar ? null : (
83
+ <p className="max-w-[300px] text-[11px] leading-[1.5] text-muted">
84
+ Want a copy of your video?{' '}
85
+ <span className="cursor-pointer text-matcha underline">Send it to my email -&gt;</span>
86
+ </p>
87
+ )}
88
+ </section>
89
+
90
+ <footer className="relative z-10 px-7 pb-6 pt-2">
91
+ <Button
92
+ variant="secondary"
93
+ onClick={handleRestart}
94
+ className={
95
+ isFoodstar
96
+ ? 'border-[#F8F3E7]/40 text-[#F8F3E7] hover:bg-[#F8F3E7]/8'
97
+ : undefined
98
+ }
99
+ >
100
+ Restart
101
+ </Button>
102
+ </footer>
103
  </main>
104
  </MobileShell>
105
  );
src/app/tv/[slug]/page.tsx ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { notFound } from 'next/navigation';
2
+ import { FoodstarTvScreen } from '@/components/FoodstarTvScreen';
3
+ import { getCampaignBySlug } from '@/lib/server/reviewStore';
4
+
5
+ type PageProps = {
6
+ params: { slug: string };
7
+ };
8
+
9
+ export default function TvQrPage({ params }: PageProps) {
10
+ const campaign = getCampaignBySlug(params.slug);
11
+ if (!campaign) notFound();
12
+
13
+ return <FoodstarTvScreen slug={params.slug} restaurantName={campaign.restaurantName} />;
14
+ }
src/components/FoodstarQr.tsx ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import QRCode from 'qrcode';
2
+
3
+ type Props = {
4
+ value: string;
5
+ size?: number;
6
+ };
7
+
8
+ export async function FoodstarQr({ value, size = 356 }: Props) {
9
+ const svg = await QRCode.toString(value, {
10
+ type: 'svg',
11
+ width: size,
12
+ margin: 1,
13
+ errorCorrectionLevel: 'M',
14
+ color: {
15
+ dark: '#17140F',
16
+ light: '#F8F3E7',
17
+ },
18
+ });
19
+
20
+ return (
21
+ <div
22
+ className="overflow-hidden rounded-[7px] bg-[#F8F3E7] p-3 shadow-[0_24px_80px_rgba(0,0,0,0.26)]"
23
+ dangerouslySetInnerHTML={{ __html: svg }}
24
+ />
25
+ );
26
+ }
src/components/FoodstarTvScreen.tsx ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { FoodstarQr } from '@/components/FoodstarQr';
2
+ import { foodstarUrl, RASA_RASA_SLUG } from '@/lib/foodstar';
3
+
4
+ type Props = {
5
+ slug?: string;
6
+ restaurantName?: string;
7
+ };
8
+
9
+ export async function FoodstarTvScreen({
10
+ slug = RASA_RASA_SLUG,
11
+ restaurantName = 'Rasa Rasa',
12
+ }: Props) {
13
+ const reviewUrl = foodstarUrl(`/c/${slug}`);
14
+
15
+ return (
16
+ <main className="min-h-dvh overflow-hidden bg-[#0D0E0C] text-[#F8F3E7]">
17
+ <div className="relative flex min-h-dvh items-center justify-center px-12 py-10">
18
+ <div
19
+ className="absolute inset-0"
20
+ style={{
21
+ background:
22
+ 'radial-gradient(circle at 22% 18%, rgba(184,201,168,0.24), transparent 28%), radial-gradient(circle at 82% 62%, rgba(239,92,54,0.18), transparent 30%), linear-gradient(135deg, #10110F 0%, #211D18 52%, #0D0E0C 100%)',
23
+ }}
24
+ aria-hidden
25
+ />
26
+
27
+ <section className="relative grid w-full max-w-6xl items-center gap-14 lg:grid-cols-[1.05fr_0.95fr]">
28
+ <div>
29
+ <div className="font-mono text-[13px] uppercase tracking-[0.24em] text-[#B8C9A8]">
30
+ foodstar.co
31
+ </div>
32
+ <h1 className="mt-5 max-w-3xl font-serif text-[72px] font-light leading-[0.98] tracking-normal text-[#F8F3E7]">
33
+ Scan the QR code.
34
+ <br />
35
+ Leave a review.
36
+ </h1>
37
+ <p className="mt-8 max-w-2xl text-[30px] leading-[1.2] text-[#F0C7A6]">
38
+ Get a free piece of chicken from {restaurantName}.
39
+ </p>
40
+ <div className="mt-9 inline-flex rounded-full border border-[#B8C9A8]/30 bg-[#B8C9A8]/12 px-6 py-3 font-mono text-[14px] uppercase tracking-[0.18em] text-[#DDE8C8]">
41
+ {reviewUrl}
42
+ </div>
43
+ </div>
44
+
45
+ <div className="flex flex-col items-center justify-center">
46
+ <FoodstarQr value={reviewUrl} size={390} />
47
+ <p className="mt-6 text-center font-mono text-[13px] uppercase tracking-[0.18em] text-[#F8F3E7]/62">
48
+ Review food for {restaurantName}
49
+ </p>
50
+ </div>
51
+ </section>
52
+ </div>
53
+ </main>
54
+ );
55
+ }
src/lib/foodstar.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const RASA_RASA_SLUG = 'rasarasa';
2
+ export const FOODSTAR_PUBLIC_ORIGIN =
3
+ (process.env.NEXT_PUBLIC_FOODSTAR_PUBLIC_URL || 'https://foodstar.co').replace(/\/$/, '');
4
+
5
+ export function foodstarUrl(path: string) {
6
+ const cleanPath = path.startsWith('/') ? path : `/${path}`;
7
+ return `${FOODSTAR_PUBLIC_ORIGIN}${cleanPath}`;
8
+ }
9
+
10
+ export function socialHandleSummary(instagramHandle?: string | null, tiktokHandle?: string | null) {
11
+ const handles = [
12
+ instagramHandle ? `IG ${instagramHandle}` : '',
13
+ tiktokHandle ? `TikTok ${tiktokHandle}` : '',
14
+ ].filter(Boolean);
15
+
16
+ return handles.join(' / ');
17
+ }
src/lib/humeoApi.ts CHANGED
@@ -42,6 +42,8 @@ export type SubmitInput = {
42
  slug: string;
43
  consentAccepted: boolean;
44
  socialHandle?: string;
 
 
45
  deviceKey: string;
46
  tableId?: string | null;
47
  durationSeconds: number;
@@ -97,6 +99,8 @@ export async function submit(input: SubmitInput): Promise<PublicSubmitResult> {
97
  form.append('slug', input.slug);
98
  form.append('consentAccepted', input.consentAccepted ? 'true' : 'false');
99
  form.append('socialHandle', input.socialHandle ?? '');
 
 
100
  form.append('deviceKey', input.deviceKey);
101
  form.append('durationSeconds', String(Math.round(input.durationSeconds)));
102
  if (input.tableId) form.append('tableId', input.tableId);
@@ -120,6 +124,8 @@ export async function submitClips(input: SubmitClipsInput): Promise<PublicSubmit
120
  form.append('slug', input.slug);
121
  form.append('consentAccepted', input.consentAccepted ? 'true' : 'false');
122
  form.append('socialHandle', input.socialHandle ?? '');
 
 
123
  form.append('deviceKey', input.deviceKey);
124
  if (input.tableId) form.append('tableId', input.tableId);
125
 
@@ -173,6 +179,8 @@ export async function submitSession(input: SubmitSessionInput): Promise<PublicSu
173
  form.append('slug', input.slug);
174
  form.append('consentAccepted', input.consentAccepted ? 'true' : 'false');
175
  form.append('socialHandle', input.socialHandle ?? '');
 
 
176
  form.append('deviceKey', input.deviceKey);
177
  form.append('sessionId', input.sessionId);
178
  if (input.tableId) form.append('tableId', input.tableId);
 
42
  slug: string;
43
  consentAccepted: boolean;
44
  socialHandle?: string;
45
+ instagramHandle?: string;
46
+ tiktokHandle?: string;
47
  deviceKey: string;
48
  tableId?: string | null;
49
  durationSeconds: number;
 
99
  form.append('slug', input.slug);
100
  form.append('consentAccepted', input.consentAccepted ? 'true' : 'false');
101
  form.append('socialHandle', input.socialHandle ?? '');
102
+ form.append('instagramHandle', input.instagramHandle ?? '');
103
+ form.append('tiktokHandle', input.tiktokHandle ?? '');
104
  form.append('deviceKey', input.deviceKey);
105
  form.append('durationSeconds', String(Math.round(input.durationSeconds)));
106
  if (input.tableId) form.append('tableId', input.tableId);
 
124
  form.append('slug', input.slug);
125
  form.append('consentAccepted', input.consentAccepted ? 'true' : 'false');
126
  form.append('socialHandle', input.socialHandle ?? '');
127
+ form.append('instagramHandle', input.instagramHandle ?? '');
128
+ form.append('tiktokHandle', input.tiktokHandle ?? '');
129
  form.append('deviceKey', input.deviceKey);
130
  if (input.tableId) form.append('tableId', input.tableId);
131
 
 
179
  form.append('slug', input.slug);
180
  form.append('consentAccepted', input.consentAccepted ? 'true' : 'false');
181
  form.append('socialHandle', input.socialHandle ?? '');
182
+ form.append('instagramHandle', input.instagramHandle ?? '');
183
+ form.append('tiktokHandle', input.tiktokHandle ?? '');
184
  form.append('deviceKey', input.deviceKey);
185
  form.append('sessionId', input.sessionId);
186
  if (input.tableId) form.append('tableId', input.tableId);
src/lib/recordingStore.ts CHANGED
@@ -1,16 +1,16 @@
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
  import type { UploadedClipRef } from '@/lib/humeoApi';
13
-
14
  export type RecordedClip = {
15
  step: number;
16
  takeId: number;
@@ -35,6 +35,8 @@ type Snapshot = {
35
  clipUploadsByStep: Record<number, ClipUploadState>;
36
  skippedSteps: Record<number, true>;
37
  socialHandle: string;
 
 
38
  tableId: string | null;
39
  slug: string | null;
40
  sessionId: string;
@@ -46,6 +48,8 @@ let clipUploadPromises: Record<number, { takeId: number; promise: Promise<Upload
46
  let takeIdsByStep: Record<number, number> = {};
47
  let skippedSteps: Record<number, true> = {};
48
  let socialHandle = '';
 
 
49
  let tableId: string | null = null;
50
  let slug: string | null = null;
51
  let sessionId = newSessionId();
@@ -61,20 +65,22 @@ function newSessionId() {
61
  function emit() {
62
  for (const l of listeners) l();
63
  }
64
-
65
- function buildSnapshot(): Snapshot {
66
- return {
67
  clipsByStep,
68
  orderedClips: Object.values(clipsByStep).sort((a, b) => a.step - b.step),
69
  clipUploadsByStep,
70
  skippedSteps,
71
  socialHandle,
 
 
72
  tableId,
73
  slug,
74
  sessionId,
75
  };
76
  }
77
-
78
  export const recordingStore = {
79
  setClip(clip: Omit<RecordedClip, 'takeId'>) {
80
  const takeId = (takeIdsByStep[clip.step] ?? 0) + 1;
@@ -155,12 +161,20 @@ export const recordingStore = {
155
  skippedSteps = { ...skippedSteps, [step]: true };
156
  emit();
157
  },
158
- setMeta(meta: { slug?: string; tableId?: string | null; socialHandle?: string }) {
159
- if (meta.slug !== undefined) slug = meta.slug;
160
- if (meta.tableId !== undefined) tableId = meta.tableId;
161
- if (meta.socialHandle !== undefined) socialHandle = meta.socialHandle;
162
- emit();
163
- },
 
 
 
 
 
 
 
 
164
  reset() {
165
  clipsByStep = {};
166
  clipUploadsByStep = {};
@@ -168,29 +182,31 @@ export const recordingStore = {
168
  takeIdsByStep = {};
169
  skippedSteps = {};
170
  socialHandle = '';
 
 
171
  tableId = null;
172
  slug = null;
173
  sessionId = newSessionId();
174
  emit();
175
  },
176
- snapshot(): Snapshot {
177
- return buildSnapshot();
178
- },
179
- subscribe(listener: Listener) {
180
- listeners.add(listener);
181
- return () => {
182
- listeners.delete(listener);
183
- };
184
- },
185
- };
186
-
187
- export function useRecordingStore(): Snapshot {
188
- const [snap, setSnap] = useState<Snapshot>(() => buildSnapshot());
189
- useEffect(() => {
190
- const unsubscribe = recordingStore.subscribe(() => setSnap(buildSnapshot()));
191
- return () => {
192
- unsubscribe();
193
- };
194
- }, []);
195
- return snap;
196
- }
 
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
  import type { UploadedClipRef } from '@/lib/humeoApi';
13
+
14
  export type RecordedClip = {
15
  step: number;
16
  takeId: number;
 
35
  clipUploadsByStep: Record<number, ClipUploadState>;
36
  skippedSteps: Record<number, true>;
37
  socialHandle: string;
38
+ instagramHandle: string;
39
+ tiktokHandle: string;
40
  tableId: string | null;
41
  slug: string | null;
42
  sessionId: string;
 
48
  let takeIdsByStep: Record<number, number> = {};
49
  let skippedSteps: Record<number, true> = {};
50
  let socialHandle = '';
51
+ let instagramHandle = '';
52
+ let tiktokHandle = '';
53
  let tableId: string | null = null;
54
  let slug: string | null = null;
55
  let sessionId = newSessionId();
 
65
  function emit() {
66
  for (const l of listeners) l();
67
  }
68
+
69
+ function buildSnapshot(): Snapshot {
70
+ return {
71
  clipsByStep,
72
  orderedClips: Object.values(clipsByStep).sort((a, b) => a.step - b.step),
73
  clipUploadsByStep,
74
  skippedSteps,
75
  socialHandle,
76
+ instagramHandle,
77
+ tiktokHandle,
78
  tableId,
79
  slug,
80
  sessionId,
81
  };
82
  }
83
+
84
  export const recordingStore = {
85
  setClip(clip: Omit<RecordedClip, 'takeId'>) {
86
  const takeId = (takeIdsByStep[clip.step] ?? 0) + 1;
 
161
  skippedSteps = { ...skippedSteps, [step]: true };
162
  emit();
163
  },
164
+ setMeta(meta: {
165
+ slug?: string;
166
+ tableId?: string | null;
167
+ socialHandle?: string;
168
+ instagramHandle?: string;
169
+ tiktokHandle?: string;
170
+ }) {
171
+ if (meta.slug !== undefined) slug = meta.slug;
172
+ if (meta.tableId !== undefined) tableId = meta.tableId;
173
+ if (meta.socialHandle !== undefined) socialHandle = meta.socialHandle;
174
+ if (meta.instagramHandle !== undefined) instagramHandle = meta.instagramHandle;
175
+ if (meta.tiktokHandle !== undefined) tiktokHandle = meta.tiktokHandle;
176
+ emit();
177
+ },
178
  reset() {
179
  clipsByStep = {};
180
  clipUploadsByStep = {};
 
182
  takeIdsByStep = {};
183
  skippedSteps = {};
184
  socialHandle = '';
185
+ instagramHandle = '';
186
+ tiktokHandle = '';
187
  tableId = null;
188
  slug = null;
189
  sessionId = newSessionId();
190
  emit();
191
  },
192
+ snapshot(): Snapshot {
193
+ return buildSnapshot();
194
+ },
195
+ subscribe(listener: Listener) {
196
+ listeners.add(listener);
197
+ return () => {
198
+ listeners.delete(listener);
199
+ };
200
+ },
201
+ };
202
+
203
+ export function useRecordingStore(): Snapshot {
204
+ const [snap, setSnap] = useState<Snapshot>(() => buildSnapshot());
205
+ useEffect(() => {
206
+ const unsubscribe = recordingStore.subscribe(() => setSnap(buildSnapshot()));
207
+ return () => {
208
+ unsubscribe();
209
+ };
210
+ }, []);
211
+ return snap;
212
+ }
src/lib/reviews/types.ts CHANGED
@@ -1,60 +1,60 @@
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),
@@ -64,27 +64,31 @@ export const ClipPromptSchema = z.object({
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;
@@ -96,19 +100,19 @@ export type PublicSubmitResult = {
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
- }
 
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', 'foodstar-humeo'] 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),
 
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
+ publicUrl?: string;
87
+ offerHeadline?: string;
88
+ offerDetail?: string;
89
+ startCta?: string;
90
+ };
91
+
92
  export type PublicSubmitResult = {
93
  submissionId: string;
94
  interviewId?: string;
 
100
  previewUrl?: string;
101
  updatedAt: string;
102
  };
103
+
104
+ export const POLLING_SUBMISSION_STATUSES = new Set<ReviewSubmissionStatus>([
105
+ 'opened',
106
+ 'uploading',
107
+ 'processing_interview',
108
+ 'evaluating_rules',
109
+ 'fail_and_retry',
110
+ ]);
111
+
112
+ export function normalizeReviewRulesConfig(value: unknown): ReviewRulesConfig {
113
+ return ReviewRulesConfigSchema.parse(value ?? {});
114
+ }
115
+
116
+ export function normalizeReviewCampaignSettings(value: unknown): ReviewCampaignSettings {
117
+ return ReviewCampaignSettingsSchema.parse(value ?? {});
118
+ }
src/lib/server/reviewStore.ts CHANGED
@@ -8,6 +8,7 @@ import type {
8
  ReviewRewardType,
9
  ReviewSubmissionStatus,
10
  } from '@/lib/reviews/types';
 
11
 
12
  /**
13
  * Review store for the standalone prototype.
@@ -30,6 +31,8 @@ export type LocalSubmission = {
30
  reasons: string[];
31
  consentAccepted: boolean;
32
  socialHandle: string | null;
 
 
33
  deviceKey: string | null;
34
  tableId: string | null;
35
  durationSeconds: number;
@@ -143,6 +146,76 @@ const SAGE_AND_STONE: PublicReviewCampaign = {
143
  theme: 'cafe-cream',
144
  };
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  let supabaseAdmin: SupabaseClient | null = null;
147
  let bucketReady = false;
148
  let envFileCache: Record<string, string> | null = null;
@@ -254,8 +327,13 @@ function isLocalSubmission(value: unknown): value is LocalSubmission {
254
  }
255
 
256
  function normalizeSubmission(value: LocalSubmission): LocalSubmission {
 
 
257
  return {
258
  ...value,
 
 
 
259
  storageBackend: value.storageBackend ?? 'local',
260
  };
261
  }
@@ -263,6 +341,7 @@ function normalizeSubmission(value: LocalSubmission): LocalSubmission {
263
  function createState(): StoreState {
264
  const campaigns = new Map<string, PublicReviewCampaign>();
265
  campaigns.set(SAGE_AND_STONE.slug, SAGE_AND_STONE);
 
266
 
267
  const submissions = new Map<string, LocalSubmission>();
268
  for (const submission of readPersistedSubmissions()) {
@@ -279,6 +358,7 @@ const globalForStore = globalThis as unknown as {
279
 
280
  const state = globalForStore.__matchaReviewStore ?? createState();
281
  state.campaigns.set(SAGE_AND_STONE.slug, SAGE_AND_STONE);
 
282
  for (const [id, submission] of state.submissions.entries()) {
283
  if (!isLocalSubmission(submission)) {
284
  state.submissions.delete(id);
@@ -491,7 +571,10 @@ function advanceSubmission(submission: LocalSubmission) {
491
  if (submission.status === 'evaluating_rules' && elapsed >= 4000) {
492
  submission.status = 'reward_issued';
493
  submission.decision = 'pass';
494
- submission.feedback = 'Office prototype approved. Show the matcha code to staff.';
 
 
 
495
  submission.reward = {
496
  type: campaign.rewardType,
497
  value: campaign.rewardValue ?? rewardCode(),
@@ -510,6 +593,8 @@ export type CreateSubmissionInput = {
510
  slug: string;
511
  consentAccepted: boolean;
512
  socialHandle?: string | null;
 
 
513
  deviceKey?: string | null;
514
  tableId?: string | null;
515
  durationSeconds: number;
@@ -574,6 +659,8 @@ export async function createSubmission(input: CreateSubmissionInput):
574
  }
575
 
576
  const now = new Date().toISOString();
 
 
577
  const submission: LocalSubmission = {
578
  submissionId,
579
  interviewId,
@@ -584,7 +671,10 @@ export async function createSubmission(input: CreateSubmissionInput):
584
  reward: null,
585
  reasons: [],
586
  consentAccepted: input.consentAccepted,
587
- socialHandle: input.socialHandle?.trim() || null,
 
 
 
588
  deviceKey: input.deviceKey?.trim() || null,
589
  tableId: input.tableId?.trim() || null,
590
  durationSeconds: Math.max(0, Math.round(input.durationSeconds)),
 
8
  ReviewRewardType,
9
  ReviewSubmissionStatus,
10
  } from '@/lib/reviews/types';
11
+ import { foodstarUrl, RASA_RASA_SLUG, socialHandleSummary } from '@/lib/foodstar';
12
 
13
  /**
14
  * Review store for the standalone prototype.
 
31
  reasons: string[];
32
  consentAccepted: boolean;
33
  socialHandle: string | null;
34
+ instagramHandle: string | null;
35
+ tiktokHandle: string | null;
36
  deviceKey: string | null;
37
  tableId: string | null;
38
  durationSeconds: number;
 
146
  theme: 'cafe-cream',
147
  };
148
 
149
+ const RASA_RASA: PublicReviewCampaign = {
150
+ id: 'rasa-rasa-foodstar',
151
+ slug: RASA_RASA_SLUG,
152
+ restaurantName: 'Rasa Rasa',
153
+ status: 'active',
154
+ rulesConfig: {
155
+ minDurationSeconds: 8,
156
+ maxDurationSeconds: 90,
157
+ minWordCount: 8,
158
+ requireRestaurantMention: false,
159
+ blockedTerms: [],
160
+ },
161
+ settings: { dailyRewardLimitPerDevice: 1 },
162
+ mode: 'guided_clips',
163
+ prompts: [
164
+ {
165
+ step: 1,
166
+ title: 'What dish / drink did you order?',
167
+ tip: 'Voice only. Say the name of the dish or drink clearly.',
168
+ mediaType: 'audio',
169
+ camera: 'front',
170
+ maxSeconds: 10,
171
+ optional: false,
172
+ },
173
+ {
174
+ step: 2,
175
+ title: 'What did you like about it?',
176
+ tip: 'Tell us the flavor, crunch, sauce, texture, or anything you loved.',
177
+ mediaType: 'audio',
178
+ camera: 'front',
179
+ maxSeconds: 10,
180
+ optional: false,
181
+ },
182
+ {
183
+ step: 3,
184
+ title: 'Record a close up shot of the food.',
185
+ tip: 'Move slowly across the crisp edges, sauce, steam, and texture.',
186
+ mediaType: 'video',
187
+ camera: 'rear',
188
+ maxSeconds: 10,
189
+ optional: false,
190
+ },
191
+ {
192
+ step: 4,
193
+ title: 'Record a table shot of the food.',
194
+ tip: 'Show the dish on the table so the whole plate feels clear.',
195
+ mediaType: 'video',
196
+ camera: 'rear',
197
+ maxSeconds: 10,
198
+ optional: false,
199
+ },
200
+ {
201
+ step: 5,
202
+ title: 'Record an action shot.',
203
+ tip: 'Take a bite, pull it apart, dip it, stir it, or show a reaction.',
204
+ mediaType: 'video',
205
+ camera: 'rear',
206
+ maxSeconds: 10,
207
+ optional: false,
208
+ },
209
+ ],
210
+ rewardType: 'static_code',
211
+ rewardValue: 'FREE-CHICKEN',
212
+ theme: 'foodstar-humeo',
213
+ publicUrl: foodstarUrl(`/c/${RASA_RASA_SLUG}`),
214
+ offerHeadline: 'Leave a review, get a free piece of chicken.',
215
+ offerDetail: 'Scan the QR, record a quick food review for Rasa Rasa, then show the reward screen to the team.',
216
+ startCta: 'Start food review',
217
+ };
218
+
219
  let supabaseAdmin: SupabaseClient | null = null;
220
  let bucketReady = false;
221
  let envFileCache: Record<string, string> | null = null;
 
327
  }
328
 
329
  function normalizeSubmission(value: LocalSubmission): LocalSubmission {
330
+ const instagramHandle = value.instagramHandle ?? null;
331
+ const tiktokHandle = value.tiktokHandle ?? null;
332
  return {
333
  ...value,
334
+ instagramHandle,
335
+ tiktokHandle,
336
+ socialHandle: value.socialHandle ?? (socialHandleSummary(instagramHandle, tiktokHandle) || null),
337
  storageBackend: value.storageBackend ?? 'local',
338
  };
339
  }
 
341
  function createState(): StoreState {
342
  const campaigns = new Map<string, PublicReviewCampaign>();
343
  campaigns.set(SAGE_AND_STONE.slug, SAGE_AND_STONE);
344
+ campaigns.set(RASA_RASA.slug, RASA_RASA);
345
 
346
  const submissions = new Map<string, LocalSubmission>();
347
  for (const submission of readPersistedSubmissions()) {
 
358
 
359
  const state = globalForStore.__matchaReviewStore ?? createState();
360
  state.campaigns.set(SAGE_AND_STONE.slug, SAGE_AND_STONE);
361
+ state.campaigns.set(RASA_RASA.slug, RASA_RASA);
362
  for (const [id, submission] of state.submissions.entries()) {
363
  if (!isLocalSubmission(submission)) {
364
  state.submissions.delete(id);
 
571
  if (submission.status === 'evaluating_rules' && elapsed >= 4000) {
572
  submission.status = 'reward_issued';
573
  submission.decision = 'pass';
574
+ submission.feedback =
575
+ campaign.slug === RASA_RASA_SLUG
576
+ ? 'Approved. Show the chicken reward screen to the Rasa Rasa team.'
577
+ : 'Office prototype approved. Show the matcha code to staff.';
578
  submission.reward = {
579
  type: campaign.rewardType,
580
  value: campaign.rewardValue ?? rewardCode(),
 
593
  slug: string;
594
  consentAccepted: boolean;
595
  socialHandle?: string | null;
596
+ instagramHandle?: string | null;
597
+ tiktokHandle?: string | null;
598
  deviceKey?: string | null;
599
  tableId?: string | null;
600
  durationSeconds: number;
 
659
  }
660
 
661
  const now = new Date().toISOString();
662
+ const instagramHandle = input.instagramHandle?.trim() || null;
663
+ const tiktokHandle = input.tiktokHandle?.trim() || null;
664
  const submission: LocalSubmission = {
665
  submissionId,
666
  interviewId,
 
671
  reward: null,
672
  reasons: [],
673
  consentAccepted: input.consentAccepted,
674
+ socialHandle:
675
+ input.socialHandle?.trim() || socialHandleSummary(instagramHandle, tiktokHandle) || null,
676
+ instagramHandle,
677
+ tiktokHandle,
678
  deviceKey: input.deviceKey?.trim() || null,
679
  tableId: input.tableId?.trim() || null,
680
  durationSeconds: Math.max(0, Math.round(input.durationSeconds)),