Better skeleton use, live services in booking
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
Reference in New Issue
Block a user