feat: redesign bank selector with popular banks, search, and one-click connect

Replace the external Enable Banking widget with a custom component that
fetches banks from our API, shows popular Swedish banks in a grid, provides
search filtering, and connects on click without an intermediate selection step.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-04 21:20:32 +01:00
co-authored by Claude Opus 4.6
parent 17eecfdb71
commit bd12e2aaf5
2 changed files with 180 additions and 108 deletions
@@ -1,9 +1,10 @@
'use client' 'use client'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { Loader2 } from 'lucide-react' import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Loader2, Search, RefreshCw } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import Script from 'next/script'
export interface Bank { export interface Bank {
name: string name: string
@@ -13,104 +14,197 @@ export interface Bank {
} }
interface BankSelectorProps { interface BankSelectorProps {
onSelect: (bank: Bank) => void onConnect: (bank: Bank) => void
selectedBank?: Bank | null isConnecting?: boolean
isLoading?: boolean connectingBankName?: string | null
className?: string className?: string
country?: string
sandbox?: boolean
} }
const POPULAR_BANK_NAMES = [
'Nordea',
'SEB',
'Swedbank',
'Handelsbanken',
'Länsförsäkringar',
'Skandia',
'Danske Bank',
]
export function BankSelector({ export function BankSelector({
onSelect, onConnect,
isLoading = false, isConnecting = false,
connectingBankName = null,
className, className,
country = 'SE',
sandbox = process.env.NEXT_PUBLIC_ENABLE_BANKING_SANDBOX === 'true',
}: BankSelectorProps) { }: BankSelectorProps) {
const widgetRef = useRef<HTMLElement | null>(null) const [banks, setBanks] = useState<Bank[]>([])
const [scriptReady, setScriptReady] = useState(false) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
// Stable ref for onSelect so the event listener never goes stale const fetchBanks = useCallback(async () => {
const onSelectRef = useRef(onSelect) setLoading(true)
useEffect(() => { setError(null)
onSelectRef.current = onSelect try {
}, [onSelect]) const res = await fetch('/api/extensions/ext/enable-banking/banks')
if (!res.ok) throw new Error('Kunde inte hämta banklistan')
const handleSelected = useCallback((e: Event) => { const data = await res.json()
const customEvent = e as CustomEvent setBanks(data.banks || [])
const detail = customEvent.detail as { } catch (e) {
name: string setError(e instanceof Error ? e.message : 'Något gick fel')
country: string } finally {
psuType: string setLoading(false)
sandbox: boolean
} }
onSelectRef.current({
name: detail.name,
country: detail.country,
})
}, []) }, [])
// Attach the event listener once the script is ready and the widget is in the DOM.
// Using onReady instead of onLoad ensures the script has fully executed and
// the custom element has been defined/upgraded before we interact with it.
useEffect(() => { useEffect(() => {
if (!scriptReady) return fetchBanks()
}, [fetchBanks])
const widget = widgetRef.current const popularBanks = useMemo(
if (!widget) return () =>
POPULAR_BANK_NAMES.map((name) =>
banks.find((b) => b.name === name)
).filter((b): b is Bank => b !== undefined),
[banks]
)
widget.addEventListener('selected', handleSelected) const filteredBanks = useMemo(() => {
if (!searchQuery.trim()) {
return () => { // Show all banks except popular ones
widget.removeEventListener('selected', handleSelected) const popularSet = new Set(POPULAR_BANK_NAMES)
return banks
.filter((b) => !popularSet.has(b.name))
.sort((a, b) => a.name.localeCompare(b.name, 'sv'))
} }
}, [scriptReady, handleSelected]) const q = searchQuery.toLowerCase()
return banks
.filter((b) => b.name.toLowerCase().includes(q))
.sort((a, b) => a.name.localeCompare(b.name, 'sv'))
}, [banks, searchQuery])
if (isLoading) { if (loading) {
return ( return (
<div className={cn('flex items-center justify-center p-8', className)}> <div className={cn('space-y-3', className)}>
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> {/* Popular banks skeleton */}
<span className="ml-2 text-sm text-muted-foreground">Ansluter...</span> <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
{Array.from({ length: 7 }).map((_, i) => (
<div
key={i}
className="h-11 rounded-lg bg-muted animate-pulse"
/>
))}
</div>
<div className="h-9 rounded-md bg-muted animate-pulse" />
<div className="space-y-1.5">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="h-10 rounded-lg bg-muted animate-pulse"
/>
))}
</div>
</div>
)
}
if (error) {
return (
<div className={cn('flex flex-col items-center gap-3 py-8', className)}>
<p className="text-sm text-muted-foreground">{error}</p>
<Button variant="outline" size="sm" onClick={fetchBanks}>
<RefreshCw className="mr-2 h-3.5 w-3.5" />
Försök igen
</Button>
</div> </div>
) )
} }
return ( return (
<div className={cn('space-y-3', className)}> <div className={cn('space-y-3', className)}>
<Script {/* Popular banks grid */}
src="https://tilisy.enablebanking.com/lib/widgets.umd.min.js" {popularBanks.length > 0 && !searchQuery.trim() && (
onReady={() => setScriptReady(true)} <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
strategy="afterInteractive" {popularBanks.map((bank) => {
/> const connecting = isConnecting && connectingBankName === bank.name
<link return (
href="https://tilisy.enablebanking.com/lib/widgets.css" <button
rel="stylesheet" key={bank.name}
/> type="button"
disabled={isConnecting}
{/* Only render the custom element after the script has defined it, onClick={() => onConnect(bank)}
so the browser can upgrade it immediately with full interactivity */} className={cn(
{scriptReady ? ( 'flex items-center justify-center gap-2 rounded-lg border px-3 py-2.5 text-sm font-medium transition-colors',
// @ts-expect-error - Enable Banking custom element 'hover:bg-muted/50 hover:border-primary/30',
<enablebanking-aspsp-list connecting && 'border-primary bg-primary/5',
ref={widgetRef} isConnecting && !connecting && 'opacity-50 cursor-not-allowed'
country={country} )}
psu-type="business" >
service="AIS" {connecting ? (
{...(sandbox ? { sandbox: true } : {})} <Loader2 className="h-3.5 w-3.5 animate-spin" />
style={{ minHeight: '200px' }} ) : bank.logo ? (
/> <img
) : ( src={bank.logo}
<div className="flex items-center justify-center" style={{ minHeight: '200px' }}> alt=""
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /> className="h-4 w-4 object-contain"
<span className="ml-2 text-sm text-muted-foreground">Laddar bankwidget...</span> />
) : null}
<span className="truncate">{bank.name}</span>
</button>
)
})}
</div> </div>
)} )}
<p className="text-xs text-muted-foreground text-center"> {/* Search */}
{sandbox ? 'Sandbox-läge: Använd testbanker för utveckling' : 'Välj din bank för att fortsätta'} <div className="relative">
</p> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Sök bland alla banker..."
className="pl-9 h-9"
/>
</div>
{/* Bank list */}
<div className="max-h-[280px] overflow-auto space-y-1 rounded-lg border p-1">
{filteredBanks.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">
{searchQuery.trim() ? 'Inga banker matchar sökningen' : 'Inga banker tillgängliga'}
</p>
) : (
filteredBanks.map((bank) => {
const connecting = isConnecting && connectingBankName === bank.name
return (
<button
key={bank.name}
type="button"
disabled={isConnecting}
onClick={() => onConnect(bank)}
className={cn(
'flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors text-left',
'hover:bg-muted/50',
connecting && 'bg-primary/5',
isConnecting && !connecting && 'opacity-50 cursor-not-allowed'
)}
>
{connecting ? (
<Loader2 className="h-4 w-4 animate-spin flex-shrink-0" />
) : bank.logo ? (
<img
src={bank.logo}
alt=""
className="h-4 w-4 object-contain flex-shrink-0"
/>
) : (
<div className="h-4 w-4 flex-shrink-0" />
)}
<span>{bank.name}</span>
</button>
)
})
)}
</div>
</div> </div>
) )
} }
@@ -2,10 +2,9 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import { Loader2, Landmark } from 'lucide-react' import { Loader2 } from 'lucide-react'
import { createClient } from '@/lib/supabase/client' import { createClient } from '@/lib/supabase/client'
import { BankSelector, type Bank } from './BankSelector' import { BankSelector, type Bank } from './BankSelector'
import { BankConnectionStatus } from './BankConnectionStatus' import { BankConnectionStatus } from './BankConnectionStatus'
@@ -24,8 +23,8 @@ export default function BankingSettingsPanel() {
const [bankConnections, setBankConnections] = useState<BankConnection[]>([]) const [bankConnections, setBankConnections] = useState<BankConnection[]>([])
const [syncingConnectionId, setSyncingConnectionId] = useState<string | null>(null) const [syncingConnectionId, setSyncingConnectionId] = useState<string | null>(null)
const [isConnecting, setIsConnecting] = useState(false) const [isConnecting, setIsConnecting] = useState(false)
const [connectingBankName, setConnectingBankName] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [selectedBank, setSelectedBank] = useState<Bank | null>(null)
useEffect(() => { useEffect(() => {
fetchConnections() fetchConnections()
@@ -46,14 +45,15 @@ export default function BankingSettingsPanel() {
setIsLoading(false) setIsLoading(false)
} }
async function handleConnectBank(bankName: string, bankCountry: string) { async function handleConnectBank(bank: Bank) {
setIsConnecting(true) setIsConnecting(true)
setConnectingBankName(bank.name)
try { try {
const response = await fetch('/api/extensions/ext/enable-banking/connect', { const response = await fetch('/api/extensions/ext/enable-banking/connect', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aspsp_name: bankName, aspsp_country: bankCountry }), body: JSON.stringify({ aspsp_name: bank.name, aspsp_country: bank.country }),
}) })
const data = await response.json() const data = await response.json()
@@ -70,6 +70,7 @@ export default function BankingSettingsPanel() {
variant: 'destructive', variant: 'destructive',
}) })
setIsConnecting(false) setIsConnecting(false)
setConnectingBankName(null)
} }
} }
@@ -183,35 +184,12 @@ export default function BankingSettingsPanel() {
Välj din bank nedan för att koppla ditt konto via PSD2. Välj din bank nedan för att koppla ditt konto via PSD2.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent>
<BankSelector <BankSelector
onSelect={setSelectedBank} onConnect={handleConnectBank}
isLoading={isConnecting} isConnecting={isConnecting}
connectingBankName={connectingBankName}
/> />
{selectedBank && (
<div className="flex items-center justify-between p-4 border rounded-lg bg-muted/50">
<div className="flex items-center gap-3">
<Landmark className="h-5 w-5 text-primary" />
<div>
<p className="text-sm font-medium">{selectedBank.name}</p>
<p className="text-xs text-muted-foreground">{selectedBank.country}</p>
</div>
</div>
<Button
onClick={() => handleConnectBank(selectedBank.name, selectedBank.country)}
disabled={isConnecting}
>
{isConnecting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Ansluter...
</>
) : (
'Anslut bank'
)}
</Button>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>