L17 Pakete: Stufen Basis/Profi, Lotse-Chat-Plätze und Kontingent

- Datenmodell: Tenant.tier (Default PROFI), lotseChatSeats, lotseChatHardLimit;
  Tabelle lotse_chat_seats (RLS, TENANT_MODELS, pii-fields), Index für die
  Monatszählung der Chat-Nachrichten (Migration 20260921100000_pakete)
- src/lib/plans.ts: Stufenregeln, 150 Chats je Platz, Mehrverbrauch in 100er-Paketen
- src/server/plan.ts: effektive Freischaltung = Stufe UND TenantModule, genutzt von
  requireModule, assertModuleEnabled, API, Sync (Offline-Op → rejected mit Klartext),
  Navigation, isLotseEnabled und planningAccess (Planung nur in Profi)
- Lotse-Chat: Platzprüfung (no_seat), Testphase ohne Platz, Kontingent mit
  hartem Limit (quota_exhausted); Platzvergabe durch den Mandanten-Admin
- Betreiber: Stufe/Plätze/hartes Limit im Mandantendetail mit Bestätigung
  und Plattform-Audit, Verbrauch laufender Monat/Vormonat, Stufe als Badge
- Demo-Seed: demo = Profi mit 3 Plätzen, demo2 = Basis
- Tests: test-pakete-{rules,gates,seats}

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 10:13:27 +02:00
co-authored by Claude Opus 5
parent 4d5be72ebb
commit 06d320908f
60 changed files with 2076 additions and 91 deletions
+7 -3
View File
@@ -19,13 +19,17 @@ const ITEMS = [
* Bottom navigation of the mobile shell (Spec §22): Heute · Aufträge · Notdienst · Sync · Profil.
* L12: badge counter of open time approvals on „Profil" (number + accessible label).
*/
export function BottomNav({ approvals = 0, lotse = false }: { approvals?: number; lotse?: boolean }) {
const GRID_COLS: Record<number, string> = { 4: "grid-cols-4", 5: "grid-cols-5", 6: "grid-cols-6" };
/** L17 Pakete: `emergency` = Notdienst im Paket enthalten und eingeschaltet; `lotse` = Chat nutzbar (inkl. Platz). */
export function BottomNav({ approvals = 0, lotse = false, emergency = true }: { approvals?: number; lotse?: boolean; emergency?: boolean }) {
const t = useTranslations("field.nav");
const pathname = usePathname();
const items = ITEMS.filter((item) => (lotse || item.key !== "lotse") && (emergency || item.key !== "emergency"));
return (
<nav aria-label={t("label")} className="fixed inset-x-0 bottom-0 z-30 border-t bg-card pb-[env(safe-area-inset-bottom)]">
<ul className={cn("mx-auto grid max-w-xl", lotse ? "grid-cols-6" : "grid-cols-5")}>
{ITEMS.filter((item) => lotse || item.key !== "lotse").map((item) => {
<ul className={cn("mx-auto grid max-w-xl", GRID_COLS[items.length] ?? "grid-cols-5")}>
{items.map((item) => {
const active = item.exact ? pathname === item.href : pathname === item.href || pathname.startsWith(`${item.href}/`);
const badge = item.key === "profile" && approvals > 0 ? approvals : 0;
return (
+13 -2
View File
@@ -41,6 +41,7 @@ export function LotseChat({
transcription,
locale,
timeZone,
quotaExhausted = false,
}: {
initial: ChatView;
workOrderId: string | null;
@@ -48,6 +49,8 @@ export function LotseChat({
transcription: boolean;
locale: string;
timeZone: string;
/** L17 Pakete: monthly chat quota used up with hard limit → hint instead of the input */
quotaExhausted?: boolean;
}) {
const t = useTranslations("lotse");
const router = useRouter();
@@ -128,6 +131,7 @@ export function LotseChat({
}
const lastAssistant = [...view.messages].reverse().find((m) => m.role !== "user");
const sendLocked = quotaExhausted || error === "quota_exhausted";
const errorText = error ? (t.has(`chat.errors.${error}`) ? t(`chat.errors.${error}`) : t("chat.errors.generic")) : null;
function notice(m: ChatMessageView): string | null {
@@ -235,7 +239,7 @@ export function LotseChat({
{m.id === lastAssistant?.id && m.content.chips && m.content.chips.length > 0 && (
<div className="flex flex-wrap gap-2" role="group" aria-label={t("chat.chipsLabel")}>
{m.content.chips.map((c) => (
<button key={c.value} type="button" className={chip(false)} disabled={pending !== null || !configured} onClick={() => send(c.value)}>
<button key={c.value} type="button" className={chip(false)} disabled={pending !== null || !configured || sendLocked} onClick={() => send(c.value)}>
{c.label}
</button>
))}
@@ -270,13 +274,19 @@ export function LotseChat({
{t("chat.offline")}
</p>
)}
{errorText && (
{errorText && error !== "quota_exhausted" && (
<p className={noticeError} role="alert">
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{errorText}
</p>
)}
{sendLocked ? (
<p className={noticeWarn} role="status">
<Info className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{t("chat.quotaExhausted")}
</p>
) : (
<form
className="space-y-2 rounded-xl border bg-card p-3 shadow-card"
onSubmit={(e) => {
@@ -315,6 +325,7 @@ export function LotseChat({
</div>
{transcription && <p className="text-[12.5px] text-muted-foreground">{t("chat.mic.hint")}</p>}
</form>
)}
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
import { getLocale, getTranslations } from "next-intl/server";
import { CheckCircle2, CircleDashed, Gauge, Info, Package, TriangleAlert, XCircle } from "lucide-react";
import { Pill } from "@/components/mockup-ui";
import { LOTSE_CHAT_CHATS_PER_SEAT, LOTSE_CHAT_OVERAGE_PACK } from "@/lib/plans";
import { cn } from "@/lib/utils";
import { setChatSeatAction } from "@/server/actions/lotse/seats";
import { getTenantPlan } from "@/server/plan";
import type { ServiceCtx } from "@/server/services/context";
import { getSeatOverview } from "@/server/services/lotse/chat/seats";
import { getChatUsageForAdmin } from "@/server/services/lotse/chat/usage";
const LEVEL_TONE = { ok: "var(--ok)", warn: "var(--warn)", exhausted: "var(--warn)", over: "var(--risk)" } as const;
/**
* L17 Pakete — /settings/lotse (tenant:manage): package (read-only), Lotse chat seats „x von y vergeben"
* with a switch per user with a field role, monthly usage bar with text, 80 % / overage hints and the
* per-user breakdown. Every change runs through setChatSeatAction (server-side limits + audit).
*/
export async function LotseSeatAdmin({ ctx, seatError, seatSaved }: { ctx: ServiceCtx; seatError?: string; seatSaved?: boolean }) {
const [t, locale, plan] = await Promise.all([getTranslations("plans"), getLocale(), getTenantPlan(ctx.tenantId)]);
const nf = new Intl.NumberFormat(locale);
const profi = plan.tier === "PROFI";
const [seats, usage] = profi ? await Promise.all([getSeatOverview(ctx), getChatUsageForAdmin(ctx)]) : [null, null];
const free = seats ? Math.max(0, seats.purchased - seats.assigned) : 0;
const monthLabel = usage ? new Date(`${usage.monthKey}-15T12:00:00Z`).toLocaleDateString(locale, { month: "long", year: "numeric", timeZone: "UTC" }) : "";
return (
<section id="seats" aria-labelledby="seats-title" className="shadow-card mt-5 max-w-5xl space-y-5 rounded-xl border bg-card p-5 text-sm">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 id="seats-title" className="flex items-center gap-2 font-heading text-sm font-semibold">
<Package className="size-4" aria-hidden /> {t("admin.title")}
</h2>
<p className="text-[12px] text-muted-foreground">{t("admin.sub")}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-[12.5px] text-muted-foreground">{t("admin.package")}</span>
<Pill tone={profi ? "ok" : "mut"}>{t(`tier.${plan.tier}`)}</Pill>
{plan.isTrial && <Pill tone="warn">{t("admin.trial")}</Pill>}
</div>
</div>
<p className="text-[12px] text-muted-foreground">{t("admin.packageReadOnly")}</p>
{seatSaved && (
<p role="status" className="flex items-center gap-2 rounded-lg border border-[var(--ok)] px-3 py-2 text-[var(--ok)]">
<CheckCircle2 className="size-4" aria-hidden /> {t("admin.saved")}
</p>
)}
{seatError && (
<p role="alert" className="flex items-center gap-2 rounded-lg border border-[var(--risk)] px-3 py-2 text-[var(--risk)]">
<XCircle className="size-4" aria-hidden /> {t.has(`admin.errors.${seatError}`) ? t(`admin.errors.${seatError}`) : t("admin.errors.failed")}
</p>
)}
{!profi || !seats || !usage ? (
<p className="flex items-start gap-2 rounded-lg bg-muted p-3">
<Info className="mt-0.5 size-4 shrink-0" aria-hidden /> {t("admin.basisHint")}
</p>
) : (
<>
{/* Plätze */}
<div className="space-y-2">
<h3 className="font-semibold">{t("admin.seatsTitle")}</h3>
{plan.isTrial ? (
<p className="flex items-start gap-2 text-muted-foreground">
<Info className="mt-0.5 size-4 shrink-0" aria-hidden /> {t("admin.trialSeats", { perSeat: LOTSE_CHAT_CHATS_PER_SEAT })}
</p>
) : (
<p className="text-[15px] font-semibold">{t("admin.seatsCount", { assigned: seats.assigned, purchased: seats.purchased })}</p>
)}
{seats.overbooked && (
<p className="flex items-start gap-2 text-[var(--warn)]">
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden /> {t("admin.overbooked", { purchased: seats.purchased })}
</p>
)}
{!plan.isTrial && seats.purchased === 0 && <p className="text-muted-foreground">{t("admin.noSeatsBought")}</p>}
{seats.users.length === 0 ? (
<p className="text-muted-foreground">{t("admin.noFieldUsers")}</p>
) : (
<ul className="divide-y rounded-lg border">
{seats.users.map((u) => {
const canAssign = !u.assigned && free > 0;
return (
<li key={u.id} className="flex flex-wrap items-center justify-between gap-3 px-3 py-2">
<div className="min-w-0">
<p className="font-medium">{u.name}</p>
<p className="truncate text-[12px] text-muted-foreground">{u.email}</p>
</div>
<div className="flex items-center gap-3">
<span className={cn("inline-flex items-center gap-1 text-[12.5px] font-semibold", !u.assigned && "text-muted-foreground")} style={u.assigned ? { color: u.valid ? "var(--ok)" : "var(--warn)" } : undefined}>
{u.assigned ? (u.valid ? <CheckCircle2 className="size-4" aria-hidden /> : <TriangleAlert className="size-4" aria-hidden />) : <CircleDashed className="size-4" aria-hidden />}
{u.assigned ? (u.valid ? t("admin.seatAssigned") : t("admin.seatOverbooked")) : t("admin.seatNone")}
</span>
<form action={setChatSeatAction}>
<input type="hidden" name="userId" value={u.id} />
<input type="hidden" name="assigned" value={u.assigned ? "0" : "1"} />
<button
type="submit"
role="switch"
aria-checked={u.assigned}
aria-label={u.assigned ? t("admin.revokeLabel", { name: u.name }) : t("admin.assignLabel", { name: u.name })}
disabled={!u.assigned && !canAssign}
className={cn(
"inline-flex min-h-11 min-w-28 items-center justify-center rounded-lg border px-3 text-[13px] font-semibold disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
u.assigned ? "border-[var(--ui-primary)] text-[var(--ui-primary)]" : "hover:bg-muted",
)}
>
{u.assigned ? t("admin.revoke") : t("admin.assign")}
</button>
</form>
</div>
</li>
);
})}
</ul>
)}
</div>
{/* Monatsverbrauch */}
<div className="space-y-2">
<h3 className="flex items-center gap-2 font-semibold">
<Gauge className="size-4" aria-hidden /> {t("admin.usageTitle", { month: monthLabel })}
</h3>
<div
role="meter"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={usage.percent}
aria-label={t("admin.usageText", { used: nf.format(usage.used), quota: nf.format(usage.quota) })}
className="h-3 w-full overflow-hidden rounded-full bg-muted"
>
<div className="h-full rounded-full" style={{ width: `${usage.percent}%`, background: LEVEL_TONE[usage.level] }} />
</div>
<p className="text-[15px] font-semibold">{t("admin.usageText", { used: nf.format(usage.used), quota: nf.format(usage.quota) })}</p>
<p className="text-[12px] text-muted-foreground">{t("admin.quotaRule", { perSeat: LOTSE_CHAT_CHATS_PER_SEAT, basis: usage.quotaBasis })}</p>
{usage.level === "warn" && (
<p className="flex items-start gap-2 text-[var(--warn)]">
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden /> {t("admin.warn80", { percent: usage.percent })}
</p>
)}
{(usage.level === "exhausted" || usage.level === "over") && usage.hardLimit && (
<p className="flex items-start gap-2 text-[var(--risk)]">
<XCircle className="mt-0.5 size-4 shrink-0" aria-hidden /> {t("admin.hardLimitReached")}
</p>
)}
{usage.level === "exhausted" && !usage.hardLimit && (
<p className="flex items-start gap-2 text-[var(--warn)]">
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden /> {t("admin.exhausted", { pack: LOTSE_CHAT_OVERAGE_PACK })}
</p>
)}
{usage.overage > 0 && (
<p className="flex items-start gap-2 text-[var(--risk)]">
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden /> {t("admin.overage", { overage: nf.format(usage.overage), packs: usage.overagePacks, pack: LOTSE_CHAT_OVERAGE_PACK })}
</p>
)}
{usage.perUser.length > 0 && (
<table className="mt-2 w-full max-w-md text-left">
<caption className="sr-only">{t("admin.perUserCaption")}</caption>
<thead>
<tr className="text-[12px] text-muted-foreground">
<th scope="col" className="py-1 font-medium">{t("admin.perUserName")}</th>
<th scope="col" className="py-1 text-right font-medium">{t("admin.perUserChats")}</th>
</tr>
</thead>
<tbody className="divide-y">
{usage.perUser.map((u) => (
<tr key={u.userId}>
<td className="py-1.5">{u.name}</td>
<td className="py-1.5 text-right tabular-nums">{nf.format(u.count)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)}
</section>
);
}
+23
View File
@@ -0,0 +1,23 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { CircleSlash } from "lucide-react";
import { card } from "@/components/field/ui";
/** „Lotse-Chat nicht verfügbar" (L16; L17: kein Platz / nicht im Paket) — text + icon, back to /m. */
export async function LotseChatUnavailable({ reason }: { reason: string }) {
const t = await getTranslations("lotse");
return (
<main className="space-y-3 p-4">
<section className={card}>
<h1 className="flex items-center gap-2 text-[20px]">
<CircleSlash className="size-5 text-muted-foreground" aria-hidden />
{t("chat.unavailable.title")}
</h1>
<p className="mt-2 text-[15px]">{t.has(`chat.errors.${reason}`) ? t(`chat.errors.${reason}`) : t("chat.errors.generic")}</p>
<Link href="/m" className="mt-3 inline-flex min-h-12 items-center font-semibold text-primary">
{t("chat.unavailable.back")}
</Link>
</section>
</main>
);
}
+82
View File
@@ -0,0 +1,82 @@
import Link from "next/link";
import { getLocale, getTranslations } from "next-intl/server";
import { Button } from "@/components/ui/button";
import { Modal } from "@/components/modal";
import { Pill } from "@/components/mockup-ui";
import { LOTSE_CHAT_CHATS_PER_SEAT, LOTSE_CHAT_OVERAGE_PACK } from "@/lib/plans";
import { updateTenantPlanAction } from "@/server/actions/plans-platform";
import type { ChatUsage } from "@/server/services/lotse/chat/usage";
import { getTenantPlanOverview, MAX_LOTSE_CHAT_SEATS } from "@/server/services/plans/platform";
import { PlanForm } from "./plan-form";
/**
* L17 Pakete — card "Paket & Lotse-Chat" on the platform tenant detail page: tier, seats assigned /
* purchased, chats of the current and previous month, overage and the resulting packs of 100 (basis for
* the invoice). Full admins change tier/seats/hard limit in a confirmation popup (?plan=edit).
*/
export async function PlanAdminCard({ tenantId, platformAdminId, isFullAdmin, base, edit, done }: { tenantId: string; platformAdminId: string; isFullAdmin: boolean; base: string; edit: boolean; done: boolean }) {
const [t, locale, overview] = await Promise.all([getTranslations("plans.platform"), getLocale(), getTenantPlanOverview({ platformAdminId }, tenantId)]);
const tt = await getTranslations("plans");
const nf = new Intl.NumberFormat(locale);
const { plan, seatsAssigned, current, previous } = overview;
const monthName = (u: ChatUsage) => new Date(`${u.monthKey}-15T12:00:00Z`).toLocaleDateString(locale, { month: "long", year: "numeric", timeZone: "UTC" });
const usageRows = [current, previous].map((u) => (
<div key={u.monthKey} className="rounded-lg border bg-muted/40 px-3 py-2" data-plan-usage={u.monthKey}>
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{u === current ? t("currentMonth", { month: monthName(u) }) : t("previousMonth", { month: monthName(u) })}</p>
<p className="text-[13.5px] font-semibold">{t("chats", { used: nf.format(u.used), quota: nf.format(u.quota) })}</p>
<p className="text-[12.5px]">
{u.overage > 0 ? t("overage", { overage: nf.format(u.overage), packs: u.overagePacks, pack: LOTSE_CHAT_OVERAGE_PACK }) : t("noOverage")}
</p>
</div>
));
return (
<div className="shadow-card rounded-xl border bg-card p-5" data-plan-card>
<p className="mb-2 font-heading text-sm font-semibold">{t("title")}</p>
{done && <p role="status" className="mb-3 rounded-lg border border-[var(--ok)] px-3 py-2 text-[12.5px] text-[var(--ok)]">{t("saved")}</p>}
<dl className="space-y-1.5 text-[13px]">
<div className="flex justify-between gap-2">
<dt className="text-muted-foreground">{t("tier")}</dt>
<dd className="flex flex-wrap justify-end gap-1.5">
<Pill tone={plan.storedTier === "PROFI" ? "ok" : "mut"}>{tt(`tier.${plan.storedTier}`)}</Pill>
{plan.isTrial && <Pill tone="warn">{t("trialProfi")}</Pill>}
</dd>
</div>
<div className="flex justify-between gap-2">
<dt className="text-muted-foreground">{t("seats")}</dt>
<dd>{t("seatsValue", { assigned: seatsAssigned, purchased: plan.lotseChatSeats })}</dd>
</div>
<div className="flex justify-between gap-2">
<dt className="text-muted-foreground">{t("hardLimit")}</dt>
<dd>{plan.lotseChatHardLimit ? t("hardLimitOn") : t("hardLimitOff")}</dd>
</div>
</dl>
<p className="mt-2 text-[11.5px] text-muted-foreground">{t("rule", { perSeat: LOTSE_CHAT_CHATS_PER_SEAT, pack: LOTSE_CHAT_OVERAGE_PACK })}</p>
<div className="mt-3 space-y-2">{usageRows}</div>
{isFullAdmin && (
<Link href={`${base}?plan=edit`} scroll={false} className="mt-3 block">
<Button variant="outline" size="sm" className="min-h-11 w-full justify-center">
{t("edit")}
</Button>
</Link>
)}
{isFullAdmin && edit && (
<Modal title={t("editTitle")} closeHref={base} closeLabel={t("close")}>
<div className="space-y-3 p-5">
<p className="text-sm">{t("editText")}</p>
<PlanForm
action={updateTenantPlanAction.bind(null, tenantId)}
tier={plan.storedTier}
seats={plan.lotseChatSeats}
hardLimit={plan.lotseChatHardLimit}
maxSeats={MAX_LOTSE_CHAT_SEATS}
closeHref={base}
/>
</div>
</Modal>
)}
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { useActionState } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { TENANT_TIERS, type TenantTier } from "@/lib/plans";
import type { PlatformPlanState } from "@/server/actions/plans-platform";
/** L17 Pakete: operator form (tier, seats, hard limit) with mandatory confirmation checkbox. */
export function PlanForm({
action,
tier,
seats,
hardLimit,
maxSeats,
closeHref,
}: {
action: (prev: PlatformPlanState, fd: FormData) => Promise<PlatformPlanState>;
tier: TenantTier;
seats: number;
hardLimit: boolean;
maxSeats: number;
closeHref: string;
}) {
const t = useTranslations("plans.platform");
const tt = useTranslations("plans");
const [state, formAction, pending] = useActionState<PlatformPlanState, FormData>(action, { status: "idle" });
return (
<form action={formAction} className="space-y-4">
<fieldset>
<legend className="text-sm font-semibold">{t("tier")}</legend>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
{TENANT_TIERS.map((v) => (
<label key={v} className="flex min-h-11 cursor-pointer items-start gap-2 rounded-lg border p-3 text-sm has-[:checked]:border-[var(--ui-primary)] has-[:checked]:font-semibold">
<input type="radio" name="tier" value={v} defaultChecked={tier === v} className="mt-0.5 size-4" />
<span>
<span className="block">{tt(`tier.${v}`)}</span>
<span className="block text-[12px] font-normal text-muted-foreground">{t(`tierHint.${v}`)}</span>
</span>
</label>
))}
</div>
<p className="mt-2 text-[12px] text-muted-foreground">{t("downgradeHint")}</p>
</fieldset>
<div className="max-w-xs">
<Label htmlFor="plan-seats">{t("seatsInput")}</Label>
<Input id="plan-seats" name="lotseChatSeats" type="number" inputMode="numeric" min={0} max={maxSeats} step={1} required defaultValue={seats} className="mt-1 h-11" />
<p className="mt-1 text-[12px] text-muted-foreground">{t("seatsHint")}</p>
</div>
<label className="flex min-h-11 items-start gap-2 text-sm">
<input type="checkbox" name="lotseChatHardLimit" defaultChecked={hardLimit} className="mt-0.5 size-5" />
<span>
<span className="block font-semibold">{t("hardLimitInput")}</span>
<span className="block text-[12px] text-muted-foreground">{t("hardLimitHint")}</span>
</span>
</label>
<label className="flex min-h-11 items-center gap-2 text-sm">
<input type="checkbox" name="confirm" className="size-5" required /> {t("confirm")}
</label>
{state.status === "error" && (
<p role="alert" className="rounded-lg border border-[var(--risk)] px-3 py-2 text-sm text-[var(--risk)]">
{t.has(`errors.${state.code}`) ? t(`errors.${state.code}`) : t("errors.failed")}
</p>
)}
<div className="flex flex-wrap gap-2">
<Button type="submit" className="h-11" disabled={pending}>
{pending ? t("submitting") : t("submit")}
</Button>
<Button variant="outline" className="h-11" nativeButton={false} render={<Link href={closeHref} />}>
{t("close")}
</Button>
</div>
</form>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { getTranslations } from "next-intl/server";
import { Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* L17 Pakete: quiet hint "Im Paket Profi enthalten" instead of an error page — shown when a page of a
* module/feature outside the tenant's package tier was opened (redirect `/dashboard?module=profi`).
* Text + icon, colours only via tokens.
*/
export async function ProfiHint({ className, compact = false }: { className?: string; compact?: boolean }) {
const t = await getTranslations("plans");
return (
<div role="status" className={cn("mb-4 flex items-start gap-3 rounded-lg border bg-card px-4 py-3 text-sm", className)}>
<Sparkles className="mt-0.5 size-4.5 shrink-0 text-[var(--ui-primary)]" aria-hidden />
<div>
<p className="font-semibold">{t("profiHint.title")}</p>
{!compact && <p className="text-muted-foreground">{t("profiHint.text")}</p>}
</div>
</div>
);
}