"use client"; import { useState } from "react"; import { useRouter } from "next/navigation"; import { useTranslations, useFormatter } from "next-intl"; import { Modal } from "@/components/ui/modal"; import { formatRelative } from "@/lib/format"; interface Props { tenantName: string; /** * Current suspend state — server-derived. Drives which control the * customer sees: "Cancel subscription" while active, the * resume-request flow while suspended. */ suspended: boolean; /** * True when the viewer has platform admin role. Platform users are * the only ones who can directly resume a tenant via PATCH; owners * must go through the resume-request flow. We use this in the * suspended branch to decide whether to render a direct "Resume" * button or the "Request reactivation" workflow. */ isPlatform: boolean; /** * If a resume request is currently pending for this tenant, its * id, when it was submitted, and the customer's optional note. * The component renders an info card with a cancel-request button * instead of the request-reactivation button. Only meaningful when * `suspended === true`. */ pendingResumeRequest: { id: string; createdAt: string; customerNotes: string | null; } | null; } /** * SubscriptionToggle — owner-side cancel/resume control. * * Three render states: * 1. Active: "Cancel subscription" button + confirmation modal * (mentions 60-day retention before permanent deletion). * 2. Suspended, no pending resume request: "Request reactivation" * button + simple confirmation modal explaining admin review. * 3. Suspended, pending resume request: status card "Reactivation * requested X days ago" + "Cancel request" button. * * Platform admins viewing a suspended tenant get a fourth state in * place of #2/#3: a direct "Resume now" button (no admin queue, no * request flow). This is the admin escape hatch. * * The control intentionally lives at the bottom of the tenant * detail page rather than near the top — putting it next to the * status badge would invite mis-clicks. */ export function SubscriptionToggle({ tenantName, suspended, isPlatform, pendingResumeRequest, }: Props) { const t = useTranslations("tenantDetail"); const tCommon = useTranslations("common"); const f = useFormatter(); const router = useRouter(); const [confirmCancelOpen, setConfirmCancelOpen] = useState(false); const [confirmResumeOpen, setConfirmResumeOpen] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(""); // Feature 6: customer's free-form note attached to the resume // request. Reset when the modal opens/closes so re-opening doesn't // show stale text from a previous abandoned attempt. const [resumeNotes, setResumeNotes] = useState(""); // Customer-side cancel: PATCH suspend=true. Same path as before. // The 60-day retention copy in the modal is the new bit (Bug 37b); // mechanics are unchanged. const cancel = async () => { setSubmitting(true); setError(""); try { const res = await fetch( `/api/tenants/${encodeURIComponent(tenantName)}/suspend`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ suspend: true }), } ); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || t("subscriptionUpdateFailed")); } setConfirmCancelOpen(false); router.refresh(); } catch (e: any) { setError(e.message); } finally { setSubmitting(false); } }; // Owner-side resume request: POST a 'resume' tenant_request that // sits pending until admin acts. Different from cancel: no PATCH // on the CR — that happens only when admin approves. const requestResume = async () => { setSubmitting(true); setError(""); try { const res = await fetch( `/api/tenants/${encodeURIComponent(tenantName)}/resume-request`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ // Trim and omit on empty so the API stores NULL rather // than empty string. The endpoint's zod transform also // handles this; double-checking on the client lets us // skip the round-trip when there's nothing to send. customerNotes: resumeNotes.trim() || undefined, }), } ); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || t("subscriptionUpdateFailed")); } setConfirmResumeOpen(false); setResumeNotes(""); router.refresh(); } catch (e: any) { setError(e.message); } finally { setSubmitting(false); } }; // Customer cancels their own pending resume request. const cancelResumeRequest = async () => { if (!pendingResumeRequest) return; setSubmitting(true); setError(""); try { const res = await fetch( `/api/onboarding/${pendingResumeRequest.id}`, { method: "DELETE" } ); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || t("subscriptionUpdateFailed")); } router.refresh(); } catch (e: any) { setError(e.message); } finally { setSubmitting(false); } }; // Platform admin: direct resume, bypassing the request flow. const adminResume = async () => { setSubmitting(true); setError(""); try { const res = await fetch( `/api/tenants/${encodeURIComponent(tenantName)}/suspend`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ suspend: false }), } ); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || t("subscriptionUpdateFailed")); } router.refresh(); } catch (e: any) { setError(e.message); } finally { setSubmitting(false); } }; // ─── Suspended branch ─────────────────────────────────────────────── if (suspended) { // Platform admin sees direct resume. Independent of pending // resume — admin can always resume immediately. if (isPlatform) { return (
{t("resumeRequestPendingNoteAdmin")}
)} {error &&{error}
}{error}
}{error}
)} {confirmResumeOpen && ({t("requestReactivationConfirmDescription")}
{/* Feature 6: optional explanatory note. Useful for customers to tell admin why they want reactivation — e.g. "we paused over winter break, picking back up". Stored on the tenant_request and surfaced in the admin queue. */}{error}
)} {confirmCancelOpen && ({t("cancelConfirmDescription")}