feat(ui): loyalty stamp redesign and admin account restrictions

Redesigned fuchsia-themed loyalty stamp card with procedural SVG flower-petal stamps (unique shape per slot via sine-wave perturbation), star cutout mask, hover animations, and gradient background. Admin accounts now see simplified account page with History, Referral, Danger Zone tabs and loyalty stamp card hidden.

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-04 01:07:43 +01:00
co-authored by Sisyphus
parent 4c26db81db
commit c76c3f52f7
3 changed files with 203 additions and 94 deletions
+8
View File
@@ -2,6 +2,8 @@
@import 'tw-animate-css';
@import "shadcn-svelte/tailwind.css";
@custom-variant dark (&:is(.dark *));
:root {
@@ -119,6 +121,12 @@
body {
@apply bg-background text-foreground;
}
.maplibregl-popup-content {
@apply bg-transparent! shadow-none! p-0! rounded-none!;
}
.maplibregl-popup-tip {
@apply hidden!;
}
}
.calendar-container {
+193 -93
View File
@@ -89,6 +89,32 @@
let pendingRedemption = $state(false);
let uploadingPic = $state(false);
function getStampPath(slotNum: number): string {
const petals = 7 + (slotNum % 4); // 7, 8, 9, 10 petals
const amp = 2.5 + (slotNum % 3) * 0.5; // 2.5, 3.0, 3.5 amplitude
const phase = slotNum * 12; // phase offset in degrees
const R = 42; // base radius
const centerX = 50;
const centerY = 50;
let path = '';
const steps = 120;
for (let i = 0; i < steps; i++) {
const angleDeg = (i * 360) / steps;
const angleRad = (angleDeg * Math.PI) / 180;
const phaseRad = (phase * Math.PI) / 180;
const r = R + amp * Math.sin(petals * angleRad + phaseRad);
const x = (centerX + r * Math.cos(angleRad)).toFixed(1);
const y = (centerY + r * Math.sin(angleRad)).toFixed(1);
if (i === 0) {
path += `M ${x} ${y}`;
} else {
path += ` L ${x} ${y}`;
}
}
return path + ' Z';
}
let notifPrefs = $state({ emailEnabled: true, smsEnabled: true, browserPushEnabled: true });
type SavedCard = {
@@ -496,7 +522,7 @@
const now = new SvelteDate();
// Filter: Calculate end time (Start + Duration) and check if it's in the future
const activeOrFutureBookings = (data.bookings || []).filter((b: any) => {
const activeOrFutureBookings = (data.bookings || []).filter((b: Booking) => {
const startTime = new SvelteDate(b.start_time);
// Add duration (in ms)
const endTime = new SvelteDate(startTime.getTime() + (b.duration_minutes || 0) * 60000);
@@ -540,7 +566,7 @@
// FIX: Manually calculate amount_due for the list
// The list API often returns 0 for amount_due/amount_paid,
// so we derive it from total_amount.
bookings = bookings.map((b: any) => {
bookings = bookings.map((b: Booking) => {
const total = b.total_amount || 0;
const paid = b.amount_paid || 0;
return {
@@ -550,7 +576,7 @@
});
// SORT LOGIC: Unpaid first, then by most recent
bookings.sort((a: any, b: any) => {
bookings.sort((a: Booking, b: Booking) => {
const aUnpaid = (a.amount_due || 0) > 0;
const bUnpaid = (b.amount_due || 0) > 0;
@@ -792,48 +818,52 @@
</svg>
General
</button>
<button
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'history'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={(_) => (activeTab = 'history')}
>
<svg
class="mx-auto mb-1 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
{#if authStore.currentUser?.role !== 'admin'}
<button
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'history'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={(_) => (activeTab = 'history')}
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
History
</button>
<button
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'referral'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={(_) => (activeTab = 'referral')}
>
<svg
class="mx-auto mb-1 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<svg
class="mx-auto mb-1 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
History
</button>
{/if}
{#if authStore.currentUser?.role !== 'admin'}
<button
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'referral'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={(_) => (activeTab = 'referral')}
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
Referral
</button>
<svg
class="mx-auto mb-1 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
Referral
</button>
{/if}
{#if canSaveCards}
<button
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
@@ -910,18 +940,18 @@
<img
src={displayUrl}
alt="Profile"
class="h-24 w-24 rounded-full object-cover ring-4 ring-blue-200"
class="h-24 w-24 rounded-full object-cover ring-4 ring-fuchsia-200"
/>
{:else}
<div
class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 text-3xl font-bold text-gray-600 ring-4 ring-blue-200"
class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 text-3xl font-bold text-gray-600 ring-4 ring-fuchsia-200"
>
{initials}
</div>
{/if}
{:else}
<div
class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 ring-4 ring-blue-200"
class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 ring-4 ring-fuchsia-200"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -1058,29 +1088,90 @@
</div>
</div>
<Separator class="my-4" />
{#if authStore.currentUser?.role !== 'admin'}
<Separator class="my-4" />
<div
class="rounded-xl border-2 border-fuchsia-200 bg-gradient-to-br from-fuchsia-50 via-fuchsia-100/30 to-fuchsia-50 p-5 sm:p-6"
>
{#if userData}
<div class="mb-5 text-center sm:text-left">
<h3 class="text-sm font-semibold text-gray-900">Loyalty Stamp Card</h3>
<p class="mt-0.5 text-xs text-gray-500">
{stamps < 10
? 'Collect 10 stamps to get 10% off your next booking.'
: 'Your card is full — enjoy 10% off your next service!'}
</p>
</div>
<div class="rounded-lg border border-emerald-200 bg-emerald-50 p-4">
<div class="flex items-center justify-between">
{#if userData && stamps < 10}
<div class="text-sm font-medium text-emerald-800">
Loyalty Stamps until next reward:
<div class="grid grid-cols-5 gap-3 sm:grid-cols-10">
{#each Array(10) as _, i}
{@const slotNum = i + 1}
{@const rot = ((slotNum * 37 + 13) % 7) - 3}
{#if slotNum <= stamps}
<div
class="aspect-square transition-transform duration-200 hover:scale-110"
>
<div
class="relative flex h-full w-full items-center justify-center text-fuchsia-300"
style="transform: rotate({rot}deg)"
>
<svg class="absolute inset-0 h-full w-full" viewBox="0 0 100 100">
<defs>
<mask id="stamp-mask-{slotNum}">
<path d={getStampPath(slotNum)} fill="white" />
<path
d="M 50 25 L 56 43 L 75 43 L 60 53.5 L 66 71.5 L 50 62 L 34 71.5 L 40 53.5 L 25 43 L 44 43 Z"
fill="black"
/>
</mask>
</defs>
<path
d={getStampPath(slotNum)}
fill="currentColor"
mask="url(#stamp-mask-{slotNum})"
/>
</svg>
</div>
</div>
{:else}
<div
class="aspect-square transition-transform duration-200 hover:scale-110"
>
<div
class="relative flex h-full w-full items-center justify-center text-fuchsia-300/40 transition-colors duration-200 hover:text-fuchsia-400/60"
style="transform: rotate({rot}deg)"
>
<svg class="absolute inset-0 h-full w-full" viewBox="0 0 100 100">
<path
d={getStampPath(slotNum)}
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-dasharray="3 3"
/>
</svg>
<span
class="relative z-10 text-[10px] leading-none font-semibold text-fuchsia-400/50"
>{slotNum}</span
>
</div>
</div>
{/if}
{/each}
</div>
<div class="text-3xl font-bold text-emerald-700">
{10 - stamps}
</div>
{:else if userData && stamps >= 10}
<div class="w-full text-center">
<div class="text-sm font-medium text-emerald-800">
Your loyalty card is full! Your next completed appointment will receive 10%
off.
{#if stamps >= 10}
<div
class="mt-5 rounded-lg bg-gradient-to-r from-fuchsia-500 to-pink-500 px-4 py-3 text-center"
>
<p class="text-sm font-bold tracking-wide text-white">
🎉 Card Completed — 10% Off Your Next Booking!
</p>
</div>
</div>
{/if}
{/if}
<Separator />
</div>
</div>
{/if}
{/if}
</Card.Content>
</Card.Root>
@@ -1473,7 +1564,7 @@
}}
/>
<div
class="peer h-5 w-9 rounded-full bg-gray-200 peer-checked:bg-blue-600 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
class="peer h-5 w-9 rounded-full border border-gray-200 bg-gray-200 peer-checked:bg-fuchsia-300 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
></div>
</label>
</div>
@@ -1494,7 +1585,7 @@
}}
/>
<div
class="peer h-5 w-9 rounded-full bg-gray-200 peer-checked:bg-blue-600 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
class="peer h-5 w-9 rounded-full border border-gray-200 bg-gray-200 peer-checked:bg-fuchsia-300 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
></div>
</label>
</div>
@@ -1515,7 +1606,7 @@
}}
/>
<div
class="peer h-5 w-9 rounded-full bg-gray-200 peer-checked:bg-blue-600 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
class="peer h-5 w-9 rounded-full border border-gray-200 bg-gray-200 peer-checked:bg-fuchsia-300 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
></div>
</label>
</div>
@@ -1546,29 +1637,31 @@
<Separator />
<!-- Delete Account -->
<div>
<h3 class="mb-2 text-sm font-semibold text-red-600">Danger Zone</h3>
<p class="mb-3 text-sm text-gray-600">
Once you delete your account, there is no going back. Please be certain.
</p>
<Button onclick={() => (showDeleteAlert = true)} variant="destructive">
<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"
>
<polyline points="3 6 5 6 21 6" />
<path
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
/>
</svg>
Delete Account
</Button>
</div>
{#if authStore.currentUser?.role !== 'admin'}
<!-- Delete Account -->
<div>
<h3 class="mb-2 text-sm font-semibold text-red-600">Danger Zone</h3>
<p class="mb-3 text-sm text-gray-600">
Once you delete your account, there is no going back. Please be certain.
</p>
<Button onclick={() => (showDeleteAlert = true)} variant="destructive">
<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"
>
<polyline points="3 6 5 6 21 6" />
<path
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
/>
</svg>
Delete Account
</Button>
</div>
{/if}
</Card.Content>
</Card.Root>
{/if}
@@ -1597,6 +1690,7 @@
General
</button>
{#if authStore.currentUser?.role !== 'admin'}
<button
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'history'
@@ -1618,7 +1712,9 @@
</svg>
History
</button>
{/if}
{#if authStore.currentUser?.role !== 'admin'}
<button
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'referral'
@@ -1640,14 +1736,18 @@
</svg>
Referral
</button>
{/if}
{#if canSaveCards}
<button
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
'general'
'cards'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'}"
onclick={(_) => (activeTab = 'general')}
onclick={(_) => {
activeTab = 'cards';
fetchSavedCards();
}}
>
<svg
class="mx-auto mb-1 h-4 w-4"
@@ -1782,7 +1882,7 @@
<!-- Delete Account Alert -->
<AlertDialog.Root bind:open={showDeleteAlert}>
<AlertDialog.Content class="z-[60]">
<AlertDialog.Content class="z-60">
<AlertDialog.Header>
<AlertDialog.Title>Delete Account?</AlertDialog.Title>
<AlertDialog.Description>