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 "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 {
|
||||
|
||||
@@ -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,6 +818,7 @@
|
||||
</svg>
|
||||
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'
|
||||
@@ -813,6 +840,8 @@
|
||||
</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'
|
||||
@@ -834,6 +863,7 @@
|
||||
</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,30 +1088,91 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#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 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.
|
||||
{: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>
|
||||
|
||||
<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>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{:else if activeTab === 'history'}
|
||||
@@ -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,6 +1637,7 @@
|
||||
|
||||
<Separator />
|
||||
|
||||
{#if authStore.currentUser?.role !== 'admin'}
|
||||
<!-- Delete Account -->
|
||||
<div>
|
||||
<h3 class="mb-2 text-sm font-semibold text-red-600">Danger Zone</h3>
|
||||
@@ -1569,6 +1661,7 @@
|
||||
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>
|
||||
|
||||
@@ -21,7 +21,8 @@ export default {
|
||||
// Safelist fuchsia colors you intend to use
|
||||
{ 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: /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)
|
||||
]
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user