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:
Marc-André Bombeck committed 2026-05-15 18:59:55 +02:00
1 parent de0c16338f
commit 246c1def21
13 files changed
+347 -28

No files matched your search

@@ -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}
/>
);
}