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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-20 16:59:35 +01:00
co-authored by Sisyphus
parent 1a3829b4d9
commit 059c578775
2 changed files with 372 additions and 50 deletions
+290 -50
View File
@@ -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<string | null>(null);
let countdownTimer: ReturnType<typeof setInterval> | 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 =============== // =============== Fetch User Data ===============
async function fetchUserData() { async function fetchUserData() {
if (pageState !== 'authorized') return; if (pageState !== 'authorized') return;
@@ -946,6 +1120,7 @@
fetchUpcomingBookings(); fetchUpcomingBookings();
fetchPastBookings(); fetchPastBookings();
fetchNotifPrefs(); fetchNotifPrefs();
fetchGdprExportStatus();
} }
}); });
@@ -1371,18 +1546,78 @@
{/each} {/each}
{:else if userData} {:else if userData}
<div class="grid gap-4 md:grid-cols-2"> <div class="grid gap-4 md:grid-cols-2">
<div> <div>
<span class="text-sm font-medium text-gray-600">First Name</span> <span class="text-sm font-medium text-gray-600">First Name</span>
<div class="mt-1 rounded-lg border bg-gray-50 p-3 font-medium"> {#if editingFirstName}
{userData.firstName} <div class="mt-1 space-y-2">
<Input
id="first-name"
bind:value={firstNameInput}
placeholder="Enter first name"
maxlength={50}
error={firstNameError}
/>
{#if firstNameError}
<p class="text-xs text-red-500">{firstNameError}</p>
{/if}
<div class="flex gap-2">
<Button size="sm" onclick={saveName} disabled={savingName || !firstNameInput.trim()}>
{savingName ? 'Saving...' : 'Save'}
</Button>
<Button size="sm" variant="outline" onclick={cancelEditName} disabled={savingName}>
Cancel
</Button>
</div>
</div> </div>
</div> {:else}
<div> <div class="mt-1 flex items-center justify-between rounded-lg border bg-gray-50 p-3 font-medium">
<span class="text-sm font-medium text-gray-600">Last Name</span> <span>{userData.firstName}</span>
<div class="mt-1 rounded-lg border bg-gray-50 p-3 font-medium"> <Button size="sm" variant="ghost" onclick={startEditFirstName}>
{userData.lastName} <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Edit
</Button>
</div> </div>
</div> {/if}
</div>
<div>
<span class="text-sm font-medium text-gray-600">Last Name</span>
{#if editingLastName}
<div class="mt-1 space-y-2">
<Input
id="last-name"
bind:value={lastNameInput}
placeholder="Enter last name"
maxlength={50}
error={lastNameError}
/>
{#if lastNameError}
<p class="text-xs text-red-500">{lastNameError}</p>
{/if}
<div class="flex gap-2">
<Button size="sm" onclick={saveName} disabled={savingName || !lastNameInput.trim()}>
{savingName ? 'Saving...' : 'Save'}
</Button>
<Button size="sm" variant="outline" onclick={cancelEditName} disabled={savingName}>
Cancel
</Button>
</div>
</div>
{:else}
<div class="mt-1 flex items-center justify-between rounded-lg border bg-gray-50 p-3 font-medium">
<span>{userData.lastName}</span>
<Button size="sm" variant="ghost" onclick={startEditLastName}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Edit
</Button>
</div>
{/if}
</div>
<div> <div>
<span class="text-sm font-medium text-gray-600">Email</span> <span class="text-sm font-medium text-gray-600">Email</span>
<div class="mt-1 rounded-lg border bg-gray-50 p-3 font-medium"> <div class="mt-1 rounded-lg border bg-gray-50 p-3 font-medium">
@@ -1696,14 +1931,14 @@
<div class="text-3xl font-bold"> <div class="text-3xl font-bold">
{userData.referralCodeUses || 0} {userData.referralCodeUses || 0}
</div> </div>
<div class="text-sm">Times Used</div> <div class="text-sm">Friends Referred</div>
</div> </div>
<div class="rounded-lg border p-4 text-center"> <div class="rounded-lg border p-4 text-center">
<div class="text-3xl font-bold"> <div class="text-3xl font-bold">
£{(userData.referralCodeUses || 0) * 5} £{(userData.referralSavings || 0).toFixed(2)}
</div>
<div class="text-sm">Total Saved</div>
</div> </div>
<div class="text-sm">Total Saved</div>
</div>
</div> </div>
<Card.Root <Card.Root
@@ -2281,6 +2516,28 @@
<Separator /> <Separator />
<!-- Log Out Button -->
<div>
<h3 class="mb-2 text-sm font-semibold">Session</h3>
<p class="mb-3 text-sm text-gray-600">Log out of this account on this device.</p>
<Button onclick={() => authStore.logout()} variant="outline">
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17 8l4 4-4 4" />
<path d="M3 12h18" />
</svg>
Log Out
</Button>
</div>
<Separator />
<!-- Export My Data --> <!-- Export My Data -->
<div> <div>
<h3 class="mb-2 text-sm font-semibold">Data Privacy</h3> <h3 class="mb-2 text-sm font-semibold">Data Privacy</h3>
@@ -2302,23 +2559,11 @@
</svg> </svg>
Export My Data Export My Data
</Button> </Button>
</div> {#if gdprCountdown}
<p class="mt-2 text-xs text-gray-500">
<Separator /> {gdprCountdown}
</p>
<div> {/if}
<h3 class="mb-2 text-sm font-semibold">Policies</h3>
<p class="mb-3 text-sm text-gray-600">
View our cancellation, deposit, and no-show policies
</p>
<PolicyPopover>
{#snippet trigger()}
<Button variant="outline">
<svg xmlns="http://www.w3.org/2000/svg" class="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
Cancellation & Deposit Policy
</Button>
{/snippet}
</PolicyPopover>
</div> </div>
<Separator /> <Separator />
@@ -2398,24 +2643,19 @@
<Separator /> <Separator />
{/if} {/if}
<!-- Log Out Button -->
<div> <div>
<h3 class="mb-2 text-sm font-semibold">Session</h3> <h3 class="mb-2 text-sm font-semibold">Policies</h3>
<p class="mb-3 text-sm text-gray-600">Log out of this account on this device.</p> <p class="mb-3 text-sm text-gray-600">
<Button onclick={() => authStore.logout()} variant="outline"> View our cancellation, deposit, and no-show policies
<svg </p>
xmlns="http://www.w3.org/2000/svg" <PolicyPopover>
class="mr-2 h-4 w-4" {#snippet trigger()}
viewBox="0 0 24 24" <Button variant="outline">
fill="none" <svg xmlns="http://www.w3.org/2000/svg" class="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
stroke="currentColor" Cancellation & Deposit Policy
stroke-width="2" </Button>
> {/snippet}
<path d="M17 8l4 4-4 4" /> </PolicyPopover>
<path d="M3 12h18" />
</svg>
Log Out
</Button>
</div> </div>
<Separator /> <Separator />
+82
View File
@@ -81,6 +81,13 @@
referred_at: string; referred_at: string;
}>; }>;
}; };
referral_discounts?: Array<{
id: string;
discount_percent: number;
used: boolean;
created_at: string;
used_at?: string;
}>;
notification_preferences?: Array<{ notification_preferences?: Array<{
email_enabled: boolean; email_enabled: boolean;
sms_enabled: boolean; sms_enabled: boolean;
@@ -161,6 +168,12 @@
booking_id: string; booking_id: string;
created_at: string; created_at: string;
}>; }>;
name_history?: Array<{
previous_first_name: string;
previous_last_name: string;
booking_id?: string;
changed_at: string;
}>;
login_audit?: Array<{ login_audit?: Array<{
attempt_type: string; attempt_type: string;
ip_address: string; ip_address: string;
@@ -727,6 +740,37 @@
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
<!-- Name History -->
{#if gdprData.name_history && gdprData.name_history.length > 0}
<Card.Root class="mb-4">
<Card.Header><Card.Title>Name Change History</Card.Title></Card.Header>
<Card.Content class="p-0">
<div class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead
><tr class="border-b"
><th class="px-4 py-3 font-medium text-gray-500">Previous Name</th
><th class="px-4 py-3 font-medium text-gray-500">Changed At</th
><th class="px-4 py-3 font-medium text-gray-500">Booking</th></tr
></thead
>
<tbody>
{#each gdprData.name_history as nh (nh.changed_at)}
<tr class="border-b last:border-b-0">
<td class="px-4 py-3 font-medium"
>{nh.previous_first_name} {nh.previous_last_name}</td
>
<td class="px-4 py-3 whitespace-nowrap">{fmtDateTime(nh.changed_at)}</td>
<td class="px-4 py-3 font-mono text-xs text-gray-500">{nh.booking_id || '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</Card.Content>
</Card.Root>
{/if}
<!-- Social Logins --> <!-- Social Logins -->
{#if gdprData.social_logins && gdprData.social_logins.length > 0} {#if gdprData.social_logins && gdprData.social_logins.length > 0}
<Card.Root class="mb-4"> <Card.Root class="mb-4">
@@ -911,6 +955,44 @@
</Card.Root> </Card.Root>
{/if} {/if}
<!-- Referral Discounts -->
{#if gdprData.referral_discounts && gdprData.referral_discounts.length > 0}
<Card.Root class="mb-4">
<Card.Header><Card.Title>Referral Discounts</Card.Title></Card.Header>
<Card.Content class="p-0">
<div class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead
><tr class="border-b"
><th class="px-4 py-3 font-medium text-gray-500">Created</th
><th class="px-4 py-3 font-medium text-gray-500">Discount</th
><th class="px-4 py-3 font-medium text-gray-500">Status</th
><th class="px-4 py-3 font-medium text-gray-500">Used At</th></tr
></thead
>
<tbody>
{#each gdprData.referral_discounts as rd (rd.id)}
<tr class="border-b last:border-b-0">
<td class="px-4 py-3 whitespace-nowrap">{fmtDateTime(rd.created_at)}</td>
<td class="px-4 py-3">{rd.discount_percent}%</td>
<td class="px-4 py-3"
><span
class="rounded-full px-2 py-0.5 text-xs font-medium {rd.used
? 'bg-green-100 text-green-700'
: 'bg-yellow-100 text-yellow-700'}"
>{rd.used ? 'Used' : 'Available'}</span
></td
>
<td class="px-4 py-3 whitespace-nowrap">{rd.used_at ? fmtDateTime(rd.used_at) : '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</Card.Content>
</Card.Root>
{/if}
<!-- Login Audit --> <!-- Login Audit -->
{#if gdprData.login_audit && gdprData.login_audit.length > 0} {#if gdprData.login_audit && gdprData.login_audit.length > 0}
<Card.Root class="mb-4"> <Card.Root class="mb-4">