From 0d54cedff28682776130c99c751c9dc42a1915d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 19:54:56 +0000 Subject: [PATCH] =?UTF-8?q?feat(scan):=20simplify=20to=20capture=20?= =?UTF-8?q?=E2=86=92=20suggest=20=E2=86=92=20ask=20on=20chat=20(Round=2017?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Product direction: the app should be simple — just capture a photo, get a suggestion, and ask follow-ups on chat. This removes upfront scan friction and adds the missing chat hand-off. - Scan page leads with the upload area; the Meal/Menu/Drink picker is no longer the first thing users see. Meal is pre-selected; Menu/Drink move behind an optional "Scanning a menu or a drink instead?" toggle. No capability removed — only the required choice before capture. - Every suggestion gets a primary "💬 Ask Shinny about this" CTA (desktop meal sidebar + mobile result block, covering meal/menu/drink); "Scan Another" becomes the secondary action. - New lib/chat-seed.ts hands the scanned dish to chat as a pre-written question via localStorage (read-once, never auto-sends, never clobbers an in-progress draft). localStorage survives the chat login-gate redirect, so the seed is still waiting after sign-in. Kept the existing tested meal pipeline (no AI mode auto-detection) and the chat login gate (anonymous chat = AI-cost/abuse exposure, a separate decision) — both safe, reversible defaults. 4 new i18n keys × 4 locales (216 → 233). type-check + i18n + 193/193 unit green; next build clean. Live scan→chat round-trip is the post-deploy user-journey e2e gate. --- CHANGELOG.md | 14 ++++++ README.md | 2 +- frontend/src/app/[locale]/chat/page.tsx | 11 +++++ frontend/src/app/[locale]/scan/page.tsx | 58 ++++++++++++++++++++++--- frontend/src/lib/chat-seed.ts | 38 ++++++++++++++++ frontend/src/messages/da.json | 4 ++ frontend/src/messages/de.json | 4 ++ frontend/src/messages/en.json | 4 ++ frontend/src/messages/th.json | 4 ++ 9 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 frontend/src/lib/chat-seed.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 103f6b6..ed6120c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Round 17 (simplify the core loop) — capture → suggest → ask on chat + +Product direction: *"the app should be not complex — just capture photos and then suggest users; if any question, just ask on chat further."* This round removes the upfront friction on the scan flow and adds the chat hand-off that was missing. + +| Change | Detail | +|---|---| +| **Lead with the photo, not a mode choice** | The scan page no longer opens with the 3-button Meal/Menu/Drink picker above the upload area. Meal is pre-selected and the upload area leads, so a first-time user can just snap a photo. Menu/Drink move behind an optional **"Scanning a menu or a drink instead?"** toggle (`scan.more_options`). No capability removed — only the *required choice* before capture. | +| **"Ask Shinny about this" on every result** | Each suggestion now carries a primary `💬 Ask Shinny about this` CTA (beside the existing "Scan Another", which becomes secondary) on both the desktop meal sidebar and the mobile result block (covers meal/menu/drink). | +| **Scan → chat context hand-off** | New `lib/chat-seed.ts` stashes a pre-written question (`scan.ask_seed` with the identified dish name, or `scan.ask_seed_generic`) in `localStorage`; the chat page reads it **read-once** on mount and pre-fills the composer (never auto-sends, never clobbers an in-progress draft). localStorage (not a query param) so the seed survives the login-gate redirect for logged-out users. | + +**Decisions taken** (the in-product picker for these didn't resolve, so safe/reversible defaults were chosen and are easy to revisit): kept the existing tested meal pipeline rather than building AI mode auto-detection; kept the chat **login gate** (opening chat to anonymous users has real AI-cost/abuse exposure — a deliberate, separate decision). The "Ask Shinny" CTA only renders on an actual suggestion (`hasResult`), not on the not-food/error cards — you ask about a result, not a rejection. + +4 new i18n keys × 4 locales (`ask_shinny`, `ask_seed`, `ask_seed_generic`, `more_options`); locale key count 216 → **233**. Type-check + i18n + **193/193** unit green; `next build` clean. The live scan→chat round-trip against the real AI is covered by the post-deploy `user-journey` e2e gate (the noise-PNG fixture routes to the not-food branch, so the CTA itself is a manual/post-deploy check with a real food photo). + ### Round 16 (discoverability) — SEO + social + package metadata rework (v2.1.14) The product was indexed under brand-led copy ("Smart Food Sequencing") while real users search by **problem and tool** ("AI food scanner", "blood sugar", "what to eat first"). This round rewrote the discovery surface — search metadata, social cards, repo/package topic tags, and the README hero — to match search intent. Pure metadata/docs; no runtime behaviour, no test-posture change. diff --git a/README.md b/README.md index 9cef8ba..1e5a5b5 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ Individually: | Script | Purpose | |--------|---------| | `npm run type-check` | Strict TypeScript (`tsc --noEmit`) across the whole frontend | -| `npm run check:i18n` | Ensures every `t('…')` key used in code exists in **all 4 locale JSONs** (th/en/de/da) — 216 keys each | +| `npm run check:i18n` | Ensures every `t('…')` key used in code exists in **all 4 locale JSONs** (th/en/de/da) — 233 keys each | | `npm test` | Vitest unit tests — crypto (PBKDF2 + constant-time compare), ai-prompt validators, zod schemas, auth-store `authChecked` lifecycle | | `npm run test:e2e` | Playwright e2e against the live deploy — **99 cases** across `smoke`, `ui-ux`, `deep-probes`, `a11y`, `responsive-perf`, `user-journey` (opt-in, needs Chromium + network) | | `npm run build` | Full Next.js production build, same thing Cloudflare Pages runs | diff --git a/frontend/src/app/[locale]/chat/page.tsx b/frontend/src/app/[locale]/chat/page.tsx index dfc7fb7..10e80e0 100644 --- a/frontend/src/app/[locale]/chat/page.tsx +++ b/frontend/src/app/[locale]/chat/page.tsx @@ -29,6 +29,7 @@ import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { ArrowLeft, Send, Sparkles, AlertCircle, RotateCcw } from 'lucide-react'; import { useAuthStore } from '@/lib/auth-store'; +import { takeChatSeed } from '@/lib/chat-seed'; const STORAGE_KEY = 'shinny-chat-history-v1'; const MAX_HISTORY_TURNS = 50; @@ -107,6 +108,16 @@ export default function ChatPage() { setTurns(loadHistory()); }, []); + // Pick up a pending "Ask Shinny about this" question handed over from the + // scan result. Read-once (takeChatSeed clears it), and only pre-fill an + // empty composer so it never clobbers something the user is mid-typing. + // The user still taps Send — we don't auto-fire, so they can tweak the + // question first and the login gate stays in control of delivery. + useEffect(() => { + const seed = takeChatSeed(); + if (seed) setDraft((current) => (current ? current : seed)); + }, []); + // Auto-scroll to the latest message whenever turns or sending changes. useEffect(() => { if (scrollRef.current) { diff --git a/frontend/src/app/[locale]/scan/page.tsx b/frontend/src/app/[locale]/scan/page.tsx index f3fefa4..c2504fc 100644 --- a/frontend/src/app/[locale]/scan/page.tsx +++ b/frontend/src/app/[locale]/scan/page.tsx @@ -3,7 +3,10 @@ import { useRef, useEffect, useState } from 'react'; import { useTranslations, useLocale } from 'next-intl'; import Link from 'next/link'; -import { Scan, Cpu } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { Scan, Cpu, MessageCircle } from 'lucide-react'; + +import { setChatSeed } from '@/lib/chat-seed'; import { useAuthStore } from '@/lib/auth-store'; import { TIER_LIMITS } from '@/lib/tier-config'; @@ -50,6 +53,7 @@ export default function ScanPage() { const tCommon = useTranslations('common'); const tMascot = useTranslations('mascot'); const locale = useLocale(); + const router = useRouter(); const { user, isAuthenticated } = useAuthStore(); const tier = (isAuthenticated && user?.subscriptionTier) ? user.subscriptionTier : 'free'; @@ -58,7 +62,12 @@ export default function ScanPage() { const fileInputRef = useRef(null); const cameraInputRef = useRef(null); + // Default flow is "just snap a meal": meal mode is pre-selected and the + // upload area leads. The Menu / Drink modes live behind an optional + // toggle so a first-time user never has to choose a mode before they + // can take a photo. const [scanMode, setScanMode] = useState('meal'); + const [showModeOptions, setShowModeOptions] = useState(false); // Debug Hook const debug = useScanDebug(); @@ -110,6 +119,19 @@ export default function ScanPage() { analysis.resetAnalysis(); }; + // "Ask Shinny about this" — hand the just-scanned result to the chat + // page as a pre-written question so a follow-up is one tap away. Use + // the first identified dish name when we have it; otherwise a generic + // "about my meal" seed. The chat page reads + clears this on mount. + const askShinnyAboutScan = () => { + const dishName = analysis.mealResult?.dishes?.[0]?.name?.trim(); + const seed = dishName + ? t('ask_seed', { dish: dishName }) + : t('ask_seed_generic'); + setChatSeed(seed); + router.push(`/${locale}/chat`); + }; + const isUploading = upload.uploadedImages.length > 0 && !analysis.isAnalyzing && !analysis.hasResult && !analysis.nonFoodReason && !upload.uploadError && !analysis.analysisError; const isReadyForUpload = upload.uploadedImages.length === 0 && !analysis.isAnalyzing && !analysis.hasResult && !analysis.nonFoodReason; @@ -151,7 +173,9 @@ export default function ScanPage() { {isReadyForUpload && ( <> - + {/* Upload area leads — the default path is "just snap a + meal". Meal mode is pre-selected; no choice required + before taking a photo. */} {t('privacy_note')}

+ + {/* Menu / Drink modes are optional — tucked behind a + toggle so they don't make the default "snap a meal" + path feel like a decision. Picking a non-meal mode + keeps the picker open. */} +
+ {!showModeOptions ? ( + + ) : ( + + )} +
)} @@ -290,7 +332,10 @@ export default function ScanPage() {
Shinny - + {analysis.modelUsed && ( @@ -393,8 +438,11 @@ export default function ScanPage() {
Shinny Celebrating
-
- +
diff --git a/frontend/src/lib/chat-seed.ts b/frontend/src/lib/chat-seed.ts new file mode 100644 index 0000000..6babb1a --- /dev/null +++ b/frontend/src/lib/chat-seed.ts @@ -0,0 +1,38 @@ +/** + * Hand-off channel from a scan result to the chat page. + * + * The scan page's "Ask Shinny about this" CTA stashes a pre-written + * question here, then navigates to /[locale]/chat. The chat page reads + * it once on mount, pre-fills the input, and clears it. + * + * Why localStorage and not a query param: chat is login-gated. A + * logged-out user who taps the CTA gets bounced to /login first; a + * query param would be lost on that redirect, but localStorage survives + * it, so the seeded question is still waiting when they land back on + * /chat after signing in. The value is read-once (cleared on read) so a + * stale seed never re-pops on a later manual visit to /chat. + */ +export const CHAT_SEED_KEY = 'shinny_chat_seed'; + +/** Stash a seed question for the chat page. Safe on the server (no-op). */ +export function setChatSeed(question: string): void { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(CHAT_SEED_KEY, question); + } catch { + // Quota / disabled storage — the CTA still navigates; chat just + // opens with an empty composer. Non-fatal. + } +} + +/** Read and clear the pending seed (read-once). Returns '' if none. */ +export function takeChatSeed(): string { + if (typeof window === 'undefined') return ''; + try { + const seed = window.localStorage.getItem(CHAT_SEED_KEY); + if (seed) window.localStorage.removeItem(CHAT_SEED_KEY); + return seed ?? ''; + } catch { + return ''; + } +} diff --git a/frontend/src/messages/da.json b/frontend/src/messages/da.json index b717507..473f7a3 100644 --- a/frontend/src/messages/da.json +++ b/frontend/src/messages/da.json @@ -88,6 +88,10 @@ "fat_g": "Fedt", "fiber_g": "Fibre", "try_again": "Scan Igen", + "ask_shinny": "💬 Spørg Shinny om dette", + "ask_seed": "Jeg har lige scannet {dish}. Har du tips til at gøre det sundere eller spise det i den bedste rækkefølge?", + "ask_seed_generic": "Jeg har lige scannet mit måltid. Har du tips til at gøre det sundere eller spise det i den bedste rækkefølge?", + "more_options": "Scanner du en menu eller en drik i stedet?", "error_title": "Undskyld! 🙏", "error_message": "Shinny kunne ikke analysere dette billede", "upload_unsupported": "Den filtype understøttes ikke – prøv et JPG-, PNG- eller HEIC-foto.", diff --git a/frontend/src/messages/de.json b/frontend/src/messages/de.json index b6d16eb..9eab9ad 100644 --- a/frontend/src/messages/de.json +++ b/frontend/src/messages/de.json @@ -88,6 +88,10 @@ "fat_g": "Fett", "fiber_g": "Ballaststoffe", "try_again": "Erneut scannen", + "ask_shinny": "💬 Frag Shinny dazu", + "ask_seed": "Ich habe gerade {dish} gescannt. Hast du Tipps, um es gesünder zu machen oder in der besten Reihenfolge zu essen?", + "ask_seed_generic": "Ich habe gerade meine Mahlzeit gescannt. Hast du Tipps, um sie gesünder zu machen oder in der besten Reihenfolge zu essen?", + "more_options": "Scannst du stattdessen eine Speisekarte oder ein Getränk?", "error_title": "Entschuldigung! 🙏", "error_message": "Shinny konnte dieses Bild nicht analysieren", "upload_unsupported": "Dieser Dateityp wird nicht unterstützt – probier ein JPG-, PNG- oder HEIC-Foto.", diff --git a/frontend/src/messages/en.json b/frontend/src/messages/en.json index fb0f7db..ac3893a 100644 --- a/frontend/src/messages/en.json +++ b/frontend/src/messages/en.json @@ -88,6 +88,10 @@ "fat_g": "Fat", "fiber_g": "Fiber", "try_again": "Scan Another", + "ask_shinny": "💬 Ask Shinny about this", + "ask_seed": "I just scanned {dish}. Any tips to make it healthier or eat it in the best order?", + "ask_seed_generic": "I just scanned my meal. Any tips to make it healthier or eat it in the best order?", + "more_options": "Scanning a menu or a drink instead?", "error_title": "Sorry! 🙏", "error_message": "Shinny couldn't analyze this image", "upload_unsupported": "That file type isn't supported — try a JPG, PNG, or HEIC photo.", diff --git a/frontend/src/messages/th.json b/frontend/src/messages/th.json index 408d3c3..b2a30a7 100644 --- a/frontend/src/messages/th.json +++ b/frontend/src/messages/th.json @@ -88,6 +88,10 @@ "fat_g": "ไขมัน", "fiber_g": "ไฟเบอร์", "try_again": "สแกนอาหารอื่น", + "ask_shinny": "💬 ถามชินนี่เกี่ยวกับเมนูนี้", + "ask_seed": "เพิ่งสแกน {dish} มา มีเคล็ดลับให้กินดีต่อสุขภาพขึ้นหรือกินตามลำดับที่ดีที่สุดไหมคะ?", + "ask_seed_generic": "เพิ่งสแกนมื้อนี้มา มีเคล็ดลับให้กินดีต่อสุขภาพขึ้นหรือกินตามลำดับที่ดีที่สุดไหมคะ?", + "more_options": "กำลังสแกนเมนูหรือเครื่องดื่มอยู่ใช่ไหม?", "error_title": "ขอโทษนะคะ! 🙏", "error_message": "ชินนี่วิเคราะห์ภาพนี้ไม่สำเร็จ", "upload_unsupported": "ไฟล์ประเภทนี้ยังไม่รองรับนะคะ ลองใช้รูป JPG, PNG หรือ HEIC ดูนะคะ",