mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(coach): mount the drawer at the insights layout with a launch context
Promote the Coach drawer mount from the mother /insights page up to /insights/layout.tsx so every routed sub-page can reach it. A new CoachLaunchProvider exposes useCoachLaunch() for the hero strip, suggested-prompt chips, and the seven sub-pages; the layout renders the drawer beside the provider so navigation across Insights surfaces no longer unmounts the conversation. Each sub-page (blutdruck, gewicht, puls, stimmung, medikamente, bmi, schlaf) mounts a new CoachLaunchButton that pins to the bottom on narrow viewports and renders as an inline header action on lg+.
This commit is contained in:
1 parent
de0c16338f
commit
246c1def21
13 files changed
+347
-28
No files matched your search
@@ -11,6 +11,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
@@ -139,6 +140,8 @@ export default function InsightsBlutdruckPage() {
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
loading={isStatusLoading}
|
||||
/>
|
||||
|
||||
<CoachLaunchButton />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
@@ -140,6 +141,8 @@ export default function InsightsBmiPage() {
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
loading={isStatusLoading}
|
||||
/>
|
||||
|
||||
<CoachLaunchButton />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { buildWeightBandsFromHeight } from "@/lib/analytics/value-bands";
|
||||
@@ -117,6 +118,8 @@ export default function InsightsGewichtPage() {
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
loading={isStatusLoading}
|
||||
/>
|
||||
|
||||
<CoachLaunchButton />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { InsightsLayoutShell } from "@/components/insights/insights-layout-shell";
|
||||
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
|
||||
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
|
||||
|
||||
/**
|
||||
* v1.4.25 W4 — shared layout for the routed `/insights` sub-pages.
|
||||
@@ -10,12 +12,20 @@ import { InsightsLayoutShell } from "@/components/insights/insights-layout-shell
|
||||
* into a sub-page. The actual page body still owns its own data fetches
|
||||
* — the layout is a thin presentational frame.
|
||||
*
|
||||
* Critical design rule (Marc directive 2026-05-11):
|
||||
* The `<CoachDrawer>` is NOT mounted in this layout. It stays inside
|
||||
* `src/app/insights/page.tsx` body so navigating into a sub-page
|
||||
* unmounts the drawer (matches the Apple Health convention that AI/
|
||||
* coach surfaces only live on the overview, never on a metric page).
|
||||
* v1.4.27 R3d MB4 — the Coach drawer now mounts here (above the routed
|
||||
* children) inside a `<CoachLaunchProvider>` so navigating into a sub-
|
||||
* page no longer unmounts the drawer. Every sub-page mounts a
|
||||
* `<CoachLaunchButton>` that calls `askCoach()` on the same context;
|
||||
* the mother `/insights/page.tsx` consumes the same hook for its hero
|
||||
* strip + suggested-prompt chips. Decision F (audit MA3) drove the
|
||||
* promotion: a mobile user who navigates from `/insights` to
|
||||
* `/insights/blutdruck` keeps a one-tap path back into the Coach.
|
||||
*/
|
||||
export default function InsightsLayout({ children }: { children: ReactNode }) {
|
||||
return <InsightsLayoutShell>{children}</InsightsLayoutShell>;
|
||||
return (
|
||||
<CoachLaunchProvider>
|
||||
<InsightsLayoutShell>{children}</InsightsLayoutShell>
|
||||
<LayoutCoachMount />
|
||||
</CoachLaunchProvider>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { ComplianceHeatmap } from "@/components/charts/compliance-heatmap";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { TherapyTimeline } from "@/components/insights/therapy-timeline";
|
||||
@@ -225,6 +226,8 @@ export default function InsightsMedikamentePage() {
|
||||
without an active GLP-1 medication, so the page collapses
|
||||
back to the compliance grid for everyone else. */}
|
||||
<TherapyTimeline />
|
||||
|
||||
<CoachLaunchButton />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
+17
-22
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { Loader2, TrendingUp } from "lucide-react";
|
||||
@@ -12,10 +11,10 @@ import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { InsightAdvisorCard } from "@/components/insights/insight-advisor-card";
|
||||
import { HeroStrip } from "@/components/insights/hero-strip";
|
||||
import { DailyBriefing } from "@/components/insights/daily-briefing";
|
||||
import { CoachDrawer } from "@/components/insights/coach-panel/coach-drawer";
|
||||
import { TrendsRow } from "@/components/insights/trends-row";
|
||||
import { CorrelationRow } from "@/components/insights/correlation-row";
|
||||
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import type { CorrelationResult } from "@/lib/insights/correlations";
|
||||
import { toWeekISO } from "@/lib/insights/week-iso";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
@@ -96,12 +95,12 @@ export default function InsightsPage() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
|
||||
// CoachDrawer state. The hero strip's "Ask the coach" button +
|
||||
// suggested-prompt chips toggle the drawer here; the drawer ingests
|
||||
// `coachPrefill` once on open and resets on close so the next open
|
||||
// starts blank.
|
||||
const [coachOpen, setCoachOpen] = useState<boolean>(false);
|
||||
const [coachPrefill, setCoachPrefill] = useState<string | null>(null);
|
||||
// v1.4.27 R3d MB4 — Coach drawer state lives in the layout-level
|
||||
// `<CoachLaunchProvider>` so every routed sub-page can reach it.
|
||||
// The hero strip + suggested-prompt chips call `askCoach(prefill)`
|
||||
// on the same context, and the drawer itself is mounted next to
|
||||
// the provider in `src/app/insights/layout.tsx`.
|
||||
const coachLaunch = useCoachLaunch();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["insights", "comprehensive"],
|
||||
@@ -183,14 +182,16 @@ export default function InsightsPage() {
|
||||
briefing={briefingPayload}
|
||||
updatedAt={heroStripUpdatedAt}
|
||||
userName={heroGreetingName}
|
||||
onAskCoach={(prefill?: string) => {
|
||||
setCoachPrefill(prefill ?? null);
|
||||
setCoachOpen(true);
|
||||
}}
|
||||
onPickPrompt={(prompt) => {
|
||||
setCoachPrefill(prompt);
|
||||
setCoachOpen(true);
|
||||
}}
|
||||
onAskCoach={
|
||||
coachLaunch
|
||||
? (prefill?: string) => coachLaunch.askCoach(prefill ?? null)
|
||||
: undefined
|
||||
}
|
||||
onPickPrompt={
|
||||
coachLaunch
|
||||
? (prompt) => coachLaunch.askCoach(prompt)
|
||||
: undefined
|
||||
}
|
||||
weeklyReportReady={weeklyReportReady}
|
||||
weeklyReportHref={currentWeekHref}
|
||||
healthScore={analytics?.healthScore ?? undefined}
|
||||
@@ -217,12 +218,6 @@ export default function InsightsPage() {
|
||||
cachedAt={advisor.payload?.cachedAt ?? null}
|
||||
legacyPayload={advisor.payload?.legacyPayload ?? false}
|
||||
/>
|
||||
|
||||
<CoachDrawer
|
||||
open={coachOpen}
|
||||
onOpenChange={setCoachOpen}
|
||||
prefill={coachPrefill}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { Vo2MaxChartRow } from "@/components/insights/vo2-max-chart-row";
|
||||
@@ -164,6 +165,8 @@ export default function InsightsPulsPage() {
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
|
||||
<CoachLaunchButton />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { SleepOverview } from "@/components/insights/sleep-overview";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
@@ -79,6 +80,8 @@ export default function InsightsSchlafPage() {
|
||||
description={t("insights.sleep.description")}
|
||||
>
|
||||
<SleepOverview />
|
||||
|
||||
<CoachLaunchButton />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
|
||||
@@ -99,6 +100,8 @@ export default function InsightsStimmungPage() {
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
loading={isStatusLoading}
|
||||
/>
|
||||
|
||||
<CoachLaunchButton />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
|
||||
/**
|
||||
* v1.4.27 R3d MB4 — Coach launch button.
|
||||
*
|
||||
* Decision F: every routed Insights sub-page mounts this button so the
|
||||
* Coach drawer (mounted at the layout root) is always reachable. The
|
||||
* button renders as a sticky-bottom FAB on `<lg` viewports (so a phone
|
||||
* user always sees it within thumb range) and as an inline header
|
||||
* action on `lg+` (where the desktop drawer slides in from the right
|
||||
* edge and the FAB would overlap the chart legend).
|
||||
*
|
||||
* The button is a thin wrapper around `useCoachLaunch()` — it only
|
||||
* renders when the context provider is mounted (so dropping the button
|
||||
* onto a non-Insights page is a no-op rather than a crash).
|
||||
*/
|
||||
export interface CoachLaunchButtonProps {
|
||||
/** Optional override for the visual label. Defaults to the shared CTA. */
|
||||
label?: string;
|
||||
/** Optional prefill seed for the next Coach turn. */
|
||||
prefill?: string;
|
||||
/** Optional className passthrough for inline overrides. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CoachLaunchButton({
|
||||
label,
|
||||
prefill,
|
||||
className,
|
||||
}: CoachLaunchButtonProps) {
|
||||
const { t } = useTranslations();
|
||||
const launch = useCoachLaunch();
|
||||
|
||||
if (!launch) {
|
||||
// The button only makes sense beneath the provider. Render nothing
|
||||
// so the sub-page doesn't paint a dead control.
|
||||
return null;
|
||||
}
|
||||
|
||||
const accessibleLabel = label ?? t("insights.heroActionAskCoach");
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Sticky FAB on `<lg` viewports — pinned to the bottom-right so
|
||||
the user's thumb always finds it. `bottom-20` clears the
|
||||
mobile bottom navigation; `safe-area-inset-bottom` plus the
|
||||
existing app shell padding keep it clear of the iOS home bar.
|
||||
Hidden on `lg+` where the inline button below takes over. */}
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
data-slot="coach-launch-fab"
|
||||
onClick={() => launch.askCoach(prefill ?? null)}
|
||||
aria-label={accessibleLabel}
|
||||
title={accessibleLabel}
|
||||
className={cn(
|
||||
"fixed right-4 bottom-20 z-40 h-12 rounded-full px-4 shadow-lg",
|
||||
"from-dracula-purple to-dracula-pink bg-gradient-to-br text-white",
|
||||
"hover:from-dracula-purple/90 hover:to-dracula-pink/90",
|
||||
"lg:hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Sparkles className="size-4" aria-hidden="true" />
|
||||
<span>{accessibleLabel}</span>
|
||||
</Button>
|
||||
{/* Inline header-style action on `lg+`. Lives in the page body so
|
||||
it picks up the sub-page layout's spacing instead of fighting
|
||||
a sticky header overlay. */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-slot="coach-launch-inline"
|
||||
onClick={() => launch.askCoach(prefill ?? null)}
|
||||
className={cn(
|
||||
"hidden h-10 gap-2 self-end lg:inline-flex",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Sparkles className="size-4" aria-hidden="true" />
|
||||
<span>{accessibleLabel}</span>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { CoachDrawer } from "@/components/insights/coach-panel/coach-drawer";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
|
||||
/**
|
||||
* v1.4.27 R3d MB4 — bridge between the layout-level
|
||||
* `<CoachLaunchProvider>` and the Coach drawer mount.
|
||||
*
|
||||
* The layout file is a server component (it lives next to other server
|
||||
* components and feeds the `metadata` chain), so the actual `useState`
|
||||
* + `<CoachDrawer>` consumer has to live inside a client island. This
|
||||
* file is intentionally tiny — its only job is to read the context and
|
||||
* render the drawer at the layout's mount site.
|
||||
*/
|
||||
export function LayoutCoachMount() {
|
||||
const launch = useCoachLaunch();
|
||||
if (!launch) return null;
|
||||
return (
|
||||
<CoachDrawer
|
||||
open={launch.open}
|
||||
onOpenChange={launch.setOpen}
|
||||
prefill={launch.prefill}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import {
|
||||
CoachLaunchProvider,
|
||||
useCoachLaunch,
|
||||
} from "../coach-launch-context";
|
||||
|
||||
/**
|
||||
* v1.4.27 R3d MB4 — Coach launch context smoke contract.
|
||||
*
|
||||
* The project's test convention is `renderToStaticMarkup` (no
|
||||
* `@testing-library/react` dependency). We can't simulate interaction
|
||||
* inside SSR, but we can verify:
|
||||
* 1. The provider mounts and renders its children.
|
||||
* 2. The hook returns a value-shape (open, prefill, askCoach,
|
||||
* setOpen) when called beneath the provider.
|
||||
* 3. The hook returns `null` when called outside the provider —
|
||||
* consumers degrade gracefully (e.g. the launch button renders
|
||||
* nothing rather than crashing).
|
||||
*/
|
||||
|
||||
function Probe({ output }: { output: string[] }) {
|
||||
const launch = useCoachLaunch();
|
||||
if (!launch) {
|
||||
output.push("null");
|
||||
return null;
|
||||
}
|
||||
output.push(
|
||||
JSON.stringify({
|
||||
hasAskCoach: typeof launch.askCoach,
|
||||
hasSetOpen: typeof launch.setOpen,
|
||||
open: launch.open,
|
||||
prefill: launch.prefill,
|
||||
}),
|
||||
);
|
||||
return <span data-slot="probe-output">{output.join(",")}</span>;
|
||||
}
|
||||
|
||||
describe("CoachLaunchProvider", () => {
|
||||
it("returns null when consumed outside the provider", () => {
|
||||
const output: string[] = [];
|
||||
renderToStaticMarkup(<Probe output={output} />);
|
||||
expect(output).toEqual(["null"]);
|
||||
});
|
||||
|
||||
it("exposes the full context shape when consumed beneath the provider", () => {
|
||||
const output: string[] = [];
|
||||
renderToStaticMarkup(
|
||||
<CoachLaunchProvider>
|
||||
<Probe output={output} />
|
||||
</CoachLaunchProvider>,
|
||||
);
|
||||
expect(output).toHaveLength(1);
|
||||
const parsed = JSON.parse(output[0]);
|
||||
expect(parsed.hasAskCoach).toBe("function");
|
||||
expect(parsed.hasSetOpen).toBe("function");
|
||||
expect(parsed.open).toBe(false);
|
||||
expect(parsed.prefill).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the children inside the provider mount", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CoachLaunchProvider>
|
||||
<div data-slot="child-mount">child</div>
|
||||
</CoachLaunchProvider>,
|
||||
);
|
||||
expect(html).toContain('data-slot="child-mount"');
|
||||
expect(html).toContain("child");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
/**
|
||||
* v1.4.27 R3d MB4 — Coach launch context.
|
||||
*
|
||||
* Until v1.4.27 the Coach drawer was mounted inline on
|
||||
* `/insights/page.tsx` body, which meant navigating to a routed
|
||||
* sub-page (`/insights/blutdruck`, etc.) unmounted the drawer and
|
||||
* removed every entry point to the Coach. The MA3 audit + Decision F
|
||||
* promoted the drawer up to `/insights/layout.tsx` so every routed
|
||||
* Insights surface can launch it.
|
||||
*
|
||||
* Shape:
|
||||
* - `<CoachLaunchProvider>` owns the drawer's open / prefill state
|
||||
* and renders the children beneath the launch surface.
|
||||
* - `useCoachLaunch()` returns `{ open, askCoach, setOpen, prefill }`
|
||||
* so any descendant can open the drawer with an optional prefill
|
||||
* string (the hero-strip suggested-prompt chips feed prefills;
|
||||
* the sticky `<CoachLaunchButton>` opens without one).
|
||||
*
|
||||
* The provider is intentionally tiny — the actual drawer mount sits
|
||||
* next to it in the layout, reading the same state via the hook.
|
||||
*/
|
||||
export interface CoachLaunchScope {
|
||||
/**
|
||||
* Optional metric the user is looking at when they open the Coach.
|
||||
* Reserved for v1.4.28 so the drawer can pre-narrow the source rail
|
||||
* to the active metric; ignored in v1.4.27.
|
||||
*/
|
||||
metric?: string;
|
||||
}
|
||||
|
||||
interface CoachLaunchValue {
|
||||
/** Whether the Coach drawer is currently open. */
|
||||
open: boolean;
|
||||
/** Current prefill string (or null when the next open should start blank). */
|
||||
prefill: string | null;
|
||||
/** Open the drawer with an optional prefill + scope hint. */
|
||||
askCoach: (prefill?: string | null, scope?: CoachLaunchScope) => void;
|
||||
/** Direct setter for the open state — the drawer consumes this. */
|
||||
setOpen: (next: boolean) => void;
|
||||
}
|
||||
|
||||
const CoachLaunchContext = createContext<CoachLaunchValue | null>(null);
|
||||
|
||||
export interface CoachLaunchProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const [prefill, setPrefill] = useState<string | null>(null);
|
||||
|
||||
const askCoach = useCallback(
|
||||
(nextPrefill?: string | null, scope?: CoachLaunchScope) => {
|
||||
// `scope` is currently reserved for v1.4.28 (metric-narrow on the
|
||||
// sources rail). Accept the parameter today so the call sites
|
||||
// don't need to change shape when the rail starts honouring it.
|
||||
void scope;
|
||||
setPrefill(nextPrefill ?? null);
|
||||
setOpen(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSetOpen = useCallback((next: boolean) => {
|
||||
setOpen(next);
|
||||
if (!next) {
|
||||
// Drop the prefill on close so the next open starts blank.
|
||||
setPrefill(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = useMemo<CoachLaunchValue>(
|
||||
() => ({ open, prefill, askCoach, setOpen: handleSetOpen }),
|
||||
[open, prefill, askCoach, handleSetOpen],
|
||||
);
|
||||
|
||||
return (
|
||||
<CoachLaunchContext.Provider value={value}>
|
||||
{children}
|
||||
</CoachLaunchContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the Coach launch context. Returns `null` when called outside a
|
||||
* `<CoachLaunchProvider>` so consumer components can degrade gracefully
|
||||
* (e.g. the hero strip's "Ask the coach" action stays disabled until
|
||||
* the provider mounts).
|
||||
*/
|
||||
export function useCoachLaunch(): CoachLaunchValue | null {
|
||||
return useContext(CoachLaunchContext);
|
||||
}
|
||||
Reference in new issue
Block a user