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. */}
+