Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
236 lines
7.0 KiB
Svelte
236 lines
7.0 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { navigating, page } from '$app/stores';
|
|
import { goto } from '$app/navigation';
|
|
import { authStore } from '$lib/stores/auth.svelte';
|
|
import { Button } from '$lib/components/ui/button';
|
|
import { Skeleton } from '$lib/components/ui/skeleton';
|
|
|
|
// Centralized link definition
|
|
const links = [
|
|
{ href: '/', label: 'Home', showWhen: 'always', width: 'w-12' },
|
|
{ href: '/prices', label: 'Price List', showWhen: 'guest', width: 'w-20' },
|
|
{ href: '/schedule', label: 'My Schedule', showWhen: 'auth-not-admin', width: 'w-24' },
|
|
{ href: '/book', label: 'Book an appointment', showWhen: 'auth', width: 'w-36' },
|
|
{ href: '/portfolio', label: 'Portfolio', showWhen: 'always', width: 'w-20' },
|
|
{ href: '/admin', label: 'Admin Dashboard', showWhen: 'admin', width: 'w-28' },
|
|
{ href: '/today', label: 'Today', showWhen: 'admin', width: 'w-28' },
|
|
{ href: '/contact', label: 'Contact', showWhen: 'always', width: 'w-16' },
|
|
{ href: '/account', label: 'My Account', showWhen: 'auth', width: 'w-24' }
|
|
];
|
|
|
|
let mobileMenuOpen = $state(false);
|
|
function toggleMenu() {
|
|
mobileMenuOpen = !mobileMenuOpen;
|
|
}
|
|
|
|
// Close mobile menu when navigation starts
|
|
$effect(() => {
|
|
if ($navigating) {
|
|
mobileMenuOpen = false;
|
|
}
|
|
});
|
|
|
|
// Helper to decide visibility - use $derived for reactivity
|
|
const canShow = (link: { href: string; label: string; showWhen: string; width: string }) => {
|
|
if (authStore.isLoading) return true;
|
|
|
|
// 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 === 'auth-not-admin' &&
|
|
authStore.isAuthenticated &&
|
|
authStore.currentUser?.role !== 'admin'
|
|
)
|
|
return true;
|
|
if (link.showWhen === 'admin' && authStore.currentUser?.role === 'admin') return true;
|
|
return false;
|
|
};
|
|
|
|
// Derived values for auth state - ensures reactivity
|
|
let isAuthenticated = $derived(authStore.isAuthenticated);
|
|
let isLoading = $derived(authStore.isLoading);
|
|
|
|
// Notification bell state
|
|
let unreadCount = $state(0);
|
|
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
|
async function fetchUnreadCount() {
|
|
if (!authStore.isAuthenticated || !authStore.currentToken) return;
|
|
try {
|
|
const res = await fetch('/api/admin/notifications/unread-count', {
|
|
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
unreadCount = data.count;
|
|
}
|
|
} catch {
|
|
// Silently fail - bell is non-critical
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
if (authStore.isAuthenticated) {
|
|
fetchUnreadCount();
|
|
if (authStore.currentUser?.role === 'admin') {
|
|
pollInterval = setInterval(fetchUnreadCount, 60000);
|
|
}
|
|
}
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (pollInterval) {
|
|
clearInterval(pollInterval);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<nav
|
|
class="fixed top-0 left-0 z-50 w-full border-b border-gray-200"
|
|
class:frosty-nav={!mobileMenuOpen}
|
|
class:bg-background={mobileMenuOpen}
|
|
>
|
|
<div class="mx-auto max-w-7xl px-4 md:px-6 lg:px-8">
|
|
<div class="flex h-16 items-center justify-between">
|
|
<!-- Left: Social Icons -->
|
|
<div class="flex items-center space-x-4">
|
|
<a
|
|
href="https://instagram.com"
|
|
target="_blank"
|
|
rel="noopener"
|
|
class="text-gray-600 hover:text-primary"
|
|
aria-label="Instagram"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<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>
|
|
</div>
|
|
|
|
<!-- Center: Desktop Links -->
|
|
<div class="hidden space-x-8 md:flex">
|
|
{#each links as link}
|
|
{#if canShow(link)}
|
|
{#if isLoading}
|
|
<Skeleton class={`h-4 ${link.width} rounded`} />
|
|
{:else}
|
|
<a href={link.href} class="font-medium text-gray-800 hover:text-primary"
|
|
>{link.label}</a
|
|
>
|
|
{/if}
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
|
|
<div class="hidden items-center gap-4 md:flex">
|
|
{#if isAuthenticated}
|
|
<a
|
|
href="/notifications"
|
|
class="relative text-gray-600 hover:text-primary"
|
|
aria-label="Notifications"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
|
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
|
</svg>
|
|
{#if unreadCount > 0}
|
|
<span
|
|
class="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white"
|
|
>
|
|
{unreadCount > 9 ? '9+' : unreadCount}
|
|
</span>
|
|
{/if}
|
|
</a>
|
|
{/if}
|
|
{#if !isLoading && !isAuthenticated && $page.url.pathname !== '/login'}
|
|
<Button href="/login">Login</Button>
|
|
{/if}
|
|
{#if isLoading}
|
|
<Skeleton class="h-8 w-16 rounded" />
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Mobile: Burger -->
|
|
<div class="flex items-center md:hidden">
|
|
<button onclick={toggleMenu} class="focus:outline-none" aria-label="Toggle menu">
|
|
<svg class="h-6 w-6 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M4 6h16M4 12h16M4 18h16"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Mobile Menu -->
|
|
{#if mobileMenuOpen}
|
|
<div class="border-b border-gray-200 bg-background md:hidden">
|
|
<div class="space-y-1 px-4 pt-2 pb-3">
|
|
{#each links as link}
|
|
{#if canShow(link)}
|
|
{#if isLoading}
|
|
<Skeleton class="h-4 w-full rounded" />
|
|
{:else}
|
|
<a
|
|
href={link.href}
|
|
class="block rounded px-3 py-3 text-center text-primary hover:text-gray-800"
|
|
>
|
|
{link.label}
|
|
</a>
|
|
{/if}
|
|
{/if}
|
|
{/each}
|
|
|
|
{#if isAuthenticated}
|
|
<a
|
|
href="/notifications"
|
|
class="flex items-center justify-center gap-2 rounded px-3 py-3 text-primary hover:text-gray-800"
|
|
>
|
|
<span>Notifications</span>
|
|
{#if unreadCount > 0}
|
|
<span class="flex h-6 w-6 min-w-6 items-center justify-center rounded-full bg-red-500 px-1.5 text-[10px] font-bold text-white">
|
|
{unreadCount > 9 ? '9+' : unreadCount}
|
|
</span>
|
|
{/if}
|
|
</a>
|
|
{/if}
|
|
|
|
{#if isLoading}
|
|
<Skeleton class="mt-2 h-8 w-full rounded" />
|
|
{:else if !isAuthenticated}
|
|
<Button href="/login" class="mt-2 w-full text-center">Login</Button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</nav>
|
|
|
|
<style>
|
|
.frosty-nav {
|
|
backdrop-filter: saturate(180%) blur(10px);
|
|
}
|
|
</style>
|