mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
The Stripe client was built without an apiVersion, so the SDK followed whatever version it shipped with. Two fields moved in the Basil API version: the billing period went from the subscription onto its items, and the invoice link to its subscription went under parent.subscription_details. Both reads returned undefined without failing, which left stripeCurrentPeriodEnd null for every subscriber and left the app with no invoice handling at all. A customer whose card failed saw nothing about the invoice that was still retrying, and a cancellation did nothing to stop those retries. - Pin the API version, with `satisfies` so an SDK bump is a compile error here before it is a null read in production. - Read the period off subscription items and the subscription off invoice parents, keeping the legacy fields as a fallback for older payloads. - Handle invoice.paid, invoice.payment_failed, invoice.voided and invoice.marked_uncollectible through the existing customer-wide resync, so the mirror reflects payment health during dunning rather than after it. - Add an in-app cancellation route: at period end when the subscription is paid, immediately plus voiding the open invoices when it is not, because cancelling alone does not stop collection on an invoice already issued. - Ask Stripe, not just the local mirror, before opening checkout. - Show the open invoice, the retry date and a payment-method-update shortcut in settings, and put a confirmation in front of cancellation. Access no longer rests on the reported period alone. Stripe advances the period when it issues the renewal invoice, paid or not, and the period survives cancellation, so once the period field started being read correctly that check would have handed a full free month to anyone whose renewal failed, and the new cancel route would have let them void the invoice and keep the month. Access now follows the subscription status, billingAccessEndedAt is enforced as a hard cutoff in both hasBillingAccess and the query that mirrors it, and a subscription behind on payment keeps access for Stripe's retry window rather than for the period it never paid for.
1039 lines
40 KiB
TypeScript
1039 lines
40 KiB
TypeScript
'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 {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from '@/components/ui/alert-dialog';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
/**
|
|
* 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;
|
|
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 (
|
|
<button
|
|
type="button"
|
|
onClick={onToggle}
|
|
className={cn(
|
|
'flex items-center justify-between w-full p-3 rounded-lg border transition-colors text-left',
|
|
enabled ? 'border-primary/50 bg-primary/5' : 'border-border hover:bg-accent/50'
|
|
)}
|
|
>
|
|
<div className="flex-1 min-w-0 pr-4">
|
|
<span className="text-sm font-medium">{label}</span>
|
|
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
'w-10 h-6 shrink-0 rounded-full relative transition-colors',
|
|
enabled ? 'bg-primary' : 'bg-muted'
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
'absolute top-1 w-4 h-4 rounded-full bg-white transition-transform',
|
|
enabled ? 'translate-x-5' : 'translate-x-1'
|
|
)}
|
|
/>
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export default function SettingsPage({ billingOnly = false }: { billingOnly?: boolean }) {
|
|
const [settings, setSettings] = useState<NotificationSettings>({
|
|
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<string | null>(null);
|
|
const [billing, setBilling] = useState<BillingOverview | null>(null);
|
|
const [billingLoading, setBillingLoading] = useState(true);
|
|
const [billingAction, setBillingAction] = useState<
|
|
'checkout' | 'portal' | 'trial' | 'cancel' | null
|
|
>(null);
|
|
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(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 () => {
|
|
setBillingAction('cancel');
|
|
try {
|
|
const res = await fetch('/api/billing/cancel', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
const data = await res.json();
|
|
|
|
if (!res.ok) {
|
|
showMessage('error', data.error || 'Failed to cancel subscription');
|
|
return;
|
|
}
|
|
|
|
const billingRes = await fetch('/api/billing');
|
|
if (billingRes.ok) {
|
|
setBilling((await billingRes.json()).data);
|
|
}
|
|
|
|
showMessage(
|
|
'success',
|
|
data.data.canceledImmediately
|
|
? 'Subscription canceled. No further payment will be attempted.'
|
|
: 'Subscription canceled. Access remains until the end of the current billing period.'
|
|
);
|
|
} catch {
|
|
showMessage('error', 'Failed to cancel subscription');
|
|
} finally {
|
|
setBillingAction(null);
|
|
}
|
|
}, [showMessage]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="max-w-2xl mx-auto py-8 px-4 space-y-6">
|
|
<div>
|
|
<Skeleton className="h-9 w-56" />
|
|
<Skeleton className="h-4 w-80 mt-2" />
|
|
</div>
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<Card key={i}>
|
|
<CardHeader>
|
|
<Skeleton className="h-5 w-40" />
|
|
<Skeleton className="h-4 w-64 mt-1" />
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{Array.from({ length: 3 }).map((_, j) => (
|
|
<div key={j} className="flex items-center justify-between">
|
|
<div className="space-y-1">
|
|
<Skeleton className="h-4 w-32" />
|
|
<Skeleton className="h-3 w-48" />
|
|
</div>
|
|
<Skeleton className="h-5 w-10 rounded-full" />
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
<div className="flex justify-end">
|
|
<Skeleton className="h-10 w-32 rounded-md" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-2xl mx-auto py-8 px-4">
|
|
<div className="mb-8">
|
|
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
|
|
<p className="text-muted-foreground mt-1">
|
|
{billingOnly ? 'Manage your billing access' : 'Manage your notification preferences'}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Status message */}
|
|
{message && (
|
|
<div
|
|
className={cn(
|
|
'flex items-center gap-2 p-3 rounded-lg mb-6 text-sm',
|
|
message.type === 'success'
|
|
? 'bg-green-500/10 text-green-700 dark:text-green-400'
|
|
: 'bg-destructive/10 text-destructive'
|
|
)}
|
|
>
|
|
{message.type === 'success' ? (
|
|
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
|
) : (
|
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
|
)}
|
|
{message.text}
|
|
</div>
|
|
)}
|
|
|
|
<Card className="mb-6">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<CreditCard className="h-5 w-5" />
|
|
Billing
|
|
</CardTitle>
|
|
<CardDescription>Manage your paid plan and workspace creation access</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{billingLoading || !billing ? (
|
|
<div className="space-y-3">
|
|
<Skeleton className="h-4 w-40" />
|
|
<Skeleton className="h-4 w-full" />
|
|
<Skeleton className="h-10 w-44 rounded-md" />
|
|
</div>
|
|
) : !billing.isEnabled ? (
|
|
<div className="rounded-md border border-muted bg-muted/40 p-4 text-sm text-muted-foreground">
|
|
Stripe billing is disabled by this host. Workspace creation is unrestricted in this
|
|
environment.
|
|
</div>
|
|
) : !billing.isConfigured ? (
|
|
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-700 dark:text-amber-400">
|
|
Stripe is not configured yet. Add your Stripe environment variables before using
|
|
billing.
|
|
</div>
|
|
) : (
|
|
<>
|
|
{!billing.subscription.hasActiveSubscription &&
|
|
billing.subscription.hasActiveTrial &&
|
|
billing.subscription.trialEndsAt ? (
|
|
<div className="rounded-md border border-primary/30 bg-primary/5 p-4 space-y-2">
|
|
<p className="text-sm font-semibold">
|
|
Your free trial runs until{' '}
|
|
{new Date(billing.subscription.trialEndsAt).toLocaleDateString()}
|
|
</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
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.
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
|
<div>
|
|
<p className="text-sm font-medium">Current plan</p>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
{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.'}
|
|
</p>
|
|
</div>
|
|
<Badge
|
|
variant={billing.subscription.hasActiveSubscription ? 'default' : 'secondary'}
|
|
>
|
|
{billing.subscription.label}
|
|
</Badge>
|
|
</div>
|
|
|
|
{billing.subscription.hasRecoverableSubscription &&
|
|
!billing.subscription.hasActiveSubscription ? (
|
|
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive">
|
|
Your latest payment didn't go through. Update your payment method to keep
|
|
your subscription — starting a new one would create a duplicate.
|
|
</p>
|
|
) : null}
|
|
|
|
{billing.subscription.hasActiveTrial &&
|
|
billing.subscription.trialEndsAt &&
|
|
hasScheduledCancellation ? (
|
|
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive">
|
|
Access ends on {new Date(billing.subscription.trialEndsAt).toLocaleDateString()}.
|
|
</p>
|
|
) : null}
|
|
|
|
{billing.subscription.currentPeriodEnd ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
{hasScheduledCancellation
|
|
? 'Your subscription ends on '
|
|
: 'Current billing period ends on '}
|
|
{new Date(billing.subscription.currentPeriodEnd).toLocaleDateString()}.
|
|
</p>
|
|
) : null}
|
|
|
|
{hasScheduledCancellation && billing.subscription.cancelAt ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
Cancellation was scheduled on{' '}
|
|
{new Date(billing.subscription.cancelAt).toLocaleDateString()}.
|
|
</p>
|
|
) : 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 ? (
|
|
<p className="text-sm text-amber-700 dark:text-amber-400">
|
|
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.
|
|
</p>
|
|
) : null}
|
|
|
|
{!billing.workspaceCreation.canCreateWorkspace ? (
|
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-2">
|
|
<p className="text-sm font-medium">Workspace creation</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{billing.workspaceCreation.reason || 'Upgrade to create another workspace.'}
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
|
|
{billing.openInvoice ? (
|
|
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-4 space-y-2">
|
|
<p className="text-sm font-semibold text-destructive">
|
|
A payment of{' '}
|
|
{formatInvoiceAmount(
|
|
billing.openInvoice.amountDue,
|
|
billing.openInvoice.currency
|
|
)}{' '}
|
|
did not go through
|
|
</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{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.
|
|
</p>
|
|
{billing.subscription.billingAccessEndedAt ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
Access to your workspaces continues until{' '}
|
|
{new Date(billing.subscription.billingAccessEndedAt).toLocaleDateString()}.
|
|
</p>
|
|
) : null}
|
|
{billing.openInvoice.hostedInvoiceUrl ? (
|
|
<a
|
|
href={billing.openInvoice.hostedInvoiceUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="inline-block text-sm font-medium text-primary hover:underline"
|
|
>
|
|
View and pay this invoice
|
|
</a>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="flex flex-col sm:flex-row gap-3">
|
|
{billing.subscription.hasRecoverableSubscription && billing.portalAvailable ? (
|
|
<Button
|
|
onClick={() =>
|
|
handleBillingRedirect(
|
|
'/api/billing/portal',
|
|
billing.needsPaymentFix ? 'payment_method_update' : undefined
|
|
)
|
|
}
|
|
disabled={billingAction !== null}
|
|
>
|
|
{billingAction === 'portal' ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
Opening Portal...
|
|
</>
|
|
) : billing.subscription.hasActiveSubscription ? (
|
|
'Manage Subscription'
|
|
) : (
|
|
'Update Payment Method'
|
|
)}
|
|
</Button>
|
|
) : (
|
|
<>
|
|
{billing.workspaceCreation.canStartTrial ? (
|
|
<Button onClick={handleStartTrial} disabled={billingAction !== null}>
|
|
{billingAction === 'trial' ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
Starting Trial...
|
|
</>
|
|
) : (
|
|
'Start Free Trial'
|
|
)}
|
|
</Button>
|
|
) : null}
|
|
<Button
|
|
variant={billing.workspaceCreation.canStartTrial ? 'outline' : 'default'}
|
|
onClick={() => handleBillingRedirect('/api/billing/checkout')}
|
|
disabled={!billing.checkoutAvailable || billingAction !== null}
|
|
>
|
|
{billingAction === 'checkout' ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
Redirecting...
|
|
</>
|
|
) : (
|
|
'Upgrade with Stripe'
|
|
)}
|
|
</Button>
|
|
</>
|
|
)}
|
|
|
|
{billing.cancelAvailable ? (
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
disabled={billingAction !== null}
|
|
className="text-destructive hover:text-destructive"
|
|
>
|
|
{billingAction === 'cancel' ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
Canceling...
|
|
</>
|
|
) : (
|
|
'Cancel Subscription'
|
|
)}
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Cancel your subscription?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{billing.needsPaymentFix
|
|
? 'Your subscription ends right away and the unpaid invoice is canceled, so no further payment is attempted. This cannot be undone: getting the subscription back means going through checkout again.'
|
|
: 'Your subscription stays active until the end of the current billing period and is not renewed after that. This cannot be undone from here.'}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Keep Subscription</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleCancelSubscription}
|
|
disabled={billingAction !== null}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
Cancel Subscription
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
) : null}
|
|
</div>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{billing?.subscription.hasBillingAccess && (
|
|
<Card className="mb-6">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<HardDrive className="h-5 w-5" />
|
|
Storage
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Combined usage across video files and media attachments
|
|
{storageInfo ? ` (${formatBytes(storageInfo.limitBytes)} limit)` : ''}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
{storageLoading || !storageInfo ? (
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-4 w-48" />
|
|
<Skeleton className="h-2 w-full rounded-full" />
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-muted-foreground">
|
|
{formatBytes(storageInfo.usedBytes)} used of{' '}
|
|
{formatBytes(storageInfo.limitBytes)}
|
|
</span>
|
|
<span
|
|
className={
|
|
storageInfo.percentage >= 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)}%`}
|
|
</span>
|
|
</div>
|
|
<Progress
|
|
value={storageInfo.percentage}
|
|
className={
|
|
storageInfo.percentage >= 90
|
|
? '[&>div]:bg-destructive'
|
|
: storageInfo.percentage >= 75
|
|
? '[&>div]:bg-amber-500'
|
|
: ''
|
|
}
|
|
/>
|
|
{storageInfo.percentage >= 90 &&
|
|
(storageInfo.isPaid ? (
|
|
<p className="text-xs text-destructive">
|
|
Storage is almost full. Delete unused files or contact support.
|
|
</p>
|
|
) : (
|
|
<p className="text-xs text-destructive">
|
|
Your free trial storage is almost full. Subscribe above for more room, or
|
|
delete unused files.
|
|
</p>
|
|
))}
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{!billingOnly && (
|
|
<>
|
|
{/* Event Subscriptions */}
|
|
<Card className="mb-6">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Bell className="h-5 w-5" />
|
|
Notification Events
|
|
</CardTitle>
|
|
<CardDescription>Choose which events trigger notifications</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<ToggleButton
|
|
enabled={settings.onNewVideo}
|
|
onToggle={() => setSettings((s) => ({ ...s, onNewVideo: !s.onNewVideo }))}
|
|
label="New Video Added"
|
|
description="When a new video is added to one of your projects"
|
|
/>
|
|
<ToggleButton
|
|
enabled={settings.onNewVersion}
|
|
onToggle={() => setSettings((s) => ({ ...s, onNewVersion: !s.onNewVersion }))}
|
|
label="New Version Added"
|
|
description="When a new version is added to an existing video"
|
|
/>
|
|
<ToggleButton
|
|
enabled={settings.onNewComment}
|
|
onToggle={() => setSettings((s) => ({ ...s, onNewComment: !s.onNewComment }))}
|
|
label="New Comment"
|
|
description="When someone leaves a comment on your videos"
|
|
/>
|
|
<ToggleButton
|
|
enabled={settings.onNewReply}
|
|
onToggle={() => setSettings((s) => ({ ...s, onNewReply: !s.onNewReply }))}
|
|
label="New Reply"
|
|
description="When someone replies to a comment thread"
|
|
/>
|
|
<ToggleButton
|
|
enabled={settings.onApprovalEvents}
|
|
onToggle={() =>
|
|
setSettings((s) => ({ ...s, onApprovalEvents: !s.onApprovalEvents }))
|
|
}
|
|
label="Approval Workflow"
|
|
description="When approval requests are created, responded to, or finalized"
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Telegram */}
|
|
<Card className="mb-6">
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Send className="h-5 w-5" />
|
|
Telegram
|
|
</CardTitle>
|
|
<Badge variant={settings.telegramEnabled ? 'default' : 'secondary'}>
|
|
{settings.telegramEnabled ? 'Enabled' : 'Disabled'}
|
|
</Badge>
|
|
</div>
|
|
<CardDescription>Get instant notifications via Telegram</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="rounded-md border bg-muted/40 p-3 space-y-2 text-sm text-muted-foreground">
|
|
<p className="font-medium text-foreground">Setup instructions</p>
|
|
<ol className="space-y-1.5 list-decimal list-inside">
|
|
<li>
|
|
Message{' '}
|
|
<a
|
|
href="https://t.me/UserInfeBot"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-primary underline underline-offset-2"
|
|
>
|
|
@UserInfeBot
|
|
</a>{' '}
|
|
on Telegram and send{' '}
|
|
<code className="bg-muted px-1 rounded text-xs">/start</code> to get your Chat
|
|
ID
|
|
</li>
|
|
<li>
|
|
Start{' '}
|
|
<a
|
|
href="https://t.me/openframe_bot"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-primary underline underline-offset-2"
|
|
>
|
|
@openframe_bot
|
|
</a>{' '}
|
|
and send <code className="bg-muted px-1 rounded text-xs">/start</code> so it can
|
|
message you
|
|
</li>
|
|
<li>Paste your Chat ID below and enable notifications</li>
|
|
</ol>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="telegram-chat-id">Your Chat ID</Label>
|
|
<Input
|
|
id="telegram-chat-id"
|
|
placeholder="123456789"
|
|
value={telegramChatId}
|
|
onChange={(e) => setTelegramChatId(e.target.value)}
|
|
className="mt-1 font-mono text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<ToggleButton
|
|
enabled={settings.telegramEnabled}
|
|
onToggle={() => setSettings((s) => ({ ...s, telegramEnabled: !s.telegramEnabled }))}
|
|
label="Enable Telegram notifications"
|
|
/>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleTest('telegram')}
|
|
disabled={!telegramChatId || testing === 'telegram'}
|
|
>
|
|
{testing === 'telegram' ? (
|
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
) : (
|
|
<Send className="h-4 w-4 mr-2" />
|
|
)}
|
|
Send Test Message
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Email */}
|
|
<Card className="mb-6">
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Mail className="h-5 w-5" />
|
|
Email
|
|
</CardTitle>
|
|
<Badge variant={settings.emailEnabled ? 'default' : 'secondary'}>
|
|
{settings.emailEnabled ? 'Enabled' : 'Disabled'}
|
|
</Badge>
|
|
</div>
|
|
<CardDescription>
|
|
Receive notification emails to your account email address
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<ToggleButton
|
|
enabled={settings.emailEnabled}
|
|
onToggle={() => setSettings((s) => ({ ...s, emailEnabled: !s.emailEnabled }))}
|
|
label="Enable email notifications"
|
|
/>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleTest('email')}
|
|
disabled={!settings.emailEnabled || testing === 'email'}
|
|
>
|
|
{testing === 'email' ? (
|
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
) : (
|
|
<Mail className="h-4 w-4 mr-2" />
|
|
)}
|
|
Send Test Email
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Timezone */}
|
|
<Card className="mb-6">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Globe className="h-5 w-5" />
|
|
Timezone
|
|
</CardTitle>
|
|
<CardDescription>Timestamps in notifications will use this timezone</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Select
|
|
value={settings.timezone}
|
|
onValueChange={(value) => setSettings((s) => ({ ...s, timezone: value }))}
|
|
>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="Select timezone" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectLabel>Americas</SelectLabel>
|
|
<SelectItem value="America/New_York">Eastern Time (New York)</SelectItem>
|
|
<SelectItem value="America/Chicago">Central Time (Chicago)</SelectItem>
|
|
<SelectItem value="America/Denver">Mountain Time (Denver)</SelectItem>
|
|
<SelectItem value="America/Los_Angeles">Pacific Time (Los Angeles)</SelectItem>
|
|
<SelectItem value="America/Anchorage">Alaska (Anchorage)</SelectItem>
|
|
<SelectItem value="Pacific/Honolulu">Hawaii (Honolulu)</SelectItem>
|
|
<SelectItem value="America/Toronto">Toronto</SelectItem>
|
|
<SelectItem value="America/Vancouver">Vancouver</SelectItem>
|
|
<SelectItem value="America/Mexico_City">Mexico City</SelectItem>
|
|
<SelectItem value="America/Sao_Paulo">São Paulo</SelectItem>
|
|
<SelectItem value="America/Argentina/Buenos_Aires">Buenos Aires</SelectItem>
|
|
<SelectItem value="America/Bogota">Bogotá</SelectItem>
|
|
</SelectGroup>
|
|
<SelectGroup>
|
|
<SelectLabel>Europe</SelectLabel>
|
|
<SelectItem value="Europe/London">London (GMT/BST)</SelectItem>
|
|
<SelectItem value="Europe/Paris">Paris (CET)</SelectItem>
|
|
<SelectItem value="Europe/Berlin">Berlin (CET)</SelectItem>
|
|
<SelectItem value="Europe/Amsterdam">Amsterdam (CET)</SelectItem>
|
|
<SelectItem value="Europe/Madrid">Madrid (CET)</SelectItem>
|
|
<SelectItem value="Europe/Rome">Rome (CET)</SelectItem>
|
|
<SelectItem value="Europe/Zurich">Zurich (CET)</SelectItem>
|
|
<SelectItem value="Europe/Stockholm">Stockholm (CET)</SelectItem>
|
|
<SelectItem value="Europe/Helsinki">Helsinki (EET)</SelectItem>
|
|
<SelectItem value="Europe/Athens">Athens (EET)</SelectItem>
|
|
<SelectItem value="Europe/Istanbul">Istanbul (TRT)</SelectItem>
|
|
<SelectItem value="Europe/Moscow">Moscow (MSK)</SelectItem>
|
|
<SelectItem value="Europe/Kiev">Kyiv (EET)</SelectItem>
|
|
<SelectItem value="Europe/Warsaw">Warsaw (CET)</SelectItem>
|
|
</SelectGroup>
|
|
<SelectGroup>
|
|
<SelectLabel>Asia & Pacific</SelectLabel>
|
|
<SelectItem value="Asia/Dubai">Dubai (GST)</SelectItem>
|
|
<SelectItem value="Asia/Kolkata">India (IST)</SelectItem>
|
|
<SelectItem value="Asia/Bangkok">Bangkok (ICT)</SelectItem>
|
|
<SelectItem value="Asia/Singapore">Singapore (SGT)</SelectItem>
|
|
<SelectItem value="Asia/Hong_Kong">Hong Kong (HKT)</SelectItem>
|
|
<SelectItem value="Asia/Shanghai">Shanghai (CST)</SelectItem>
|
|
<SelectItem value="Asia/Tokyo">Tokyo (JST)</SelectItem>
|
|
<SelectItem value="Asia/Seoul">Seoul (KST)</SelectItem>
|
|
<SelectItem value="Asia/Taipei">Taipei (CST)</SelectItem>
|
|
<SelectItem value="Asia/Jakarta">Jakarta (WIB)</SelectItem>
|
|
<SelectItem value="Australia/Sydney">Sydney (AEST)</SelectItem>
|
|
<SelectItem value="Australia/Melbourne">Melbourne (AEST)</SelectItem>
|
|
<SelectItem value="Australia/Perth">Perth (AWST)</SelectItem>
|
|
<SelectItem value="Pacific/Auckland">Auckland (NZST)</SelectItem>
|
|
</SelectGroup>
|
|
<SelectGroup>
|
|
<SelectLabel>Africa & Middle East</SelectLabel>
|
|
<SelectItem value="Africa/Cairo">Cairo (EET)</SelectItem>
|
|
<SelectItem value="Africa/Lagos">Lagos (WAT)</SelectItem>
|
|
<SelectItem value="Africa/Johannesburg">Johannesburg (SAST)</SelectItem>
|
|
<SelectItem value="Africa/Nairobi">Nairobi (EAT)</SelectItem>
|
|
<SelectItem value="Asia/Riyadh">Riyadh (AST)</SelectItem>
|
|
<SelectItem value="Asia/Tehran">Tehran (IRST)</SelectItem>
|
|
</SelectGroup>
|
|
<SelectGroup>
|
|
<SelectLabel>Other</SelectLabel>
|
|
<SelectItem value="UTC">UTC</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Separator className="my-6" />
|
|
|
|
{/* Save button */}
|
|
<div className="flex justify-end">
|
|
<Button onClick={handleSave} disabled={saving}>
|
|
{saving ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
Saving...
|
|
</>
|
|
) : (
|
|
'Save Settings'
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|