whisper-money/resources/js/hooks/use-connect-flow.ts

256 lines
8.0 KiB
TypeScript

import {
alreadyConnectedBankNames,
hasLiveConnectionForProvider,
} from '@/lib/banking-connections';
import {
CONNECT_PROVIDERS,
connectProviderForBank,
credentialPayload,
isProviderComplete,
} from '@/lib/connect-providers';
import { getCsrfToken } from '@/lib/csrf';
import type { SharedData } from '@/types';
import type {
BankingConnection,
EnableBankingInstitution,
} from '@/types/banking';
import { __ } from '@/utils/i18n';
import { usePage } from '@inertiajs/react';
import { useCallback, useEffect, useMemo, useState } from 'react';
export const CONNECT_COUNTRIES = [
{ code: 'ES', name: 'Spain' },
{ code: 'DE', name: 'Germany' },
{ code: 'FR', name: 'France' },
{ code: 'IT', name: 'Italy' },
{ code: 'NL', name: 'Netherlands' },
{ code: 'PT', name: 'Portugal' },
{ code: 'BE', name: 'Belgium' },
{ code: 'AT', name: 'Austria' },
{ code: 'FI', name: 'Finland' },
{ code: 'IE', name: 'Ireland' },
{ code: 'LT', name: 'Lithuania' },
{ code: 'LV', name: 'Latvia' },
{ code: 'EE', name: 'Estonia' },
{ code: 'SE', name: 'Sweden' },
{ code: 'NO', name: 'Norway' },
{ code: 'DK', name: 'Denmark' },
{ code: 'PL', name: 'Poland' },
{ code: 'GB', name: 'United Kingdom' },
] as const;
export type ConnectStep = 'country' | 'bank' | 'confirm';
/**
* Shared state and behavior for the bank-connect flow: country → bank list →
* confirm/credentials → POST. Both the dialog and the inline flow consume this;
* they only differ in chrome (layout, haptics, back navigation), which stays in
* the components.
*/
export function useConnectFlow(connections: BankingConnection[]) {
const { features } = usePage<SharedData>().props;
const [step, setStep] = useState<ConnectStep>('country');
const [country, setCountry] = useState('');
const [institutions, setInstitutions] = useState<
EnableBankingInstitution[]
>([]);
const [filteredInstitutions, setFilteredInstitutions] = useState<
EnableBankingInstitution[]
>([]);
const [searchQuery, setSearchQuery] = useState('');
const [selectedBank, setSelectedBank] =
useState<EnableBankingInstitution | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [credentials, setCredentials] = useState<Record<string, string>>({});
const [acknowledgedReplace, setAcknowledgedReplace] = useState(false);
const provider = useMemo(
() => connectProviderForBank(selectedBank?.name),
[selectedBank],
);
const connectedBankNames = useMemo(
() => alreadyConnectedBankNames(connections),
[connections],
);
const isAlreadyConnected = useMemo(
() => !!selectedBank && connectedBankNames.has(selectedBank.name),
[selectedBank, connectedBankNames],
);
const setCredential = useCallback((key: string, value: string) => {
setCredentials((current) => ({ ...current, [key]: value }));
}, []);
useEffect(() => {
setAcknowledgedReplace(false);
}, [selectedBank]);
useEffect(() => {
setFilteredInstitutions(
searchQuery
? institutions.filter((i) =>
i.name.toLowerCase().includes(searchQuery.toLowerCase()),
)
: institutions,
);
}, [searchQuery, institutions]);
const reset = useCallback(() => {
setStep('country');
setCountry('');
setInstitutions([]);
setFilteredInstitutions([]);
setSearchQuery('');
setSelectedBank(null);
setIsLoading(false);
setIsSubmitting(false);
setError(null);
setCredentials({});
setAcknowledgedReplace(false);
}, []);
const clearBankSelection = useCallback(() => {
setInstitutions([]);
setFilteredInstitutions([]);
setSearchQuery('');
setSelectedBank(null);
}, []);
const fetchInstitutions = useCallback(
async (countryCode: string) => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(
`/open-banking/institutions?country=${countryCode}`,
{
headers: {
Accept: 'application/json',
'X-XSRF-TOKEN': getCsrfToken(),
},
},
);
if (!response.ok) {
throw new Error('Failed to fetch banks');
}
const data = await response.json();
const extraInstitutions = CONNECT_PROVIDERS.filter(
(p) =>
(!p.feature || features[p.feature]) &&
(!p.onlyCountry || p.onlyCountry === countryCode) &&
!hasLiveConnectionForProvider(
connections,
p.providerKey,
),
).map((p) => p.institution);
const allInstitutions = [...extraInstitutions, ...data].sort(
(a, b) => a.name.localeCompare(b.name),
);
setInstitutions(allInstitutions);
setFilteredInstitutions(allInstitutions);
setStep('bank');
} catch {
setError(__('Failed to load banks. Please try again.'));
} finally {
setIsLoading(false);
}
},
[connections, features],
);
const handleAuthorize = useCallback(async () => {
if (!selectedBank) {
return;
}
setIsSubmitting(true);
setError(null);
try {
const url = provider
? provider.endpoint
: '/open-banking/authorize';
const body = provider
? {
...credentialPayload(provider, credentials),
...(provider.sendsCountry ? { country } : {}),
}
: {
aspsp_name: selectedBank.name,
country,
logo: selectedBank.logo,
};
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-XSRF-TOKEN': getCsrfToken(),
},
body: JSON.stringify(body),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(
data.message || 'Failed to start authorization',
);
}
const data = await response.json();
window.location.href = data.redirect_url;
} catch (e) {
setError(
e instanceof Error
? e.message
: __('Failed to connect. Please try again.'),
);
setIsSubmitting(false);
}
}, [selectedBank, provider, credentials, country]);
const canSubmit =
!isSubmitting &&
!(isAlreadyConnected && !acknowledgedReplace) &&
(!provider || isProviderComplete(provider, credentials));
return {
step,
setStep,
country,
setCountry,
filteredInstitutions,
searchQuery,
setSearchQuery,
selectedBank,
setSelectedBank,
isLoading,
isSubmitting,
error,
credentials,
setCredential,
provider,
connectedBankNames,
isAlreadyConnected,
acknowledgedReplace,
setAcknowledgedReplace,
canSubmit,
fetchInstitutions,
handleAuthorize,
reset,
clearBankSelection,
};
}