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:
@@ -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 ===============
|
||||
async function fetchUserData() {
|
||||
if (pageState !== 'authorized') return;
|
||||
@@ -946,6 +1120,7 @@
|
||||
fetchUpcomingBookings();
|
||||
fetchPastBookings();
|
||||
fetchNotifPrefs();
|
||||
fetchGdprExportStatus();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1371,18 +1546,78 @@
|
||||
{/each}
|
||||
{:else if userData}
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<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">
|
||||
{userData.firstName}
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-600">First Name</span>
|
||||
{#if editingFirstName}
|
||||
<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>
|
||||
<span class="text-sm font-medium text-gray-600">Last Name</span>
|
||||
<div class="mt-1 rounded-lg border bg-gray-50 p-3 font-medium">
|
||||
{userData.lastName}
|
||||
{:else}
|
||||
<div class="mt-1 flex items-center justify-between rounded-lg border bg-gray-50 p-3 font-medium">
|
||||
<span>{userData.firstName}</span>
|
||||
<Button size="sm" variant="ghost" onclick={startEditFirstName}>
|
||||
<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>
|
||||
{/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>
|
||||
<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">
|
||||
@@ -1696,14 +1931,14 @@
|
||||
<div class="text-3xl font-bold">
|
||||
{userData.referralCodeUses || 0}
|
||||
</div>
|
||||
<div class="text-sm">Times Used</div>
|
||||
<div class="text-sm">Friends Referred</div>
|
||||
</div>
|
||||
<div class="rounded-lg border p-4 text-center">
|
||||
<div class="text-3xl font-bold">
|
||||
£{(userData.referralCodeUses || 0) * 5}
|
||||
</div>
|
||||
<div class="text-sm">Total Saved</div>
|
||||
<div class="rounded-lg border p-4 text-center">
|
||||
<div class="text-3xl font-bold">
|
||||
£{(userData.referralSavings || 0).toFixed(2)}
|
||||
</div>
|
||||
<div class="text-sm">Total Saved</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card.Root
|
||||
@@ -2281,6 +2516,28 @@
|
||||
|
||||
<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 -->
|
||||
<div>
|
||||
<h3 class="mb-2 text-sm font-semibold">Data Privacy</h3>
|
||||
@@ -2302,23 +2559,11 @@
|
||||
</svg>
|
||||
Export My Data
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<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>
|
||||
{#if gdprCountdown}
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
{gdprCountdown}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
@@ -2398,24 +2643,19 @@
|
||||
<Separator />
|
||||
{/if}
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -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 @@
|
||||
</Card.Content>
|
||||
</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 -->
|
||||
{#if gdprData.social_logins && gdprData.social_logins.length > 0}
|
||||
<Card.Root class="mb-4">
|
||||
@@ -911,6 +955,44 @@
|
||||
</Card.Root>
|
||||
{/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 -->
|
||||
{#if gdprData.login_audit && gdprData.login_audit.length > 0}
|
||||
<Card.Root class="mb-4">
|
||||
|
||||
Reference in New Issue
Block a user