whisper-money/resources/js/components/onboarding/step-create-account.tsx

334 lines
12 KiB
TypeScript

import { store } from '@/actions/App/Http/Controllers/Settings/AccountController';
import { store as storeBank } from '@/actions/App/Http/Controllers/Settings/BankController';
import {
AccountForm,
AccountFormData,
} from '@/components/accounts/account-form';
import { StepHeader } from '@/components/onboarding/step-header';
import { Button } from '@/components/ui/button';
import { CreatedAccount } from '@/hooks/use-onboarding-state';
import { encrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import { type AccountType, formatAccountType } from '@/types/account';
import { __ } from '@/utils/i18n';
import { AlertCircle, CheckCircle2, CreditCard } from 'lucide-react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { StepButton } from './step-button';
interface ExistingAccount {
id: string;
name: string;
name_iv: string;
type: string;
currency_code: string;
bank_id: string;
bank?: {
id: string;
name: string;
logo: string | null;
};
}
interface StepCreateAccountProps {
banks: { id: string; name: string; logo: string | null }[];
isFirstAccount: boolean;
existingAccounts?: ExistingAccount[];
onAccountCreated: (account: CreatedAccount) => void;
onSkip?: () => void;
}
export function StepCreateAccount({
isFirstAccount,
existingAccounts = [],
onAccountCreated,
onSkip,
}: StepCreateAccountProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const formDataRef = useRef<AccountFormData>({
displayName: '',
bankId: null,
type: isFirstAccount ? 'checking' : null,
currencyCode: null,
customBank: null,
});
const handleFormChange = useCallback((data: AccountFormData) => {
formDataRef.current = data;
}, []);
async function createBankAndGetId(): Promise<string | null> {
const customBank = formDataRef.current.customBank;
if (!customBank) return null;
const formData = new FormData();
formData.append('name', customBank.name);
if (customBank.logo) {
formData.append('logo', customBank.logo);
}
const response = await fetch(storeBank.url(), {
method: 'POST',
body: formData,
headers: {
'X-XSRF-TOKEN': decodeURIComponent(
document.cookie
.split('; ')
.find((row) => row.startsWith('XSRF-TOKEN='))
?.split('=')[1] || '',
),
Accept: 'application/json',
},
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to create bank');
}
const data = await response.json();
return data.id;
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
const { displayName, bankId, type, currencyCode, customBank } =
formDataRef.current;
const keyString = getStoredKey();
if (!keyString) {
setError(
__(
'Encryption key not available. Please go back and set up encryption.',
),
);
return;
}
if (!displayName.trim()) {
setError(__('Please enter an account name.'));
return;
}
if (!type || !currencyCode) {
setError(__('Please fill in all required fields.'));
return;
}
if (isFirstAccount && type !== 'checking') {
setError(__('Your first account must be a checking account.'));
return;
}
setIsSubmitting(true);
try {
let finalBankId: string;
if (customBank) {
if (!customBank.name.trim()) {
setError(__('Please enter a bank name.'));
setIsSubmitting(false);
return;
}
const createdBankId = await createBankAndGetId();
if (!createdBankId) {
throw new Error('Failed to create bank');
}
finalBankId = createdBankId;
} else {
if (!bankId) {
setError(__('Please select a bank.'));
setIsSubmitting(false);
return;
}
finalBankId = String(bankId);
}
const key = await importKey(keyString);
const { encrypted, iv } = await encrypt(displayName, key);
const response = await fetch(store.url(), {
method: 'POST',
body: JSON.stringify({
name: encrypted,
name_iv: iv,
bank_id: finalBankId,
type: type,
currency_code: currencyCode,
}),
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': decodeURIComponent(
document.cookie
.split('; ')
.find((row) => row.startsWith('XSRF-TOKEN='))
?.split('=')[1] || '',
),
Accept: 'application/json',
},
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(
errorData.message ||
Object.values(errorData.errors || {})[0] ||
'Failed to create account',
);
}
const accountData = await response.json();
onAccountCreated({
id: accountData.id || finalBankId,
name: displayName,
type: type,
currencyCode: currencyCode,
});
setIsSubmitting(false);
} catch (err) {
console.error('Account creation failed:', err);
setError(
err instanceof Error
? err.message
: __('Failed to create account. Please try again.'),
);
setIsSubmitting(false);
}
}
const hasExistingAccounts = existingAccounts.length > 0;
const { title, description } = useMemo(() => {
if (hasExistingAccounts) {
return {
title: __('Your Accounts'),
description: __(
"You already have accounts set up. Let's continue with the onboarding.",
),
};
}
if (isFirstAccount) {
return {
title: __('Create an Account'),
description: __(
"Let's start with your main checking account. You can add more accounts later.",
),
};
}
return {
title: __('Add Another Account'),
description: __(
'Add another account to track more of your finances.',
),
};
}, [hasExistingAccounts, isFirstAccount]);
return (
<div className="flex animate-in flex-col items-center duration-500 fade-in slide-in-from-bottom-4">
<StepHeader
icon={CreditCard}
iconContainerClassName="bg-gradient-to-br from-emerald-400 to-teal-500"
title={title}
description={description}
/>
{hasExistingAccounts ? (
<div className="w-full max-w-md">
<div className="mb-6 space-y-2">
{existingAccounts.map((account) => (
<div
key={account.id}
className="flex items-center gap-3 rounded-lg border bg-card p-4"
>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/30">
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
</div>
<div className="flex-1">
<p className="font-medium">
{account.bank?.name || 'Account'}
</p>
<p className="text-sm text-muted-foreground">
{formatAccountType(
account.type as AccountType,
)}{' '}
{account.currency_code}
</p>
</div>
</div>
))}
</div>
<StepButton
text={__('Continue')}
className="w-full sm:w-full"
onClick={() =>
onAccountCreated({
id: existingAccounts[0].id,
name:
existingAccounts[0].bank?.name || 'Account',
type: existingAccounts[0].type,
currencyCode: existingAccounts[0].currency_code,
})
}
/>
</div>
) : (
<form
onSubmit={handleSubmit}
autoFocus
className="w-full max-w-md space-y-4"
>
<AccountForm
forceAccountType={
isFirstAccount ? 'checking' : undefined
}
onChange={handleFormChange}
/>
{isFirstAccount && (
<div className="rounded-lg border border-blue-100 bg-blue-50 p-3 text-sm dark:border-blue-900/50 dark:bg-blue-900/20">
<p className="text-center">
{__('Your first account must be a')}{' '}
<strong>{__('Checking')}</strong>{' '}
{__('account.')}
</p>
</div>
)}
{error && (
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
<StepButton
type="submit"
className="w-full sm:w-full"
disabled={isSubmitting}
loading={isSubmitting}
loadingText={__('Creating...')}
text={__('Create Account')}
/>
{!isFirstAccount && onSkip && (
<Button
size="lg"
className="w-full opacity-50 transition-all duration-200 hover:opacity-100"
variant={'ghost'}
disabled={isSubmitting}
onClick={() => onSkip()}
>
{__('Ignore')}
</Button>
)}
</form>
)}
</div>
);
}