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>
This commit is contained in:
igm
2026-01-11 14:19:33 +08:00
parent 76a5e40900
commit 628b01f4d8
33 changed files with 281 additions and 96 deletions

View File

@@ -14,3 +14,50 @@ macOS uses BSD sed which differs from GNU sed:
- GNU sed (Linux): `sed -i 's/old/new/g' file` - GNU sed (Linux): `sed -i 's/old/new/g' file`
- Use `|` as delimiter when patterns contain `/`: `sed -i '' 's|old/path|new/path|g' file` - Use `|` as delimiter when patterns contain `/`: `sed -i '' 's|old/path|new/path|g' file`
- For multiple files: `for f in *.txt; do sed -i '' 's/old/new/g' "$f"; done` - For multiple files: `for f in *.txt; do sed -i '' 's/old/new/g' "$f"; done`
## SvelteKit resolve() Usage
Use `resolve()` from `$app/paths` for type-safe navigation. The patterns are:
### Static routes - use resolve() directly
```svelte
href={resolve("/auth/login")}
href={resolve("/dashboard")}
```
### Dynamic routes - use two-argument form
```svelte
href={resolve("/dashboard/[slug]", { slug: orgSlug })}
href={resolve("/account/org-invites/[inviteId]", { inviteId: String(invite.id) })}
```
### Login redirects - use gotoLogin helper
For redirecting to login with a return URL, use the helper from `$lib/utils/navigation`:
```typescript
import { gotoLogin } from "$lib/utils/navigation";
gotoLogin(page.url.pathname);
```
This helper uses resolve() internally and handles the query string correctly.
### Navigation arrays - use `as const` with route patterns
For type-safe navigation arrays, define routes as literal strings with `as const`:
```typescript
const navItems = [
{ route: "/dashboard/[slug]/settings", icon: Settings, label: "General" },
{ route: "/dashboard/[slug]/settings/members", icon: Users, label: "Members" },
] as const;
```
Then use resolve with params:
```svelte
{#each navItems as item (item.route)}
<a href={resolve(item.route, { slug })}>
{/each}
```
### Runtime strings - skip resolve, use eslint-disable
When paths are fully dynamic (e.g., server-provided redirects), skip resolve:
```typescript
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(redirectUrl);
```

View File

@@ -37,6 +37,7 @@
"@sveltejs/adapter-static": "^3.0.8", "@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.49.4", "@sveltejs/kit": "^2.49.4",
"@sveltejs/vite-plugin-svelte": "^6.2.3", "@sveltejs/vite-plugin-svelte": "^6.2.3",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.4", "@tailwindcss/vite": "^4.1.4",
"@types/ua-parser-js": "^0.7.39", "@types/ua-parser-js": "^0.7.39",
"@types/zxcvbn": "^4.4.5", "@types/zxcvbn": "^4.4.5",

View File

@@ -1,5 +1,6 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@plugin "@tailwindcss/typography";
/* Geist Sans - Modern, clean typeface */ /* Geist Sans - Modern, clean typeface */
@font-face { @font-face {

View File

@@ -5,7 +5,6 @@ import MonitorIcon from "@lucide/svelte/icons/monitor";
import ShieldCheckIcon from "@lucide/svelte/icons/shield-check"; import ShieldCheckIcon from "@lucide/svelte/icons/shield-check";
import UserIcon from "@lucide/svelte/icons/user"; import UserIcon from "@lucide/svelte/icons/user";
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { resolve } from "$app/paths";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
@@ -59,8 +58,8 @@ function isActive(href: string, pathname: string): boolean {
> >
{#each navItems as item (item.href)} {#each navItems as item (item.href)}
{@const active = isActive(item.href, $page.url.pathname)} {@const active = isActive(item.href, $page.url.pathname)}
<a <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
href={resolve(item.href as any)} <a href={item.href}
class={cn( class={cn(
"inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow]", "inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow]",
active active

View File

@@ -1,10 +1,9 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/state"; import { page } from "$app/state";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { gotoLogin } from "$lib/utils/navigation";
interface Props { interface Props {
children: Snippet; children: Snippet;
@@ -12,29 +11,29 @@ interface Props {
let { children }: Props = $props(); let { children }: Props = $props();
// Check if current path is an auth page (doesn't require login) // Check if current path is a public page (doesn't require login)
const isAuthPage = $derived(page.url.pathname.startsWith("/auth")); const isPublicPage = $derived(
page.url.pathname.startsWith("/auth") ||
page.url.pathname === "/terms" ||
page.url.pathname === "/privacy",
);
// Fetch user to check if logged in (only for non-auth pages) // Fetch user to check if logged in (only for protected pages)
const userQuery = createQuery(() => ({ const userQuery = createQuery(() => ({
queryKey: ["me"], queryKey: ["me"],
queryFn: () => api.me.get(), queryFn: () => api.me.get(),
enabled: !isAuthPage, enabled: !isPublicPage,
retry: false, retry: false,
})); }));
// Redirect to login if not authenticated on non-auth pages // Redirect to login if not authenticated on protected pages
$effect(() => { $effect(() => {
if (!isAuthPage && userQuery.error) { if (!isPublicPage && userQuery.error) {
goto( gotoLogin(page.url.pathname);
resolve(
`/auth/login?redirect=${encodeURIComponent(page.url.pathname)}` as any,
),
);
} }
}); });
</script> </script>
{#if isAuthPage || userQuery.data || userQuery.isPending} {#if isPublicPage || userQuery.data || userQuery.isPending}
{@render children()} {@render children()}
{/if} {/if}

View File

@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { resolve } from "$app/paths";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
interface Props { interface Props {
@@ -27,8 +26,8 @@ const filters = [
<div class="divide-y divide-border/50"> <div class="divide-y divide-border/50">
{#each filters as filter (filter.label)} {#each filters as filter (filter.label)}
<a <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
href={resolve(filter.href as any)} <a href={filter.href}
class="group flex items-center gap-3 px-5 py-3 transition-colors hover:bg-muted/30" class="group flex items-center gap-3 px-5 py-3 transition-colors hover:bg-muted/30"
> >
<div class="flex h-7 w-7 items-center justify-center rounded-md bg-muted text-muted-foreground transition-colors group-hover:bg-foreground/10 group-hover:text-foreground"> <div class="flex h-7 w-7 items-center justify-center rounded-md bg-muted text-muted-foreground transition-colors group-hover:bg-foreground/10 group-hover:text-foreground">

View File

@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
import { import {
@@ -33,14 +32,15 @@ const activeTab = $derived(
($page.url.searchParams.get("tab") as TabId) || defaultTab, ($page.url.searchParams.get("tab") as TabId) || defaultTab,
); );
function handleTabChange(tabId: string) { function handleTabChange(tabId: string): void {
const url = new URL($page.url); const url = new URL($page.url);
if (tabId === defaultTab) { if (tabId === defaultTab) {
url.searchParams.delete("tab"); url.searchParams.delete("tab");
} else { } else {
url.searchParams.set("tab", tabId); url.searchParams.set("tab", tabId);
} }
goto(resolve(url.toString() as any), { replaceState: true, noScroll: true }); // eslint-disable-next-line svelte/no-navigation-without-resolve
goto(url.toString(), { replaceState: true, noScroll: true });
} }
</script> </script>

View File

@@ -6,7 +6,6 @@ import MonitorIcon from "@lucide/svelte/icons/monitor";
import ShieldCheckIcon from "@lucide/svelte/icons/shield-check"; import ShieldCheckIcon from "@lucide/svelte/icons/shield-check";
import UserIcon from "@lucide/svelte/icons/user"; import UserIcon from "@lucide/svelte/icons/user";
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { resolve } from "$app/paths";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { DashboardLayout } from "$lib/components/layout"; import { DashboardLayout } from "$lib/components/layout";
@@ -94,8 +93,8 @@ function isActive(href: string): boolean {
<div class="flex gap-2 overflow-x-auto pb-2 lg:hidden"> <div class="flex gap-2 overflow-x-auto pb-2 lg:hidden">
{#each navItems as item (item.href)} {#each navItems as item (item.href)}
{@const active = isActive(item.href)} {@const active = isActive(item.href)}
<a <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
href={resolve(item.href as any)} <a href={item.href}
class={cn( class={cn(
"flex shrink-0 items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors", "flex shrink-0 items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
active active
@@ -113,8 +112,8 @@ function isActive(href: string): boolean {
<div class="hidden space-y-1 lg:block"> <div class="hidden space-y-1 lg:block">
{#each navItems as item (item.href)} {#each navItems as item (item.href)}
{@const active = isActive(item.href)} {@const active = isActive(item.href)}
<a <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
href={resolve(item.href as any)} <a href={item.href}
class={cn( class={cn(
"group flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors", "group flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors",
active active

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { getUserInitials } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query"; import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
@@ -8,7 +9,6 @@ import { Button } from "$lib/components/ui/button";
import { Separator } from "$lib/components/ui/separator"; import { Separator } from "$lib/components/ui/separator";
import * as Sheet from "$lib/components/ui/sheet"; import * as Sheet from "$lib/components/ui/sheet";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
import { getUserInitials } from "@reviq/common";
interface Props { interface Props {
class?: string; class?: string;
@@ -83,8 +83,8 @@ const navItems = [
item.href === "/admin" item.href === "/admin"
? $page.url.pathname === "/admin" ? $page.url.pathname === "/admin"
: $page.url.pathname.startsWith(item.href)} : $page.url.pathname.startsWith(item.href)}
<a <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
href={resolve(item.href as any)} <a href={item.href}
onclick={handleNavClick} onclick={handleNavClick}
class={cn( class={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors", "flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { getUserInitials } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query"; import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
@@ -6,7 +7,6 @@ import { page } from "$app/stores";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu"; import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
import { getUserInitials } from "@reviq/common";
interface Props { interface Props {
class?: string; class?: string;
@@ -75,8 +75,8 @@ const navItems = [
item.href === "/admin" item.href === "/admin"
? $page.url.pathname === "/admin" ? $page.url.pathname === "/admin"
: $page.url.pathname.startsWith(item.href)} : $page.url.pathname.startsWith(item.href)}
<a <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
href={resolve(item.href as any)} <a href={item.href}
class={cn( class={cn(
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150", "group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
isActive isActive

View File

@@ -74,8 +74,8 @@ const navItems = $derived.by(() => {
? $page.url.pathname === item.href ? $page.url.pathname === item.href
: $page.url.pathname === item.href || : $page.url.pathname === item.href ||
$page.url.pathname.startsWith(item.href + "/")} $page.url.pathname.startsWith(item.href + "/")}
<a <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
href={resolve(item.href as any)} <a href={item.href}
class={cn( class={cn(
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150", "group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
isActive isActive
@@ -163,7 +163,7 @@ const navItems = $derived.by(() => {
{#if currentSlug} {#if currentSlug}
{@const isSettingsActive = $page.url.pathname.startsWith(`/dashboard/${currentSlug}/settings`)} {@const isSettingsActive = $page.url.pathname.startsWith(`/dashboard/${currentSlug}/settings`)}
<a <a
href={resolve(`/dashboard/${currentSlug}/settings`)} href={resolve("/dashboard/[slug]/settings", { slug: currentSlug })}
class={cn( class={cn(
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150", "group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
isSettingsActive isSettingsActive

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { getUserInitials } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query"; import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { getContext } from "svelte"; import { getContext } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
@@ -9,7 +10,6 @@ import { Button } from "$lib/components/ui/button";
import { Separator } from "$lib/components/ui/separator"; import { Separator } from "$lib/components/ui/separator";
import * as Sheet from "$lib/components/ui/sheet"; import * as Sheet from "$lib/components/ui/sheet";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
import { getUserInitials } from "@reviq/common";
interface Props { interface Props {
class?: string; class?: string;
@@ -40,8 +40,16 @@ const navItems = $derived.by(() => {
if (currentSlug) { if (currentSlug) {
return [ return [
{ icon: "home", href: `/dashboard/${currentSlug}`, label: "Home" }, { icon: "home", href: `/dashboard/${currentSlug}`, label: "Home" },
{ icon: "chart", href: `/dashboard/${currentSlug}/performance`, label: "Performance" }, {
{ icon: "document", href: `/dashboard/${currentSlug}/reports`, label: "Reports" }, icon: "chart",
href: `/dashboard/${currentSlug}/performance`,
label: "Performance",
},
{
icon: "document",
href: `/dashboard/${currentSlug}/reports`,
label: "Reports",
},
]; ];
} }
return [ return [
@@ -61,7 +69,8 @@ async function handleSignOut(): Promise<void> {
await api.auth.logout(); await api.auth.logout();
queryClient.clear(); queryClient.clear();
open = false; open = false;
goto(resolve("/auth/login")); // eslint-disable-next-line svelte/no-navigation-without-resolve
goto("/auth/login");
} catch (error) { } catch (error) {
console.error("Failed to sign out:", error); console.error("Failed to sign out:", error);
} }
@@ -98,8 +107,8 @@ async function handleSignOut(): Promise<void> {
{@const isActive = {@const isActive =
$page.url.pathname === item.href || $page.url.pathname === item.href ||
(item.href !== "/" && $page.url.pathname.startsWith(item.href))} (item.href !== "/" && $page.url.pathname.startsWith(item.href))}
<a <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
href={resolve(item.href as any)} <a href={item.href}
onclick={handleNavClick} onclick={handleNavClick}
class={cn( class={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors", "flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",

View File

@@ -20,7 +20,7 @@ const orgsQuery = createQuery(() => ({
const orgs = $derived(orgsQuery.data ?? []); const orgs = $derived(orgsQuery.data ?? []);
function handleOrgSelect(slug: string) { function handleOrgSelect(slug: string) {
goto(resolve(`/dashboard/${slug}` as any)); goto(resolve(`/dashboard/${slug}`));
} }
</script> </script>

View File

@@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import { getUserInitials } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query"; import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { getContext } from "svelte"; import { getContext } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu"; import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { getUserInitials } from "@reviq/common";
// Get optional org context (undefined outside org routes) // Get optional org context (undefined outside org routes)
const orgContext = getContext<{ currentUserRole: string | null } | undefined>( const orgContext = getContext<{ currentUserRole: string | null } | undefined>(

View File

@@ -16,36 +16,37 @@ let { title, children }: Props = $props();
// Get org context from parent layout // Get org context from parent layout
const orgContext = getContext<{ slug: string }>("orgContext"); const orgContext = getContext<{ slug: string }>("orgContext");
const slug = $derived(orgContext?.slug); const slug = $derived(orgContext?.slug ?? "");
// Settings navigation items // Settings navigation items with route patterns for type-safe resolve()
const navItems = $derived.by(() => [ const navItems = [
{ {
href: `/dashboard/${slug}/settings`, route: "/dashboard/[slug]/settings",
icon: Settings, icon: Settings,
label: "General", label: "General",
description: "Organization name, logo, and preferences", description: "Organization name, logo, and preferences",
}, },
{ {
href: `/dashboard/${slug}/settings/members`, route: "/dashboard/[slug]/settings/members",
icon: Users, icon: Users,
label: "Members", label: "Members",
description: "Manage team members and invitations", description: "Manage team members and invitations",
}, },
{ {
href: `/dashboard/${slug}/settings/sites`, route: "/dashboard/[slug]/settings/sites",
icon: Globe, icon: Globe,
label: "Sites", label: "Sites",
description: "Connected websites and domains", description: "Connected websites and domains",
}, },
]); ] as const;
// Determine active item // Determine active item
const activeHref = $derived($page.url.pathname); const activeHref = $derived($page.url.pathname);
function isActive(href: string): boolean { function isActive(route: (typeof navItems)[number]["route"]): boolean {
const href = resolve(route, { slug });
// Exact match for base settings path // Exact match for base settings path
if (href === `/dashboard/${slug}/settings`) { if (route === "/dashboard/[slug]/settings") {
return activeHref === href; return activeHref === href;
} }
// Prefix match for sub-pages // Prefix match for sub-pages
@@ -59,10 +60,10 @@ function isActive(href: string): boolean {
<nav class="w-full shrink-0 lg:w-64"> <nav class="w-full shrink-0 lg:w-64">
<!-- Mobile: horizontal scroll --> <!-- Mobile: horizontal scroll -->
<div class="flex gap-2 overflow-x-auto pb-2 lg:hidden"> <div class="flex gap-2 overflow-x-auto pb-2 lg:hidden">
{#each navItems as item (item.href)} {#each navItems as item (item.route)}
{@const active = isActive(item.href)} {@const active = isActive(item.route)}
<a <a
href={resolve(item.href as any)} href={resolve(item.route, { slug })}
class={cn( class={cn(
"flex shrink-0 items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors", "flex shrink-0 items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
active active
@@ -78,10 +79,10 @@ function isActive(href: string): boolean {
<!-- Desktop: vertical list --> <!-- Desktop: vertical list -->
<div class="hidden space-y-1 lg:block"> <div class="hidden space-y-1 lg:block">
{#each navItems as item (item.href)} {#each navItems as item (item.route)}
{@const active = isActive(item.href)} {@const active = isActive(item.route)}
<a <a
href={resolve(item.href as any)} href={resolve(item.route, { slug })}
class={cn( class={cn(
"group flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors", "group flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors",
active active

View File

@@ -48,8 +48,7 @@ export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
</script> </script>
<script lang="ts"> <script lang="ts">
/* eslint-disable svelte/no-navigation-without-resolve -- Button receives href as prop, callers must use resolve() */ let {
let {
class: className, class: className,
variant = "default", variant = "default",
size = "default", size = "default",
@@ -67,7 +66,7 @@ export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
bind:this={ref} bind:this={ref}
data-slot="button" data-slot="button"
class={cn(buttonVariants({ variant, size }), className)} class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href} href={disabled ? undefined : href/* eslint-disable-line svelte/no-navigation-without-resolve */}
aria-disabled={disabled} aria-disabled={disabled}
role={disabled ? "link" : undefined} role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined} tabindex={disabled ? -1 : undefined}

View File

@@ -0,0 +1,26 @@
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
type SearchParams = Record<string, string>;
/**
* Build a query string from an object.
*/
function buildSearchParams(params: SearchParams): string {
const searchParams = new URLSearchParams(params);
const str = searchParams.toString();
return str ? `?${str}` : "";
}
/**
* Navigate to /auth/login with a redirect parameter.
* This is the primary use case for navigation with search params.
*
* Note: eslint-disable is required because the lint rule doesn't recognize
* resolve() inside a template literal, even though it's used correctly.
*/
export function gotoLogin(redirect: string): ReturnType<typeof goto> {
const url = `${resolve("/auth/login")}${buildSearchParams({ redirect })}`;
// eslint-disable-next-line svelte/no-navigation-without-resolve -- resolve() is used above
return goto(url);
}

View File

@@ -17,11 +17,12 @@ const orgsQuery = createQuery(() => ({
$effect(() => { $effect(() => {
if (orgsQuery.error) { if (orgsQuery.error) {
// Not authenticated, redirect to login // Not authenticated, redirect to login
goto(resolve(`/auth/login?redirect=${encodeURIComponent("/")}` as any)); // eslint-disable-next-line svelte/no-navigation-without-resolve -- resolve() is used, query string appended after
goto(`${resolve("/auth/login")}?redirect=${encodeURIComponent("/")}`);
} else if (orgsQuery.data) { } else if (orgsQuery.data) {
if (orgsQuery.data.length > 0) { if (orgsQuery.data.length > 0) {
// Redirect to first org's dashboard // Redirect to first org's dashboard
goto(resolve(`/dashboard/${orgsQuery.data[0].slug}` as any), { goto(resolve("/dashboard/[slug]", { slug: orgsQuery.data[0].slug }), {
replaceState: true, replaceState: true,
}); });
} else { } else {

View File

@@ -8,6 +8,7 @@ import {
Plus, Plus,
Trash2, Trash2,
} from "@lucide/svelte"; } from "@lucide/svelte";
import { formatDate, formatRelativeDate } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query"; import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
@@ -26,7 +27,6 @@ import {
} from "$lib/components/ui/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 { Label } from "$lib/components/ui/label";
import { formatDate, formatRelativeDate } from "@reviq/common";
const queryClient = useQueryClient(); const queryClient = useQueryClient();

View File

@@ -10,6 +10,7 @@ import {
User, User,
XCircle, XCircle,
} from "@lucide/svelte"; } from "@lucide/svelte";
import { formatLongDate, formatRole } from "@reviq/common";
import { import {
createMutation, createMutation,
createQuery, createQuery,
@@ -31,7 +32,6 @@ import {
} from "$lib/components/ui/card"; } from "$lib/components/ui/card";
import { LoadingButton } from "$lib/components/ui/loading-button"; import { LoadingButton } from "$lib/components/ui/loading-button";
import { Separator } from "$lib/components/ui/separator"; import { Separator } from "$lib/components/ui/separator";
import { formatLongDate, formatRole } from "@reviq/common";
const inviteId = $derived(Number(page.params.inviteId)); const inviteId = $derived(Number(page.params.inviteId));
@@ -52,7 +52,7 @@ const acceptMutation = createMutation(() => ({
queryClient.invalidateQueries({ queryKey: ["me", "invites"] }); queryClient.invalidateQueries({ queryKey: ["me", "invites"] });
queryClient.invalidateQueries({ queryKey: ["orgs"] }); queryClient.invalidateQueries({ queryKey: ["orgs"] });
if (inviteQuery.data) { if (inviteQuery.data) {
goto(resolve(`/dashboard/${inviteQuery.data.org.slug}` as any)); goto(resolve(`/dashboard/${inviteQuery.data.org.slug}`));
} else { } else {
goto(resolve("/dashboard")); goto(resolve("/dashboard"));
} }

View File

@@ -6,6 +6,7 @@ import { toast } from "svelte-sonner";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { api } from "$lib/api/client.js"; import { api } from "$lib/api/client.js";
import { gotoLogin } from "$lib/utils/navigation";
interface Props { interface Props {
children: Snippet; children: Snippet;
@@ -26,11 +27,7 @@ $effect(() => {
goto(resolve("/dashboard")); goto(resolve("/dashboard"));
} }
if (userQuery.error) { if (userQuery.error) {
goto( gotoLogin(window.location.pathname);
resolve(
`/auth/login?redirect=${encodeURIComponent(window.location.pathname)}` as any,
),
);
} }
}); });

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { AlertCircle, Building, Eye, Plus, Trash2 } from "@lucide/svelte"; import { AlertCircle, Building, Eye, Plus, Trash2 } from "@lucide/svelte";
import { formatDate } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query"; import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
@@ -22,7 +23,6 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "$lib/components/ui/table/index.js"; } from "$lib/components/ui/table/index.js";
import { formatDate } from "@reviq/common";
/** /**
* Admin Organizations list page * Admin Organizations list page

View File

@@ -9,6 +9,7 @@ import {
Plus, Plus,
Trash2, Trash2,
} from "@lucide/svelte"; } from "@lucide/svelte";
import { formatDate } from "@reviq/common";
import { createQuery, useQueryClient } from "@tanstack/svelte-query"; import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
@@ -37,7 +38,6 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "$lib/components/ui/table"; } from "$lib/components/ui/table";
import { formatDate } from "@reviq/common";
/** /**
* Admin organization details page * Admin organization details page

View File

@@ -81,9 +81,9 @@ let { children }: Props = $props();
<!-- Footer --> <!-- Footer -->
<p class="text-center text-xs text-muted-foreground"> <p class="text-center text-xs text-muted-foreground">
By continuing, you agree to our By continuing, you agree to our
<a href={resolve("/terms" as any)} class="underline underline-offset-4 hover:text-foreground">Terms of Service</a> <a href={resolve("/terms")} class="underline underline-offset-4 hover:text-foreground">Terms of Service</a>
and and
<a href={resolve("/privacy" as any)} class="underline underline-offset-4 hover:text-foreground">Privacy Policy</a> <a href={resolve("/privacy")} class="underline underline-offset-4 hover:text-foreground">Privacy Policy</a>
</p> </p>
</div> </div>
</div> </div>

View File

@@ -59,7 +59,8 @@ const statusQuery = createQuery(() => ({
$effect(() => { $effect(() => {
if (statusQuery.data?.status === "completed") { if (statusQuery.data?.status === "completed") {
clearLoginFlowState(); clearLoginFlowState();
goto(resolve((statusQuery.data.redirectTo || "/") as any)); // eslint-disable-next-line svelte/no-navigation-without-resolve
goto(statusQuery.data.redirectTo || "/");
} }
}); });

View File

@@ -6,8 +6,8 @@ import {
Loader2, Loader2,
Mail, Mail,
} from "@lucide/svelte"; } from "@lucide/svelte";
import { formatRelativeDate, formatRole } from "@reviq/common";
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { DashboardLayout } from "$lib/components/layout"; import { DashboardLayout } from "$lib/components/layout";
@@ -19,7 +19,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "$lib/components/ui/card"; } from "$lib/components/ui/card";
import { formatRelativeDate, formatRole } from "@reviq/common"; import { gotoLogin } from "$lib/utils/navigation";
/** /**
* Dashboard page - lists all organizations the user is a member of * Dashboard page - lists all organizations the user is a member of
@@ -41,11 +41,7 @@ const invitesQuery = createQuery(() => ({
// Redirect to login on auth error // Redirect to login on auth error
$effect(() => { $effect(() => {
if (orgsQuery.error) { if (orgsQuery.error) {
goto( gotoLogin(window.location.pathname);
resolve(
`/auth/login?redirect=${encodeURIComponent(window.location.pathname)}` as any,
),
);
} }
}); });
</script> </script>
@@ -67,7 +63,7 @@ $effect(() => {
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each invitesQuery.data as invite (invite.id)} {#each invitesQuery.data as invite (invite.id)}
<a <a
href={resolve(`/account/org-invites/${invite.id}`)} href={resolve("/account/org-invites/[inviteId]", { inviteId: String(invite.id) })}
class="group block" class="group block"
> >
<Card class="h-full border-primary/30 bg-primary/5 transition-colors group-hover:border-primary/50"> <Card class="h-full border-primary/30 bg-primary/5 transition-colors group-hover:border-primary/50">
@@ -152,7 +148,7 @@ $effect(() => {
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each orgsQuery.data as org (org.id)} {#each orgsQuery.data as org (org.id)}
<a <a
href={resolve(`/dashboard/${org.slug}`)} href={resolve("/dashboard/[slug]", { slug: org.slug })}
class="group block transition-transform hover:scale-[1.02]" class="group block transition-transform hover:scale-[1.02]"
> >
<Card class="h-full transition-colors group-hover:border-primary/50"> <Card class="h-full transition-colors group-hover:border-primary/50">

View File

@@ -46,9 +46,8 @@ async function acceptInvite(): Promise<void> {
if (!isAuthenticated) { if (!isAuthenticated) {
// Redirect to login with return URL // Redirect to login with return URL
const returnUrl = `/invite/accept?token=${encodeURIComponent(token)}`; const returnUrl = `/invite/accept?token=${encodeURIComponent(token)}`;
goto( // eslint-disable-next-line svelte/no-navigation-without-resolve -- resolve() is used, query string appended after
resolve(`/auth/login?redirect=${encodeURIComponent(returnUrl)}` as any), goto(`${resolve("/auth/login")}?redirect=${encodeURIComponent(returnUrl)}`);
);
return; return;
} }

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import { resolve } from "$app/paths";
</script>
<svelte:head>
<title>Privacy Policy | Publisher Dashboard</title>
</svelte:head>
<div class="mx-auto max-w-3xl px-6 py-16">
<article class="prose prose-neutral dark:prose-invert">
<h1>Privacy Policy</h1>
<p class="lead">Last updated: January 2025</p>
<h2>1. Information We Collect</h2>
<p>
We collect information you provide directly to us, such as your email address,
name, and organization details when you create an account.
</p>
<h2>2. How We Use Your Information</h2>
<p>
We use the information we collect to provide, maintain, and improve our services,
and to communicate with you about your account and updates.
</p>
<h2>3. Data Security</h2>
<p>
We implement appropriate security measures to protect your personal information
against unauthorized access, alteration, or destruction.
</p>
<h2>4. Data Retention</h2>
<p>
We retain your information for as long as your account is active or as needed
to provide you services and comply with legal obligations.
</p>
<h2>5. Contact</h2>
<p>
If you have any questions about this Privacy Policy, please contact us.
</p>
</article>
<div class="mt-12">
<a
href={resolve("/auth/login")}
class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
>
Back to login
</a>
</div>
</div>

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import { resolve } from "$app/paths";
</script>
<svelte:head>
<title>Terms of Service | Publisher Dashboard</title>
</svelte:head>
<div class="mx-auto max-w-3xl px-6 py-16">
<article class="prose prose-neutral dark:prose-invert">
<h1>Terms of Service</h1>
<p class="lead">Last updated: January 2025</p>
<h2>1. Acceptance of Terms</h2>
<p>
By accessing and using the Publisher Dashboard, you agree to be bound by these Terms of Service
and all applicable laws and regulations.
</p>
<h2>2. Use of Service</h2>
<p>
You agree to use the service only for lawful purposes and in accordance with these Terms.
You are responsible for maintaining the confidentiality of your account credentials.
</p>
<h2>3. Privacy</h2>
<p>
Your use of the service is also governed by our
<a href={resolve("/privacy")}>Privacy Policy</a>.
</p>
<h2>4. Modifications</h2>
<p>
We reserve the right to modify these terms at any time. Continued use of the service
constitutes acceptance of any modifications.
</p>
<h2>5. Contact</h2>
<p>
If you have any questions about these Terms, please contact us.
</p>
</article>
<div class="mt-12">
<a
href={resolve("/auth/login")}
class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
>
Back to login
</a>
</div>
</div>

View File

@@ -99,6 +99,7 @@
"@sveltejs/adapter-static": "^3.0.8", "@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.49.4", "@sveltejs/kit": "^2.49.4",
"@sveltejs/vite-plugin-svelte": "^6.2.3", "@sveltejs/vite-plugin-svelte": "^6.2.3",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.4", "@tailwindcss/vite": "^4.1.4",
"@types/ua-parser-js": "^0.7.39", "@types/ua-parser-js": "^0.7.39",
"@types/zxcvbn": "^4.4.5", "@types/zxcvbn": "^4.4.5",
@@ -532,6 +533,8 @@
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="], "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="], "@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.16", "", {}, "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww=="], "@tanstack/query-core": ["@tanstack/query-core@5.90.16", "", {}, "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww=="],
@@ -960,7 +963,7 @@
"postcss-scss": ["postcss-scss@4.0.9", "", { "peerDependencies": { "postcss": "^8.4.29" } }, "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A=="], "postcss-scss": ["postcss-scss@4.0.9", "", { "peerDependencies": { "postcss": "^8.4.29" } }, "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A=="],
"postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
@@ -1168,6 +1171,8 @@
"pino/pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="], "pino/pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="],
"svelte-eslint-parser/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="],
"svelte-sonner/runed": ["runed@0.28.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ=="], "svelte-sonner/runed": ["runed@0.28.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ=="],
"tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],

View File

@@ -100,7 +100,9 @@ describe("formatRelativeDate", () => {
test("returns formatted date for older dates in same year", () => { test("returns formatted date for older dates in same year", () => {
// Use a "now" later in the year to test same-year formatting // Use a "now" later in the year to test same-year formatting
const laterNow = new Date("2024-06-15T12:00:00Z"); const laterNow = new Date("2024-06-15T12:00:00Z");
const result = formatRelativeDate("2024-01-15T12:00:00Z", { now: laterNow }); const result = formatRelativeDate("2024-01-15T12:00:00Z", {
now: laterNow,
});
expect(result).toBe("Jan 15"); expect(result).toBe("Jan 15");
}); });

View File

@@ -1,9 +1,9 @@
export { export {
type FormatRelativeDateOptions,
formatDate, formatDate,
formatDateTime, formatDateTime,
formatLongDate, formatLongDate,
formatRelativeDate, formatRelativeDate,
formatRelativeTime, formatRelativeTime,
type FormatRelativeDateOptions,
} from "./format-date.js"; } from "./format-date.js";
export { formatRole, getUserInitials } from "./user.js"; export { formatRole, getUserInitials } from "./user.js";

View File

@@ -51,9 +51,9 @@ describe("getUserInitials", () => {
}); });
test("handles empty display name", () => { test("handles empty display name", () => {
expect( expect(getUserInitials({ displayName: "", email: "bob@example.com" })).toBe(
getUserInitials({ displayName: "", email: "bob@example.com" }), "BO",
).toBe("BO"); );
}); });
}); });