'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'; 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; 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') => { setBillingAction(endpoint.endsWith('checkout') ? 'checkout' : 'portal'); try { const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); 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', 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.hasActiveTrial ? '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 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.hasActiveTrial && 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 was scheduled 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.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.subscription.hasActiveSubscription && billing.portalAvailable && !hasScheduledCancellation ? ( ) : 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 */}
)}
); }