diff --git a/src/app/analytics/page.tsx b/src/app/analytics/page.tsx index baaac115..1d9b7538 100644 --- a/src/app/analytics/page.tsx +++ b/src/app/analytics/page.tsx @@ -8,6 +8,8 @@ import { getFreighterPublicKey } from "@/lib/freighter"; import { generateCsv, type CsvRow } from "@/lib/csvExport"; import type { Invoice } from "@stellar-split/sdk"; import ChartBuilder, { type ChartBuilderDataPoint } from "@/components/analytics/ChartBuilder"; +import AnalyticsPanel from "@/components/subscriptions/AnalyticsPanel"; +import { loadSubscriptions, type Subscription } from "@/lib/subscriptions"; const STROOPS = 10_000_000; @@ -119,6 +121,10 @@ export default function AnalyticsPage() { const [error, setError] = useState(null); const [dateFrom, setDateFrom] = useState(""); const [dateTo, setDateTo] = useState(""); + const [subscriptions, setSubscriptions] = useState([]); + const [activeTab, setActiveTab] = useState<"invoices" | "subscriptions">( + "invoices" + ); useEffect(() => { getFreighterPublicKey() @@ -155,6 +161,17 @@ export default function AnalyticsPage() { fetchInvoices(); }, [publicKey]); + useEffect(() => { + if (!publicKey) return; + try { + setSubscriptions(loadSubscriptions(publicKey)); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to load subscriptions" + ); + } + }, [publicKey]); + const filtered = useMemo(() => { return invoices.filter((inv) => { const ts = inv.createdAt ?? inv.deadline - 7 * 86400; @@ -397,6 +414,37 @@ export default function AnalyticsPage() { +
+ + +
+ + {activeTab === "subscriptions" ? ( +
+ +
+ ) : ( + <>
)}
+ + )} ("30d"); @@ -96,6 +101,20 @@ export default function AnalyticsPanel({ invoices }: Props) { .sort(([a], [b]) => a.localeCompare(b)) .map(([week, count]) => ({ week, invoices: count })); + // Subscription analytics (scoped to the same date window as invoices). + const subscriptionAnalytics = useMemo( + () => aggregateSubscriptionAnalytics(subscriptions, from, to), + [subscriptions, from, to] + ); + + const cancellationReasonData = useMemo( + () => + Object.entries(subscriptionAnalytics.cancellationReasons) + .sort(([, a], [, b]) => b - a) + .map(([reason, count]) => ({ reason, count })), + [subscriptionAnalytics.cancellationReasons] + ); + return (
+ {/* Subscription Analytics */} +
+

{t("dashboard.subscriptions")}

+
+
+

{t("dashboard.activeSubscriptions")}

+

+ {subscriptionAnalytics.active} +

+
+
+

{t("dashboard.pausedSubscriptions")}

+

+ {subscriptionAnalytics.paused} +

+
+
+

{t("dashboard.cancelledSubscriptions")}

+

+ {subscriptionAnalytics.cancelled} +

+
+
+
+
+

{t("dashboard.mrr")}

+

+ {formatAmount(subscriptionAnalytics.mrr)} +

+
+
+

{t("dashboard.churnRate")}

+

+ {(subscriptionAnalytics.churnRate * 100).toFixed(1)}% +

+
+
+ + {cancellationReasonData.length > 0 && ( +
+

+ {t("dashboard.cancellationReasons")} +

+ + + + + + + + + +
+ )} +
+ {/* Weekly Chart */} {chartData.length > 0 && (
diff --git a/src/components/CancelModal.tsx b/src/components/CancelModal.tsx index 36563376..d5939384 100644 --- a/src/components/CancelModal.tsx +++ b/src/components/CancelModal.tsx @@ -5,16 +5,33 @@ import { useRouter } from "next/navigation"; import FocusTrap from "./FocusTrap"; import type { Payment } from "@stellar-split/sdk"; +export interface CancelReasonOption { + value: string; + label: string; +} + export interface CancelModalProps { invoiceId: string; invoiceTitle?: string; payments?: Payment[]; - onConfirm: () => Promise; + onConfirm: (reason?: string, note?: string) => Promise; onClose: () => void; onSuccess?: () => void; + /** When true, a reason-capture step is shown before the destructive-action gate. */ + requireReason?: boolean; + /** Options for the reason select. Defaults to a generic set when omitted. */ + reasons?: CancelReasonOption[]; } -export type CancelModalStep = "warn" | "confirm_gate" | "loading" | "success"; +export type CancelModalStep = "warn" | "reason" | "confirm_gate" | "loading" | "success"; + +const DEFAULT_REASONS: CancelReasonOption[] = [ + { value: "no_longer_needed", label: "No longer needed" }, + { value: "too_expensive", label: "Too expensive" }, + { value: "missing_features", label: "Missing features" }, + { value: "switching_provider", label: "Switching to another provider" }, + { value: "other", label: "Other" }, +]; /** * Multi-step invoice cancellation confirmation modal. @@ -30,22 +47,31 @@ export default function CancelModal({ onConfirm, onClose, onSuccess, + requireReason = false, + reasons, }: CancelModalProps) { const router = useRouter(); const [step, setStep] = useState("warn"); const [confirmText, setConfirmText] = useState(""); + const [reason, setReason] = useState(""); + const [note, setNote] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const expectedTitle = (invoiceTitle || `Invoice #${invoiceId}`).trim(); const isMatch = confirmText.trim().toLowerCase() === expectedTitle.toLowerCase(); + const reasonOptions = reasons && reasons.length > 0 ? reasons : DEFAULT_REASONS; + const isReasonValid = !requireReason || reason.trim().length > 0; const handleStartCancellation = async () => { setStep("loading"); setLoading(true); setError(null); try { - await onConfirm(); + await onConfirm( + requireReason ? reason || undefined : undefined, + requireReason && note.trim() ? note.trim() : undefined + ); setLoading(false); setStep("success"); } catch (err) { @@ -131,7 +157,7 @@ export default function CancelModal({
)} + {/* Step 1.5: Reason Capture (optional) */} + {step === "reason" && ( +
+

+ Please tell us why you are cancelling. This helps us improve. +

+ +
+ + +
+ +
+ +