diff --git a/app/(dashboard)/settings/settings-page-client.tsx b/app/(dashboard)/settings/settings-page-client.tsx index 149ae91..96cf3ff 100644 --- a/app/(dashboard)/settings/settings-page-client.tsx +++ b/app/(dashboard)/settings/settings-page-client.tsx @@ -29,18 +29,9 @@ import { 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'; +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 @@ -193,6 +184,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo 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); @@ -350,37 +342,48 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo } }, [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(); + 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; + 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); } - - 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]); + }, + [showMessage] + ); if (loading) { return ( @@ -498,7 +501,9 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo : 'Paid account with workspace creation unlocked.' : billing.subscription.hasActiveTrial ? 'Free trial, no card required.' - : 'Billing access has ended.'} + : billing.subscription.hasBillingAccess + ? 'Workspace access remains available while you resolve your payment.' + : 'Billing access has ended.'}

- ) : ( + ) : 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 ? ( )} - - {billing.cancelAvailable ? ( - - - - - - - Cancel your subscription? - - {billing.cancelIsImmediate - ? '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.'} - - - - Keep Subscription - - Cancel Subscription - - - - - ) : null} )} + {billing ? ( + + ) : null} + {billing?.subscription.hasBillingAccess && ( diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 61551ca..e4987fa 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,4 +1,5 @@ import { Metadata } from 'next'; +import { Suspense } from 'react'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; import { isBunnyUploadsFeatureEnabled, isStripeBillingEnabled } from '@/lib/feature-flags'; @@ -10,6 +11,7 @@ import { } from '@/lib/admin-stats'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { RefreshR2StatsButton } from '@/components/admin/refresh-r2-stats-button'; +import { CancellationReasonsCard } from '@/components/admin/cancellation-reasons-card'; import { Users, Folder, @@ -289,6 +291,14 @@ export default async function AdminDashboardPage() { )} + + {/* Outside the `stripeStats` guard on purpose: the answers live in our own + table and must stay readable while a Stripe outage blanks the cards above. */} + {isStripeBillingEnabled() && ( + + + + )} ); } diff --git a/app/api/billing/cancel/route.ts b/app/api/billing/cancel/route.ts index a1b6a05..5aba713 100644 --- a/app/api/billing/cancel/route.ts +++ b/app/api/billing/cancel/route.ts @@ -2,18 +2,25 @@ import { NextRequest } from 'next/server'; import { auth } from '@/lib/auth'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { - findLiveStripeSubscription, - getBillingOverview, - isUnpaidStripeSubscription, - syncStripeCustomerSubscriptions, - voidOpenSubscriptionInvoices, -} from '@/lib/billing'; -import { rateLimit } from '@/lib/rate-limit'; + CANCELLATION_NOTE_MAX_LENGTH, + cancelSubscription, + isCancellationReason, +} from '@/lib/cancellation'; +import { RATE_LIMIT_CONFIGS, checkRateLimit, rateLimit, rateLimitHeaders } from '@/lib/rate-limit'; import { isStripeFeatureEnabled } from '@/lib/feature-flags'; -import { getStripe, isStripeConfigured } from '@/lib/stripe'; +import { isStripeConfigured } from '@/lib/stripe'; import { isTrustedSameOriginRequest } from '@/lib/request-origin'; import { logError } from '@/lib/logger'; +/** + * In-app cancellation: end unpaid subscriptions immediately, schedule paid + * subscriptions for period end, and record the optional reason. + * + * This exists beside the Stripe portal rather than instead of it. The portal + * cannot ask a question of our own, and by the time its webhook arrives the + * customer has already left the page. Both fields are optional: skipping the + * question is allowed and must never stand between someone and cancelling. + */ export async function POST(request: NextRequest) { try { const limited = await rateLimit(request, 'mutate'); @@ -28,6 +35,22 @@ export async function POST(request: NextRequest) { return apiErrors.unauthorized(); } + // A second limit keyed on the account. The IP-keyed one above is shared by + // every mutating route and, without TRUSTED_PROXY_MODE, by every caller, + // so it is the wrong thing to lean on for the one action a leaving + // customer most needs to succeed. + const config = RATE_LIMIT_CONFIGS['billing-cancel']; + const limit = await checkRateLimit(session.user.id, 'billing-cancel', config); + if (!limit.allowed) { + return new Response(JSON.stringify({ error: 'Too many requests. Please try again later.' }), { + status: 429, + headers: { + 'Content-Type': 'application/json', + ...rateLimitHeaders(limit, config.maxRequests), + }, + }); + } + if (!isStripeFeatureEnabled()) { return apiErrors.badRequest('Stripe billing is disabled by this host'); } @@ -36,46 +59,55 @@ export async function POST(request: NextRequest) { return apiErrors.internalError('Stripe billing is not configured'); } - const billing = await getBillingOverview(session.user.id); - const customerId = billing.subscription.stripeCustomerId; - if (!customerId) { - return apiErrors.badRequest('No Stripe customer exists for this account'); + const body = await request.json().catch(() => null); + const rawReason = body?.reason ?? null; + if (rawReason !== null && !isCancellationReason(rawReason)) { + return apiErrors.badRequest('Unknown cancellation reason'); } - const subscription = await findLiveStripeSubscription(customerId); - if (!subscription) { - return apiErrors.badRequest('No subscription to cancel'); + const rawNote = body?.note; + if (rawNote !== undefined && rawNote !== null && typeof rawNote !== 'string') { + return apiErrors.badRequest('Note must be text'); + } + const trimmedNote = typeof rawNote === 'string' ? rawNote.trim() : ''; + if (trimmedNote.length > CANCELLATION_NOTE_MAX_LENGTH) { + return apiErrors.badRequest( + `Note must be at most ${CANCELLATION_NOTE_MAX_LENGTH} characters` + ); } - const stripe = getStripe(); - const unpaid = isUnpaidStripeSubscription(subscription); + const result = await cancelSubscription({ + userId: session.user.id, + reason: rawReason, + note: trimmedNote.length > 0 ? trimmedNote : null, + }); - // Scheduling an unpaid subscription to the end of its period leaves the customer - // owing money for a period they never paid for, while the already issued invoice - // keeps retrying their card on its own. Those cancel immediately instead, and the - // invoice for the unserved period is voided in the same pass. - const canceled = unpaid - ? await stripe.subscriptions.cancel(subscription.id) - : await stripe.subscriptions.update(subscription.id, { cancel_at_period_end: true }); - - const voidedInvoices = unpaid - ? await voidOpenSubscriptionInvoices(customerId, subscription.id) - : []; - - // Re-derived from the customer's whole set rather than written from `canceled` alone. - // A customer can hold more than one subscription, and mirroring just the one that was - // cancelled would lock out an account still being billed on another. - await syncStripeCustomerSubscriptions(customerId); + if (!result.ok) { + switch (result.code) { + case 'ALREADY_CANCELING': + return apiErrors.conflict( + 'Your subscription is already set to end at the close of this period' + ); + case 'STRIPE_REJECTED': + return apiErrors.conflict( + 'Stripe could not find this subscription. Open Manage Subscription to see its current state.' + ); + default: + return apiErrors.conflict('There is no active subscription to cancel'); + } + } const response = successResponse({ - canceledImmediately: unpaid, - status: canceled.status, - cancelAt: canceled.cancel_at ? new Date(canceled.cancel_at * 1000).toISOString() : null, - voidedInvoices, + cancelAtPeriodEnd: !result.canceledImmediately, + canceledImmediately: result.canceledImmediately, + status: result.status, + cancelAt: result.cancelAt?.toISOString() ?? null, + voidedInvoices: result.voidedInvoices, + periodEnd: result.periodEnd?.toISOString() ?? null, }); return withCacheControl(response, 'private, no-store'); } catch (error) { - logError('Error canceling Stripe subscription:', error); + logError('billing.cancel', error); return apiErrors.internalError('Failed to cancel subscription'); } } diff --git a/app/api/billing/route.ts b/app/api/billing/route.ts index 5c49907..844ef6f 100644 --- a/app/api/billing/route.ts +++ b/app/api/billing/route.ts @@ -1,7 +1,12 @@ import { BillingSubscriptionStatus } from '@prisma/client'; import { auth } from '@/lib/auth'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; -import { getBillingOverview, getOpenInvoiceForCustomer } from '@/lib/billing'; +import { + findCancelableStripeSubscription, + isUnpaidStripeSubscription, + getBillingOverview, + getOpenInvoiceForCustomer, +} from '@/lib/billing'; import { isStripeFeatureEnabled } from '@/lib/feature-flags'; import { hasStripeRuntimeConfig, isStripeConfigured } from '@/lib/stripe'; import { logError } from '@/lib/logger'; @@ -17,8 +22,7 @@ export async function GET() { const isEnabled = isStripeFeatureEnabled(); const isConfigured = hasStripeRuntimeConfig(); - // Only looked up when the account actually owes something, so the common path does not - // pay for a Stripe round trip. + // Invoice details are only needed when the current subscription is behind on payment. const needsPaymentFix = billing.subscription.status === BillingSubscriptionStatus.PAST_DUE || billing.subscription.status === BillingSubscriptionStatus.UNPAID; @@ -30,6 +34,11 @@ export async function GET() { ) : null; + const cancelable = + isStripeConfigured() && billing.subscription.stripeCustomerId + ? await findCancelableStripeSubscription(billing.subscription.stripeCustomerId) + : null; + const response = successResponse({ isEnabled, isConfigured, @@ -42,20 +51,15 @@ export async function GET() { Boolean(billing.subscription.stripeCustomerId) && (billing.subscription.hasRecoverableSubscription || Boolean(billing.subscription.stripeSubscriptionId)), - // Gated on the status rather than on the mirrored subscription id: the id survives a - // cancellation until the deletion webhook arrives, and offering Cancel on an already - // canceled subscription just returns an error. - cancelAvailable: - isStripeConfigured() && - billing.subscription.hasRecoverableSubscription && - !billing.subscription.cancelAt && - !billing.subscription.cancelAtPeriodEnd, + // An already scheduled unpaid subscription still needs immediate cancellation. + // A different unscheduled subscription may also remain after an earlier cancel. + cancelAvailable: Boolean(cancelable), needsPaymentFix, - // Whether cancelling ends the subscription there and then rather than at the period - // end, which is what the confirmation copy has to say. Mirrors the branch the cancel - // route takes: nothing was paid for the open period, so there is nothing to run out. - cancelIsImmediate: - needsPaymentFix || billing.subscription.status === BillingSubscriptionStatus.INCOMPLETE, + cancelIsImmediate: Boolean( + cancelable && + (isUnpaidStripeSubscription(cancelable) || + ['canceled', 'incomplete_expired'].includes(cancelable.status)) + ), openInvoice: openInvoice ? { id: openInvoice.id, diff --git a/components/LandingPage.tsx b/components/LandingPage.tsx index d73f2f0..3797640 100644 --- a/components/LandingPage.tsx +++ b/components/LandingPage.tsx @@ -10,24 +10,20 @@ import { Video, MoveRight, Play, - PenTool, - Keyboard, - BellRing, - FolderOpen, - FileDown, - History, - Smartphone, - Link as LinkIcon, - CheckSquare, - MessageSquare, - Github, - ArrowRight, - XCircle, - ArrowDown, - CheckCircle, + Mic, + Users, + Tag, + Code, + Lock, Upload, Share2, + MessageSquare, Check, + CheckCircle2, + Copy, + Link as LinkIcon, + Github, + ArrowRight, } from 'lucide-react'; interface LandingPageProps { @@ -37,57 +33,162 @@ interface LandingPageProps { const controlButtonClass = 'group relative isolate inline-flex h-8 items-center justify-center overflow-hidden border border-border bg-background px-2.5 text-[11px] font-medium text-foreground transition-colors duration-300 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring sm:h-9 sm:px-4 sm:text-xs'; -const coreWorkflowFeatures = [ +const primaryCtaClass = + 'group relative isolate inline-flex min-h-12 items-center justify-center gap-2 overflow-hidden border border-primary bg-primary px-6 py-3 text-center text-[13px] font-semibold text-primary-foreground transition-colors duration-300 hover:bg-primary/90 sm:px-8 sm:text-sm'; + +const labelClass = 'text-[11px] uppercase tracking-[0.14em] text-muted-foreground'; + +const trustSignals = [ + { label: 'No client accounts', icon: Users }, + { label: 'Flat $10 per month', icon: Tag }, + { label: 'Fair Source, self-hostable', icon: Code }, + { label: 'Private by default', icon: Lock }, +]; + +const steps = [ { - title: 'Version Compare', - description: 'Compare any two versions side-by-side on a single timeline.', - icon: History, + label: 'Upload a cut', + description: 'Drop a file, or import an unlisted YouTube video.', + icon: Upload, }, { - title: 'Asset Management', - description: 'Keep images and supplementary videos grouped perfectly per cut.', - icon: FolderOpen, + label: 'Share the link', + description: 'Set permissions once, the client needs no account.', + icon: Share2, }, { - title: 'Version History', - description: 'Infinite versioning. Toggle between V1 and V10 without losing where you are.', - icon: History, + label: 'Timestamped feedback', + description: 'Comments, voice notes and drawings land on the frame.', + icon: MessageSquare, }, { - title: 'Approval Workflow', - description: - 'Assign specific team members or clients to review and sign off on a cut. Get an exact \"Approved\" status.', - icon: CheckCircle, + label: 'Approve and move on', + description: 'The cut gets a signed off Approved status, export the notes.', + icon: Check, }, ]; -const workflowAcceleratorFeatures = [ +const hostedFeatures = [ + 'Unlimited collaborators and clients', + 'Comments, voice notes, annotations', + 'Version compare, history, approvals', + 'Permissioned share links, PDF and CSV export', + 'Unlimited unlisted YouTube imports', + '200 GB storage, add 100 GB for $5/mo', +]; + +const selfHostedFeatures = [ + 'Full source code, read it and audit it', + 'Docker setup for self-hosting', + 'Every release becomes Apache 2.0 two years after publication', +]; + +const faq = [ { - title: 'Keyboard Shortcuts', - description: 'J, K, L, Space, and M controls for professional editing workflows.', - icon: Keyboard, + q: 'Do clients need an account?', + a: 'No. They can review in the browser with a share link.', }, { - title: 'PDF/CSV Exports', - description: 'Turn video comments into a professional feedback report in one click.', - icon: FileDown, + q: 'Is OpenFrame open source?', + a: 'OpenFrame is Fair Source, licensed under the Functional Source License (FSL). You can read and audit the full source code and self-host it, and every release automatically becomes Apache 2.0 open source two years after publication.', }, { - title: 'Real-time Webhooks', - description: - 'Get instant Telegram alerts the second a comment is dropped. More integrations coming soon.', - icon: BellRing, + q: 'Is there a free trial?', + a: 'Yes. Hosted Cloud starts with a 7-day free trial and never asks for a card to begin it. After that it is a flat $10/mo, with no per-seat or per-client fees.', }, { - title: 'Mobile-Optimized Review', - description: 'Touch-optimized player for clients reviewing cuts on the move.', - icon: Smartphone, + q: 'How is this different from a Google Drive link?', + a: 'Drive does not give timestamped discussion, voice notes, annotations, or version compare, which is where approval time is actually saved.', + }, + { + q: 'What happens if I exceed my storage?', + a: 'You can add 100 GB for $5/mo. If you need much more, contact us at info@open-frame.net and we will help you choose the best setup.', + }, + { + q: 'Can I self-host?', + a: 'Yes. The full source code is public and ships with a Docker setup for self-hosting. Hosted Cloud is for teams who want zero setup.', }, ]; +// Bar heights for the voice note waveform, in percent. The first twenty read +// as "played", the rest as "remaining". +const waveformBars = [ + 28, 52, 74, 40, 96, 62, 34, 80, 46, 90, 38, 66, 88, 30, 72, 50, 84, 42, 94, 56, 32, 68, 44, 86, + 36, 60, 92, 48, 26, 70, 54, 82, 38, 64, +]; + +const shareOptions = [ + { label: 'Can comment', on: true }, + { label: 'Ask for a name before commenting', on: true }, + { label: 'Allow download of the original file', on: false }, + { label: 'Show earlier versions', on: false }, +]; + +const heroGridStyle = { + backgroundImage: + 'linear-gradient(to right, color-mix(in oklab, var(--foreground) 5%, transparent) 1px, transparent 1px), linear-gradient(to bottom, color-mix(in oklab, var(--foreground) 5%, transparent) 1px, transparent 1px)', + backgroundSize: '48px 48px', + maskImage: + 'radial-gradient(ellipse 820px 640px at 50% 30%, #000 0%, rgba(0,0,0,0.6) 52%, transparent 80%)', + WebkitMaskImage: + 'radial-gradient(ellipse 820px 640px at 50% 30%, #000 0%, rgba(0,0,0,0.6) 52%, transparent 80%)', +} as const; + +const heroGlowStyle = { + background: + 'radial-gradient(closest-side, color-mix(in oklab, var(--primary) 18%, transparent), color-mix(in oklab, var(--primary) 6%, transparent) 55%, transparent 100%)', +} as const; + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +function MockToolbar({ left, right }: { left: React.ReactNode; right?: React.ReactNode }) { + return ( +
+ + {left} + + {right} +
+ ); +} + +function Avatar({ initial }: { initial: string }) { + return ( +
+ {initial} +
+ ); +} + +function Timecode({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function Toggle({ on }: { on: boolean }) { + return ( +