'use client'; import { useState, useEffect, useCallback } from 'react'; import { Bell, Send, Mail, CheckCircle2, AlertCircle, Loader2, Globe, CreditCard, HardDrive, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { Skeleton } from '@/components/ui/skeleton'; import { Badge } from '@/components/ui/badge'; import { Progress } from '@/components/ui/progress'; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { cn } from '@/lib/utils'; import { CancelSubscriptionDialog } from '@/components/settings/cancel-subscription-dialog'; import type { CancellationReason } from '@/lib/cancellation-reasons'; /** * Stripe reports amounts in the currency's smallest unit, and how many of those make a * whole unit differs per currency: two for USD, none for JPY. The formatter knows the * exponent, so it decides the divisor instead of a hardcoded 100. */ function formatInvoiceAmount(amountInMinorUnits: number, currency: string) { const currencyCode = currency.toUpperCase(); try { const formatter = new Intl.NumberFormat(undefined, { style: 'currency', currency: currencyCode, }); const fractionDigits = formatter.resolvedOptions().maximumFractionDigits ?? 2; return formatter.format(amountInMinorUnits / 10 ** fractionDigits); } catch { return `${(amountInMinorUnits / 100).toFixed(2)} ${currencyCode}`; } } interface NotificationSettings { telegramChatId: string | null; telegramEnabled: boolean; emailEnabled: boolean; onNewVideo: boolean; onNewVersion: boolean; onNewComment: boolean; onNewReply: boolean; onApprovalEvents: boolean; timezone: string; } interface BillingOverview { isEnabled: boolean; isConfigured: boolean; status: 'disabled' | 'ready' | 'misconfigured'; checkoutAvailable: boolean; portalAvailable: boolean; cancelAvailable: boolean; cancelIsImmediate: boolean; needsPaymentFix: boolean; openInvoice: { id: string | null; hostedInvoiceUrl: string | null; amountDue: number; currency: string; attemptCount: number; nextPaymentAttempt: string | null; } | null; subscription: { status: string; label: string; hasActiveSubscription: boolean; hasRecoverableSubscription: boolean; hasActiveTrial: boolean; hasBillingAccess: boolean; isPaid: boolean; priceId: string | null; currentPeriodEnd: string | null; cancelAtPeriodEnd: boolean; cancelAt: string | null; trialEndsAt: string | null; billingAccessEndedAt: string | null; storageCleanupEligibleAt: string | null; }; workspaceCreation: { canCreateWorkspace: boolean; canStartTrial?: boolean; reason: string | null; ownedWorkspaceCount: number; invitedWorkspaceCount: number; }; } interface StorageInfo { usedBytes: string; limitBytes: string; percentage: number; /** False on the free trial, where the way out is subscribing rather than deleting. */ isPaid: boolean; } function formatBytes(bytesStr: string): string { const bytes = Number(bytesStr); if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; } function ToggleButton({ enabled, onToggle, label, description, }: { enabled: boolean; onToggle: () => void; label: string; description?: string; }) { return ( ); } export default function SettingsPage({ billingOnly = false }: { billingOnly?: boolean }) { const [settings, setSettings] = useState({ telegramChatId: null, telegramEnabled: false, emailEnabled: false, onNewVideo: true, onNewVersion: true, onNewComment: true, onNewReply: true, onApprovalEvents: true, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', }); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(null); const [billing, setBilling] = useState(null); const [billingLoading, setBillingLoading] = useState(true); const [billingAction, setBillingAction] = useState< 'checkout' | 'portal' | 'trial' | 'cancel' | null >(null); const [cancelDialogOpen, setCancelDialogOpen] = useState(false); const [storageInfo, setStorageInfo] = useState(null); const [storageLoading, setStorageLoading] = useState(true); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); // Form state for Telegram chat ID (separate from saved settings for editing) const [telegramChatId, setTelegramChatId] = useState(''); const hasScheduledCancellation = Boolean( billing?.subscription.cancelAtPeriodEnd || billing?.subscription.cancelAt ); useEffect(() => { async function fetchSettings() { try { const [settingsRes, billingRes, storageRes] = await Promise.all([ fetch('/api/settings/notifications'), fetch('/api/billing'), fetch('/api/settings/storage'), ]); if (settingsRes.ok) { const data = await settingsRes.json(); setSettings(data.data); setTelegramChatId(data.data.telegramChatId || ''); } if (billingRes.ok) { const data = await billingRes.json(); setBilling(data.data); } if (storageRes.ok) { const data = await storageRes.json(); setStorageInfo(data.data); } } catch { console.error('Failed to fetch settings'); } finally { setLoading(false); setBillingLoading(false); setStorageLoading(false); } } fetchSettings(); }, []); const showMessage = useCallback((type: 'success' | 'error', text: string) => { setMessage({ type, text }); setTimeout(() => setMessage(null), 4000); }, []); const handleSave = useCallback(async () => { setSaving(true); try { const res = await fetch('/api/settings/notifications', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...settings, telegramChatId: telegramChatId || null, }), }); if (res.ok) { const data = await res.json(); setSettings(data.data); showMessage('success', 'Settings saved'); } else { const data = await res.json(); showMessage('error', data.error || 'Failed to save'); } } catch { showMessage('error', 'Failed to save settings'); } finally { setSaving(false); } }, [settings, telegramChatId, showMessage]); const handleTest = useCallback( async (channel: 'telegram' | 'email') => { setTesting(channel); try { const res = await fetch('/api/settings/notifications', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ channel, telegramChatId, }), }); const data = await res.json(); if (res.ok) { showMessage('success', data.data.message); } else { showMessage('error', data.error || 'Test failed'); } } catch { showMessage('error', 'Test failed'); } finally { setTesting(null); } }, [telegramChatId, showMessage] ); const handleBillingRedirect = useCallback( async ( endpoint: '/api/billing/checkout' | '/api/billing/portal', flow?: 'payment_method_update' ) => { setBillingAction(endpoint.endsWith('checkout') ? 'checkout' : 'portal'); try { const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(flow ? { flow } : {}), }); const data = await res.json(); if (!res.ok) { showMessage('error', data.error || 'Failed to open billing flow'); return; } window.location.href = data.data.url; } catch { showMessage('error', 'Failed to open billing flow'); } finally { setBillingAction(null); } }, [showMessage] ); const handleStartTrial = useCallback(async () => { setBillingAction('trial'); try { const res = await fetch('/api/billing/trial', { method: 'POST' }); const data = await res.json(); if (!res.ok) { showMessage('error', data.error || 'Failed to start your free trial'); return; } const billingRes = await fetch('/api/billing'); if (billingRes.ok) { setBilling((await billingRes.json()).data); } showMessage('success', 'Your free trial has started'); } catch { showMessage('error', 'Failed to start your free trial'); } finally { setBillingAction(null); } }, [showMessage]); const handleCancelSubscription = useCallback( async (input: { reason: CancellationReason | null; note: string | null }) => { setBillingAction('cancel'); try { const res = await fetch('/api/billing/cancel', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); const data = await res.json(); if (!res.ok) { showMessage('error', data.error || 'Failed to cancel subscription'); return false; } setCancelDialogOpen(false); const billingRes = await fetch('/api/billing'); if (billingRes.ok) { setBilling((await billingRes.json()).data); } const endsOn = data.data?.periodEnd ? new Date(data.data.periodEnd).toLocaleDateString() : null; showMessage( 'success', data.data?.canceledImmediately ? 'Subscription canceled. Automatic collection has stopped for its open invoices. Charges for prior service may still be owed.' : endsOn ? `Your subscription ends on ${endsOn}. You keep full access until then.` : 'Your subscription ends at the close of the current period.' ); return true; } catch { showMessage('error', 'Failed to cancel subscription'); return false; } finally { setBillingAction(null); } }, [showMessage] ); if (loading) { return (
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 3 }).map((_, j) => (
))}
))}
); } return (

Settings

{billingOnly ? 'Manage your billing access' : 'Manage your notification preferences'}

{/* Status message */} {message && (
{message.type === 'success' ? ( ) : ( )} {message.text}
)} Billing Manage your paid plan and workspace creation access {billingLoading || !billing ? (
) : !billing.isEnabled ? (
Stripe billing is disabled by this host. Workspace creation is unrestricted in this environment.
) : !billing.isConfigured ? (
Stripe is not configured yet. Add your Stripe environment variables before using billing.
) : ( <> {!billing.subscription.hasActiveSubscription && billing.subscription.hasActiveTrial && billing.subscription.trialEndsAt ? (

Your free trial runs until{' '} {new Date(billing.subscription.trialEndsAt).toLocaleDateString()}

Every feature is on and no card is on file. The trial covers one workspace and one project. Subscribing starts your paid month straight away, so there is no reason to do it before you are ready.

) : null}

Current plan

{billing.subscription.hasActiveSubscription ? hasScheduledCancellation ? billing.subscription.status === 'TRIALING' ? 'Trial canceled. Access remains active until the trial ends.' : 'Subscription canceled. Access remains active until the end of the current billing period.' : 'Paid account with workspace creation unlocked.' : billing.subscription.hasActiveTrial ? 'Free trial, no card required.' : billing.subscription.hasBillingAccess ? 'Workspace access remains available while you resolve your payment.' : 'Billing access has ended.'}

{billing.subscription.label}
{billing.subscription.hasRecoverableSubscription && !billing.subscription.hasActiveSubscription ? (

Your latest payment didn't go through. Update your payment method to keep your subscription. Starting a new one would create a duplicate.

) : null} {billing.subscription.status === 'TRIALING' && billing.subscription.trialEndsAt && hasScheduledCancellation ? (

Access ends on {new Date(billing.subscription.trialEndsAt).toLocaleDateString()}.

) : null} {billing.subscription.currentPeriodEnd ? (

{hasScheduledCancellation ? 'Your subscription ends on ' : 'Current billing period ends on '} {new Date(billing.subscription.currentPeriodEnd).toLocaleDateString()}.

) : null} {hasScheduledCancellation && billing.subscription.cancelAt ? (

Cancellation takes effect on{' '} {new Date(billing.subscription.cancelAt).toLocaleDateString()}.

) : null} {/* Deliberately not conditioned on `billingAccessEndedAt`: an account that only ever had the cardless trial never gets one written, and it is exactly that account which most needs to be told its work is still recoverable. */} {!billing.subscription.hasBillingAccess && billing.subscription.storageCleanupEligibleAt ? (

Nothing has been deleted. Your projects and media are kept until{' '} {new Date(billing.subscription.storageCleanupEligibleAt).toLocaleDateString()}; subscribe before then and everything is where you left it.

) : null} {!billing.workspaceCreation.canCreateWorkspace ? (

Workspace creation

{billing.workspaceCreation.reason || 'Upgrade to create another workspace.'}

) : null} {billing.openInvoice ? (

A payment of{' '} {formatInvoiceAmount( billing.openInvoice.amountDue, billing.openInvoice.currency )}{' '} did not go through

{billing.openInvoice.attemptCount} attempt {billing.openInvoice.attemptCount === 1 ? '' : 's'} so far {billing.openInvoice.nextPaymentAttempt ? `, next one on ${new Date(billing.openInvoice.nextPaymentAttempt).toLocaleDateString()}` : ''} . Update your payment method or pay the invoice to stop the retries, or cancel to stop them for good.

{billing.subscription.billingAccessEndedAt ? (

{new Date(billing.subscription.billingAccessEndedAt) > new Date() ? `Access to your workspaces continues until ${new Date(billing.subscription.billingAccessEndedAt).toLocaleDateString()}.` : `Access to your workspaces ended on ${new Date(billing.subscription.billingAccessEndedAt).toLocaleDateString()}. Paying this invoice restores it.`}

) : null} {billing.openInvoice.hostedInvoiceUrl ? ( View and pay this invoice ) : null}
) : null}
{billing.subscription.hasRecoverableSubscription && billing.portalAvailable ? ( ) : null} {/* Beside the portal button, not inside it. Someone who came to cancel should not have to guess that "Manage" is the way, and the portal cannot ask why they are leaving. */} {billing.cancelAvailable ? ( ) : null} {billing.subscription.hasRecoverableSubscription && billing.portalAvailable ? null : ( <> {billing.workspaceCreation.canStartTrial ? ( ) : null} )}
)}
{billing ? ( ) : null} {billing?.subscription.hasBillingAccess && ( Storage Combined usage across video files and media attachments {storageInfo ? ` (${formatBytes(storageInfo.limitBytes)} limit)` : ''} {storageLoading || !storageInfo ? (
) : ( <>
{formatBytes(storageInfo.usedBytes)} used of{' '} {formatBytes(storageInfo.limitBytes)} = 90 ? 'text-destructive font-medium' : storageInfo.percentage >= 75 ? 'text-amber-600 dark:text-amber-400 font-medium' : 'text-muted-foreground' } > {storageInfo.percentage < 0.1 ? '<0.1%' : `${storageInfo.percentage.toFixed(1)}%`}
= 90 ? '[&>div]:bg-destructive' : storageInfo.percentage >= 75 ? '[&>div]:bg-amber-500' : '' } /> {storageInfo.percentage >= 90 && (storageInfo.isPaid ? (

Storage is almost full. Delete unused files or contact support.

) : (

Your free trial storage is almost full. Subscribe above for more room, or delete unused files.

))} )}
)} {!billingOnly && ( <> {/* Event Subscriptions */} Notification Events Choose which events trigger notifications setSettings((s) => ({ ...s, onNewVideo: !s.onNewVideo }))} label="New Video Added" description="When a new video is added to one of your projects" /> setSettings((s) => ({ ...s, onNewVersion: !s.onNewVersion }))} label="New Version Added" description="When a new version is added to an existing video" /> setSettings((s) => ({ ...s, onNewComment: !s.onNewComment }))} label="New Comment" description="When someone leaves a comment on your videos" /> setSettings((s) => ({ ...s, onNewReply: !s.onNewReply }))} label="New Reply" description="When someone replies to a comment thread" /> setSettings((s) => ({ ...s, onApprovalEvents: !s.onApprovalEvents })) } label="Approval Workflow" description="When approval requests are created, responded to, or finalized" /> {/* Telegram */}
Telegram {settings.telegramEnabled ? 'Enabled' : 'Disabled'}
Get instant notifications via Telegram

Setup instructions

  1. Message{' '} @UserInfeBot {' '} on Telegram and send{' '} /start to get your Chat ID
  2. Start{' '} @openframe_bot {' '} and send /start so it can message you
  3. Paste your Chat ID below and enable notifications
setTelegramChatId(e.target.value)} className="mt-1 font-mono text-sm" />
setSettings((s) => ({ ...s, telegramEnabled: !s.telegramEnabled }))} label="Enable Telegram notifications" />
{/* Email */}
Email {settings.emailEnabled ? 'Enabled' : 'Disabled'}
Receive notification emails to your account email address
setSettings((s) => ({ ...s, emailEnabled: !s.emailEnabled }))} label="Enable email notifications" />
{/* Timezone */} Timezone Timestamps in notifications will use this timezone {/* Save button */}
)}
); }