diff --git a/CLAUDE.md b/CLAUDE.md index cea3024..0395504 100644 --- a/CLAUDE.md +++ b/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` - 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` + +## 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)} + +{/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); +``` diff --git a/apps/publisher-dashboard/package.json b/apps/publisher-dashboard/package.json index 95fb8df..2bad62f 100644 --- a/apps/publisher-dashboard/package.json +++ b/apps/publisher-dashboard/package.json @@ -37,6 +37,7 @@ "@sveltejs/adapter-static": "^3.0.8", "@sveltejs/kit": "^2.49.4", "@sveltejs/vite-plugin-svelte": "^6.2.3", + "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.4", "@types/ua-parser-js": "^0.7.39", "@types/zxcvbn": "^4.4.5", diff --git a/apps/publisher-dashboard/src/app.css b/apps/publisher-dashboard/src/app.css index 36cf805..dd2b369 100644 --- a/apps/publisher-dashboard/src/app.css +++ b/apps/publisher-dashboard/src/app.css @@ -1,5 +1,6 @@ @import "tailwindcss"; @import "tw-animate-css"; +@plugin "@tailwindcss/typography"; /* Geist Sans - Modern, clean typeface */ @font-face { diff --git a/apps/publisher-dashboard/src/lib/components/account/account-nav.svelte b/apps/publisher-dashboard/src/lib/components/account/account-nav.svelte index 4cc6bfc..ab11528 100644 --- a/apps/publisher-dashboard/src/lib/components/account/account-nav.svelte +++ b/apps/publisher-dashboard/src/lib/components/account/account-nav.svelte @@ -5,7 +5,6 @@ import MonitorIcon from "@lucide/svelte/icons/monitor"; import ShieldCheckIcon from "@lucide/svelte/icons/shield-check"; import UserIcon from "@lucide/svelte/icons/user"; import { createQuery } from "@tanstack/svelte-query"; -import { resolve } from "$app/paths"; import { page } from "$app/stores"; import { api } from "$lib/api/client"; import { cn } from "$lib/utils.js"; @@ -59,8 +58,8 @@ function isActive(href: string, pathname: string): boolean { > {#each navItems as item (item.href)} {@const active = isActive(item.href, $page.url.pathname)} - + import type { Snippet } from "svelte"; import { createQuery } from "@tanstack/svelte-query"; -import { goto } from "$app/navigation"; -import { resolve } from "$app/paths"; import { page } from "$app/state"; import { api } from "$lib/api/client"; +import { gotoLogin } from "$lib/utils/navigation"; interface Props { children: Snippet; @@ -12,29 +11,29 @@ interface Props { let { children }: Props = $props(); -// Check if current path is an auth page (doesn't require login) -const isAuthPage = $derived(page.url.pathname.startsWith("/auth")); +// Check if current path is a public page (doesn't require login) +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(() => ({ queryKey: ["me"], queryFn: () => api.me.get(), - enabled: !isAuthPage, + enabled: !isPublicPage, retry: false, })); -// Redirect to login if not authenticated on non-auth pages +// Redirect to login if not authenticated on protected pages $effect(() => { - if (!isAuthPage && userQuery.error) { - goto( - resolve( - `/auth/login?redirect=${encodeURIComponent(page.url.pathname)}` as any, - ), - ); + if (!isPublicPage && userQuery.error) { + gotoLogin(page.url.pathname); } }); -{#if isAuthPage || userQuery.data || userQuery.isPending} +{#if isPublicPage || userQuery.data || userQuery.isPending} {@render children()} {/if} diff --git a/apps/publisher-dashboard/src/lib/components/dashboard/frequent-filters.svelte b/apps/publisher-dashboard/src/lib/components/dashboard/frequent-filters.svelte index 477f4c1..fd0ef92 100644 --- a/apps/publisher-dashboard/src/lib/components/dashboard/frequent-filters.svelte +++ b/apps/publisher-dashboard/src/lib/components/dashboard/frequent-filters.svelte @@ -1,5 +1,4 @@ diff --git a/apps/publisher-dashboard/src/lib/components/layout/account/account-settings-layout.svelte b/apps/publisher-dashboard/src/lib/components/layout/account/account-settings-layout.svelte index 8d62342..ab6fcb5 100644 --- a/apps/publisher-dashboard/src/lib/components/layout/account/account-settings-layout.svelte +++ b/apps/publisher-dashboard/src/lib/components/layout/account/account-settings-layout.svelte @@ -6,7 +6,6 @@ import MonitorIcon from "@lucide/svelte/icons/monitor"; import ShieldCheckIcon from "@lucide/svelte/icons/shield-check"; import UserIcon from "@lucide/svelte/icons/user"; import { createQuery } from "@tanstack/svelte-query"; -import { resolve } from "$app/paths"; import { page } from "$app/stores"; import { api } from "$lib/api/client"; import { DashboardLayout } from "$lib/components/layout"; @@ -94,8 +93,8 @@ function isActive(href: string): boolean {
{#each navItems as item (item.href)} {@const active = isActive(item.href)} - + {#each navItems as item (item.href)} {@const active = isActive(item.href)} - + +import { getUserInitials } from "@reviq/common"; import { createQuery, useQueryClient } from "@tanstack/svelte-query"; import { goto } from "$app/navigation"; import { resolve } from "$app/paths"; @@ -8,7 +9,6 @@ import { Button } from "$lib/components/ui/button"; import { Separator } from "$lib/components/ui/separator"; import * as Sheet from "$lib/components/ui/sheet"; import { cn } from "$lib/utils.js"; -import { getUserInitials } from "@reviq/common"; interface Props { class?: string; @@ -83,8 +83,8 @@ const navItems = [ item.href === "/admin" ? $page.url.pathname === "/admin" : $page.url.pathname.startsWith(item.href)} - + +import { getUserInitials } from "@reviq/common"; import { createQuery, useQueryClient } from "@tanstack/svelte-query"; import { goto } from "$app/navigation"; import { resolve } from "$app/paths"; @@ -6,7 +7,6 @@ import { page } from "$app/stores"; import { api } from "$lib/api/client"; import * as DropdownMenu from "$lib/components/ui/dropdown-menu"; import { cn } from "$lib/utils.js"; -import { getUserInitials } from "@reviq/common"; interface Props { class?: string; @@ -75,8 +75,8 @@ const navItems = [ item.href === "/admin" ? $page.url.pathname === "/admin" : $page.url.pathname.startsWith(item.href)} - + { ? $page.url.pathname === item.href : $page.url.pathname === item.href || $page.url.pathname.startsWith(item.href + "/")} - + { {#if currentSlug} {@const isSettingsActive = $page.url.pathname.startsWith(`/dashboard/${currentSlug}/settings`)} +import { getUserInitials } from "@reviq/common"; import { createQuery, useQueryClient } from "@tanstack/svelte-query"; import { getContext } from "svelte"; import { goto } from "$app/navigation"; @@ -9,7 +10,6 @@ import { Button } from "$lib/components/ui/button"; import { Separator } from "$lib/components/ui/separator"; import * as Sheet from "$lib/components/ui/sheet"; import { cn } from "$lib/utils.js"; -import { getUserInitials } from "@reviq/common"; interface Props { class?: string; @@ -40,8 +40,16 @@ const navItems = $derived.by(() => { if (currentSlug) { return [ { icon: "home", href: `/dashboard/${currentSlug}`, label: "Home" }, - { icon: "chart", href: `/dashboard/${currentSlug}/performance`, label: "Performance" }, - { icon: "document", href: `/dashboard/${currentSlug}/reports`, label: "Reports" }, + { + icon: "chart", + href: `/dashboard/${currentSlug}/performance`, + label: "Performance", + }, + { + icon: "document", + href: `/dashboard/${currentSlug}/reports`, + label: "Reports", + }, ]; } return [ @@ -61,7 +69,8 @@ async function handleSignOut(): Promise { await api.auth.logout(); queryClient.clear(); open = false; - goto(resolve("/auth/login")); + // eslint-disable-next-line svelte/no-navigation-without-resolve + goto("/auth/login"); } catch (error) { console.error("Failed to sign out:", error); } @@ -98,8 +107,8 @@ async function handleSignOut(): Promise { {@const isActive = $page.url.pathname === item.href || (item.href !== "/" && $page.url.pathname.startsWith(item.href))} - + ({ const orgs = $derived(orgsQuery.data ?? []); function handleOrgSelect(slug: string) { - goto(resolve(`/dashboard/${slug}` as any)); + goto(resolve(`/dashboard/${slug}`)); } diff --git a/apps/publisher-dashboard/src/lib/components/layout/dashboard/user-menu.svelte b/apps/publisher-dashboard/src/lib/components/layout/dashboard/user-menu.svelte index d43d242..189b7bc 100644 --- a/apps/publisher-dashboard/src/lib/components/layout/dashboard/user-menu.svelte +++ b/apps/publisher-dashboard/src/lib/components/layout/dashboard/user-menu.svelte @@ -1,11 +1,11 @@ @@ -67,7 +63,7 @@ $effect(() => {
{#each invitesQuery.data as invite (invite.id)} @@ -152,7 +148,7 @@ $effect(() => {
{#each orgsQuery.data as org (org.id)} diff --git a/apps/publisher-dashboard/src/routes/invite/accept/+page.svelte b/apps/publisher-dashboard/src/routes/invite/accept/+page.svelte index 4ec9d31..fda8803 100644 --- a/apps/publisher-dashboard/src/routes/invite/accept/+page.svelte +++ b/apps/publisher-dashboard/src/routes/invite/accept/+page.svelte @@ -46,9 +46,8 @@ async function acceptInvite(): Promise { if (!isAuthenticated) { // Redirect to login with return URL const returnUrl = `/invite/accept?token=${encodeURIComponent(token)}`; - goto( - resolve(`/auth/login?redirect=${encodeURIComponent(returnUrl)}` as any), - ); + // eslint-disable-next-line svelte/no-navigation-without-resolve -- resolve() is used, query string appended after + goto(`${resolve("/auth/login")}?redirect=${encodeURIComponent(returnUrl)}`); return; } diff --git a/apps/publisher-dashboard/src/routes/privacy/+page.svelte b/apps/publisher-dashboard/src/routes/privacy/+page.svelte new file mode 100644 index 0000000..1c1c8b1 --- /dev/null +++ b/apps/publisher-dashboard/src/routes/privacy/+page.svelte @@ -0,0 +1,52 @@ + + + + Privacy Policy | Publisher Dashboard + + + diff --git a/apps/publisher-dashboard/src/routes/terms/+page.svelte b/apps/publisher-dashboard/src/routes/terms/+page.svelte new file mode 100644 index 0000000..5443cb3 --- /dev/null +++ b/apps/publisher-dashboard/src/routes/terms/+page.svelte @@ -0,0 +1,52 @@ + + + + Terms of Service | Publisher Dashboard + + +
+
+

Terms of Service

+

Last updated: January 2025

+ +

1. Acceptance of Terms

+

+ By accessing and using the Publisher Dashboard, you agree to be bound by these Terms of Service + and all applicable laws and regulations. +

+ +

2. Use of Service

+

+ 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. +

+ +

3. Privacy

+

+ Your use of the service is also governed by our + Privacy Policy. +

+ +

4. Modifications

+

+ We reserve the right to modify these terms at any time. Continued use of the service + constitutes acceptance of any modifications. +

+ +

5. Contact

+

+ If you have any questions about these Terms, please contact us. +

+
+ + +
diff --git a/bun.lock b/bun.lock index 992ceaa..c30c8f3 100644 --- a/bun.lock +++ b/bun.lock @@ -99,6 +99,7 @@ "@sveltejs/adapter-static": "^3.0.8", "@sveltejs/kit": "^2.49.4", "@sveltejs/vite-plugin-svelte": "^6.2.3", + "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.4", "@types/ua-parser-js": "^0.7.39", "@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/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=="], "@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-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=="], @@ -1168,6 +1171,8 @@ "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=="], "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], diff --git a/packages/common/src/format-date.test.ts b/packages/common/src/format-date.test.ts index 45e49c9..63f6795 100644 --- a/packages/common/src/format-date.test.ts +++ b/packages/common/src/format-date.test.ts @@ -100,7 +100,9 @@ describe("formatRelativeDate", () => { test("returns formatted date for older dates in same year", () => { // Use a "now" later in the year to test same-year formatting 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"); }); diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 1a86b20..55f0564 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -1,9 +1,9 @@ export { + type FormatRelativeDateOptions, formatDate, formatDateTime, formatLongDate, formatRelativeDate, formatRelativeTime, - type FormatRelativeDateOptions, } from "./format-date.js"; export { formatRole, getUserInitials } from "./user.js"; diff --git a/packages/common/src/user.test.ts b/packages/common/src/user.test.ts index 0ee9ef0..539d4da 100644 --- a/packages/common/src/user.test.ts +++ b/packages/common/src/user.test.ts @@ -51,9 +51,9 @@ describe("getUserInitials", () => { }); test("handles empty display name", () => { - expect( - getUserInitials({ displayName: "", email: "bob@example.com" }), - ).toBe("BO"); + expect(getUserInitials({ displayName: "", email: "bob@example.com" })).toBe( + "BO", + ); }); });