From 059c5787758e39e9dcae207324285a9cad88b60e Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Sat, 20 Jun 2026 16:59:35 +0100 Subject: [PATCH] feat(frontend): add name editing and GDPR export page Add inline name editing on account page with validation, and GDPR data export page with cooldown timer. - Inline edit first/last name with unicode-aware regex validation - GDPR export page with countdown timer between exports (12h cooldown) - Display referral savings instead of calculated estimate - Add log out button in account settings section Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- frontend/src/routes/account/+page.svelte | 340 +++++++++++++++++++---- frontend/src/routes/gdpr/+page.svelte | 82 ++++++ 2 files changed, 372 insertions(+), 50 deletions(-) diff --git a/frontend/src/routes/account/+page.svelte b/frontend/src/routes/account/+page.svelte index 2e24169..729b00d 100644 --- a/frontend/src/routes/account/+page.svelte +++ b/frontend/src/routes/account/+page.svelte @@ -790,6 +790,180 @@ } } + // =============== Name Edit Mode =============== + let editingFirstName = $state(false); + let editingLastName = $state(false); + let firstNameInput = $state(''); + let lastNameInput = $state(''); + let firstNameError = $state(''); + let lastNameError = $state(''); + let savingName = $state(false); + + // Name validation (unicode letters, spaces, hyphen, apostrophe, dot) + const nameRegex = /^[\p{L}\p{M}\s\-'\.]+$/u; + + function startEditFirstName() { + firstNameInput = userData?.firstName || ''; + firstNameError = ''; + editingFirstName = true; + editingLastName = false; + } + + function startEditLastName() { + lastNameInput = userData?.lastName || ''; + lastNameError = ''; + editingLastName = true; + editingFirstName = false; + } + + function cancelEditName() { + editingFirstName = false; + editingLastName = false; + firstNameInput = ''; + lastNameInput = ''; + firstNameError = ''; + lastNameError = ''; + } + + async function saveName() { + const newFirstName = editingFirstName ? firstNameInput.trim() : (userData?.firstName || ''); + const newLastName = editingLastName ? lastNameInput.trim() : (userData?.lastName || ''); + + // Validate + if (!newFirstName || !newLastName) { + if (!newFirstName) firstNameError = 'First name is required'; + if (!newLastName) lastNameError = 'Last name is required'; + toast.error('Name is required'); + return; + } + + if (newFirstName.length > 50) { + firstNameError = 'First name must be 50 characters or less'; + toast.error('First name is too long'); + return; + } + if (newLastName.length > 50) { + lastNameError = 'Last name must be 50 characters or less'; + toast.error('Last name is too long'); + return; + } + + if (!nameRegex.test(newFirstName)) { + firstNameError = 'Invalid characters in first name'; + toast.error('Please use only letters, spaces, hyphens, apostrophes, or dots'); + return; + } + if (!nameRegex.test(newLastName)) { + lastNameError = 'Invalid characters in last name'; + toast.error('Please use only letters, spaces, hyphens, apostrophes, or dots'); + return; + } + + savingName = true; + const loadingToast = toast.loading('Updating name...'); + + try { + const response = await fetch('/api/user/profile', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authStore.currentToken}` + }, + body: JSON.stringify({ + firstName: newFirstName, + lastName: newLastName, + phone: userData?.phone || '' + }) + }); + + if (response.ok) { + toast.success('Name updated successfully!', { id: loadingToast }); + editingFirstName = false; + editingLastName = false; + firstNameInput = ''; + lastNameInput = ''; + firstNameError = ''; + lastNameError = ''; + // Refresh user data to get updated info (including previous names) + await fetchUserData(); + } else { + const text = await response.text(); + toast.error(sanitizeText(text) || 'Failed to update name', { id: loadingToast }); + } + } catch (err) { + console.error('Error updating name:', err); + toast.error('Network error', { id: loadingToast }); + } finally { + savingName = false; + } + } + + // =============== GDPR Export Status =============== + const GDPR_COOLDOWN_MS = 12 * 60 * 60 * 1000; + let gdprExportMeta = $state<{ exported_at?: string } | null>(null); + let gdprCountdown = $state(null); + let countdownTimer: ReturnType | null = null; + + function updateCountdown(exportedAt: number) { + const elapsed = Date.now() - exportedAt; + const remaining = GDPR_COOLDOWN_MS - elapsed; + if (remaining <= 0) { + gdprExportMeta = null; + gdprCountdown = null; + return; + } + const hours = Math.floor(remaining / (60 * 60 * 1000)); + const minutes = Math.floor((remaining % (60 * 60 * 1000)) / (60 * 1000)); + const seconds = Math.floor((remaining % (60 * 1000)) / 1000); + if (hours > 0) { + gdprCountdown = `New export available in ${hours}h ${minutes}m`; + } else if (minutes > 0) { + gdprCountdown = `New export available in ${minutes}m ${seconds}s`; + } else { + gdprCountdown = `New export available in ${seconds}s`; + } + } + + function startCountdown(exportedAt: string) { + stopCountdown(); + const ts = new Date(exportedAt).getTime(); + updateCountdown(ts); + if (gdprExportMeta) { + countdownTimer = setInterval(() => updateCountdown(ts), 1000); + } + } + + function stopCountdown() { + if (countdownTimer) { + clearInterval(countdownTimer); + countdownTimer = null; + } + } + + async function fetchGdprExportStatus() { + try { + const res = await fetch('/api/user/gdpr-export', { + headers: { Authorization: `Bearer ${authStore.currentToken}` } + }); + if (res.ok) { + const data = await res.json(); + if (data.export_metadata?.exported_at) { + const elapsed = Date.now() - new Date(data.export_metadata.exported_at).getTime(); + if (elapsed < GDPR_COOLDOWN_MS) { + gdprExportMeta = data.export_metadata; + startCountdown(data.export_metadata.exported_at); + return; + } + } + } + // No valid export: clear everything. + gdprExportMeta = null; + gdprCountdown = null; + } catch { + // silently fail + } + } + // =============== Fetch User Data =============== async function fetchUserData() { if (pageState !== 'authorized') return; @@ -946,6 +1120,7 @@ fetchUpcomingBookings(); fetchPastBookings(); fetchNotifPrefs(); + fetchGdprExportStatus(); } }); @@ -1371,18 +1546,78 @@ {/each} {:else if userData}
-
- First Name -
- {userData.firstName} +
+ First Name + {#if editingFirstName} +
+ + {#if firstNameError} +

{firstNameError}

+ {/if} +
+ + +
-
-
- Last Name -
- {userData.lastName} + {:else} +
+ {userData.firstName} +
-
+ {/if} +
+
+ Last Name + {#if editingLastName} +
+ + {#if lastNameError} +

{lastNameError}

+ {/if} +
+ + +
+
+ {:else} +
+ {userData.lastName} + +
+ {/if} +
Email
@@ -1696,14 +1931,14 @@
{userData.referralCodeUses || 0}
-
Times Used
+
Friends Referred
-
-
- £{(userData.referralCodeUses || 0) * 5} -
-
Total Saved
+
+
+ £{(userData.referralSavings || 0).toFixed(2)}
+
Total Saved
+
+ +
+

Session

+

Log out of this account on this device.

+ +
+ + +

Data Privacy

@@ -2302,23 +2559,11 @@ Export My Data -
- - - -
-

Policies

-

- View our cancellation, deposit, and no-show policies -

- - {#snippet trigger()} - - {/snippet} - + {#if gdprCountdown} +

+ {gdprCountdown} +

+ {/if}
@@ -2398,24 +2643,19 @@ {/if} -
-

Session

-

Log out of this account on this device.

- +

Policies

+

+ View our cancellation, deposit, and no-show policies +

+ + {#snippet trigger()} + + {/snippet} +
diff --git a/frontend/src/routes/gdpr/+page.svelte b/frontend/src/routes/gdpr/+page.svelte index bf707ed..ddcc4f4 100644 --- a/frontend/src/routes/gdpr/+page.svelte +++ b/frontend/src/routes/gdpr/+page.svelte @@ -81,6 +81,13 @@ referred_at: string; }>; }; + referral_discounts?: Array<{ + id: string; + discount_percent: number; + used: boolean; + created_at: string; + used_at?: string; + }>; notification_preferences?: Array<{ email_enabled: boolean; sms_enabled: boolean; @@ -161,6 +168,12 @@ booking_id: string; created_at: string; }>; + name_history?: Array<{ + previous_first_name: string; + previous_last_name: string; + booking_id?: string; + changed_at: string; + }>; login_audit?: Array<{ attempt_type: string; ip_address: string; @@ -727,6 +740,37 @@
+ + {#if gdprData.name_history && gdprData.name_history.length > 0} + + Name Change History + +
+ + + + {#each gdprData.name_history as nh (nh.changed_at)} + + + + + + {/each} + +
Previous NameChanged AtBooking
{nh.previous_first_name} {nh.previous_last_name}{fmtDateTime(nh.changed_at)}{nh.booking_id || '—'}
+
+
+
+ {/if} + {#if gdprData.social_logins && gdprData.social_logins.length > 0} @@ -911,6 +955,44 @@ {/if} + + {#if gdprData.referral_discounts && gdprData.referral_discounts.length > 0} + + Referral Discounts + +
+ + + + {#each gdprData.referral_discounts as rd (rd.id)} + + + + + + + {/each} + +
CreatedDiscountStatusUsed At
{fmtDateTime(rd.created_at)}{rd.discount_percent}%{rd.used ? 'Used' : 'Available'}{rd.used_at ? fmtDateTime(rd.used_at) : '—'}
+
+
+
+ {/if} + {#if gdprData.login_audit && gdprData.login_audit.length > 0}