'use client' import { useCallback, useEffect, useState } from 'react' import { useLocale, useTranslations } from 'next-intl' import { useRouter, useSearchParams } from 'next/navigation' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Switch } from '@/components/ui/switch' import { Skeleton } from '@/components/ui/skeleton' import { useToast } from '@/components/ui/use-toast' import { useFormat } from '@/lib/hooks/use-format' import { failureDescription } from '@/lib/browser/action-failure' import type { ErrorLocale } from '@/lib/errors/get-error-message' import { CreditCard, Link2, Loader2, RefreshCw, Unlink } from 'lucide-react' import { stripeRequest, syncSummary, STRIPE_SYNC_TIMEOUT_MS, type StripeSyncPayload, } from '../lib/settings-actions' import type { StripeStatusResponse } from '../types' type ConnectionInfo = NonNullable const STATUS_VARIANT: Record = { active: 'success', pending: 'secondary', revoked: 'warning', error: 'destructive', } export default function StripeSettingsPanel() { const t = useTranslations('settings_payments') const tCommon = useTranslations('common') const locale = useLocale() as ErrorLocale const { toast } = useToast() const router = useRouter() const searchParams = useSearchParams() const { formatDateLong } = useFormat() const [loading, setLoading] = useState(true) const [loadFailed, setLoadFailed] = useState(false) const [configured, setConfigured] = useState(false) const [connection, setConnection] = useState(null) const [connecting, setConnecting] = useState(false) const [disconnecting, setDisconnecting] = useState(false) const [confirmDisconnect, setConfirmDisconnect] = useState(false) const [syncing, setSyncing] = useState(false) const [togglingTransactionSync, setTogglingTransactionSync] = useState(false) // The failure copy is the same sentence for every action here: all four calls // are quick writes whose outcome the panel itself reveals once reloaded, so a // timeout says "reload and check" and a dead connection says "check the // connection", regardless of which button was pressed. const failureCopy = { timeout: t('action_timeout'), network: t('action_network') } const loadStatus = useCallback(async () => { // A failed status read must never render as "not configured": that message // tells the user to contact an administrator about an installation that is // in fact configured, and it is what a swallowed error produced here. const result = await stripeRequest({ url: '/api/extensions/ext/stripe/status', method: 'GET', locale, }) setLoading(false) if (!result.ok || !result.data) { setLoadFailed(true) return } setLoadFailed(false) setConfigured(result.data.configured) setConnection(result.data.connection) }, [locale]) useEffect(() => { void loadStatus() }, [loadStatus]) function retryLoadStatus() { setLoading(true) void loadStatus() } // Consume the one-shot OAuth bounce-back params (?stripe_connected=true / // ?stripe_error=...) off the render path, mirroring the banking section. useEffect(() => { const connected = searchParams.get('stripe_connected') const error = searchParams.get('stripe_error') if (!connected && !error) return let cancelled = false queueMicrotask(() => { if (cancelled) return if (connected) { toast({ title: t('connected_toast_title'), description: t('connected_toast_description') }) } else if (error) { // useSearchParams().get() already returns the decoded value; do not decode again. let message = error if (message === 'account_already_connected') message = t('error_account_already_connected') else if (message === 'access_denied') message = t('error_access_denied') else if (['invalid_state', 'missing_parameters', 'connection_failed', 'activation_failed'].includes(message)) { message = t('error_generic') } toast({ title: t('connect_failed_title'), description: message, variant: 'destructive' }) } router.replace('/import?mode=stripe') }) return () => { cancelled = true } }, [searchParams, router, toast, t]) async function handleConnect() { if (connecting) return setConnecting(true) try { // The route ignores the request body, so none is sent. const result = await stripeRequest<{ url?: string }>({ url: '/api/extensions/ext/stripe/connect', locale, }) if (!result.ok || !result.data?.url) { toast({ title: t('connect_failed_title'), description: result.ok // A 2xx with no url is a route bug: nothing more specific is known. ? t('error_generic') : failureDescription(result, failureCopy), variant: 'destructive', }) return } window.location.href = result.data.url } finally { setConnecting(false) } } async function handleSyncNow() { if (syncing) return setSyncing(true) try { const result = await stripeRequest({ url: '/api/extensions/ext/stripe/sync', locale, timeoutMs: STRIPE_SYNC_TIMEOUT_MS, }) // Exactly one toast per outcome: TOAST_LIMIT is 1, so a second toast in // the same tick evicts the first and only the last one renders. if (!result.ok) { toast({ title: t('sync_failed_title'), description: failureDescription(result, failureCopy), variant: 'destructive', }) return } // Honest summary: report what Stripe actually returned. An all-zero run is // a real answer ("the account had nothing in the window"), a revoked // connection is not, and a body that never parsed is neither. const summary = syncSummary(result.data) if (summary.reason === 'revoked') { toast({ title: t('sync_failed_title'), description: t('sync_revoked'), variant: 'destructive', }) } else if (summary.reason === 'empty') { toast({ title: t('sync_done_title'), description: t('sync_done_empty') }) } else if (summary.reason === 'errors') { toast({ title: t('sync_done_title'), description: t('sync_done_feed_errors', summary.values), }) } else if (summary.reason === 'feed') { toast({ title: t('sync_done_title'), description: t('sync_done_feed', summary.values) }) } else { // The sync ran, but the response never said what it did. Claim only that. toast({ title: t('sync_done_title') }) } await loadStatus() } finally { setSyncing(false) } } async function handleToggleTransactionSync(enabled: boolean) { if (togglingTransactionSync) return setTogglingTransactionSync(true) try { const result = await stripeRequest({ url: '/api/extensions/ext/stripe/transaction-sync', body: { enabled }, locale, }) if (!result.ok) { toast({ title: t('transaction_sync_toggle_failed'), description: failureDescription(result, failureCopy), variant: 'destructive', }) return } toast({ title: enabled ? t('transaction_sync_enabled_toast') : t('transaction_sync_disabled_toast'), }) await loadStatus() } finally { setTogglingTransactionSync(false) } } async function handleDisconnect() { if (!connection || disconnecting) return setDisconnecting(true) try { const result = await stripeRequest({ url: '/api/extensions/ext/stripe/disconnect', method: 'DELETE', body: { connection_id: connection.id }, locale, }) if (!result.ok) { toast({ title: t('disconnect_failed_title'), description: failureDescription(result, failureCopy), variant: 'destructive', }) return } toast({ title: t('disconnected_toast_title') }) setConfirmDisconnect(false) await loadStatus() } finally { setDisconnecting(false) } } if (loading) { return ( ) } if (loadFailed) { // Kept strictly apart from `!configured` below: a status read that never // answered says nothing about whether Stripe is configured, and the user can // fix a dropped connection themselves. Recoverable, so it offers the retry. return ( {t('title')}

{t('load_failed')}

) } if (!configured) { // Self-hosted without STRIPE_CONNECT_CLIENT_ID: honest configuration // message, for these admins it's a setup task, not a launch. return ( {t('title')}

{t('not_configured')}

) } const isActive = connection?.status === 'active' return ( {t('title')}

{t('description')}

{connection ? (
{connection.display_name || connection.stripe_account_id || t('unnamed_account')} {t(`status_${connection.status}`)} {!connection.livemode && isActive && ( {t('test_mode')} )}
{isActive && connection.connected_at && (

{t('connected_since', { date: formatDateLong(connection.connected_at) })}

)} {connection.error_message && connection.status !== 'active' && (

{connection.error_message}

)}
{isActive ? ( confirmDisconnect ? (
) : (
) ) : ( )}
) : (

{t('connect_hint')}

)} {isActive && connection && (

{t('transaction_sync_title')}

{t('transaction_sync_description')}

{connection.transaction_sync_enabled ? (

{connection.last_balance_txn_synced_at ? t('transaction_sync_last_synced', { date: formatDateLong(connection.last_balance_txn_synced_at), }) : t('transaction_sync_never_synced')}

) : (

{t('transaction_sync_backfill_note')}

)}
)}
) }