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 ===============
|
// =============== 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 />
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user