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:
co-authored by
Claude Opus 4.6
parent
17eecfdb71
commit
bd12e2aaf5
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user