"use client"; import { useState } from "react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; interface Props { tenantName: string; /** * Current suspend state — server-derived. The control toggles this * via PATCH /api/tenants/[name]/suspend, then refreshes the route * so server-component-side data (status badge, suspended notice) * re-renders. */ suspended: boolean; } /** * SubscriptionToggle — owner-side cancel/resume control (Bug 31). * * Renders a single button that toggles between "Cancel subscription" * (when active) and "Resume subscription" (when suspended). Cancellation * is gated behind a confirmation modal because it's destructive * looking from the user's POV — even though no data is lost, the * AI assistant becomes unavailable until they resume. Resume has no * modal; it's a strict subset of cancellation in terms of risk. * * The control intentionally lives at the bottom of the tenant detail * page rather than next to the status badge — putting it near the * top would invite mis-clicks. Customers who want to cancel scroll * past the running configuration, billing-relevant info, and assigned * users first; that's the right friction level. * * Suspended tenants render a top-of-page banner separately (see the * detail page); this component focuses on the action itself. */ export function SubscriptionToggle({ tenantName, suspended }: Props) { const t = useTranslations("tenantDetail"); const tCommon = useTranslations("common"); const router = useRouter(); const [confirmOpen, setConfirmOpen] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(""); const toggleSuspend = async (next: boolean) => { setSubmitting(true); setError(""); try { const res = await fetch( `/api/tenants/${encodeURIComponent(tenantName)}/suspend`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ suspend: next }), } ); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || t("subscriptionUpdateFailed")); } setConfirmOpen(false); // The status badge + suspended banner are server-rendered, so // a route refresh is the simplest way to reflect the new state. // Optimistic local toggle would diverge from the actual CR if // the operator hasn't observed the patch yet. router.refresh(); } catch (e: any) { setError(e.message); } finally { setSubmitting(false); } }; if (suspended) { return (
{error &&

{error}

}
); } return (
{error && !confirmOpen && (

{error}

)} {confirmOpen && (
{ if (e.target === e.currentTarget) setConfirmOpen(false); }} >

{t("cancelConfirmTitle")}

{t("cancelConfirmDescription")}

  • {t("cancelConfirmBullet1")}
  • {t("cancelConfirmBullet2")}
  • {t("cancelConfirmBullet3")}
{error && (
{error}
)}
)}
); }