import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { ApiError, createSubscriptionPlan, fetchSubscriptionPlans, retireSubscriptionPlan, type SubscriptionPeriod, type SubscriptionPlan, } from "./api.js"; import { Modal } from "./ui/Modal.js"; // Admin-only subscription PLAN catalog. Plans are admin-composed, versioned config the // operator sells from (so the operator never types a price). Editing a plan PUBLISHES A // NEW VERSION (new effectiveFrom) — past sales keep their recorded version. Retire is // soft (active=0). Mirrors the tariff composer. See wiki/entities/subscription.md. const DEFAULT_CURRENCY = "ALL"; const PERIODS: SubscriptionPeriod[] = ["day", "week", "month"]; const PERIOD_KEY: Record = { day: "subs.perDay", week: "subs.perWeek", month: "subs.perMonth", }; // Day-of-week picker, Monday-first (mirrors the tariff composer). Labels come from the // shared tariff.dow0..6 i18n keys (Hën..Die / Mon..Sun). const DOW_ORDER = [1, 2, 3, 4, 5, 6, 0]; interface PlanForm { planId: string; // blank on a brand-new plan; set when publishing a new version name: string; period: SubscriptionPeriod; priceMajor: string; currency: string; // Timeframes (tariff bridge). Off → 24/7. On → an allowed window (enter-after / // exit-before as HH:MM) on the SELECTED days (0=Sun..6=Sat); on unselected days the // subscriber parks free. Plus grace minutes. restrictTimes: boolean; days: number[]; // days the window applies to; empty = every day winFrom: string; // window opens (HH:MM) — when the subscriber may enter winTo: string; // window closes (HH:MM) — by when they should exit graceMin: string; } function emptyForm(): PlanForm { return { planId: "", name: "", period: "month", priceMajor: "", currency: DEFAULT_CURRENCY, restrictTimes: false, days: [1, 2, 3, 4, 5], // default Mon–Fri (the common "night plan, free weekends") winFrom: "20:00", winTo: "08:00", graceMin: "0", }; } /** "HH:MM" → minutes-of-day, or null if blank/invalid. */ function hhmmToMin(s: string): number | null { const m = /^(\d{1,2}):(\d{2})$/.exec(s.trim()); if (!m) return null; const min = Number(m[1]) * 60 + Number(m[2]); return min >= 0 && min <= 1439 ? min : null; } /** minutes-of-day → "HH:MM". */ function minToHHMM(min: number): string { return `${String(Math.floor(min / 60)).padStart(2, "0")}:${String(min % 60).padStart(2, "0")}`; } export function SubscriptionPlansManager() { const { t } = useTranslation(); const [plans, setPlans] = useState(null); const [form, setForm] = useState(null); const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null); function reload() { // ?all=1 → every version (history), so the admin sees superseded prices too. fetchSubscriptionPlans(true) .then((r) => setPlans(r.plans)) .catch((e) => setMsg({ kind: "err", text: (e as Error).message })); } useEffect(reload, []); async function save() { if (!form) return; setMsg(null); const major = Number(form.priceMajor); if (!form.name.trim()) return setMsg({ kind: "err", text: t("plans.needName") }); if (!Number.isFinite(major) || major <= 0) return setMsg({ kind: "err", text: t("plans.needPrice") }); // Build the timeframes blob from the form (null = 24/7). The window [winFrom, winTo) // (wraps midnight for a night plan) applies on the SELECTED days; unselected days are // unrestricted. Empty days = every day. The server stamps the site tz. let timeframes = null as Parameters[0]["timeframes"]; if (form.restrictTimes) { const from = hhmmToMin(form.winFrom); const to = hhmmToMin(form.winTo); if (from == null || to == null) return setMsg({ kind: "err", text: t("plans.needWindow") }); if (form.days.length === 0) return setMsg({ kind: "err", text: t("plans.needDays") }); timeframes = { days: [...form.days].sort((a, b) => a - b), fromMin: from, toMin: to, graceMin: Math.max(0, Math.round(Number(form.graceMin) || 0)), }; } try { await createSubscriptionPlan({ planId: form.planId.trim() || undefined, name: form.name.trim(), period: form.period, pricePerPeriodMinor: Math.round(major * 100), currency: form.currency.trim() || DEFAULT_CURRENCY, timeframes, }); setForm(null); reload(); setMsg({ kind: "ok", text: t("plans.saved") }); } catch (e) { const problems = e instanceof ApiError ? (e as ApiError & { problems?: string[] }).problems : undefined; setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message }); } } async function retire(p: SubscriptionPlan) { if (!confirm(t("plans.confirmRetire", { name: p.name }))) return; await retireSubscriptionPlan(p.planId).catch((e) => setMsg({ kind: "err", text: (e as Error).message })); reload(); } /** Publish a new version of an existing plan (pre-fills its identity + last values). */ function newVersionOf(p: SubscriptionPlan) { const tf = p.timeframes ?? null; setForm({ planId: p.planId, name: p.name, period: p.period, priceMajor: String(p.pricePerPeriodMinor / 100), currency: p.currency, restrictTimes: tf != null, days: tf?.days && tf.days.length > 0 ? [...tf.days] : [1, 2, 3, 4, 5], winFrom: tf?.fromMin != null ? minToHHMM(tf.fromMin) : "20:00", winTo: tf?.toMin != null ? minToHHMM(tf.toMin) : "08:00", graceMin: String(tf?.graceMin ?? 0), }); setMsg(null); } if (!plans) return null; // The CURRENT (latest active) version per planId, for the "in force" badge. const now = new Date().toISOString(); const currentVersionId = new Map(); for (const p of plans) { if (p.active && p.effectiveFrom <= now && !currentVersionId.has(p.planId)) { currentVersionId.set(p.planId, p.id); // plans come newest-first } } return (

{t("plans.title")}

{t("plans.intro")}

{msg && (
{msg.text}
)} {plans.length === 0 ? (

{t("plans.noneYet")}

) : ( {plans.map((p) => { const isCurrent = currentVersionId.get(p.planId) === p.id; return ( ); })}
{t("plans.colName")} {t("plans.colPrice")} {t("plans.colEffective")}
{p.name} {isCurrent && {t("plans.inForce")}} {!p.active && {t("plans.retired")}} {(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])} {new Date(p.effectiveFrom).toLocaleDateString()} {isCurrent && ( <> )}
)} setForm(null)} title={form?.planId ? t("plans.newVersionTitle") : t("plans.newTitle")} width="max-w-lg"> {form && ( <>
setForm((f) => f && { ...f, name: e.target.value })} placeholder={t("plans.namePlaceholder")} /> setForm((f) => f && { ...f, priceMajor: e.target.value })} placeholder="e.g. 800" /> setForm((f) => f && { ...f, currency: e.target.value })} /> / {t(PERIOD_KEY[form.period])}
{/* Timeframes (tariff bridge): restrict WHEN a subscriber may park. Outside the window they're charged the transient tariff for the gap. Off = 24/7. */}
{form.restrictTimes && (
{DOW_ORDER.map((d) => ( ))} {t("plans.enterAfter")} setForm((f) => f && { ...f, winFrom: e.target.value })} /> {t("plans.exitBefore")} setForm((f) => f && { ...f, winTo: e.target.value })} /> setForm((f) => f && { ...f, graceMin: e.target.value })} /> {t("plans.graceHint")}
)}

{t("plans.timeframesHint")}

{form.planId &&

{t("plans.newVersionHint")}

}
)}
); }