'use client' import { useEffect, useRef, useState } from 'react' import { ChevronRight, Landmark, Loader2, Search } from 'lucide-react' import { Input } from '@/components/ui/input' import { cn } from '@/lib/utils' import { matchBankByName } from '../lib/bank-match' export interface Bank { name: string country: string logo?: string bic?: string } const POPULAR_SWEDISH_BANKS = [ 'Swedbank', 'SEB', 'Nordea', 'Handelsbanken', 'Danske Bank', ] interface BankSelectorProps { onConnect: (bank: Bank) => void onPsuTypeDetected?: (psuType: 'personal' | 'business') => void isConnecting?: boolean connectingBankName?: string | null className?: string } /** * Eyebrow above a list section: same micro-label and same px-1 inset as the * SettingsGroup eyebrow, so the two sit on one left edge. */ function SectionLabel({ children }: { children: React.ReactNode }) { return (

{children}

) } /** * 32px leading mark for a row. Real bank logos are drawn for a white * background, so they sit on a white chip (same treatment as LogoMark in * components/onboarding/NewUserChecklist.tsx); the no-logo fallback uses the * neutral surface instead, which keeps the icon readable in dark mode. * While connecting, the mark is replaced by a bare spinner: quiet inline * state, no tint, no box. */ function BankMark({ bank, connecting }: { bank: Bank; connecting: boolean }) { if (connecting) { return ( ) } if (bank.logo) { return ( {/* eslint-disable-next-line @next/next/no-img-element */} ) } return ( ) } function BankRow({ bank, isConnecting, connectingBankName, onConnect }: { bank: Bank isConnecting: boolean connectingBankName: string | null onConnect: (bank: Bank) => void }) { const connecting = isConnecting && connectingBankName === bank.name return ( ) } export function BankSelector({ onConnect, onPsuTypeDetected, isConnecting = false, connectingBankName = null, className, }: BankSelectorProps) { const [banks, setBanks] = useState([]) const [isSandbox, setIsSandbox] = useState(true) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) const [searchQuery, setSearchQuery] = useState('') const searchRef = useRef(null) const autoStartHandled = useRef(false) // Deep-link preselect (?bank=, set by the MCP connect card when the // user already named their bank in chat): auto-start that bank's consent // the way clicking its row would, so the flow feels like Skatteverket's // one-click authorize. The param is stripped immediately so an abort at // the bank followed by back-navigation does not silently re-launch; an // unknown or ambiguous name just prefills the search instead of guessing. // Guards further down (duplicate pending, renew-instead-409) stay fully // interactive because this runs through the same onConnect handler. useEffect(() => { if (autoStartHandled.current || isLoading || banks.length === 0 || isConnecting) return const params = new URLSearchParams(window.location.search) const requested = params.get('bank') if (!requested) { autoStartHandled.current = true return } autoStartHandled.current = true params.delete('bank') const query = params.toString() window.history.replaceState( null, '', `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}` ) const match = matchBankByName(banks, requested) if (match) { onConnect(match) } else { setSearchQuery(requested) searchRef.current?.focus() } // eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot after the bank list arrives }, [isLoading, banks, isConnecting]) useEffect(() => { async function fetchBanks() { try { const res = await fetch('/api/extensions/ext/enable-banking/banks') if (!res.ok) { // A non-OK response (500, or the 403 capability gate) has no `banks` // array; without this guard it falls through to the "no banks // available" empty state, which misreads as a successful-but-empty // load rather than a failure. setError('Kunde inte ladda banker') return } const data = await res.json() if (data.banks) { setBanks(data.banks as Bank[]) } if (data.sandbox !== undefined) { setIsSandbox(data.sandbox) } if (data.psu_type && onPsuTypeDetected) { onPsuTypeDetected(data.psu_type) } } catch { setError('Kunde inte ladda banker') } finally { setIsLoading(false) } } fetchBanks() // eslint-disable-next-line react-hooks/exhaustive-deps -- onPsuTypeDetected is a stable setter, only run on mount }, [onPsuTypeDetected]) const filteredBanks = banks.filter((bank) => bank.name.toLowerCase().includes(searchQuery.toLowerCase()) ) const showPopular = !isSandbox && !searchQuery const popularBanks = showPopular ? filteredBanks.filter((bank) => POPULAR_SWEDISH_BANKS.includes(bank.name)) : [] const otherBanks = showPopular ? filteredBanks.filter((bank) => !POPULAR_SWEDISH_BANKS.includes(bank.name)) : filteredBanks return (
{/* Search input */}
{/* Loading state. The spinner is decorative, so the state needs a text equivalent: without it a screen-reader user gets silence between submitting and the list appearing. */} {isLoading && (
)} {/* Error state: one quiet line, not a tinted panel. role="alert" so the failure is announced rather than only redrawn. */} {error && (

{error}

)} {/* Bank list */} {!isLoading && !error && ( <> {filteredBanks.length === 0 ? (

{searchQuery ? `Inga banker matchar "${searchQuery}"` : 'Inga banker tillgängliga'}

{searchQuery && (

Försök med ett annat sökord

)}
) : ( // -mx-1 cancels the px-1 the settings panel wraps this in, so the // hairlines and the hover band run the full width of the settings // content, level with the SettingsRow hairlines above; the rows' // own px-1 then puts the bank mark on the same left edge as the // settings labels. // max-h-96 is exactly eight 48px rows: a scroll box with a reason.
{popularBanks.length > 0 && (
Populära banker
{popularBanks.map((bank) => ( ))}
)} {otherBanks.length > 0 && (
{popularBanks.length > 0 && Alla banker}
{otherBanks.map((bank) => ( ))}
)}
)} )} {/* The single worded home of the connecting state: one muted line rather than a tinted bordered panel, and it names the bank so the state is still readable when the chosen row has scrolled out of view. */} {isConnecting && connectingBankName && (

)}
) }