Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/app/[locale]/chat/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
58 changes: 53 additions & 5 deletions frontend/src/app/[locale]/scan/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand All @@ -58,7 +62,12 @@ export default function ScanPage() {
const fileInputRef = useRef<HTMLInputElement>(null);
const cameraInputRef = useRef<HTMLInputElement>(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<ScanMode>('meal');
const [showModeOptions, setShowModeOptions] = useState(false);

// Debug Hook
const debug = useScanDebug();
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -151,7 +173,9 @@ export default function ScanPage() {

{isReadyForUpload && (
<>
<ScanModeSelector selectedMode={scanMode} onSelect={setScanMode} />
{/* Upload area leads — the default path is "just snap a
meal". Meal mode is pre-selected; no choice required
before taking a photo. */}
<ScanUploadArea
scanMode={scanMode}
tier={tier}
Expand Down Expand Up @@ -182,6 +206,24 @@ export default function ScanPage() {
<p className="mt-3 text-xs text-gray-500 text-center max-w-md mx-auto">
{t('privacy_note')}
</p>

{/* 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. */}
<div className="mt-6 text-center">
{!showModeOptions ? (
<button
type="button"
onClick={() => setShowModeOptions(true)}
className="text-sm font-medium text-brand-primary-500 hover:text-brand-primary-600 hover:underline"
>
{t('more_options')}
</button>
) : (
<ScanModeSelector selectedMode={scanMode} onSelect={setScanMode} />
)}
</div>
</>
)}

Expand Down Expand Up @@ -290,7 +332,10 @@ export default function ScanPage() {
<div className="hidden lg:block">
<div className="backdrop-blur-md bg-white/90 p-6 rounded-3xl shadow-glass border border-white/40text-center">
<img src="/images/shinny_avatar_celebrating.png" alt="Shinny" className="w-20 h-20 mx-auto drop-shadow-md pb-4" />
<button onClick={resetScan} className="w-full py-3 bg-gradient-to-r from-brand-primary-400 to-brand-secondary-400 text-white font-bold rounded-xl shadow-brand hover:shadow-brand-lg transition-all">
<button onClick={askShinnyAboutScan} className="w-full py-3 mb-3 bg-gradient-to-r from-brand-primary-400 to-brand-secondary-400 text-white font-bold rounded-xl shadow-brand hover:shadow-brand-lg transition-all">
<MessageCircle className="w-5 h-5 inline mr-2" /> {t('ask_shinny')}
</button>
<button onClick={resetScan} className="w-full py-3 bg-white border border-brand-primary-200 text-brand-primary-600 font-bold rounded-xl hover:bg-brand-primary-50 transition-all">
<Scan className="w-5 h-5 inline mr-2" /> {t('try_again')}
</button>
{analysis.modelUsed && (
Expand Down Expand Up @@ -393,8 +438,11 @@ export default function ScanPage() {
<div className="mb-4">
<img src="/images/shinny_avatar_celebrating.png" alt="Shinny Celebrating" className="w-16 h-16 mx-auto drop-shadow-md animate-bounce-light" />
</div>
<div className="flex items-center justify-center gap-3">
<button onClick={resetScan} className="px-8 py-3 bg-gradient-to-r from-brand-primary-400 to-brand-secondary-400 text-white font-bold rounded-xl shadow-brand hover:shadow-brand-lg transition-all">
<div className="flex flex-col items-center justify-center gap-3 max-w-xs mx-auto">
<button onClick={askShinnyAboutScan} className="w-full px-8 py-3 bg-gradient-to-r from-brand-primary-400 to-brand-secondary-400 text-white font-bold rounded-xl shadow-brand hover:shadow-brand-lg transition-all">
<MessageCircle className="w-5 h-5 inline mr-2" /> {t('ask_shinny')}
</button>
<button onClick={resetScan} className="w-full px-8 py-3 bg-white border border-brand-primary-200 text-brand-primary-600 font-bold rounded-xl hover:bg-brand-primary-50 transition-all">
<Scan className="w-5 h-5 inline mr-2" /> {t('try_again')}
</button>
</div>
Expand Down
38 changes: 38 additions & 0 deletions frontend/src/lib/chat-seed.ts
Original file line number Diff line number Diff line change
@@ -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 '';
}
}
4 changes: 4 additions & 0 deletions frontend/src/messages/da.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/messages/th.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 ดูนะคะ",
Expand Down
Loading