refactor(insights): one CrossLinkCard for the card-sized page pointers

The VO2 max pointer on the pulse page and the ECG pointer on the heart
pages were two hand-rolled link cards; the ECG one had no md:p-6 step
and sat narrower than every card beside it. CrossLinkCard composes Card
(TileHeader, one meta line, the call to action) inside the link that
carries the focus ring.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 13:36:58 +02:00
1 parent e7ee416e19
commit f2af0c9650
3 files changed
+94 -45

No files matched your search

+11 -21
View File
@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { ArrowRight, Gauge, Heart } from "lucide-react";
import { Gauge, Heart } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
@@ -19,9 +19,9 @@ import { MeasurementDiversityNudge } from "@/components/insights/measurement-div
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { EcgCrossLink } from "@/components/insights/ecg-cross-link";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { TileHeader } from "@/components/insights/tile-header";
import dynamic from "next/dynamic";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { CrossLinkCard } from "@/components/insights/cross-link-card";
// Load the intraday area chart through the shared chart-runtime boundary (the
// one recharts chunk), not as a direct import — a direct `from "recharts"`
@@ -199,27 +199,17 @@ export default function InsightsPulsPage() {
the full chart plus the plain-language assessment — rather than
duplicating a second, divergent VO₂ max view here.
v1.12.8 — the card now leads with the canonical `<TileHeader>` so the
`Gauge` glyph reads in the foreground colour at the same size and
position as every other tile header, with the body + CTA beneath. */}
The card is the shared `CrossLinkCard`: the canonical `TileHeader`
(foreground `Gauge` glyph), the body and the CTA beneath. */}
{hasVo2 ? (
<Link
<CrossLinkCard
href="/insights/cardio-fitness"
data-slot="vo2-cardio-link"
className="bg-card hover:bg-accent/40 focus-visible:ring-ring/50 block space-y-1.5 rounded-xl border p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none md:p-6"
>
<TileHeader
icon={Gauge}
title={t("insights.vo2Max.cardioLinkTitle")}
/>
<span className="text-muted-foreground block text-xs leading-snug">
{t("insights.vo2Max.cardioLinkBody")}
</span>
<span className="text-primary inline-flex shrink-0 items-center gap-1 text-xs font-medium">
{t("insights.vo2Max.cardioLinkCta")}
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</span>
</Link>
slot="vo2-cardio-link"
icon={Gauge}
title={t("insights.vo2Max.cardioLinkTitle")}
body={t("insights.vo2Max.cardioLinkBody")}
cta={t("insights.vo2Max.cardioLinkCta")}
/>
) : null}
</SubPageShell>
);
@@ -0,0 +1,68 @@
import type { CSSProperties } from "react";
import Link from "next/link";
import { ArrowRight, type LucideIcon } from "lucide-react";
import { Card } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { cn } from "@/lib/utils";
/**
* A whole card that points to another page: the tile header, one meta line,
* and the call to action. The pulse page's way to VO₂ max and the ECG pointer
* on the heart pages were two hand-rolled copies of it, one of them without
* the `md:p-6` step every other card on the page has.
*
* The link is the outer element and carries the focus ring; the surface is a
* `Card`, so padding, radius and shadow are the card contract. The compact
* card rhythm (`gap-1.5`) keeps the three lines together.
*/
export function CrossLinkCard({
href,
slot,
icon,
title,
body,
cta,
className,
cardClassName,
style,
}: {
href: string;
slot: string;
icon: LucideIcon;
title: string;
body: string;
cta: string;
className?: string;
/** Classes on the card surface (an identity edge such as `metric-accent`). */
cardClassName?: string;
style?: CSSProperties;
}) {
return (
<Link
href={href}
data-slot={slot}
className={cn(
"group focus-visible:ring-ring/50 block rounded-xl focus-visible:ring-2 focus-visible:outline-none",
className,
)}
>
<Card
className={cn(
"group-hover:bg-accent/40 gap-1.5 px-4 transition-colors md:gap-1.5 md:px-6",
cardClassName,
)}
style={style}
>
<TileHeader icon={icon} title={title} />
<span className="text-muted-foreground block text-xs leading-snug">
{body}
</span>
<span className="text-primary inline-flex shrink-0 items-center gap-1 text-xs font-medium">
{cta}
<ArrowRight className="size-4" aria-hidden="true" />
</span>
</Card>
</Link>
);
}
+15 -24
View File
@@ -1,11 +1,9 @@
"use client";
import Link from "next/link";
import { ArrowRight, HeartPulse } from "lucide-react";
import { HeartPulse } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { TileHeader } from "@/components/insights/tile-header";
import { CrossLinkCard } from "@/components/insights/cross-link-card";
import { useEcgList } from "./use-ecg-list";
/**
@@ -61,27 +59,20 @@ export function EcgCrossLink({ enabled = true, className }: EcgCrossLinkProps) {
: null;
return (
<Link
<CrossLinkCard
href="/insights/ecg"
data-slot="ecg-cross-link"
className={cn(
// `.metric-accent` — the heart-family identity edge (`--tile-strain`,
// the wellness vocabulary's cardiovascular hue), the same mark the
// `ecg_new_recording` rail card carries, so the two ECG pointers
// read as one family.
"bg-card hover:bg-accent/40 focus-visible:ring-ring/50 metric-accent block space-y-1.5 rounded-xl border p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
slot="ecg-cross-link"
icon={HeartPulse}
title={t("insights.ecg.crossLink.title")}
body={resultText ? `${countText} ${resultText}` : countText}
cta={t("insights.ecg.crossLink.cta")}
className={className}
// `.metric-accent` — the heart-family identity edge (`--tile-strain`,
// the wellness vocabulary's cardiovascular hue), the same mark the
// `ecg_new_recording` rail card carries, so the two ECG pointers read
// as one family.
cardClassName="metric-accent"
style={{ "--tile-hue": "var(--tile-strain)" } as React.CSSProperties}
>
<TileHeader icon={HeartPulse} title={t("insights.ecg.crossLink.title")} />
<span className="text-muted-foreground block text-xs leading-snug">
{resultText ? `${countText} ${resultText}` : countText}
</span>
<span className="text-primary inline-flex shrink-0 items-center gap-1 text-xs font-medium">
{t("insights.ecg.crossLink.cta")}
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</span>
</Link>
/>
);
}