Skip to content
Open
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
30 changes: 30 additions & 0 deletions app/api/ask/discover/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> };
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 },
);
}
}
124 changes: 117 additions & 7 deletions components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -30,6 +36,14 @@ export default function AppShell({
const [pending, setPending] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);

const [mode, setMode] = useState<HomeMode>("scout");
const [askLoading, setAskLoading] = useState(false);
const [askError, setAskError] = useState<string | null>(null);
const [askProgress, setAskProgress] = useState<string | null>(null);
const [askResult, setAskResult] = useState<AskResult | null>(null);
const [fanCards, setFanCards] = useState<Card[]>(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.
Expand All @@ -39,16 +53,102 @@ export default function AppShell({
} catch {}
}, []);

// Scouting navigates to the canonical /<username> 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;
setPending(login);
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 <LoadingScreen login={pending} />;

return (
Expand All @@ -59,15 +159,25 @@ export default function AppShell({
<div className="absolute right-[clamp(20px,5vw,52px)] top-[clamp(16px,3vh,26px)] z-[3]">
<GithubStar stars={stars} />
</div>
<div className="mx-auto flex w-full max-w-[1180px] flex-1 items-center gap-[clamp(24px,5vw,72px)] px-[clamp(22px,5vw,56px)] max-[860px]:flex-col max-[860px]:gap-[34px] max-[860px]:pb-6 max-[860px]:pt-[clamp(40px,6vh,56px)] max-[860px]:text-center">
<div className="mx-auto flex w-full max-w-[1180px] flex-1 items-start gap-[clamp(24px,5vw,72px)] px-[clamp(22px,5vw,56px)] pt-[clamp(56px,12vh,120px)] max-[860px]:flex-col max-[860px]:gap-[34px] max-[860px]:pb-6 max-[860px]:pt-[clamp(40px,6vh,56px)] max-[860px]:text-center">
<ScoutForm
loading={isPending}
error={null}
mode={mode}
onModeChange={handleModeChange}
loading={isPending || askLoading}
error={askError}
scoutCount={scoutCount}
onScout={handleScout}
onAsk={handleAsk}
onOpenModal={() => setModalOpen(true)}
askProgress={askProgress}
askResult={askResult}
onFillMissing={handleFillMissing}
/>
<CardFan
cards={fanCards}
onPick={handleScout}
paginate={mode === "ask" && !!askResult?.cards.length}
/>
<CardFan cards={SAMPLE_CARDS} onPick={handleScout} />
</div>
<footer className="relative z-[2] mt-auto flex flex-none items-center justify-center p-[clamp(12px,2.6vh,24px)]">
<FooterCredit />
Expand Down
Loading