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:
47
CLAUDE.md
47
CLAUDE.md
@@ -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);
|
||||||
|
```
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ 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",
|
||||||
@@ -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}
|
||||||
|
|||||||
26
apps/publisher-dashboard/src/lib/utils/navigation.ts
Normal file
26
apps/publisher-dashboard/src/lib/utils/navigation.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 || "/");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
52
apps/publisher-dashboard/src/routes/privacy/+page.svelte
Normal file
52
apps/publisher-dashboard/src/routes/privacy/+page.svelte
Normal 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>
|
||||||
52
apps/publisher-dashboard/src/routes/terms/+page.svelte
Normal file
52
apps/publisher-dashboard/src/routes/terms/+page.svelte
Normal 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>
|
||||||
7
bun.lock
7
bun.lock
@@ -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=="],
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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");
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user