'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 { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' 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 { KeyRound, Link2, Loader2, RefreshCw, ShoppingCart, Unlink } from 'lucide-react' import { PaymentMethodMappingForm } from '@/components/orders/PaymentMethodMappingForm' import { wooRequest, syncSummary, WOO_CONNECT_TIMEOUT_MS, WOO_SYNC_TIMEOUT_MS, type WooSyncPayload, } from '../lib/settings-actions' import type { WooCommerceConnectionStatus, WooCommerceStatusResponse } from '../types' const STATUS_VARIANT: Record< WooCommerceConnectionStatus['status'], 'success' | 'secondary' | 'destructive' | 'warning' > = { active: 'success', pending: 'secondary', revoked: 'warning', error: 'destructive', } /** Client twin of wooStoreScope(): store identity used by the mapping table. */ function storeScopeOf(storeUrl: string): string { return storeUrl.replace(/^https:\/\//, '') } export default function WooCommerceSettingsPanel() { const t = useTranslations('woocommerce') 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 [connections, setConnections] = useState([]) const [storeUrl, setStoreUrl] = useState('') const [manualMode, setManualMode] = useState(false) const [consumerKey, setConsumerKey] = useState('') const [consumerSecret, setConsumerSecret] = useState('') const [connecting, setConnecting] = useState(false) // Per-store busy/confirm states, keyed by connection id. const [busyId, setBusyId] = useState(null) const [confirmDisconnectId, setConfirmDisconnectId] = useState(null) const failureCopy = { timeout: t('action_timeout'), network: t('action_network') } const loadStatus = useCallback(async () => { // A failed status read must never render as "not configured" (see the // Stripe panel: that copy sends the user to an administrator for nothing). const result = await wooRequest({ url: '/api/extensions/ext/woocommerce/status', method: 'GET', locale, }) setLoading(false) if (!result.ok || !result.data) { setLoadFailed(true) return } setLoadFailed(false) setConfigured(result.data.configured) // Older payload shape fallback: a single `connection`. setConnections( result.data.connections ?? (result.data.connection ? [result.data.connection] : []), ) }, [locale]) useEffect(() => { void loadStatus() }, [loadStatus]) function retryLoadStatus() { setLoading(true) void loadStatus() } // Consume the one-shot handshake bounce-back params off the render path, // mirroring the Stripe/banking sections. useEffect(() => { const connected = searchParams.get('woocommerce_connected') const error = searchParams.get('woocommerce_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) { const message = error === 'denied' ? t('error_denied') : error === 'wrong_user' ? t('error_wrong_user') : t('error_generic') toast({ title: t('connect_failed_title'), description: message, variant: 'destructive' }) } router.replace('/import?mode=woocommerce') }) return () => { cancelled = true } }, [searchParams, router, toast, t]) async function handleConnect() { if (connecting) return setConnecting(true) try { const result = await wooRequest<{ url?: string }>({ url: '/api/extensions/ext/woocommerce/connect', body: { store_url: storeUrl }, locale, timeoutMs: WOO_CONNECT_TIMEOUT_MS, }) if (!result.ok || !result.data?.url) { toast({ title: t('connect_failed_title'), description: result.ok ? t('error_generic') : failureDescription(result, failureCopy), variant: 'destructive', }) return } window.location.href = result.data.url } finally { setConnecting(false) } } async function handleManualConnect() { if (connecting) return setConnecting(true) try { const result = await wooRequest({ url: '/api/extensions/ext/woocommerce/manual-connect', body: { store_url: storeUrl, consumer_key: consumerKey, consumer_secret: consumerSecret, }, locale, timeoutMs: WOO_CONNECT_TIMEOUT_MS, }) if (!result.ok) { toast({ title: t('connect_failed_title'), description: failureDescription(result, failureCopy), variant: 'destructive', }) return } toast({ title: t('connected_toast_title'), description: t('connected_toast_description') }) setConsumerKey('') setConsumerSecret('') setStoreUrl('') setManualMode(false) await loadStatus() } finally { setConnecting(false) } } async function handleSyncNow(connectionId: string) { if (busyId) return setBusyId(connectionId) try { const result = await wooRequest({ url: '/api/extensions/ext/woocommerce/sync', body: { connection_id: connectionId }, locale, timeoutMs: WOO_SYNC_TIMEOUT_MS, }) if (!result.ok) { toast({ title: t('sync_failed_title'), description: failureDescription(result, failureCopy), variant: 'destructive', }) return } 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 === 'partial') { toast({ title: t('sync_partial_title'), description: t('sync_partial', summary.values) }) } 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 { toast({ title: t('sync_done_title') }) } await loadStatus() } finally { setBusyId(null) } } async function handleToggleTransactionSync(connectionId: string, enabled: boolean) { if (busyId) return setBusyId(connectionId) try { const result = await wooRequest({ url: '/api/extensions/ext/woocommerce/transaction-sync', body: { enabled, connection_id: connectionId }, 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 { setBusyId(null) } } async function handleDisconnect(connectionId: string) { if (busyId) return setBusyId(connectionId) try { const result = await wooRequest({ url: '/api/extensions/ext/woocommerce/disconnect', method: 'DELETE', body: { connection_id: connectionId }, locale, }) if (!result.ok) { toast({ title: t('disconnect_failed_title'), description: failureDescription(result, failureCopy), variant: 'destructive', }) return } // The key still exists in the store's wp-admin; only the merchant can // delete it there, so the toast says so. toast({ title: t('disconnected_toast_title'), description: t('disconnected_toast_description') }) setConfirmDisconnectId(null) await loadStatus() } finally { setBusyId(null) } } if (loading) { return ( ) } if (loadFailed) { return ( {t('title')}

{t('load_failed')}

) } if (!configured) { return ( {t('title')}

{t('not_configured')}

) } const hasActive = connections.some((c) => c.status === 'active') return ( {t('title')}

{t('description')}

{connections.map((connection) => { const isActive = connection.status === 'active' const busy = busyId === connection.id // Handlers early-return while ANY request runs (shared busyId), so // every card's controls disable; the spinner stays on the busy one. const blocked = busyId !== null return (
{connection.store_name || connection.store_url || t('unnamed_store')} {t(`status_${connection.status}`)}
{connection.store_name && (

{connection.store_url}

)} {isActive && connection.connected_at && (

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

)} {connection.status === 'pending' && (

{t('pending_note')}

)} {connection.error_message && ( // Shown for active connections too: a sync that cannot // run must not hide behind a healthy "Ansluten" badge.

{connection.error_message}

)}
{isActive && ( confirmDisconnectId === connection.id ? (
) : (
) )}
{isActive && (

{t('transaction_sync_title')}

{t('transaction_sync_description')}

{connection.transaction_sync_enabled ? (

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

) : (

{t('transaction_sync_backfill_note')}

)}
handleToggleTransactionSync(connection.id, enabled) } disabled={blocked} aria-label={t('transaction_sync_title')} />
)} {isActive && connection.store_url && ( )}
) })}
{hasActive && (

{t('add_store')}

)}
setStoreUrl(e.target.value)} disabled={connecting} />
{manualMode ? (

{t('manual_hint')}

setConsumerKey(e.target.value)} disabled={connecting} />
setConsumerSecret(e.target.value)} disabled={connecting} />
) : (

{t('connect_hint')}

)}
) }