Better skeleton use, live services in booking

This commit is contained in:
2025-10-18 21:39:57 +01:00
parent fad47df642
commit 68cf27541a
6 changed files with 408 additions and 212 deletions
+3 -1
View File
@@ -28,6 +28,7 @@ type Service struct {
} }
type ServiceResponse struct { type ServiceResponse struct {
ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
Price float64 `json:"price"` Price float64 `json:"price"`
@@ -207,7 +208,7 @@ func DeleteServiceHandler(w http.ResponseWriter, r *http.Request) {
func ServicesHandler(w http.ResponseWriter, r *http.Request) { func ServicesHandler(w http.ResponseWriter, r *http.Request) {
// Query all active services // Query all active services
query := ` query := `
SELECT name, description, price, duration_minutes, SELECT id, name, description, price, duration_minutes,
patch_test_duration_hours, minimum_age_required patch_test_duration_hours, minimum_age_required
FROM services FROM services
WHERE is_active = TRUE WHERE is_active = TRUE
@@ -227,6 +228,7 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) {
var service ServiceResponse var service ServiceResponse
err := rows.Scan( err := rows.Scan(
&service.ID,
&service.Name, &service.Name,
&service.Description, &service.Description,
&service.Price, &service.Price,
@@ -1,11 +1,21 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Skeleton } from '$lib/components/ui/skeleton';
import { navigating, page } from '$app/stores'; import { navigating, page } from '$app/stores';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
// Mobile menu state // Centralized link definition
let mobileMenuOpen: boolean = false; const links = [
{ href: '/', label: 'Home', showWhen: 'always', width: 'w-12' },
{ href: '/prices', label: 'Price List', showWhen: 'guest', width: 'w-20' },
{ href: '/book', label: 'Book your appointment', showWhen: 'auth', width: 'w-36' },
{ href: '/portfolio', label: 'Portfolio', showWhen: 'always', width: 'w-20' },
{ href: '/contact', label: 'Contact', showWhen: 'always', width: 'w-16' },
{ href: '/account', label: 'My Account', showWhen: 'auth', width: 'w-24' },
{ href: '/admin', label: 'Admin Dashboard', showWhen: 'admin', width: 'w-28' }
];
let mobileMenuOpen: boolean = false;
function toggleMenu() { function toggleMenu() {
mobileMenuOpen = !mobileMenuOpen; mobileMenuOpen = !mobileMenuOpen;
} }
@@ -14,6 +24,21 @@
$: if ($navigating) { $: if ($navigating) {
mobileMenuOpen = false; mobileMenuOpen = false;
} }
// Helper to decide visibility
const canShow = (link: { href: string; label: string; showWhen: string; width: string }) => {
if (authStore.isLoading) return true; // keep skeleton placeholders
// hide booking link for admins
if (link.href === '/book' && authStore.currentUser?.role === 'admin') return false;
if (link.href === '/contact' && authStore.currentUser?.role === 'admin') return false;
if (link.showWhen === 'always') return true;
if (link.showWhen === 'guest' && !authStore.isAuthenticated) return true;
if (link.showWhen === 'auth' && authStore.isAuthenticated) return true;
if (link.showWhen === 'admin' && authStore.currentUser?.role === 'admin') return true;
return false;
};
</script> </script>
<nav <nav
@@ -42,36 +67,37 @@
stroke-width="2" stroke-width="2"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
class="feather feather-instagram"
><rect x="2" y="2" width="20" height="20" rx="5" ry="5"></rect><path
d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"
></path><line x1="17.5" y1="6.5" x2="17.51" y2="6.5"></line></svg
> >
<rect x="2" y="2" width="20" height="20" rx="5" ry="5"></rect>
<path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"></path>
<line x1="17.5" y1="6.5" x2="17.51" y2="6.5"></line>
</svg>
</a> </a>
<!-- Add more social icons here -->
</div> </div>
<!-- Center: Links --> <!-- Center: Desktop Links -->
<div class="hidden space-x-8 md:flex"> <div class="hidden space-x-8 md:flex">
<a href="/" class="hover:text-primary font-medium text-gray-800">Home</a> {#each links as link}
{#if !authStore?.isAuthenticated} {#if canShow(link)}
<a href="/prices" class="hover:text-primary font-medium text-gray-800">Price List</a> {#if authStore.isLoading}
{:else} <Skeleton class={`h-4 ${link.width} rounded`} />
<a href="/book" class="hover:text-primary font-medium text-gray-800" {:else}
>Book your appointment</a <a href={link.href} class="hover:text-primary font-medium text-gray-800"
> >{link.label}</a
{/if} >
<a href="/portfolio" class="hover:text-primary font-medium text-gray-800">Portfolio</a> {/if}
<a href="/contact" class="hover:text-primary font-medium text-gray-800">Contact</a> {/if}
{#if authStore?.isAuthenticated} {/each}
<a href="/account" class="hover:text-primary font-medium text-gray-800">My Account</a>
{/if}
</div> </div>
<!-- Desktop Login Button -->
<div class="hidden items-center md:flex"> <div class="hidden items-center md:flex">
<!-- if not logged in and not on login page --> {#if !authStore.isLoading && !authStore.isAuthenticated && $page.url.pathname !== '/login'}
{#if !authStore?.isAuthenticated && $page.url.pathname !== '/login'}
<Button href="/login">Login</Button> <Button href="/login">Login</Button>
{/if} {/if}
{#if authStore.isLoading}
<Skeleton class="h-8 w-16 rounded" />
{/if}
</div> </div>
<!-- Mobile: Burger --> <!-- Mobile: Burger -->
@@ -94,41 +120,25 @@
{#if mobileMenuOpen} {#if mobileMenuOpen}
<div class="bg-background border-b border-gray-200 md:hidden"> <div class="bg-background border-b border-gray-200 md:hidden">
<div class="space-y-1 px-2 pb-3 pt-2"> <div class="space-y-1 px-2 pb-3 pt-2">
<a href="/" class="text-primary block rounded px-3 py-2 text-center hover:text-gray-800" {#each links as link}
>Home</a {#if canShow(link)}
> {#if authStore.isLoading}
<Skeleton class="h-4 w-full rounded" />
{:else}
<a
href={link.href}
class="text-primary block rounded px-3 py-2 text-center hover:text-gray-800"
>
{link.label}
</a>
{/if}
{/if}
{/each}
{#if !authStore?.isAuthenticated} {#if authStore.isLoading}
<a <Skeleton class="mt-2 h-8 w-full rounded" />
href="/prices" {:else if !authStore.isAuthenticated}
class="text-primary block rounded px-3 py-2 text-center hover:text-gray-800"
>Price list</a
>
{:else}
<a
href="/book"
class="text-primary block rounded px-3 py-2 text-center hover:text-gray-800"
>Book your appointment</a
>
{/if}
<a
href="/portfolio"
class="text-primary block rounded px-3 py-2 text-center hover:text-gray-800">Portfolio</a
>
<a
href="/contact"
class="text-primary block rounded px-3 py-2 text-center hover:text-gray-800">Contact</a
>
{#if !authStore?.isAuthenticated}
<Button href="/login" class="mt-2 w-full text-center">Login</Button> <Button href="/login" class="mt-2 w-full text-center">Login</Button>
{:else}
<a
href="/Account"
class="text-primary block rounded px-3 py-2 text-center hover:text-gray-800"
>My Account</a
>
{/if} {/if}
</div> </div>
</div> </div>
+5
View File
@@ -50,6 +50,11 @@ class AuthStore {
return this.loading; return this.loading;
} }
get hasLoaded() {
return !this.loading;
}
private initializeAuth() { private initializeAuth() {
const storedToken = localStorage.getItem('authToken'); const storedToken = localStorage.getItem('authToken');
if (storedToken) { if (storedToken) {
+26 -5
View File
@@ -1,14 +1,35 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import PortfolioCarousel from '$lib/components/layout/PortfolioCarousel.svelte'; import PortfolioCarousel from '$lib/components/layout/PortfolioCarousel.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { Skeleton } from '$lib/components/ui/skeleton';
</script> </script>
<section class="py-20 text-center"> <section class="py-20 text-center">
<h1 class="m-2 mb-4 text-4xl font-bold">Welcome to Crussell Nails</h1> {#if authStore.isLoading}
<p class="mb-8 text-lg text-gray-600"> <!-- Skeleton loading state -->
Professional beauty treatments in a calm and friendly environment. <div class="m-2 mb-4">
</p> <Skeleton class="mx-auto h-10 w-80 max-w-full" />
<Button href="/book" class="px-6 py-3 text-lg">Book an Appointment</Button> </div>
<div class="mb-8">
<Skeleton class="mx-auto h-6 w-96 max-w-full" />
</div>
<Skeleton class="mx-auto h-12 w-48" />
{:else}
<h1 class="m-2 mb-4 text-4xl font-bold">
{#if !authStore.isAuthenticated}
Welcome to Crussell Nails
{:else if authStore.currentUser?.role === 'unverified_email'}
Welcome {authStore.currentUser?.firstName}
{:else}
Welcome back {authStore.currentUser?.firstName}
{/if}
</h1>
<p class="mb-8 text-lg text-gray-600">
Professional beauty treatments in a calm and friendly environment.
</p>
<Button href="/book" class="px-6 py-3 text-lg">Book an Appointment</Button>
{/if}
</section> </section>
<section class="mx-auto max-w-4xl px-6 py-16"> <section class="mx-auto max-w-4xl px-6 py-16">
+213 -49
View File
@@ -6,8 +6,6 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Textarea } from '$lib/components/ui/textarea';
import { Separator } from '$lib/components/ui/separator'; import { Separator } from '$lib/components/ui/separator';
import * as Modal from '$lib/components/ui/dialog'; import * as Modal from '$lib/components/ui/dialog';
import * as AlertDialog from '$lib/components/ui/alert-dialog'; import * as AlertDialog from '$lib/components/ui/alert-dialog';
@@ -18,19 +16,32 @@
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { Skeleton } from '$lib/components/ui/skeleton'; import { Skeleton } from '$lib/components/ui/skeleton';
const user = authStore.currentUser; // =============== Auth & Permissions ===============
if (browser) { let pageState = $state<'loading' | 'authorized' | 'unauthorized'>('loading');
if (!user || user.role !== 'admin') {
setTimeout(() => {
goto('/', { replaceState: true });
}, 5000);
}
}
// =============== Alert Dialog State =============== // Check permissions immediately and on auth changes
let showSaveDefaultHoursAlert = $state(false); $effect(() => {
let showDeleteExceptionAlert = $state(false); if (!browser) return;
let exceptionToDelete = $state<number | undefined>(undefined);
if (authStore.isLoading) {
pageState = 'loading';
return;
}
if (!authStore.isAuthenticated) {
pageState = 'unauthorized';
goto('/login', { replaceState: true });
return;
}
if (authStore.currentUser?.role !== 'admin') {
pageState = 'unauthorized';
goto('/', { replaceState: true });
return;
}
pageState = 'authorized';
});
// =============== Image Upload =============== // =============== Image Upload ===============
let uploading = $state(false); let uploading = $state(false);
@@ -122,13 +133,18 @@
} }
async function fetchDefaultHours() { async function fetchDefaultHours() {
if (pageState !== 'authorized') return;
defaultHoursIsLoading = true; defaultHoursIsLoading = true;
let error = null; let error = null;
try { try {
const response = await fetch('/api/scheduling/default-hours', { const response = await fetch('/api/scheduling/default-hours', {
method: 'GET', method: 'GET',
headers: { 'Content-Type': 'application/json' } headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
}); });
if (response.ok) { if (response.ok) {
@@ -154,7 +170,9 @@
} }
$effect(() => { $effect(() => {
fetchDefaultHours(); if (pageState === 'authorized') {
fetchDefaultHours();
}
}); });
type ExceptionGroup = { type ExceptionGroup = {
@@ -205,17 +223,6 @@
let defaultHoursDraft = $state<WorkingHourRow[]>([]); let defaultHoursDraft = $state<WorkingHourRow[]>([]);
let showDefaultHoursModal = $state(false); let showDefaultHoursModal = $state(false);
// Mocked load - in a real app, this would fetch from your backend
async function loadWorkingHours() {
loadingHours = true;
// Simulate loading delay
await new Promise((r) => setTimeout(r, 500));
// Set defaultHours to the initial demo values (in a real app, it would be API data)
loadingHours = false;
}
onMount(loadWorkingHours);
let showExceptionModal = $state(false); let showExceptionModal = $state(false);
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
@@ -251,7 +258,7 @@
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('authToken')}` Authorization: `Bearer ${authStore.currentToken}`
}, },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
@@ -488,13 +495,15 @@
// Fetch services from API // Fetch services from API
async function fetchServices() { async function fetchServices() {
if (pageState !== 'authorized') return;
servicesLoading = true; servicesLoading = true;
try { try {
const response = await fetch('/api/admin/services', { const response = await fetch('/api/admin/services', {
method: 'GET', method: 'GET',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('authToken')}` Authorization: `Bearer ${authStore.currentToken}`
} }
}); });
@@ -521,7 +530,7 @@
const response = await fetch(`/api/admin/services/${serviceId}/toggle`, { const response = await fetch(`/api/admin/services/${serviceId}/toggle`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
Authorization: `Bearer ${localStorage.getItem('authToken')}` Authorization: `Bearer ${authStore.currentToken}`
} }
}); });
@@ -553,7 +562,7 @@
const response = await fetch(`/api/admin/services/${serviceId}`, { const response = await fetch(`/api/admin/services/${serviceId}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
Authorization: `Bearer ${localStorage.getItem('authToken')}` Authorization: `Bearer ${authStore.currentToken}`
} }
}); });
@@ -582,11 +591,182 @@
// Fetch services on component mount // Fetch services on component mount
$effect(() => { $effect(() => {
fetchServices(); if (pageState === 'authorized') {
fetchServices();
}
}); });
// =============== Alert Dialog State ===============
let showSaveDefaultHoursAlert = $state(false);
let showDeleteExceptionAlert = $state(false);
let exceptionToDelete = $state<number | undefined>(undefined);
</script> </script>
{#if user?.role == 'admin'} {#if pageState === 'loading'}
<!-- Full page skeleton loading -->
<div class="mx-auto max-w-6xl space-y-6 p-6">
<!-- Header Skeleton -->
<div class="mb-8 flex items-center justify-center text-center">
<div class="space-y-2">
<Skeleton class="h-8 w-64" />
<Skeleton class="h-4 w-96" />
</div>
</div>
<!-- Image Upload Card Skeleton -->
<Card.Root>
<Card.Header>
<Skeleton class="h-6 w-32" />
<Skeleton class="h-4 w-48" />
</Card.Header>
<Card.Content class="space-y-4">
<Skeleton class="h-32 w-full" />
<div class="flex justify-end">
<Skeleton class="h-10 w-40" />
</div>
</Card.Content>
</Card.Root>
<!-- Users & Bookings Grid Skeleton -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Users Card Skeleton -->
<Card.Root>
<Card.Header>
<Skeleton class="h-6 w-20" />
<Skeleton class="h-4 w-40" />
</Card.Header>
<Card.Content class="space-y-4">
<div class="flex gap-2">
<Skeleton class="h-10 flex-1" />
<Skeleton class="h-10 w-20" />
</div>
<div class="space-y-2">
{#each Array(3) as _, i}
<Skeleton class="h-16 w-full" />
{/each}
</div>
</Card.Content>
</Card.Root>
<!-- Bookings Card Skeleton -->
<Card.Root>
<Card.Header>
<Skeleton class="h-6 w-24" />
<Skeleton class="h-4 w-40" />
</Card.Header>
<Card.Content class="space-y-4">
<div class="flex gap-2">
<Skeleton class="h-10 flex-1" />
<Skeleton class="h-10 w-20" />
</div>
<div class="space-y-2">
{#each Array(3) as _, i}
<Skeleton class="h-16 w-full" />
{/each}
</div>
</Card.Content>
</Card.Root>
</div>
<!-- Holiday Hours Card Skeleton -->
<Card.Root>
<Card.Header>
<Skeleton class="h-6 w-32" />
<Skeleton class="h-4 w-64" />
</Card.Header>
<Card.Content class="space-y-4">
<div class="flex justify-between">
<Skeleton class="h-10 w-32" />
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{#each Array(2) as _, i}
<Skeleton class="h-32 w-full" />
{/each}
</div>
</Card.Content>
</Card.Root>
<!-- Working Hours Card Skeleton -->
<Card.Root>
<Card.Header>
<Skeleton class="h-6 w-32" />
<Skeleton class="h-4 w-64" />
</Card.Header>
<Card.Content class="space-y-4">
<div class="flex justify-between">
<Skeleton class="h-6 w-40" />
<Skeleton class="h-10 w-24" />
</div>
<div class="w-full overflow-x-auto">
<table class="w-full table-auto">
<thead>
<tr class="text-left text-xs text-gray-500">
<th class="py-2"><Skeleton class="h-4 w-12" /></th>
<th class="py-2"><Skeleton class="h-4 w-16" /></th>
<th class="py-2"><Skeleton class="h-4 w-16" /></th>
<th class="py-2"><Skeleton class="h-4 w-16" /></th>
</tr>
</thead>
<tbody>
{#each Array(7) as _, i}
<tr class="border-t">
<td class="py-2"><Skeleton class="h-4 w-20" /></td>
<td class="py-2"><Skeleton class="h-4 w-12" /></td>
<td class="py-2"><Skeleton class="h-4 w-12" /></td>
<td class="py-2"><Skeleton class="h-4 w-12" /></td>
</tr>
{/each}
</tbody>
</table>
</div>
</Card.Content>
</Card.Root>
<!-- Services Management Card Skeleton -->
<Card.Root>
<Card.Header>
<Skeleton class="h-6 w-40" />
<Skeleton class="h-4 w-80" />
</Card.Header>
<Card.Content class="space-y-4">
<div class="flex justify-between">
<Skeleton class="h-10 w-32" />
</div>
<div class="hidden w-full overflow-x-auto md:block">
<table class="w-full table-auto border-collapse text-sm">
<thead>
<tr class="border-b text-left text-xs text-gray-500">
<th class="py-3"><Skeleton class="h-4 w-20" /></th>
<th class="py-3"><Skeleton class="h-4 w-32" /></th>
<th class="py-3"><Skeleton class="h-4 w-16" /></th>
<th class="py-3"><Skeleton class="h-4 w-20" /></th>
<th class="py-3"><Skeleton class="h-4 w-16" /></th>
<th class="py-3"><Skeleton class="h-4 w-24" /></th>
</tr>
</thead>
<tbody>
{#each Array(3) as _, i}
<tr class="border-b">
<td class="py-3"><Skeleton class="h-4 w-32" /></td>
<td class="py-3"><Skeleton class="h-4 w-48" /></td>
<td class="py-3"><Skeleton class="h-4 w-16" /></td>
<td class="py-3"><Skeleton class="h-4 w-20" /></td>
<td class="py-3"><Skeleton class="h-4 w-16" /></td>
<td class="py-3">
<div class="flex justify-center gap-2">
<Skeleton class="h-8 w-16" />
<Skeleton class="h-8 w-16" />
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</Card.Content>
</Card.Root>
</div>
{:else if pageState === 'authorized'}
<div class="mx-auto max-w-6xl space-y-6 p-6"> <div class="mx-auto max-w-6xl space-y-6 p-6">
<div class="mb-4 flex items-center justify-center text-center"> <div class="mb-4 flex items-center justify-center text-center">
<div> <div>
@@ -1279,20 +1459,4 @@
</Modal.Content> </Modal.Content>
</Modal.Root> </Modal.Root>
{/if} {/if}
{:else if !user}
<div class="mx-auto max-w-6xl space-y-6 p-6">
<div class="text-center">
<h1 class="text-3xl font-bold">Checking your permissions…</h1>
</div>
</div>
{:else}
<div class="mx-auto max-w-6xl space-y-6 p-6">
<div class="text-center">
<h1 class="text-3xl font-bold">403 Forbidden</h1>
<p class="text-gray-600">You do not have permission to access this page.</p>
<p class="text-gray-600">
Redirecting you to the <a href="/">homepage</a> in 5 seconds…
</p>
</div>
</div>
{/if} {/if}
+95 -101
View File
@@ -7,10 +7,12 @@
import { Separator } from '$lib/components/ui/separator/index.js'; import { Separator } from '$lib/components/ui/separator/index.js';
import Calendar from '$lib/components/ui/calendar/calendar.svelte'; import Calendar from '$lib/components/ui/calendar/calendar.svelte';
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date'; import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from 'svelte-sonner';
// Booking state // Booking state
let currentStep = $state<number>(1); let currentStep = $state<number>(1);
let selectedServices = $state<any[]>([]); let selectedServices = $state<Service[]>([]);
let selectedDate = $state<CalendarDate | undefined>(undefined); let selectedDate = $state<CalendarDate | undefined>(undefined);
let selectedTime = $state<string | null>(null); let selectedTime = $state<string | null>(null);
let customerInfo = $state({ let customerInfo = $state({
@@ -21,65 +23,47 @@
specialRequests: '' specialRequests: ''
}); });
// Service options // =============== Services Management (User View) ===============
const services = [ type Service = {
{ id: string;
id: 'manicure', name: string;
name: 'Classic Manicure', description: string;
duration: 45, price: number;
price: 25, duration_minutes: number;
description: 'Shape, cuticle care, polish' patch_test_duration_hours: number;
}, minimum_age_required: number;
{ };
id: 'gel-manicure',
name: 'Gel Manicure', let services = $state<Service[]>([]);
duration: 60, let servicesLoading = $state(true);
price: 35,
description: 'Long-lasting gel polish application' // Fetch active services for standard users
}, async function fetchServices() {
{ servicesLoading = true;
id: 'pedicure', try {
name: 'Classic Pedicure', const response = await fetch('/api/services', {
duration: 60, method: 'GET',
price: 30, headers: {
description: 'Soak, exfoliate, shape, polish' 'Content-Type': 'application/json',
}, // Optional: If your user endpoint requires auth
{ Authorization: `Bearer ${authStore.currentToken}`
id: 'gel-pedicure', }
name: 'Gel Pedicure', });
duration: 75,
price: 40, if (response.ok) {
description: 'Premium pedicure with gel polish' const data: Service[] = await response.json();
}, services = data;
{ } else {
id: 'eyebrow-shape', console.error('Failed to fetch services:', response.status);
name: 'Eyebrow Shaping', toast.error('Failed to load services');
duration: 30, }
price: 15, } catch (err) {
description: 'Wax and tweeze to perfect shape' console.error('Error fetching services:', err);
}, toast.error('Network error loading services');
{ } finally {
id: 'eyebrow-tint', servicesLoading = false;
name: 'Eyebrow Tint & Shape',
duration: 45,
price: 25,
description: 'Shape and tint for definition'
},
{
id: 'leg-wax',
name: 'Full Leg Wax',
duration: 60,
price: 35,
description: 'Smooth legs from ankle to hip'
},
{
id: 'bikini-wax',
name: 'Bikini Wax',
duration: 30,
price: 20,
description: 'Clean bikini line waxing'
} }
]; }
// Working hours and available hours state // Working hours and available hours state
let workingHours = $state<Record< let workingHours = $state<Record<
@@ -127,6 +111,7 @@
if (!workingHoursCache.has(monthKey) || !availableHoursCache.has(monthKey)) { if (!workingHoursCache.has(monthKey) || !availableHoursCache.has(monthKey)) {
fetchHoursForMonth(placeholder); fetchHoursForMonth(placeholder);
} }
fetchServices();
}); });
// Fetch both working hours and available hours for a given month // Fetch both working hours and available hours for a given month
@@ -528,11 +513,14 @@
} }
function getTotalDuration() { function getTotalDuration() {
return selectedServices.reduce((total, service) => total + service.duration, 0); return selectedServices.reduce(
(total, service: Service) => total + service.duration_minutes,
0
);
} }
function getTotalPrice() { function getTotalPrice() {
return selectedServices.reduce((total, service) => total + service.price, 0); return selectedServices.reduce((total, service: Service) => total + service.price, 0);
} }
function toggleService(service: any) { function toggleService(service: any) {
@@ -738,47 +726,53 @@
<Card.Description>Select one or more treatments for your appointment</Card.Description> <Card.Description>Select one or more treatments for your appointment</Card.Description>
</Card.Header> </Card.Header>
<Card.Content class="space-y-4"> <Card.Content class="space-y-4">
<div class="grid gap-4 md:grid-cols-2"> <div class="grid w-full grid-cols-2 gap-4">
{#each services as service} {#if servicesLoading}
<button <p>Loading services...</p>
type="button" {:else if services.length === 0}
class="focus:ring-primary cursor-pointer rounded-lg p-4 text-left shadow-sm transition-colors hover:bg-fuchsia-50 {isServiceSelected( <p>No services available at the moment.</p>
service {:else}
) {#each services as service}
? 'bg-fuchsia-200' <button
: 'border-ring'}" type="button"
onclick={() => toggleService(service)} class="focus:ring-primary cursor-pointer rounded-lg p-4 text-left shadow-sm transition-colors hover:bg-fuchsia-50 {isServiceSelected(
> service
<div class="flex items-start justify-between"> )
<div class="flex-1"> ? 'bg-fuchsia-200'
<h3 class="font-semibold">{service.name}</h3> : 'border-ring'}"
<p class="text-sm text-gray-600">{service.description}</p> onclick={() => toggleService(service)}
<div class="mt-2 flex items-center space-x-4 text-sm text-gray-500"> >
<span>{service.duration} mins</span> <div class="flex items-start justify-between">
<span>£{service.price}</span> <div class="flex-1">
<h3 class="font-semibold">{service.name}</h3>
<p class="text-sm text-gray-600">{service.description}</p>
<div class="mt-2 flex items-center space-x-4 text-sm text-gray-500">
<span>{service.duration_minutes} mins</span>
<span>£{service.price}</span>
</div>
</div>
<div
class="ml-3 flex h-5 w-5 items-center justify-center rounded border-2 {isServiceSelected(
service
)
? 'border-primary bg-primary'
: 'border-gray-300'}"
aria-hidden="true"
>
{#if isServiceSelected(service)}
<svg class="h-3 w-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
></path>
</svg>
{/if}
</div> </div>
</div> </div>
<div </button>
class="ml-3 flex h-5 w-5 items-center justify-center rounded border-2 {isServiceSelected( {/each}
service {/if}
)
? 'border-primary bg-primary'
: 'border-gray-300'}"
aria-hidden="true"
>
{#if isServiceSelected(service)}
<svg class="h-3 w-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
></path>
</svg>
{/if}
</div>
</div>
</button>
{/each}
</div> </div>
{#if selectedServices.length > 0} {#if selectedServices.length > 0}
@@ -788,7 +782,7 @@
{#each selectedServices as service} {#each selectedServices as service}
<div class="flex justify-between text-sm"> <div class="flex justify-between text-sm">
<span>{service.name}</span> <span>{service.name}</span>
<span>{service.duration} mins • £{service.price}</span> <span>{service.duration_minutes} mins • £{service.price}</span>
</div> </div>
{/each} {/each}
<Separator class="my-2" /> <Separator class="my-2" />