diff --git a/app/api/ask/discover/route.ts b/app/api/ask/discover/route.ts new file mode 100644 index 0000000..c2e188b --- /dev/null +++ b/app/api/ask/discover/route.ts @@ -0,0 +1,30 @@ +import { discoverForAsk } from "@/lib/scoutAsk/discover"; +import type { Recipe } from "@/lib/scoutAsk/types"; + +export async function POST(req: Request) { + let body: { countryCode?: string; recipe?: Recipe; vars?: Record }; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON body." }, { status: 400 }); + } + + const countryCode = body.countryCode?.trim().toLowerCase(); + if (!countryCode || !body.recipe) { + return Response.json({ error: "countryCode and recipe are required." }, { status: 400 }); + } + + try { + const result = await discoverForAsk({ + countryCode, + recipe: body.recipe, + vars: body.vars ?? { country: countryCode }, + }); + return Response.json(result); + } catch (e) { + return Response.json( + { error: (e as Error).message ?? "Discovery failed." }, + { status: 502 }, + ); + } +} diff --git a/components/AppShell.tsx b/components/AppShell.tsx index 610b9d7..dd2dbcb 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -10,6 +10,12 @@ import FooterCredit from "@/components/FooterCredit"; import BuyMeACoffee from "@/components/BuyMeACoffee"; import GithubStar from "@/components/GithubStar"; import { SAMPLE_CARDS } from "@/lib/github/samples"; +import { askScout } from "@/lib/scoutAsk/ask"; +import { bootstrapScoutAsk } from "@/lib/scoutAsk/bootstrap"; +import { completeAskWithVars } from "@/lib/scoutAsk/complete"; +import { enrichAskWithLiveDiscovery } from "@/lib/scoutAsk/live"; +import type { AskResult, HomeMode } from "@/lib/scoutAsk/types"; +import type { Card } from "@/lib/scoring/types"; const HowItWorksModal = dynamic(() => import("@/components/HowItWorksModal"), { ssr: false, @@ -30,6 +36,14 @@ export default function AppShell({ const [pending, setPending] = useState(null); const [modalOpen, setModalOpen] = useState(false); + const [mode, setMode] = useState("scout"); + const [askLoading, setAskLoading] = useState(false); + const [askError, setAskError] = useState(null); + const [askProgress, setAskProgress] = useState(null); + const [askResult, setAskResult] = useState(null); + const [fanCards, setFanCards] = useState(SAMPLE_CARDS); + const [bootstrapped, setBootstrapped] = useState(false); + // Mark this tab as "has visited home" so a scouted card shows BACK, while a // directly-opened / shared card link (no home visit) shows a "make your card" // CTA instead. sessionStorage is per-tab, so a fresh tab from a share is direct. @@ -39,9 +53,35 @@ export default function AppShell({ } catch {} }, []); - // Scouting navigates to the canonical / route. The transition keeps - // the loading screen up (with the mascot + puns) while the report is fetched - // and server-rendered; the route then plays its own reveal. + // Warm IndexedDB catalog + card corpus when Ask is first used (or eagerly on mount + // in the background so the first ask is faster). + useEffect(() => { + let cancelled = false; + void (async () => { + try { + setAskProgress("Warming scout brain…"); + const { cards } = await bootstrapScoutAsk((msg) => { + if (!cancelled) setAskProgress(msg); + }); + if (cancelled) return; + setBootstrapped(true); + setAskProgress(null); + if (mode === "ask" && !askResult) { + setFanCards(cards.length ? cards : SAMPLE_CARDS); + } + } catch (e) { + if (!cancelled) { + setAskProgress(null); + setAskError((e as Error).message); + } + } + })(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- bootstrap once on mount + }, []); + const handleScout = (name: string) => { const login = name.trim().replace(/^@/, ""); if (!login) return; @@ -49,6 +89,66 @@ export default function AppShell({ startTransition(() => router.push(`/${encodeURIComponent(login)}`)); }; + const finishAsk = async (result: AskResult) => { + if (result.missing.length) { + setAskResult(result); + setAskProgress(null); + return; + } + // Country recipes: second step - GitHub search → scout → rank. + const enriched = result.vars.country + ? await enrichAskWithLiveDiscovery(result, setAskProgress) + : result; + setAskResult(enriched); + setAskProgress(null); + setFanCards(enriched.cards.length ? enriched.cards : SAMPLE_CARDS); + }; + + const handleAsk = async (query: string) => { + setAskLoading(true); + setAskError(null); + try { + if (!bootstrapped) { + setAskProgress("Warming scout brain…"); + await bootstrapScoutAsk(setAskProgress); + setBootstrapped(true); + } + setAskProgress("Matching question…"); + const result = await askScout(query); + await finishAsk(result); + } catch (e) { + setAskError((e as Error).message); + setAskProgress(null); + } finally { + setAskLoading(false); + } + }; + + const handleFillMissing = async (name: string, value: string) => { + if (!askResult) return; + setAskLoading(true); + setAskError(null); + try { + const vars = { ...askResult.vars, [name]: value }; + const result = await completeAskWithVars(askResult.template, askResult.score, vars); + await finishAsk(result); + } catch (e) { + setAskError((e as Error).message); + setAskProgress(null); + } finally { + setAskLoading(false); + } + }; + + const handleModeChange = (next: HomeMode) => { + setMode(next); + setAskError(null); + if (next === "scout") { + setFanCards(SAMPLE_CARDS); + setAskResult(null); + } + }; + if (isPending && pending) return ; return ( @@ -59,15 +159,25 @@ export default function AppShell({
-
+
setModalOpen(true)} + askProgress={askProgress} + askResult={askResult} + onFillMissing={handleFillMissing} + /> + -