Commit ·
1905abd
1
Parent(s): 9399dcd
Add Foodstar Rasa Rasa campaign
Browse files- package-lock.json +0 -0
- package.json +38 -36
- src/app/admin/reviews/page.tsx +20 -3
- src/app/api/public/reviews/submit-clips/route.ts +4 -0
- src/app/api/public/reviews/submit-session/route.ts +4 -0
- src/app/api/public/reviews/submit/route.ts +4 -0
- src/app/c/[slug]/LandingClient.tsx +129 -16
- src/app/layout.tsx +46 -46
- src/app/page.tsx +2 -74
- src/app/preview/page.tsx +52 -17
- src/app/reward/page.tsx +95 -65
- src/app/tv/[slug]/page.tsx +14 -0
- src/components/FoodstarQr.tsx +26 -0
- src/components/FoodstarTvScreen.tsx +55 -0
- src/lib/foodstar.ts +17 -0
- src/lib/humeoApi.ts +8 -0
- src/lib/recordingStore.ts +58 -42
- src/lib/reviews/types.ts +98 -94
- src/lib/server/reviewStore.ts +92 -2
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 |
-
"
|
| 21 |
-
"react
|
| 22 |
-
"
|
| 23 |
-
"
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
"@types/
|
| 28 |
-
"@types/
|
| 29 |
-
"
|
| 30 |
-
"
|
| 31 |
-
"
|
| 32 |
-
"
|
| 33 |
-
"
|
| 34 |
-
"
|
| 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
|
| 57 |
|
| 58 |
try {
|
| 59 |
-
return new URL(
|
| 60 |
} catch {
|
| 61 |
-
return
|
| 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 |
-
|
| 22 |
-
|
| 23 |
-
|
| 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'} ->
|
| 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: '
|
| 29 |
-
description: '
|
| 30 |
-
};
|
| 31 |
-
|
| 32 |
-
export const viewport: Viewport = {
|
| 33 |
-
width: 'device-width',
|
| 34 |
-
initialScale: 1,
|
| 35 |
-
maximumScale: 1,
|
| 36 |
-
userScalable: false,
|
| 37 |
-
themeColor: '#
|
| 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 |
-
|
| 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 |
-
|
| 12 |
-
|
| 13 |
-
useEffect(() => {
|
| 14 |
-
let raf = 0;
|
| 15 |
-
const start = performance.now();
|
| 16 |
-
const tick = (now: number) => {
|
| 17 |
-
const t = ((now - start) / 2000) % 1;
|
| 18 |
-
// up-down ping-pong
|
| 19 |
-
setScanY(Math.abs(Math.sin(t * Math.PI)));
|
| 20 |
-
raf = requestAnimationFrame(tick);
|
| 21 |
-
};
|
| 22 |
-
raf = requestAnimationFrame(tick);
|
| 23 |
-
return () => cancelAnimationFrame(raf);
|
| 24 |
-
}, []);
|
| 25 |
-
|
| 26 |
-
return (
|
| 27 |
-
<MobileShell>
|
| 28 |
-
<main className="flex min-h-dvh flex-col bg-cream">
|
| 29 |
-
<section className="flex flex-1 flex-col items-center justify-center px-7 text-center">
|
| 30 |
-
<div className="relative mb-5 h-40 w-40 overflow-hidden rounded-2xl border border-ink/10 bg-white p-3.5">
|
| 31 |
-
<div
|
| 32 |
-
className="h-full w-full rounded-md"
|
| 33 |
-
style={{
|
| 34 |
-
backgroundImage:
|
| 35 |
-
'linear-gradient(90deg, #2A2520 25%, transparent 25% 50%, #2A2520 50% 75%, transparent 75%), linear-gradient(#2A2520 25%, transparent 25% 50%, #2A2520 50% 75%, transparent 75%)',
|
| 36 |
-
backgroundSize: '8px 8px',
|
| 37 |
-
backgroundBlendMode: 'multiply',
|
| 38 |
-
}}
|
| 39 |
-
/>
|
| 40 |
-
<div className="absolute left-3.5 top-3.5 h-[30px] w-[30px] border-[6px] border-ink bg-white" />
|
| 41 |
-
<div className="absolute right-3.5 top-3.5 h-[30px] w-[30px] border-[6px] border-ink bg-white" />
|
| 42 |
-
<div className="absolute bottom-3.5 left-3.5 h-[30px] w-[30px] border-[6px] border-ink bg-white" />
|
| 43 |
-
<div
|
| 44 |
-
className="pointer-events-none absolute left-3.5 right-3.5 h-[2px]"
|
| 45 |
-
style={{
|
| 46 |
-
top: `${10 + scanY * 75}%`,
|
| 47 |
-
background:
|
| 48 |
-
'linear-gradient(90deg, transparent, #4A6B3D, transparent)',
|
| 49 |
-
}}
|
| 50 |
-
/>
|
| 51 |
-
</div>
|
| 52 |
-
|
| 53 |
-
<div className="text-eyebrow mb-3 text-matcha">Step 00 · context</div>
|
| 54 |
-
<h2 className="text-display max-w-[280px] text-[26px] text-ink">
|
| 55 |
-
She scans the QR <em className="text-matcha">on the table</em>
|
| 56 |
-
</h2>
|
| 57 |
-
<p className="mt-3 max-w-[300px] text-[14px] leading-[1.5] text-ink/65">
|
| 58 |
-
Tabletop card reads:{' '}
|
| 59 |
-
<em className="font-serif italic text-matcha">
|
| 60 |
-
“Free matcha for an honest review.”
|
| 61 |
-
</em>{' '}
|
| 62 |
-
She points her camera. Browser opens.
|
| 63 |
-
</p>
|
| 64 |
-
</section>
|
| 65 |
-
|
| 66 |
-
<footer className="px-7 pb-6 pt-4">
|
| 67 |
-
<Link href={`/c/${DEFAULT_SLUG}`} prefetch>
|
| 68 |
-
<Button>Tap to load the page →</Button>
|
| 69 |
-
</Link>
|
| 70 |
-
<p className="mt-3 text-center font-mono text-[10px] text-muted">
|
| 71 |
-
Dev note: in production, the QR camera scan deep-links to /c/[slug].
|
| 72 |
-
</p>
|
| 73 |
-
</footer>
|
| 74 |
-
</main>
|
| 75 |
-
</MobileShell>
|
| 76 |
-
);
|
| 77 |
}
|
|
|
|
| 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:
|
| 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:
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
}
|
| 195 |
};
|
| 196 |
|
| 197 |
const handleRerecord = () => {
|
| 198 |
recordingStore.reset();
|
| 199 |
-
router.replace(`/c/${encodeURIComponent(
|
| 200 |
};
|
| 201 |
|
| 202 |
const isReady = phase.kind === 'ready';
|
|
@@ -261,31 +275,39 @@ export default function PreviewPage() {
|
|
| 261 |
|
| 262 |
return (
|
| 263 |
<MobileShell>
|
| 264 |
-
<main
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
<section className="flex flex-1 flex-col px-7 pt-8">
|
| 266 |
<div className="mb-6">
|
| 267 |
{isReady ? (
|
| 268 |
<>
|
| 269 |
-
<div className=
|
| 270 |
-
<h1 className=
|
| 271 |
Watch your
|
| 272 |
<br />
|
| 273 |
-
<em className=
|
| 274 |
</h1>
|
| 275 |
</>
|
| 276 |
) : (
|
| 277 |
<>
|
| 278 |
-
<div className=
|
| 279 |
-
<h1 className=
|
| 280 |
Saving your
|
| 281 |
<br />
|
| 282 |
-
<em className=
|
| 283 |
</h1>
|
| 284 |
</>
|
| 285 |
)}
|
| 286 |
</div>
|
| 287 |
|
| 288 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 323 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
<div className="mt-3">
|
| 325 |
-
<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=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
<main
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
<
|
| 43 |
-
|
| 44 |
-
<
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
<
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
</
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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'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 -></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: {
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 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 =
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
|
|
|
|
|
|
| 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)),
|