import { AiSuggestion, AiSuggestionCard, SuggestionDraft, } from '@/components/onboarding/ai-suggestion-card'; import { StepButton } from '@/components/onboarding/step-button'; import { StepHeader } from '@/components/onboarding/step-header'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { store as storeConsent } from '@/routes/ai/consent'; import { accept, generate, show } from '@/routes/ai/rule-suggestions'; import { type SharedData } from '@/types'; import { type Category } from '@/types/category'; import { formatCurrency } from '@/utils/currency'; import { __ } from '@/utils/i18n'; import { router, usePage } from '@inertiajs/react'; import axios from 'axios'; import { Loader2, PartyPopper, Sparkles, Wand2 } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; interface SuggestionState { available: boolean; consented: boolean; requires_upgrade: boolean; eligible: boolean; transaction_count: number; min_transactions: number; auto_select_confidence: number; throttled: boolean; throttled_until: string | null; run: { id: string; status: string; suggestions_count: number } | null; suggestions: AiSuggestion[]; } interface AcceptResponse { summary: { rules_created: number; transactions_categorized: number }; applied_to_existing: boolean; } interface StepAiSuggestionsProps { categories: Category[]; onComplete: () => void; } export function StepAiSuggestions({ categories, onComplete, }: StepAiSuggestionsProps) { const [state, setState] = useState(null); const [drafts, setDrafts] = useState>({}); const [busy, setBusy] = useState(false); const [submitting, setSubmitting] = useState(false); const [summary, setSummary] = useState( null, ); const pollRef = useRef>(undefined); const onCompleteRef = useRef(onComplete); onCompleteRef.current = onComplete; const applyState = useCallback((data: SuggestionState) => { setState(data); setDrafts((prev) => { const next = { ...prev }; for (const suggestion of data.suggestions) { if (!next[suggestion.id]) { next[suggestion.id] = { // Auto-select only confident suggestions; weaker ones // are shown but left for the user to opt into. include: suggestion.confidence >= data.auto_select_confidence, categoryId: suggestion.proposed_category?.id ?? null, values: suggestion.values.map((value) => ({ field: value.match_field, operator: value.match_operator, token: value.match_token, })), }; } } return next; }); }, []); const isRunning = (data: SuggestionState | null): boolean => data?.run?.status === 'pending' || data?.run?.status === 'processing'; const poll = useCallback(async () => { const { data } = await axios.get(show().url); applyState(data); if (isRunning(data)) { pollRef.current = setTimeout(poll, 3000); } }, [applyState]); const startGenerate = useCallback(async () => { setBusy(true); try { const { data } = await axios.post(generate().url); applyState(data); if (isRunning(data)) { poll(); } } catch (error) { if (axios.isAxiosError(error) && error.response?.status === 422) { applyState(error.response.data as SuggestionState); } } finally { setBusy(false); } }, [applyState, poll]); useEffect(() => { let cancelled = false; (async () => { try { const { data } = await axios.get(show().url); if (cancelled) { return; } applyState(data); if (isRunning(data)) { poll(); } else if ( data.consented && data.eligible && !data.throttled && !data.run ) { startGenerate(); } } catch { // Never block onboarding if the AI step can't load. onCompleteRef.current(); } })(); return () => { cancelled = true; clearTimeout(pollRef.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const acceptConsent = async () => { setBusy(true); try { await axios.post(storeConsent().url); } finally { setBusy(false); } startGenerate(); }; const submit = async () => { if (!state) { return; } const chosen = state.suggestions.filter((s) => { const draft = drafts[s.id]; return ( draft?.include && draft.values.some((value) => value.token.trim() !== '') ); }); if (chosen.length === 0) { onCompleteRef.current(); return; } setSubmitting(true); try { const payload = chosen.map((suggestion) => { const draft = drafts[suggestion.id]; const categoryId = draft.categoryId && draft.categoryId !== 'uncategorized' ? draft.categoryId : null; return { ids: suggestion.values.map((value) => value.id), values: draft.values .filter((value) => value.token.trim() !== '') .map((value) => ({ match_field: value.field, match_operator: value.operator, match_token: value.token.trim(), })), proposed_category_id: categoryId, new_category_name: categoryId ? null : suggestion.new_category_name, new_category_direction: categoryId ? null : suggestion.new_category_direction, }; }); const { data } = await axios.post(accept().url, { suggestions: payload, }); // The rules were created via axios, so refresh the Inertia props the // later onboarding steps rely on (newly created rules + categories, // and the transactions that just got categorized) before advancing. router.reload({ only: ['automationRules', 'categories', 'transactions'], onFinish: () => { setSummary(data.summary); setSubmitting(false); }, }); } catch { setSubmitting(false); } }; // --- Render states ----------------------------------------------------- if (summary) { return ( ); } if (!state || busy || isRunning(state)) { return (

{__('This can take up to two minutes.')}

); } if (!state.consented) { return ( {state.requires_upgrade && }
); } if (!state.eligible) { return ( ); } if (state.run?.status === 'failed') { return (
); } if (state.run?.status === 'empty' || state.suggestions.length === 0) { return ( ); } const selectedCount = state.suggestions.filter( (s) => drafts[s.id]?.include, ).length; return (
{state.suggestions.map((suggestion) => ( = state.auto_select_confidence, categoryId: suggestion.proposed_category?.id ?? null, values: suggestion.values.map((value) => ({ field: value.match_field, operator: value.match_operator, token: value.match_token, })), } } categories={categories} onChange={(draft) => setDrafts((prev) => ({ ...prev, [suggestion.id]: draft, })) } /> ))}
0 ? __('Create :count rules & apply', { count: selectedCount, }) : __('Continue') } onClick={submit} loading={submitting} loadingText={__('Applying…')} />
); } /** * Warns free users (who haven't linked a bank yet) that turning on AI * suggestions commits them to picking a paid plan at the end of onboarding, * mirroring the notice shown when choosing a connected account. */ function UpgradeNotice() { const { pricing, locale } = usePage().props; const cheapestMonthlyPrice = useMemo(() => { const plans = Object.values(pricing.plans); if (plans.length === 0) { return null; } return Math.min( ...plans.map((plan) => plan.billing_period === 'year' ? plan.price / 12 : plan.price, ), ); }, [pricing.plans]); return (

{__( "AI suggestions are a Standard Plan feature. You'll choose a plan at the end of the onboarding.", )}

{cheapestMonthlyPrice !== null && (

{__('From')}{' '} {formatCurrency( cheapestMonthlyPrice * 100, pricing.currency, locale, )} {__('/month')}

)}
); } function Centered({ children }: { children: React.ReactNode }) { return (
{children}
); } const GENERATING_MESSAGE_INTERVAL_MS = 3500; /** * Cycles through reassuring status messages while a run is in flight. The * backend exposes no real progress, so the messages step forward on a timer and * hold on the last one rather than looping back to the start (which would read * as the process restarting). */ function GeneratingMessages() { const messages = [ __('Analysing your transactions…'), __('Finding related groups…'), __('Finding the right categories…'), __('Grouping everything together…'), __('Polishing your suggestions…'), ]; const [index, setIndex] = useState(0); useEffect(() => { const id = setInterval(() => { setIndex((current) => Math.min(current + 1, messages.length - 1)); }, GENERATING_MESSAGE_INTERVAL_MS); return () => clearInterval(id); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return (
{messages[index]}
); } /** * Mirrors the collapsed {@link AiSuggestionCard} layout so the loading state * resembles the final UI: checkbox, summary line, match count, expand chevron. */ function SuggestionCardSkeleton() { return (
); }