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:
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
@import 'tw-animate-css';
|
@import 'tw-animate-css';
|
||||||
|
|
||||||
|
@import "shadcn-svelte/tailwind.css";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -119,6 +121,12 @@
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
.maplibregl-popup-content {
|
||||||
|
@apply bg-transparent! shadow-none! p-0! rounded-none!;
|
||||||
|
}
|
||||||
|
.maplibregl-popup-tip {
|
||||||
|
@apply hidden!;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-container {
|
.calendar-container {
|
||||||
|
|||||||
@@ -89,6 +89,32 @@
|
|||||||
let pendingRedemption = $state(false);
|
let pendingRedemption = $state(false);
|
||||||
let uploadingPic = $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 });
|
let notifPrefs = $state({ emailEnabled: true, smsEnabled: true, browserPushEnabled: true });
|
||||||
|
|
||||||
type SavedCard = {
|
type SavedCard = {
|
||||||
@@ -496,7 +522,7 @@
|
|||||||
const now = new SvelteDate();
|
const now = new SvelteDate();
|
||||||
|
|
||||||
// Filter: Calculate end time (Start + Duration) and check if it's in the future
|
// 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);
|
const startTime = new SvelteDate(b.start_time);
|
||||||
// Add duration (in ms)
|
// Add duration (in ms)
|
||||||
const endTime = new SvelteDate(startTime.getTime() + (b.duration_minutes || 0) * 60000);
|
const endTime = new SvelteDate(startTime.getTime() + (b.duration_minutes || 0) * 60000);
|
||||||
@@ -540,7 +566,7 @@
|
|||||||
// FIX: Manually calculate amount_due for the list
|
// FIX: Manually calculate amount_due for the list
|
||||||
// The list API often returns 0 for amount_due/amount_paid,
|
// The list API often returns 0 for amount_due/amount_paid,
|
||||||
// so we derive it from total_amount.
|
// so we derive it from total_amount.
|
||||||
bookings = bookings.map((b: any) => {
|
bookings = bookings.map((b: Booking) => {
|
||||||
const total = b.total_amount || 0;
|
const total = b.total_amount || 0;
|
||||||
const paid = b.amount_paid || 0;
|
const paid = b.amount_paid || 0;
|
||||||
return {
|
return {
|
||||||
@@ -550,7 +576,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// SORT LOGIC: Unpaid first, then by most recent
|
// 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 aUnpaid = (a.amount_due || 0) > 0;
|
||||||
const bUnpaid = (b.amount_due || 0) > 0;
|
const bUnpaid = (b.amount_due || 0) > 0;
|
||||||
|
|
||||||
@@ -792,6 +818,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
General
|
General
|
||||||
</button>
|
</button>
|
||||||
|
{#if authStore.currentUser?.role !== 'admin'}
|
||||||
<button
|
<button
|
||||||
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
|
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
|
||||||
'history'
|
'history'
|
||||||
@@ -813,6 +840,8 @@
|
|||||||
</svg>
|
</svg>
|
||||||
History
|
History
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if authStore.currentUser?.role !== 'admin'}
|
||||||
<button
|
<button
|
||||||
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
|
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
|
||||||
'referral'
|
'referral'
|
||||||
@@ -834,6 +863,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Referral
|
Referral
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
{#if canSaveCards}
|
{#if canSaveCards}
|
||||||
<button
|
<button
|
||||||
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
|
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
|
||||||
@@ -910,18 +940,18 @@
|
|||||||
<img
|
<img
|
||||||
src={displayUrl}
|
src={displayUrl}
|
||||||
alt="Profile"
|
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}
|
{:else}
|
||||||
<div
|
<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}
|
{initials}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<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
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -1058,30 +1088,91 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if authStore.currentUser?.role !== 'admin'}
|
||||||
<Separator class="my-4" />
|
<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="grid grid-cols-5 gap-3 sm:grid-cols-10">
|
||||||
<div class="flex items-center justify-between">
|
{#each Array(10) as _, i}
|
||||||
{#if userData && stamps < 10}
|
{@const slotNum = i + 1}
|
||||||
<div class="text-sm font-medium text-emerald-800">
|
{@const rot = ((slotNum * 37 + 13) % 7) - 3}
|
||||||
Loyalty Stamps until next reward:
|
{#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>
|
||||||
<div class="text-3xl font-bold text-emerald-700">
|
|
||||||
{10 - stamps}
|
|
||||||
</div>
|
</div>
|
||||||
{:else if userData && stamps >= 10}
|
{:else}
|
||||||
<div class="w-full text-center">
|
<div
|
||||||
<div class="text-sm font-medium text-emerald-800">
|
class="aspect-square transition-transform duration-200 hover:scale-110"
|
||||||
Your loyalty card is full! Your next completed appointment will receive 10%
|
>
|
||||||
off.
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Separator />
|
{#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}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
{:else if activeTab === 'history'}
|
{:else if activeTab === 'history'}
|
||||||
@@ -1473,7 +1564,7 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<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>
|
></div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -1494,7 +1585,7 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<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>
|
></div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -1515,7 +1606,7 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<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>
|
></div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -1546,6 +1637,7 @@
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
{#if authStore.currentUser?.role !== 'admin'}
|
||||||
<!-- Delete Account -->
|
<!-- Delete Account -->
|
||||||
<div>
|
<div>
|
||||||
<h3 class="mb-2 text-sm font-semibold text-red-600">Danger Zone</h3>
|
<h3 class="mb-2 text-sm font-semibold text-red-600">Danger Zone</h3>
|
||||||
@@ -1569,6 +1661,7 @@
|
|||||||
Delete Account
|
Delete Account
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1597,6 +1690,7 @@
|
|||||||
General
|
General
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{#if authStore.currentUser?.role !== 'admin'}
|
||||||
<button
|
<button
|
||||||
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
|
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
|
||||||
'history'
|
'history'
|
||||||
@@ -1618,7 +1712,9 @@
|
|||||||
</svg>
|
</svg>
|
||||||
History
|
History
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if authStore.currentUser?.role !== 'admin'}
|
||||||
<button
|
<button
|
||||||
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
|
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
|
||||||
'referral'
|
'referral'
|
||||||
@@ -1640,14 +1736,18 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Referral
|
Referral
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if canSaveCards}
|
{#if canSaveCards}
|
||||||
<button
|
<button
|
||||||
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
|
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'
|
? 'bg-white text-gray-900 shadow-sm'
|
||||||
: 'text-gray-600 hover:text-gray-900'}"
|
: 'text-gray-600 hover:text-gray-900'}"
|
||||||
onclick={(_) => (activeTab = 'general')}
|
onclick={(_) => {
|
||||||
|
activeTab = 'cards';
|
||||||
|
fetchSavedCards();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="mx-auto mb-1 h-4 w-4"
|
class="mx-auto mb-1 h-4 w-4"
|
||||||
@@ -1782,7 +1882,7 @@
|
|||||||
|
|
||||||
<!-- Delete Account Alert -->
|
<!-- Delete Account Alert -->
|
||||||
<AlertDialog.Root bind:open={showDeleteAlert}>
|
<AlertDialog.Root bind:open={showDeleteAlert}>
|
||||||
<AlertDialog.Content class="z-[60]">
|
<AlertDialog.Content class="z-60">
|
||||||
<AlertDialog.Header>
|
<AlertDialog.Header>
|
||||||
<AlertDialog.Title>Delete Account?</AlertDialog.Title>
|
<AlertDialog.Title>Delete Account?</AlertDialog.Title>
|
||||||
<AlertDialog.Description>
|
<AlertDialog.Description>
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ export default {
|
|||||||
// Safelist fuchsia colors you intend to use
|
// Safelist fuchsia colors you intend to use
|
||||||
{ pattern: /bg-(fuchsia)-(50|100|200|300|400|500|600|700|800|900)/ },
|
{ pattern: /bg-(fuchsia)-(50|100|200|300|400|500|600|700|800|900)/ },
|
||||||
{ pattern: /text-(fuchsia)-(50|100|200|300|400|500|600|700|800|900)/ },
|
{ pattern: /text-(fuchsia)-(50|100|200|300|400|500|600|700|800|900)/ },
|
||||||
{ pattern: /border-(fuchsia)-(50|100|200|300|400|500|600|700|800|900)/ }
|
{ pattern: /border-(fuchsia)-(50|100|200|300|400|500|600|700|800|900)/ },
|
||||||
|
{ pattern: /ring-(fuchsia)-(50|100|200|300|400|500|600|700|800|900)/ }
|
||||||
// Add other fuchsia utilities if needed (e.g., ring, placeholder)
|
// Add other fuchsia utilities if needed (e.g., ring, placeholder)
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user