Files
publisher-dashboard/apps/publisher-dashboard/src/lib/components/layout/dashboard/mobile-nav.svelte
igm 628b01f4d8 Add type-safe navigation helpers and public pages
- Create gotoLogin() helper for login redirects with search params
- Add /terms and /privacy public routes with Tailwind typography
- Update auth-guard to allow unauthenticated access to public pages
- Fix resolve() usage across navigation components using as const pattern
- Fix eslint-disable-next-line placement for svelte/no-navigation-without-resolve
- Document SvelteKit resolve() patterns in CLAUDE.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 14:19:33 +08:00

198 lines
7.8 KiB
Svelte

<script lang="ts">
import { getUserInitials } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { getContext } from "svelte";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/stores";
import { api } from "$lib/api/client";
import { Button } from "$lib/components/ui/button";
import { Separator } from "$lib/components/ui/separator";
import * as Sheet from "$lib/components/ui/sheet";
import { cn } from "$lib/utils.js";
interface Props {
class?: string;
}
let { class: className }: Props = $props();
let open = $state(false);
// Get optional org context (undefined outside org routes)
const orgContext = getContext<
{ slug: string; currentUserRole: string | null } | undefined
>("orgContext");
const currentSlug = $derived(orgContext?.slug);
const currentUserRole = $derived(orgContext?.currentUserRole);
// Fetch current user
const userQuery = createQuery(() => ({
queryKey: ["me"],
queryFn: () => api.me.get(),
}));
const user = $derived(userQuery.data);
const initials = $derived(getUserInitials(user));
// Nav items depend on whether we're in an org context
const navItems = $derived.by(() => {
if (currentSlug) {
return [
{ icon: "home", href: `/dashboard/${currentSlug}`, label: "Home" },
{
icon: "chart",
href: `/dashboard/${currentSlug}/performance`,
label: "Performance",
},
{
icon: "document",
href: `/dashboard/${currentSlug}/reports`,
label: "Reports",
},
];
}
return [
{ icon: "home", href: "/", label: "Home" },
{ icon: "building", href: "/dashboard", label: "Organizations" },
];
});
const queryClient = useQueryClient();
function handleNavClick(): void {
open = false;
}
async function handleSignOut(): Promise<void> {
try {
await api.auth.logout();
queryClient.clear();
open = false;
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto("/auth/login");
} catch (error) {
console.error("Failed to sign out:", error);
}
}
</script>
<Sheet.Root bind:open>
<Sheet.Trigger>
{#snippet child({ props })}
<Button variant="ghost" size="icon" class={cn("h-9 w-9 lg:hidden", className)} {...props}>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M3 12h18M3 6h18M3 18h18" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span class="sr-only">Open menu</span>
</Button>
{/snippet}
</Sheet.Trigger>
<Sheet.Content side="left" class="w-72 p-0">
<Sheet.Header class="border-b border-border px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-foreground">
<svg class="h-5 w-5 text-background" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<Sheet.Title class="text-lg font-semibold">Publisher Dashboard</Sheet.Title>
</div>
</Sheet.Header>
<nav class="flex flex-1 flex-col p-4">
<div class="space-y-1">
{#each navItems as item (item.href)}
{@const isActive =
$page.url.pathname === item.href ||
(item.href !== "/" && $page.url.pathname.startsWith(item.href))}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={item.href}
onclick={handleNavClick}
class={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
isActive
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
{#if item.icon === "home"}
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9 22V12h6v10" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{:else if item.icon === "chart"}
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M18 20V10M12 20V4M6 20v-6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{:else if item.icon === "document"}
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{:else if item.icon === "building"}
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M3 21h18M5 21V5a2 2 0 012-2h10a2 2 0 012 2v16" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9 6.5h1.5M9 10h1.5M9 13.5h1.5M13.5 6.5H15M13.5 10H15M13.5 13.5H15M9 21v-4h6v4" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{/if}
{item.label}
</a>
{/each}
</div>
<!-- User section at bottom -->
<div class="mt-auto pt-4">
<Separator class="mb-4" />
<div class="flex items-center gap-3 rounded-lg px-3 py-2">
{#if user?.avatarUrl}
<img src={user.avatarUrl} alt="" class="h-9 w-9 rounded-full object-cover" />
{:else}
<div class="flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-amber-500 to-orange-600 text-xs font-semibold text-white">
{initials}
</div>
{/if}
<div class="flex-1">
<p class="text-sm font-medium text-foreground">{user?.displayName ?? user?.email ?? "Loading..."}</p>
{#if currentUserRole}
<p class="text-xs capitalize text-muted-foreground">{currentUserRole}</p>
{:else if user?.email && user?.displayName}
<p class="text-xs text-muted-foreground">{user.email}</p>
{/if}
</div>
</div>
<div class="mt-2 space-y-1">
<a
href={resolve("/account")}
onclick={handleNavClick}
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="12" cy="7" r="4" />
</svg>
Account Settings
</a>
<button
onclick={handleSignOut}
class="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" stroke-linecap="round" stroke-linejoin="round" />
<polyline points="16,17 21,12 16,7" stroke-linecap="round" stroke-linejoin="round" />
<line x1="21" y1="12" x2="9" y2="12" stroke-linecap="round" stroke-linejoin="round" />
</svg>
Sign out
</button>
</div>
</div>
</nav>
</Sheet.Content>
</Sheet.Root>