Skip to content

Commit e1a20a8

Browse files
committed
fix(web): improve import handling and onboarding
- Add shared IBAN detection during CSV import - IBANs with multiple different names are moved to shared_ibans table - Users can resolve shared IBANs via Address Book UI - Fix onboarding page matching when avatar clicked - Strip /app and /dashboard base paths before matching routes - Fix ING import payment method mapping - Map overschrijving to lowercase to match transaction badge filter - Add comprehensive method map for all ING payment types - Fix UNIQUE constraint error for payment_provider_rules - Use INSERT OR IGNORE when seeding default payment providers
1 parent 998ab3e commit e1a20a8

11 files changed

Lines changed: 377 additions & 188 deletions

File tree

apps/landing/src/lib/i18n/nl.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -927,7 +927,7 @@ export const nl: LandingTranslationKeys = {
927927
create: 'Maak nieuw contact',
928928
get: 'Haal contact op met ID',
929929
update: 'Update contact',
930-
delete: 'Verwijder contact',
930+
delete: 'Contacten verwijderen',
931931
},
932932
listTitle: 'Lijst Contacten',
933933
listText:

apps/web/src/components/onboarding/OnboardingContext.tsx

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,16 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
179179
// trigger navigation). This ensures the mascot click starts onboarding
180180
// at the active tab.
181181
const normalizePath = (p: string) => {
182-
const trimmed = p.replace(/\/+$/, '');
182+
// Strip any base path (e.g., /app/) to get the route path
183+
let trimmed = p.replace(/\/+$/, '');
184+
// Check for common base paths
185+
const basePaths = ['/app', '/dashboard'];
186+
for (const base of basePaths) {
187+
if (trimmed.startsWith(base)) {
188+
trimmed = trimmed.slice(base.length);
189+
break;
190+
}
191+
}
183192
return trimmed === '' ? '/' : trimmed;
184193
};
185194
const requestedPath = normalizePath(window.location.pathname);
@@ -208,11 +217,8 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
208217
// Find chapter matching current route
209218
let startChapterIndex = 0;
210219

211-
// If starting at current page, but user never completed initial setup, start from beginning
212-
if (startAtCurrentPage && !hasCompletedInitialSetup) {
213-
startAtCurrentPage = false;
214-
}
215-
220+
// If starting at current page (mascot click), find the matching chapter
221+
// Note: We always honor startAtCurrentPage when explicitly requested
216222
if (startAtCurrentPage) {
217223
const currentPath = requestedPath;
218224
// Find all chapters matching this route (excluding welcome at index 0 and completion at last index)
@@ -249,7 +255,8 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
249255
}
250256

251257
setState((prev) => {
252-
// If starting at current page, always start fresh at that chapter
258+
// If starting at current page (mascot click), always start at that chapter
259+
// Note: startChapterIndex > 0 because index 0 is welcome, and we found a content chapter
253260
if (startAtCurrentPage && startChapterIndex > 0) {
254261
return {
255262
...prev,
@@ -259,37 +266,37 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
259266
};
260267
}
261268

262-
// If restarting, start from beginning
263-
if (restart) {
269+
// If mascot was clicked but no matching chapter found, still start but from navigation (index 1)
270+
if (startAtCurrentPage) {
264271
return {
265272
...prev,
266273
isActive: true,
267-
currentChapterIndex: 0,
274+
currentChapterIndex: 1, // Start at navigation chapter
268275
currentStepIndex: 0,
269276
};
270277
}
271278

272-
// Otherwise resume from saved position only if it matches the current route
273-
const savedChapter = onboardingChapters[prev.currentChapterIndex];
274-
const currentPath = window.location.pathname;
275-
if (savedChapter && savedChapter.route === currentPath) {
276-
// Resume at saved position since we're on the same page
279+
// If restarting, start from beginning
280+
if (restart) {
277281
return {
278282
...prev,
279283
isActive: true,
284+
currentChapterIndex: 0,
285+
currentStepIndex: 0,
280286
};
281287
}
282288

283-
// If on different page, find matching chapter and start there
284-
const matchingChapter = onboardingChapters.findIndex(
285-
(chapter) => chapter.route === currentPath
286-
);
287-
if (matchingChapter > 0) {
289+
// Resume from saved position (continue session)
290+
// Only resume if we have made progress (not at the start)
291+
if (prev.currentChapterIndex > 0 || prev.currentStepIndex > 0) {
292+
// Navigate to the saved chapter's route
293+
const savedChapter = onboardingChapters[prev.currentChapterIndex];
294+
if (savedChapter) {
295+
navigate(savedChapter.route);
296+
}
288297
return {
289298
...prev,
290299
isActive: true,
291-
currentChapterIndex: matchingChapter,
292-
currentStepIndex: 0,
293300
};
294301
}
295302

apps/web/src/components/settings/ProfileDataSettings.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,23 @@ export function ProfileDataSettings() {
111111
fallbackError: 'Fout bij verwijderen budgetten',
112112
fallbackButton: 'Verwijder budgetten',
113113
},
114+
{
115+
titleKey: 'deleteAddressBookTitle',
116+
descKey: 'deleteAddressBookDescription',
117+
confirmKey: 'deleteAddressBookConfirm',
118+
successKey: 'deleteAddressBookSuccess',
119+
errorKey: 'deleteAddressBookError',
120+
buttonKey: 'deleteAddressBookButton',
121+
action: api.deleteAllAddressBook,
122+
fallbackTitle: 'Adresboek verwijderen',
123+
fallbackDesc:
124+
'Verwijder alle contacten (IBANs verschijnen weer in voorgestelde adressen)',
125+
fallbackConfirm:
126+
'Weet je zeker dat je alle contacten wilt verwijderen? De IBANs worden weer voorgesteld.',
127+
fallbackSuccess: 'Alle contacten verwijderd',
128+
fallbackError: 'Fout bij verwijderen contacten',
129+
fallbackButton: 'Verwijder contacten',
130+
},
114131
];
115132

116133
// Helper to get translation with fallback

apps/web/src/components/settings/ProfileManager.tsx

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export function ProfileManager() {
111111
const [editingProfile, setEditingProfile] = useState<Profile | null>(null);
112112
const [copiedProfileId, setCopiedProfileId] = useState<string | null>(null);
113113
const [toastMessage, setToastMessage] = useState<string | null>(null);
114+
const [toastType, setToastType] = useState<'success' | 'error'>('success');
114115

115116
// Step state for create dialog: 'profile' | 'accounts'
116117
const [createStep, setCreateStep] = useState<'profile' | 'accounts'>(
@@ -221,17 +222,21 @@ export function ProfileManager() {
221222
setCreateStep('accounts');
222223
setCreatedAccounts([]);
223224
} else {
225+
setToastType('error');
224226
setToastMessage(
225227
t.settings?.profileManager?.createError ||
226228
'Er ging iets mis bij het aanmaken van het profiel.'
227229
);
230+
setIsCreateOpen(false);
228231
}
229232
} catch (error) {
230233
console.error('Failed to create profile', error);
234+
setToastType('error');
231235
setToastMessage(
232236
t.settings?.profileManager?.createError ||
233237
'Er ging iets mis bij het aanmaken van het profiel.'
234238
);
239+
setIsCreateOpen(false);
235240
} finally {
236241
setIsCreating(false);
237242
}
@@ -312,6 +317,7 @@ export function ProfileManager() {
312317
try {
313318
await deleteProfile(id);
314319
if (isDemoProfile) {
320+
setToastType('success');
315321
setToastMessage(
316322
t.settings?.profileManager?.demoDeleted ||
317323
'Demo profile deleted. Restart onboarding to create a new one.'
@@ -336,6 +342,7 @@ export function ProfileManager() {
336342
try {
337343
await navigator.clipboard.writeText(profileId);
338344
setCopiedProfileId(profileId);
345+
setToastType('success');
339346
setToastMessage(
340347
t.settings?.profileManager?.idCopied ||
341348
'ID successfully copied to clipboard'
@@ -652,7 +659,13 @@ export function ProfileManager() {
652659
return (
653660
<Card
654661
key={profile.id}
655-
className={`transition-all ${isActive ? 'border-primary ring-1 ring-primary' : 'hover:border-primary/50'}`}
662+
className={cn(
663+
'transition-all',
664+
isActive
665+
? 'border-primary ring-1 ring-primary'
666+
: 'hover:border-primary/50',
667+
profile.isHidden && 'opacity-50'
668+
)}
656669
>
657670
<CardHeader className='pb-3'>
658671
<div className='flex items-start justify-between'>
@@ -740,15 +753,22 @@ export function ProfileManager() {
740753
</div>
741754
</CardContent>
742755
<CardFooter className='flex justify-between border-t bg-muted/20 p-3'>
743-
<Button
744-
variant='ghost'
745-
size='sm'
746-
onClick={() => switchProfile(profile.id)}
747-
disabled={isActive}
748-
className={isActive ? 'invisible' : ''}
749-
>
750-
{t.settings.profileManager.switchTo}
751-
</Button>
756+
{/* Hide switch button for hidden profiles and when active */}
757+
{profile.isHidden ? (
758+
<span className='text-xs text-muted-foreground'>
759+
{t.settings?.profileManager?.hidden || 'Hidden'}
760+
</span>
761+
) : (
762+
<Button
763+
variant='ghost'
764+
size='sm'
765+
onClick={() => switchProfile(profile.id)}
766+
disabled={isActive}
767+
className={isActive ? 'invisible' : ''}
768+
>
769+
{t.settings.profileManager.switchTo}
770+
</Button>
771+
)}
752772
<div className='flex gap-1'>
753773
{/* Hide edit button for Demo profile - it's only removable, not editable */}
754774
{profile.id !== DEMO_PROFILE_ID &&
@@ -961,7 +981,7 @@ export function ProfileManager() {
961981
{toastMessage && (
962982
<Toast
963983
message={toastMessage}
964-
type='success'
984+
type={toastType}
965985
onClose={() => setToastMessage(null)}
966986
/>
967987
)}

apps/web/src/lib/api-compat.ts

Lines changed: 6 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,11 @@ export const api = {
298298
return ds.deleteAllBudgets();
299299
},
300300

301+
deleteAllAddressBook: async () => {
302+
const ds = getDataService();
303+
return ds.deleteAllAddressBook();
304+
},
305+
301306
// ============= Analytics =============
302307
getDashboardStats: async (
303308
startDate?: string,
@@ -1037,123 +1042,7 @@ export const api = {
10371042
contactId?: string
10381043
) => {
10391044
const ds = getDataService();
1040-
1041-
const db = (
1042-
ds as unknown as {
1043-
db: {
1044-
queryAsync: <T>(sql: string, params: unknown[]) => Promise<T[]>;
1045-
runAsync: (
1046-
sql: string,
1047-
params: unknown[]
1048-
) => Promise<{ changes?: number }>;
1049-
queryOneAsync: <T>(
1050-
sql: string,
1051-
params: unknown[]
1052-
) => Promise<T | null>;
1053-
};
1054-
}
1055-
).db;
1056-
1057-
const pid =
1058-
typeof window !== 'undefined'
1059-
? window.localStorage.getItem('fluxby.activeProfileId')
1060-
: null;
1061-
if (!pid) throw new Error('No active profile');
1062-
1063-
const normalizedIban = iban.toUpperCase().trim();
1064-
const normalizedName = name.trim();
1065-
const now = Date.now();
1066-
1067-
// If a contact with EXACT same name exists, always merge into it.
1068-
const existingByName = await db.queryAsync<{ id: string }>(
1069-
`SELECT id FROM address_book
1070-
WHERE profile_id = ? AND is_deleted = 0 AND TRIM(name) = TRIM(?)
1071-
LIMIT 1`,
1072-
[pid, normalizedName]
1073-
);
1074-
1075-
let addressBookId: string;
1076-
let isNewContact = false;
1077-
1078-
if (contactId) {
1079-
addressBookId = contactId;
1080-
await ds.updateAddressBookEntry(contactId, { name: normalizedName });
1081-
} else if (existingByName.length > 0) {
1082-
addressBookId = existingByName[0].id;
1083-
} else {
1084-
// Create a new contact. For shared IBANs we use original_name to disambiguate.
1085-
addressBookId = crypto.randomUUID();
1086-
isNewContact = true;
1087-
const primaryOriginalName = (originalNames[0] || normalizedName).trim();
1088-
await db.runAsync(
1089-
`INSERT INTO address_book (id, iban, name, original_name, profile_id, created_at, updated_at)
1090-
VALUES (?, ?, ?, ?, ?, ?, ?)`,
1091-
[
1092-
addressBookId,
1093-
normalizedIban,
1094-
normalizedName,
1095-
primaryOriginalName,
1096-
pid,
1097-
now,
1098-
now,
1099-
]
1100-
);
1101-
}
1102-
1103-
// Ensure the IBAN is linked to the contact (shared IBANs can be linked to multiple contacts).
1104-
await db.runAsync(
1105-
'INSERT OR IGNORE INTO contact_ibans (id, contact_id, iban, is_primary, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
1106-
[
1107-
crypto.randomUUID(),
1108-
addressBookId,
1109-
normalizedIban,
1110-
isNewContact ? 1 : 0,
1111-
now,
1112-
now,
1113-
]
1114-
);
1115-
1116-
// Update transactions with the new merchant name + link them to the contact.
1117-
let transactionsUpdated = 0;
1118-
if (originalNames.length > 0) {
1119-
for (const originalName of originalNames) {
1120-
const normalizedOriginal = originalName.trim();
1121-
const result = await db.runAsync(
1122-
`UPDATE transactions
1123-
SET merchant_name = ?, address_book_id = ?, updated_at = ?
1124-
WHERE profile_id = ?
1125-
AND opposing_account_iban = ?
1126-
AND (
1127-
LOWER(opposing_account_name) = LOWER(?) OR
1128-
LOWER(merchant_name) = LOWER(?)
1129-
)
1130-
AND is_deleted = 0`,
1131-
[
1132-
normalizedName,
1133-
addressBookId,
1134-
now,
1135-
pid,
1136-
normalizedIban,
1137-
normalizedOriginal,
1138-
normalizedOriginal,
1139-
]
1140-
);
1141-
transactionsUpdated += result?.changes || 0;
1142-
}
1143-
} else {
1144-
// Fallback: link by IBAN only if no originalNames were provided.
1145-
const result = await db.runAsync(
1146-
`UPDATE transactions
1147-
SET merchant_name = ?, address_book_id = ?, updated_at = ?
1148-
WHERE profile_id = ?
1149-
AND opposing_account_iban = ?
1150-
AND is_deleted = 0`,
1151-
[normalizedName, addressBookId, now, pid, normalizedIban]
1152-
);
1153-
transactionsUpdated += result?.changes || 0;
1154-
}
1155-
1156-
return { success: true, data: { transactionsUpdated } };
1045+
return ds.resolveSharedIban(iban, name, originalNames, contactId);
11571046
},
11581047

11591048
addContactIban: async (contactId: string, iban: string) => {

0 commit comments

Comments
 (0)