'use client' import { useCallback, useEffect, useState } from 'react' import { 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 { EmptyState } from '@/components/ui/empty-state' import { Skeleton } from '@/components/ui/skeleton' import { useToast } from '@/components/ui/use-toast' import { useFormat } from '@/lib/hooks/use-format' import { formatCurrency, formatDate } from '@/lib/utils' import { CreditCard, Link2, RefreshCw, Unlink } from 'lucide-react' import type { StripeReviewEvent, StripeStatusResponse } from '../types' type ConnectionInfo = NonNullable const KNOWN_REVIEW_REASONS = new Set([ 'invoice_not_found', 'invoice_already_paid', 'amount_mismatch', 'currency_mismatch', 'non_sek_invoice', ]) const STATUS_VARIANT: Record = { active: 'success', pending: 'secondary', revoked: 'warning', error: 'destructive', } export default function StripeSettingsPanel() { const t = useTranslations('settings_payments') const { toast } = useToast() const router = useRouter() const searchParams = useSearchParams() const { formatDateLong } = useFormat() const [loading, setLoading] = useState(true) 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 [needsReviewCount, setNeedsReviewCount] = useState(0) const [needsReview, setNeedsReview] = useState([]) const loadStatus = useCallback(async () => { try { const res = await fetch('/api/extensions/ext/stripe/status') if (!res.ok) return const data = (await res.json()) as StripeStatusResponse setConfigured(data.configured) setConnection(data.connection) setNeedsReviewCount(data.needs_review_count ?? 0) setNeedsReview(data.needs_review ?? []) } finally { setLoading(false) } }, []) useEffect(() => { void loadStatus() }, [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('/settings/payments') }) return () => { cancelled = true } }, [searchParams, router, toast, t]) async function handleConnect() { setConnecting(true) try { const res = await fetch('/api/extensions/ext/stripe/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const data = (await res.json()) as { url?: string; error?: string } if (!res.ok || !data.url) { toast({ title: t('connect_failed_title'), description: data.error || t('error_generic'), variant: 'destructive', }) return } window.location.href = data.url } catch { toast({ title: t('connect_failed_title'), description: t('error_generic'), variant: 'destructive' }) } finally { setConnecting(false) } } async function handleSyncNow() { setSyncing(true) try { const res = await fetch('/api/extensions/ext/stripe/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const data = (await res.json().catch(() => ({}))) as { settled?: number needsReview?: number error?: string } if (!res.ok) { toast({ title: t('sync_failed_title'), description: data.error || t('error_generic'), variant: 'destructive', }) return } toast({ title: t('sync_done_title'), description: t('sync_done_description', { settled: data.settled ?? 0, review: data.needsReview ?? 0, }), }) await loadStatus() } finally { setSyncing(false) } } async function handleDisconnect() { if (!connection) return setDisconnecting(true) try { const res = await fetch('/api/extensions/ext/stripe/disconnect', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ connection_id: connection.id }), }) if (!res.ok) { const data = (await res.json().catch(() => ({}))) as { error?: string } toast({ title: t('disconnect_failed_title'), description: data.error || t('error_generic'), variant: 'destructive', }) return } toast({ title: t('disconnected_toast_title') }) setConfirmDisconnect(false) await loadStatus() } finally { setDisconnecting(false) } } if (loading) { return ( ) } if (!configured) { // Hosted: the Connect platform isn't live yet, so the whole integration // presents as "coming soon" (every server path is already a no-op without // STRIPE_CONNECT_CLIENT_ID). Self-hosted admins get the honest // configuration message instead: for them it's a setup task, not a launch. const isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === 'true' if (isSelfHosted) { return ( {t('title')}

{t('not_configured')}

) } return ( ) } 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 && needsReviewCount > 0 && (

{t('needs_review_title')}

{needsReviewCount}

{t('needs_review_hint')}

    {needsReview.map((event) => (
  • {event.reason && KNOWN_REVIEW_REASONS.has(event.reason) ? t(`reason_${event.reason}`) : event.reason || t('reason_unknown')}

    {event.event_created_at && (

    {formatDate(event.event_created_at)}

    )}
    {event.amount != null && ( {formatCurrency(event.amount, event.currency ?? 'SEK')} )}
  • ))}
)}
) }