116 lines
3.9 KiB
TypeScript
116 lines
3.9 KiB
TypeScript
"use client";
|
||
|
||
import { useTranslations } from "next-intl";
|
||
import { Modal } from "@/components/ui/modal";
|
||
|
||
interface Props {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
onConfirm: () => void;
|
||
skillName: string;
|
||
dailyPriceChf: number;
|
||
setupFeeChf: number;
|
||
busy?: boolean;
|
||
}
|
||
|
||
/**
|
||
* Cost-disclosure modal shown before activating a priced skill.
|
||
*
|
||
* Shows the daily rate and setup fee (each only if > 0) and
|
||
* requires an explicit Confirm before the activation request goes
|
||
* through. Rendered every time the user toggles on a priced skill,
|
||
* not once-and-remember — this is recurring-charge consent, not a
|
||
* one-time terms agreement.
|
||
*
|
||
* The setup fee is always shown when configured, with a note
|
||
* clarifying it's "one-time, charged on first activation". The
|
||
* backend (billing.ts tenantSkillHasBeenBilled) is the authority
|
||
* on whether the fee actually fires — we don't second-guess from
|
||
* the client. If you've previously activated this skill on this
|
||
* tenant, the fee won't appear on the next invoice even though
|
||
* the dialog mentions it.
|
||
*/
|
||
export function SkillCostDialog({
|
||
open,
|
||
onClose,
|
||
onConfirm,
|
||
skillName,
|
||
dailyPriceChf,
|
||
setupFeeChf,
|
||
busy = false,
|
||
}: Props) {
|
||
const t = useTranslations("skillCostDialog");
|
||
const showSetupFee = setupFeeChf > 0;
|
||
const showDaily = dailyPriceChf > 0;
|
||
// Nothing to disclose? Bail to confirm immediately — shouldn't
|
||
// normally be shown in this case but guard anyway.
|
||
if (!showSetupFee && !showDaily) {
|
||
return null;
|
||
}
|
||
return (
|
||
<Modal open={open} onClose={onClose} ariaLabel={t("title")}>
|
||
<div className="bg-surface-1 rounded-lg border border-border p-6 max-w-md w-full">
|
||
<h2 className="text-lg font-semibold mb-2">{t("title")}</h2>
|
||
<p className="text-sm text-text-secondary mb-4">
|
||
{t("intro", { skill: skillName })}
|
||
</p>
|
||
|
||
<div className="rounded-md bg-surface-2 border border-border p-4 mb-4 space-y-2">
|
||
{showSetupFee && (
|
||
<div className="flex justify-between items-baseline">
|
||
<div>
|
||
<div className="text-sm">{t("setupFeeLabel")}</div>
|
||
<div className="text-xs text-text-muted">
|
||
{t("setupFeeNote")}
|
||
</div>
|
||
</div>
|
||
<div className="text-sm font-mono">
|
||
CHF {setupFeeChf.toFixed(2)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{showDaily && (
|
||
/* Display reference monthly cost (daily × 30) plus the
|
||
actual daily rate as a sub-note. Billing is always
|
||
per UTC day enabled — partial months prorate to that
|
||
same daily rate, full months land at roughly the
|
||
figure shown (varies ±~3% by month length). */
|
||
<div className="flex justify-between items-baseline">
|
||
<div>
|
||
<div className="text-sm">{t("monthlyPriceLabel")}</div>
|
||
<div className="text-xs text-text-muted">
|
||
{t("monthlyPriceNote", {
|
||
daily: dailyPriceChf.toFixed(2),
|
||
})}
|
||
</div>
|
||
</div>
|
||
<div className="text-sm font-mono">
|
||
CHF {(dailyPriceChf * 30).toFixed(2)} / {t("monthUnit")}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<p className="text-xs text-text-muted mb-4">{t("disclaimer")}</p>
|
||
|
||
<div className="flex justify-end gap-2">
|
||
<button
|
||
onClick={onClose}
|
||
disabled={busy}
|
||
className="px-4 py-2 rounded-md border border-border text-sm disabled:opacity-50"
|
||
>
|
||
{t("cancel")}
|
||
</button>
|
||
<button
|
||
onClick={onConfirm}
|
||
disabled={busy}
|
||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||
>
|
||
{busy ? t("confirming") : t("confirm")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|