'use client' import { useEffect, useState } from 'react' import Link from 'next/link' import { useTranslations } from 'next-intl' import { AlertTriangle, RefreshCw } from 'lucide-react' import { createClient } from '@/lib/supabase/client' import { onBankSyncUpdated } from '@/lib/transactions/bank-sync-signal' import { useCompany, useCapability } from '@/contexts/CompanyContext' import { CAPABILITY } from '@/lib/entitlements/keys' import { isSelfHosted } from '@/lib/env/public-flags' import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/info-tooltip' import { getChipState, type ConnectionRow, } from '@/lib/transactions/bank-sync-chip-state' export function useAgeFormatter() { const t = useTranslations('transactions') return (iso: string): string => { const ms = Date.now() - new Date(iso).getTime() const min = Math.floor(ms / 60000) if (min < 1) return t('bank_sync_age_just_now') if (min < 60) return t('bank_sync_age_minutes', { count: min }) const h = Math.floor(min / 60) if (h < 24) return t('bank_sync_age_hours', { count: h }) const d = Math.floor(h / 24) return t('bank_sync_age_days', { count: d }) } } export default function BankSyncStatusChip() { const t = useTranslations('transactions') const formatAge = useAgeFormatter() const { company } = useCompany() const hasBankSync = useCapability(CAPABILITY.bank_sync) const [rows, setRows] = useState(null) useEffect(() => { if (!company?.id) return const companyId = company.id let cancelled = false const supabase = createClient() const load = () => { supabase .from('bank_connections') .select('id, status, last_synced_at, consent_expires') .eq('company_id', companyId) .then(({ data, error }) => { if (cancelled) return // On error keep the chip hidden (empty rows) rather than rendering a // false "healthy" state from stale data. setRows(error ? [] : (data ?? [])) }) } load() // Refetch when a manual "Sync now" / reconnect elsewhere on the page // changes the connections, so the chip doesn't keep showing "synced 2d ago". const unsubscribe = onBankSyncUpdated(load) return () => { cancelled = true unsubscribe() } }, [company?.id]) if (!rows) return null const state = getChipState(rows, { hasBankSync }) if (state.kind === 'none') return null if (state.kind === 'paused') { // Same remedy split as BankSyncNowButton: hosted points at billing, // self-host at the connector key (never the Stripe page). const selfHosted = isSelfHosted() return ( {selfHosted ? t('bank_sync_paused_connector_key') : t('bank_sync_paused_subscription')} ) } if (state.kind === 'attention') { return ( {state.count === 1 ? t('bank_sync_attention_one') : t('bank_sync_attention_many', { count: state.count })} ) } if (state.kind === 'expiring') { // The consent is still alive, so the connection syncs today; the ochre // text says "act before it dies" without the terracotta of a dead one. return ( {state.count === 1 ? t('bank_sync_expiring_one', { days: state.daysLeft }) : t('bank_sync_expiring_many', { count: state.count, days: state.daysLeft })} ) } if (state.kind === 'stale') { // Same neutral shape as the healthy chip: the ochre text is the signal, // never an amber box (convention 12: status colors are data, not chrome). return ( {t('bank_sync_stale_warning')} ({formatAge(state.mostRecent)}) ) } // healthy return ( {t('bank_sync_auto_nightly')} {state.mostRecent && ( <> {t('bank_sync_last_separator')} {formatAge(state.mostRecent)} )}
{t('bank_sync_latency_hint')}
) }