Better skeleton use, live services in booking
This commit is contained in:
@@ -28,6 +28,7 @@ type Service struct {
|
||||
}
|
||||
|
||||
type ServiceResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Price float64 `json:"price"`
|
||||
@@ -207,7 +208,7 @@ func DeleteServiceHandler(w http.ResponseWriter, r *http.Request) {
|
||||
func ServicesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Query all active services
|
||||
query := `
|
||||
SELECT name, description, price, duration_minutes,
|
||||
SELECT id, name, description, price, duration_minutes,
|
||||
patch_test_duration_hours, minimum_age_required
|
||||
FROM services
|
||||
WHERE is_active = TRUE
|
||||
@@ -227,6 +228,7 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var service ServiceResponse
|
||||
|
||||
err := rows.Scan(
|
||||
&service.ID,
|
||||
&service.Name,
|
||||
&service.Description,
|
||||
&service.Price,
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import { navigating, page } from '$app/stores';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
// Mobile menu state
|
||||
let mobileMenuOpen: boolean = false;
|
||||
// Centralized link definition
|
||||
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() {
|
||||
mobileMenuOpen = !mobileMenuOpen;
|
||||
}
|
||||
@@ -14,6 +24,21 @@
|
||||
$: if ($navigating) {
|
||||
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>
|
||||
|
||||
<nav
|
||||
@@ -42,36 +67,37 @@
|
||||
stroke-width="2"
|
||||
stroke-linecap="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>
|
||||
<!-- Add more social icons here -->
|
||||
</div>
|
||||
|
||||
<!-- Center: Links -->
|
||||
<!-- Center: Desktop Links -->
|
||||
<div class="hidden space-x-8 md:flex">
|
||||
<a href="/" class="hover:text-primary font-medium text-gray-800">Home</a>
|
||||
{#if !authStore?.isAuthenticated}
|
||||
<a href="/prices" class="hover:text-primary font-medium text-gray-800">Price List</a>
|
||||
{:else}
|
||||
<a href="/book" class="hover:text-primary font-medium text-gray-800"
|
||||
>Book your appointment</a
|
||||
>
|
||||
{/if}
|
||||
<a href="/portfolio" class="hover:text-primary font-medium text-gray-800">Portfolio</a>
|
||||
<a href="/contact" class="hover:text-primary font-medium text-gray-800">Contact</a>
|
||||
{#if authStore?.isAuthenticated}
|
||||
<a href="/account" class="hover:text-primary font-medium text-gray-800">My Account</a>
|
||||
{/if}
|
||||
{#each links as link}
|
||||
{#if canShow(link)}
|
||||
{#if authStore.isLoading}
|
||||
<Skeleton class={`h-4 ${link.width} rounded`} />
|
||||
{:else}
|
||||
<a href={link.href} class="hover:text-primary font-medium text-gray-800"
|
||||
>{link.label}</a
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Desktop Login Button -->
|
||||
<div class="hidden items-center md:flex">
|
||||
<!-- if not logged in and not on login page -->
|
||||
{#if !authStore?.isAuthenticated && $page.url.pathname !== '/login'}
|
||||
{#if !authStore.isLoading && !authStore.isAuthenticated && $page.url.pathname !== '/login'}
|
||||
<Button href="/login">Login</Button>
|
||||
{/if}
|
||||
{#if authStore.isLoading}
|
||||
<Skeleton class="h-8 w-16 rounded" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Mobile: Burger -->
|
||||
@@ -94,41 +120,25 @@
|
||||
{#if mobileMenuOpen}
|
||||
<div class="bg-background border-b border-gray-200 md:hidden">
|
||||
<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"
|
||||
>Home</a
|
||||
>
|
||||
{#each links as link}
|
||||
{#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}
|
||||
<a
|
||||
href="/prices"
|
||||
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}
|
||||
{#if authStore.isLoading}
|
||||
<Skeleton class="mt-2 h-8 w-full rounded" />
|
||||
{:else if !authStore.isAuthenticated}
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,6 +50,11 @@ class AuthStore {
|
||||
return this.loading;
|
||||
}
|
||||
|
||||
get hasLoaded() {
|
||||
return !this.loading;
|
||||
}
|
||||
|
||||
|
||||
private initializeAuth() {
|
||||
const storedToken = localStorage.getItem('authToken');
|
||||
if (storedToken) {
|
||||
|
||||
@@ -1,14 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import PortfolioCarousel from '$lib/components/layout/PortfolioCarousel.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
</script>
|
||||
|
||||
<section class="py-20 text-center">
|
||||
<h1 class="m-2 mb-4 text-4xl font-bold">Welcome to Crussell Nails</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 authStore.isLoading}
|
||||
<!-- Skeleton loading state -->
|
||||
<div class="m-2 mb-4">
|
||||
<Skeleton class="mx-auto h-10 w-80 max-w-full" />
|
||||
</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 class="mx-auto max-w-4xl px-6 py-16">
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
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 * as Modal from '$lib/components/ui/dialog';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
@@ -18,19 +16,32 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
|
||||
const user = authStore.currentUser;
|
||||
if (browser) {
|
||||
if (!user || user.role !== 'admin') {
|
||||
setTimeout(() => {
|
||||
goto('/', { replaceState: true });
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
// =============== Auth & Permissions ===============
|
||||
let pageState = $state<'loading' | 'authorized' | 'unauthorized'>('loading');
|
||||
|
||||
// =============== Alert Dialog State ===============
|
||||
let showSaveDefaultHoursAlert = $state(false);
|
||||
let showDeleteExceptionAlert = $state(false);
|
||||
let exceptionToDelete = $state<number | undefined>(undefined);
|
||||
// Check permissions immediately and on auth changes
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
|
||||
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 ===============
|
||||
let uploading = $state(false);
|
||||
@@ -122,13 +133,18 @@
|
||||
}
|
||||
|
||||
async function fetchDefaultHours() {
|
||||
if (pageState !== 'authorized') return;
|
||||
|
||||
defaultHoursIsLoading = true;
|
||||
let error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/scheduling/default-hours', {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
@@ -154,7 +170,9 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
fetchDefaultHours();
|
||||
if (pageState === 'authorized') {
|
||||
fetchDefaultHours();
|
||||
}
|
||||
});
|
||||
|
||||
type ExceptionGroup = {
|
||||
@@ -205,17 +223,6 @@
|
||||
let defaultHoursDraft = $state<WorkingHourRow[]>([]);
|
||||
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);
|
||||
|
||||
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
@@ -251,7 +258,7 @@
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('authToken')}`
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
@@ -488,13 +495,15 @@
|
||||
|
||||
// Fetch services from API
|
||||
async function fetchServices() {
|
||||
if (pageState !== 'authorized') return;
|
||||
|
||||
servicesLoading = true;
|
||||
try {
|
||||
const response = await fetch('/api/admin/services', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'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`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('authToken')}`
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
@@ -553,7 +562,7 @@
|
||||
const response = await fetch(`/api/admin/services/${serviceId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('authToken')}`
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
@@ -582,11 +591,182 @@
|
||||
|
||||
// Fetch services on component mount
|
||||
$effect(() => {
|
||||
fetchServices();
|
||||
if (pageState === 'authorized') {
|
||||
fetchServices();
|
||||
}
|
||||
});
|
||||
|
||||
// =============== Alert Dialog State ===============
|
||||
let showSaveDefaultHoursAlert = $state(false);
|
||||
let showDeleteExceptionAlert = $state(false);
|
||||
let exceptionToDelete = $state<number | undefined>(undefined);
|
||||
</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="mb-4 flex items-center justify-center text-center">
|
||||
<div>
|
||||
@@ -1279,20 +1459,4 @@
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
{/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}
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||
import Calendar from '$lib/components/ui/calendar/calendar.svelte';
|
||||
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
// Booking state
|
||||
let currentStep = $state<number>(1);
|
||||
let selectedServices = $state<any[]>([]);
|
||||
let selectedServices = $state<Service[]>([]);
|
||||
let selectedDate = $state<CalendarDate | undefined>(undefined);
|
||||
let selectedTime = $state<string | null>(null);
|
||||
let customerInfo = $state({
|
||||
@@ -21,65 +23,47 @@
|
||||
specialRequests: ''
|
||||
});
|
||||
|
||||
// Service options
|
||||
const services = [
|
||||
{
|
||||
id: 'manicure',
|
||||
name: 'Classic Manicure',
|
||||
duration: 45,
|
||||
price: 25,
|
||||
description: 'Shape, cuticle care, polish'
|
||||
},
|
||||
{
|
||||
id: 'gel-manicure',
|
||||
name: 'Gel Manicure',
|
||||
duration: 60,
|
||||
price: 35,
|
||||
description: 'Long-lasting gel polish application'
|
||||
},
|
||||
{
|
||||
id: 'pedicure',
|
||||
name: 'Classic Pedicure',
|
||||
duration: 60,
|
||||
price: 30,
|
||||
description: 'Soak, exfoliate, shape, polish'
|
||||
},
|
||||
{
|
||||
id: 'gel-pedicure',
|
||||
name: 'Gel Pedicure',
|
||||
duration: 75,
|
||||
price: 40,
|
||||
description: 'Premium pedicure with gel polish'
|
||||
},
|
||||
{
|
||||
id: 'eyebrow-shape',
|
||||
name: 'Eyebrow Shaping',
|
||||
duration: 30,
|
||||
price: 15,
|
||||
description: 'Wax and tweeze to perfect shape'
|
||||
},
|
||||
{
|
||||
id: 'eyebrow-tint',
|
||||
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'
|
||||
// =============== Services Management (User View) ===============
|
||||
type Service = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
duration_minutes: number;
|
||||
patch_test_duration_hours: number;
|
||||
minimum_age_required: number;
|
||||
};
|
||||
|
||||
let services = $state<Service[]>([]);
|
||||
let servicesLoading = $state(true);
|
||||
|
||||
// Fetch active services for standard users
|
||||
async function fetchServices() {
|
||||
servicesLoading = true;
|
||||
try {
|
||||
const response = await fetch('/api/services', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Optional: If your user endpoint requires auth
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data: Service[] = await response.json();
|
||||
services = data;
|
||||
} else {
|
||||
console.error('Failed to fetch services:', response.status);
|
||||
toast.error('Failed to load services');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching services:', err);
|
||||
toast.error('Network error loading services');
|
||||
} finally {
|
||||
servicesLoading = false;
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// Working hours and available hours state
|
||||
let workingHours = $state<Record<
|
||||
@@ -127,6 +111,7 @@
|
||||
if (!workingHoursCache.has(monthKey) || !availableHoursCache.has(monthKey)) {
|
||||
fetchHoursForMonth(placeholder);
|
||||
}
|
||||
fetchServices();
|
||||
});
|
||||
|
||||
// Fetch both working hours and available hours for a given month
|
||||
@@ -528,11 +513,14 @@
|
||||
}
|
||||
|
||||
function getTotalDuration() {
|
||||
return selectedServices.reduce((total, service) => total + service.duration, 0);
|
||||
return selectedServices.reduce(
|
||||
(total, service: Service) => total + service.duration_minutes,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -738,47 +726,53 @@
|
||||
<Card.Description>Select one or more treatments for your appointment</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
{#each services as service}
|
||||
<button
|
||||
type="button"
|
||||
class="focus:ring-primary cursor-pointer rounded-lg p-4 text-left shadow-sm transition-colors hover:bg-fuchsia-50 {isServiceSelected(
|
||||
service
|
||||
)
|
||||
? 'bg-fuchsia-200'
|
||||
: 'border-ring'}"
|
||||
onclick={() => toggleService(service)}
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<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} mins</span>
|
||||
<span>£{service.price}</span>
|
||||
<div class="grid w-full grid-cols-2 gap-4">
|
||||
{#if servicesLoading}
|
||||
<p>Loading services...</p>
|
||||
{:else if services.length === 0}
|
||||
<p>No services available at the moment.</p>
|
||||
{:else}
|
||||
{#each services as service}
|
||||
<button
|
||||
type="button"
|
||||
class="focus:ring-primary cursor-pointer rounded-lg p-4 text-left shadow-sm transition-colors hover:bg-fuchsia-50 {isServiceSelected(
|
||||
service
|
||||
)
|
||||
? 'bg-fuchsia-200'
|
||||
: 'border-ring'}"
|
||||
onclick={() => toggleService(service)}
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<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
|
||||
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>
|
||||
</button>
|
||||
{/each}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selectedServices.length > 0}
|
||||
@@ -788,7 +782,7 @@
|
||||
{#each selectedServices as service}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span>{service.name}</span>
|
||||
<span>{service.duration} mins • £{service.price}</span>
|
||||
<span>{service.duration_minutes} mins • £{service.price}</span>
|
||||
</div>
|
||||
{/each}
|
||||
<Separator class="my-2" />
|
||||
|
||||
Reference in New Issue
Block a user