diff --git a/AGENTS.md b/AGENTS.md index 7b15e09..3889be2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,7 @@ # Agent Guidelines for freno-dev ### Tech Stack + - **Framework**: SolidJS with SolidStart (Vinxi) - **Routing**: @solidjs/router - **API**: tRPC v10 with Zod validation @@ -9,20 +10,33 @@ - **Runtime**: Bun (Node >=22) - **Deployment**: Vercel preset +## Content Rules + +### No Competitor Mentions + +- **Never mention a competitor's product by name** in any user-facing content (landing pages, marketing copy, comparison tables, meta descriptions, emails, FAQs, etc.). + - This includes but is not limited to products like Strava, Garmin, etc. + - Describe the product's own merits and positioning on its own terms. Use generic descriptions instead of naming competitors. + - This applies to ALL products (Nessa, Lineage, Gaze, InputHalo) and the main site. + - If existing content already names a competitor, remove or generalize it when working in that file. + ## Code Style ### Naming Conventions + - **Files/Components**: PascalCase (e.g., `Button.tsx`, `UserProfile.tsx`) - **Variables/Functions**: camelCase (e.g., `getUserID`, `displayName`) - **Types/Interfaces**: PascalCase (e.g., `User`, `ButtonProps`) - **Constants**: camelCase or UPPER_SNAKE_CASE for true constants ### Imports + - Prefer named imports from solid-js: `import { createSignal, Show, For } from "solid-js"` - Use `~/*` path alias for src imports: `import { api } from "~/lib/api"` - Group imports: external deps → solid-js → local (~/) ### SolidJS Patterns (NOT React!) + - **State**: Use `createSignal()` not `useState`. Always call signals: `count()` to read - **Effects**: Use `createEffect()` not `useEffect`. Auto-tracks dependencies (no array) - **Conditionals**: Prefer `` over `&&` or ternary @@ -31,6 +45,7 @@ - **Refs**: Use `let ref` binding or `createSignal()` for reactive refs ### TypeScript + - **Strict mode enabled** - always type function params and returns - Use interfaces for props: `export interface ButtonProps extends JSX.HTMLAttributes` - Use `splitProps()` for component prop destructuring @@ -38,6 +53,7 @@ - Database types: Cast with `as unknown as User` for SQL results ### API/Server Patterns + - **tRPC routers**: Export from `src/server/api/routers/*.ts` - **Procedures**: Use `.query()` for reads, `.mutation()` for writes - **Validation**: Use Zod schemas in `.input()` - validate all user input @@ -46,17 +62,20 @@ - **Database**: Use `ConnectionFactory()` singleton, parameterized queries only ### Error Handling + - Use TRPCError with semantic codes on server - Validate inputs with Zod schemas before processing - Check auth state before mutations: throw UNAUTHORIZED if missing userId - Return structured responses: `{ success: boolean, message?: string }` ### Comments + - **Minimal comments** - prefer self-documenting code - JSDoc for exported functions/components only - Inline comments for non-obvious logic only ### File Organization + - Routes in `src/routes/` (file-based routing) - Components in `src/components/` (reusable) or co-located with routes - API routers in `src/server/api/routers/` @@ -73,7 +92,9 @@ This project serves four product subdomains (`nessa.freno.me`, `lineage.freno.me - **Auth:** Host-scoped only — no cookie domain broadening. ## Key Differences from React + See `src/lib/SOLID-PATTERNS.md` for comprehensive React→Solid conversion guide. Key gotchas: + - Signals must be called with `()` to read value - `onChange` → `onInput` for real-time input updates - `useEffect` → `createEffect` (auto-tracking, no deps array) diff --git a/public/Nessa Exports/01-home-tab.png b/public/Nessa Exports/01-home-tab.png new file mode 100644 index 0000000..8c095fd Binary files /dev/null and b/public/Nessa Exports/01-home-tab.png differ diff --git a/public/Nessa Exports/04-plans-tab-strength.png b/public/Nessa Exports/04-plans-tab-strength.png new file mode 100644 index 0000000..d5e46a8 Binary files /dev/null and b/public/Nessa Exports/04-plans-tab-strength.png differ diff --git a/public/Nessa Exports/09-clubs-list.png b/public/Nessa Exports/09-clubs-list.png new file mode 100644 index 0000000..4362c61 Binary files /dev/null and b/public/Nessa Exports/09-clubs-list.png differ diff --git a/public/Nessa Exports/29-apple-health.png b/public/Nessa Exports/29-apple-health.png new file mode 100644 index 0000000..e28de96 Binary files /dev/null and b/public/Nessa Exports/29-apple-health.png differ diff --git a/public/Nessa Exports/38-workout-summary.png b/public/Nessa Exports/38-workout-summary.png new file mode 100644 index 0000000..a7adba5 Binary files /dev/null and b/public/Nessa Exports/38-workout-summary.png differ diff --git a/public/Nessa Exports/40-segment-list.png b/public/Nessa Exports/40-segment-list.png new file mode 100644 index 0000000..db0b5b8 Binary files /dev/null and b/public/Nessa Exports/40-segment-list.png differ diff --git a/public/Nessa Exports/Nessa-iOS-Dark-1024x1024.png b/public/Nessa Exports/Nessa-iOS-Dark-1024x1024.png new file mode 100644 index 0000000..11049ae Binary files /dev/null and b/public/Nessa Exports/Nessa-iOS-Dark-1024x1024.png differ diff --git a/public/Nessa Exports/Nessa-iOS-Default-1024x1024.png b/public/Nessa Exports/Nessa-iOS-Default-1024x1024.png new file mode 100644 index 0000000..7726cec Binary files /dev/null and b/public/Nessa Exports/Nessa-iOS-Default-1024x1024.png differ diff --git a/src/app.tsx b/src/app.tsx index 06e1210..eda125f 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -5,6 +5,7 @@ import { ErrorBoundary, onMount, onCleanup, + Show, Suspense } from "solid-js"; import "./app.css"; @@ -15,7 +16,7 @@ import ErrorBoundaryFallback from "./components/ErrorBoundaryFallback"; import { BarsProvider, useBars } from "./context/bars"; import { DarkModeProvider } from "./context/darkMode"; import { AuthProvider } from "./context/auth"; -import { SiteProvider } from "./context/SiteContext"; +import { SiteProvider, useSite } from "./context/SiteContext"; import { createWindowWidth, isMobile } from "~/lib/resize-utils"; import { MOBILE_CONFIG } from "./config"; import CustomScrollbar from "./components/CustomScrollbar"; @@ -155,16 +156,52 @@ function AppLayout(props: { children: any }) { } }; + const site = useSite(); + const isMainSite = () => site().id === "main"; + return ( <> -
- -
+ +
+ +
+ + ( + + )} + > +
+ }> + + {props.children} + + +
+
+
+ +
+
+ + +
+
); } diff --git a/src/components/SubdomainHeader.tsx b/src/components/SubdomainHeader.tsx new file mode 100644 index 0000000..1b5289d --- /dev/null +++ b/src/components/SubdomainHeader.tsx @@ -0,0 +1,87 @@ +/** + * Minimal top navigation for product subdomains. + * + * Replaces the dual-left/right sidebar used on `freno.me` for each product + * subdomain (`nessa.*`, `lineage.*`, `gaze.*`, `inputhalo.*`). The header is + * sticky, site-aware, and derives its links from `NAV_CONFIG` so the nav set + * remains the single source of truth. `lineage.freno.me/` intentionally does + * not use this component so the original full-bleed parallax landing page is + * preserved. + */ +import { For, Show } from "solid-js"; +import { A, useLocation } from "@solidjs/router"; +import { useSite } from "~/context/SiteContext"; +import { NAV_CONFIG, BACK_TO_FRENO } from "~/lib/nav-config"; + +export default function SubdomainHeader() { + const site = useSite(); + const location = useLocation(); + + const brandName = () => site().displayName; + const brandColor = () => site().brandColor; + const navItems = () => + NAV_CONFIG[site().id].filter((item) => item.label !== "Home"); + + const isActive = (href: string) => { + const path = location.pathname; + return href === "/" ? path === "/" : path === href; + }; + + return ( +
+
+ + {brandName()} + + + +
+
+ ); +} diff --git a/src/context/SiteContext.tsx b/src/context/SiteContext.tsx index d6082aa..2c67799 100644 --- a/src/context/SiteContext.tsx +++ b/src/context/SiteContext.tsx @@ -8,8 +8,10 @@ * - Client (hydration): reads the SSR-injected `window.__SITE__` id (written * into the document shell by `entry-server.tsx`) so the post-hydration * value matches the `data-site` attribute on ``. Falls back to - * `resolveSiteFromHost(window.location.hostname)` if the injected id is - * missing (e.g. client-side navigation / hard refresh quirks). + * `resolveSiteFromLocation(window.location.hostname, window.location.pathname)` + * if the injected id is missing — the pathname fallback covers localhost + * dev where the host is `localhost` but the URL still carries a subdomain + * prefix (`/nessa/contact`). * * NOTE on race-safety: SSR of a personal site is single-render-per-request in * practice; the module-level holder is adequate here. Server functions that @@ -29,6 +31,7 @@ import { isServer } from "solid-js/web"; import { resolveSiteFromHost, resolveSiteFromLocation, + resolveSiteFromPath, SITE_CONFIG, MAIN_SITE, type Site, @@ -59,7 +62,13 @@ export function resolveClientSite(): Site { if (typeof window === "undefined") return MAIN_SITE; const injected = window.__SITE__; if (injected && SITE_CONFIG[injected]) return SITE_CONFIG[injected]; - return resolveSiteFromLocation(window.location.hostname); + // Fall back to hostname + URL-path prefix. On localhost (dev) the host is + // `localhost` (→ main) but the path carries the subdomain prefix + // (`/nessa/contact` → nessa), so we must consider both. + return resolveSiteFromLocation( + window.location.hostname, + window.location.pathname + ); } // ── Context ────────────────────────────────────────────────────────────── @@ -101,4 +110,4 @@ export function useSite(): Accessor { export { SITE_CONFIG, MAIN_SITE }; export type { Site, SiteId }; -export { resolveSiteFromHost, resolveSiteFromLocation }; +export { resolveSiteFromHost, resolveSiteFromLocation, resolveSiteFromPath }; diff --git a/src/lib/nav-config.test.ts b/src/lib/nav-config.test.ts index 8b129db..2122999 100644 --- a/src/lib/nav-config.test.ts +++ b/src/lib/nav-config.test.ts @@ -16,13 +16,7 @@ import { } from "./nav-config"; import { SITE_CONFIG, type SiteId } from "./site-context"; -const ALL_SITES: SiteId[] = [ - "main", - "nessa", - "lineage", - "gaze", - "inputhalo" -]; +const ALL_SITES: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo"]; describe("NAV_CONFIG — per-site link sets", () => { it("main → Home, Blog, Downloads, Resume, Contact, GitHub, LinkedIn", () => { @@ -55,12 +49,22 @@ describe("NAV_CONFIG — per-site link sets", () => { ]); }); - it("gaze → Home, Contact, Privacy", () => { - expect(navLabelsFor("gaze")).toEqual(["Home", "Contact", "Privacy"]); + it("gaze → Home, Contact, Privacy, Downloads", () => { + expect(navLabelsFor("gaze")).toEqual([ + "Home", + "Contact", + "Privacy", + "Downloads" + ]); }); - it("inputhalo → Home, Contact, Privacy", () => { - expect(navLabelsFor("inputhalo")).toEqual(["Home", "Contact", "Privacy"]); + it("inputhalo → Home, Contact, Privacy, Downloads", () => { + expect(navLabelsFor("inputhalo")).toEqual([ + "Home", + "Contact", + "Privacy", + "Downloads" + ]); }); }); diff --git a/src/lib/nav-config.ts b/src/lib/nav-config.ts index 3304c64..bbf23f9 100644 --- a/src/lib/nav-config.ts +++ b/src/lib/nav-config.ts @@ -69,8 +69,8 @@ export const BACK_TO_FRENO: NavItem = { * - main: Home, Blog, Downloads, Resume, Contact, GitHub, LinkedIn * - nessa: Home, Contact, Privacy * - lineage: Home, Downloads, Contact, Privacy, Account Deletion - * - gaze: Home, Contact, Privacy - * - inputhalo: Home, Contact, Privacy + * - gaze: Home, Contact, Privacy, Downloads + * - inputhalo: Home, Contact, Privacy, Downloads */ export const NAV_CONFIG: Record = { main: [ @@ -107,12 +107,14 @@ export const NAV_CONFIG: Record = { gaze: [ { label: "Home", href: "/", icon: "home" }, { label: "Contact", href: "/contact", icon: "contact" }, - { label: "Privacy", href: "/privacy", icon: "privacy" } + { label: "Privacy", href: "/privacy", icon: "privacy" }, + { label: "Downloads", href: "/downloads", icon: "downloads" } ], inputhalo: [ { label: "Home", href: "/", icon: "home" }, { label: "Contact", href: "/contact", icon: "contact" }, - { label: "Privacy", href: "/privacy", icon: "privacy" } + { label: "Privacy", href: "/privacy", icon: "privacy" }, + { label: "Downloads", href: "/downloads", icon: "downloads" } ] }; diff --git a/src/lib/site-context.test.ts b/src/lib/site-context.test.ts index 4f29a02..3501a60 100644 --- a/src/lib/site-context.test.ts +++ b/src/lib/site-context.test.ts @@ -8,6 +8,8 @@ import { describe, it, expect } from "bun:test"; import { resolveSiteFromHost, + resolveSiteFromPath, + resolveSiteFromLocation, SITE_CONFIG, type SiteId } from "./site-context"; @@ -80,15 +82,12 @@ describe("resolveSiteFromHost", () => { }); it("every SITE_CONFIG entry has a non-empty baseRoutePrefix for subdomains", () => { - for (const id of [ - "nessa", - "lineage", - "gaze", - "inputhalo" - ] as SiteId[]) { + for (const id of ["nessa", "lineage", "gaze", "inputhalo"] as SiteId[]) { expect(SITE_CONFIG[id].baseRoutePrefix).toBe(`/${id}`); expect(SITE_CONFIG[id].subdomain).toBe(id); - expect(SITE_CONFIG[id].titleSuffix).toBe(` | ${SITE_CONFIG[id].displayName}`); + expect(SITE_CONFIG[id].titleSuffix).toBe( + ` | ${SITE_CONFIG[id].displayName}` + ); } }); @@ -97,3 +96,67 @@ describe("resolveSiteFromHost", () => { expect(SITE_CONFIG.main.baseRoutePrefix).toBe(""); }); }); + +describe("resolveSiteFromPath", () => { + it("maps a subdomain-prefixed path to the right site", () => { + expect(resolveSiteFromPath("/nessa/contact")?.id).toBe("nessa"); + expect(resolveSiteFromPath("/lineage/downloads")?.id).toBe("lineage"); + expect(resolveSiteFromPath("/gaze/")?.id).toBe("gaze"); + expect(resolveSiteFromPath("/inputhalo/privacy")?.id).toBe("inputhalo"); + }); + + it("matches the exact prefix (landing page)", () => { + expect(resolveSiteFromPath("/nessa")?.id).toBe("nessa"); + expect(resolveSiteFromPath("/gaze")?.id).toBe("gaze"); + }); + + it("returns null for non-subdomain paths", () => { + expect(resolveSiteFromPath("/")).toBeNull(); + expect(resolveSiteFromPath("/contact")).toBeNull(); + expect(resolveSiteFromPath("/blog/post")).toBeNull(); + expect(resolveSiteFromPath("/downloads")).toBeNull(); + }); + + it("does not match prefix substrings (no false positives)", () => { + // `/nessa-extra` must NOT match `/nessa`. + expect(resolveSiteFromPath("/nessa-extra")).toBeNull(); + expect(resolveSiteFromPath("/gazette")).toBeNull(); + }); + + it("handles empty / null / undefined", () => { + expect(resolveSiteFromPath("")).toBeNull(); + expect(resolveSiteFromPath(null)).toBeNull(); + expect(resolveSiteFromPath(undefined)).toBeNull(); + }); + + it("returns the full SITE_CONFIG entry", () => { + expect(resolveSiteFromPath("/nessa/contact")).toEqual(SITE_CONFIG.nessa); + expect(resolveSiteFromPath("/gaze")).toEqual(SITE_CONFIG.gaze); + }); +}); + +describe("resolveSiteFromLocation", () => { + it("prefers the host when it identifies a subdomain", () => { + expect(resolveSiteFromLocation("nessa.freno.me", "/contact").id).toBe( + "nessa" + ); + expect(resolveSiteFromLocation("gaze.localhost", "/").id).toBe("gaze"); + }); + + it("falls back to the path prefix when the host is localhost/main", () => { + expect(resolveSiteFromLocation("localhost", "/nessa/contact").id).toBe( + "nessa" + ); + expect(resolveSiteFromLocation("localhost", "/lineage/").id).toBe( + "lineage" + ); + expect(resolveSiteFromLocation("freno.me", "/gaze/downloads").id).toBe( + "gaze" + ); + }); + + it("returns main when neither host nor path identifies a subdomain", () => { + expect(resolveSiteFromLocation("localhost", "/contact").id).toBe("main"); + expect(resolveSiteFromLocation("freno.me", "/").id).toBe("main"); + }); +}); diff --git a/src/lib/site-context.ts b/src/lib/site-context.ts index df89d4a..e5794c9 100644 --- a/src/lib/site-context.ts +++ b/src/lib/site-context.ts @@ -159,13 +159,54 @@ export function resolveSiteFromHost(host: string | null | undefined): Site { return MAIN_SITE; } +/** + * Resolve a `Site` from a URL pathname by matching a known subdomain route + * prefix (e.g. `/nessa/contact` → nessa, `/gaze/downloads` → gaze). + * + * Returns `null` when the path does not begin with a subdomain prefix so the + * caller can distinguish "no match" from "main" and decide whether to fall + * back to the host-based result. + * + * This is the dev-server safety net: on `localhost:3000/nessa/contact` the + * Host header is `localhost` (→ main), but the URL path still carries the + * subdomain prefix because vercel.json host rewrites do not run locally. + * Without this fallback, `useSite()` returns `main` on every subdomain page + * in dev, causing `` to render the main-site nav. + * + * In production the host rewrite strips the prefix, so the path is `/contact` + * (no prefix) and this returns `null` — the host-based result already + * resolved correctly. + * + * Pure & synchronous — no I/O, no env access. + */ +export function resolveSiteFromPath( + pathname: string | null | undefined +): Site | null { + if (!pathname) return null; + for (const site of SUBDOMAIN_SITES) { + const prefix = site.baseRoutePrefix; // e.g. "/nessa" + // Exact prefix (`/nessa`) or prefix + `/` (`/nessa/contact`). + if (pathname === prefix || pathname.startsWith(prefix + "/")) { + return site; + } + } + return null; +} + /** * Resolve the active site from a client `window.location`, used by the * SolidJS `SiteContext` provider during hydration. Server codepaths should * use `getSiteFromEvent` / `getSiteFromRequest` instead. + * + * When the hostname does not identify a subdomain (e.g. `localhost` in dev), + * falls back to checking the URL pathname for a subdomain route prefix so + * that `localhost:3000/nessa/contact` resolves to nessa. */ export function resolveSiteFromLocation( - hostname: string | null | undefined + hostname: string | null | undefined, + pathname?: string | null | undefined ): Site { - return resolveSiteFromHost(hostname); + const hostResult = resolveSiteFromHost(hostname); + if (hostResult.id !== "main") return hostResult; + return resolveSiteFromPath(pathname) ?? hostResult; } diff --git a/src/routes/gaze/contact.tsx b/src/routes/gaze/contact.tsx index b1a1ba3..d4d2794 100644 --- a/src/routes/gaze/contact.tsx +++ b/src/routes/gaze/contact.tsx @@ -1,4 +1,5 @@ import { ContactForm } from "~/components/ContactForm"; +import SubdomainHeader from "~/components/SubdomainHeader"; /** * Gaze contact page (`gaze.freno.me/contact`). @@ -16,5 +17,10 @@ import { ContactForm } from "~/components/ContactForm"; * `[Gaze] Contact Request`. */ export default function GazeContactPage() { - return ; + return ( + <> + + + + ); } diff --git a/src/routes/gaze/downloads.tsx b/src/routes/gaze/downloads.tsx new file mode 100644 index 0000000..94799a3 --- /dev/null +++ b/src/routes/gaze/downloads.tsx @@ -0,0 +1,130 @@ +/** + * Gaze per-subdomain downloads page — `gaze.freno.me/downloads`. + * + * Public browser path `/downloads`; vercel.json host rewrites serve this + * from the internal `/gaze/*` route prefix while keeping the URL clean. + * + * Mirrors the download surface already exposed on the Gaze landing page: + * - Direct signed-S3 DMG download via `downloads.getDownloadUrl({ asset_name: "gaze" })`. + * - macOS App Store listing link. + */ +import { A } from "@solidjs/router"; +import { createSignal } from "solid-js"; +import { PageHead } from "~/components/PageHead"; +import SubdomainHeader from "~/components/SubdomainHeader"; +import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark"; +import Button from "~/components/ui/Button"; +import { useSite } from "~/context/SiteContext"; +import { downloadAsset } from "~/lib/download-asset"; + +const GAZE_APP_STORE_URL = "https://apps.apple.com/us/app/gaze/id6757759498"; +const GAZE_MIN_MACOS = "14.6"; + +export default function GazeDownloadsPage() { + const site = useSite(); + const [loading, setLoading] = createSignal(false); + + const handleDownload = () => { + if (loading()) return; + setLoading(true); + import("~/lib/api") + .then(({ api }) => + downloadAsset({ + api, + assetName: "gaze", + onError: (error) => { + console.error("Gaze download error:", error); + alert("Failed to initiate download. Please try again."); + } + }) + ) + .finally(() => setLoading(false)); + }; + + return ( + <> + + + + +
+
+ + ); +} diff --git a/src/routes/gaze/index.tsx b/src/routes/gaze/index.tsx index 7dc8246..7192209 100644 --- a/src/routes/gaze/index.tsx +++ b/src/routes/gaze/index.tsx @@ -1,26 +1,30 @@ -import { createSignal } from "solid-js"; +import { createSignal, For } from "solid-js"; import { PageHead } from "~/components/PageHead"; +import SubdomainHeader from "~/components/SubdomainHeader"; import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark"; import Button from "~/components/ui/Button"; import { useDarkMode } from "~/context/darkMode"; import { downloadAsset } from "~/lib/download-asset"; -const GAZE_APP_STORE_URL = - "https://apps.apple.com/us/app/gaze/id6757759498"; -const GAZE_MIN_MACOS = "13.0"; +const GAZE_APP_STORE_URL = "https://apps.apple.com/us/app/gaze/id6757759498"; +const GAZE_MIN_MACOS = "14.6"; const FEATURES = [ { - title: "Take regular breaks", - body: "Gentle, dismissable reminders nudge you to step away from the screen so you can rest and reset." + title: "Blink reminders", + body: "Subtle prompts help you remember to blink more often, reducing dry-eye strain during long sessions." }, { - title: "Eye health reminders", + title: "20-20-20 eye breaks", body: "Follow the 20-20-20 rule — every 20 minutes, look at something 20 feet away for 20 seconds." }, { - title: "Posture reminders", - body: "Periodic check-ins help you catch slouching before it becomes a habit." + title: "Posture check-ins", + body: "Periodic reminders help you catch slouching before it becomes a habit." + }, + { + title: "Customizable intervals", + body: "Set the reminder cadence that fits your workflow, from gentle nudges to strict schedules." }, { title: "Lives in your menu bar", @@ -53,19 +57,21 @@ export default function GazeLanding() { <> + + {/* ── Hero ─────────────────────────────────────────────────────── */}
Look away — Gaze hero background
@@ -120,17 +126,17 @@ export default function GazeLanding() {

Small reminders, healthier habits

-
- {FEATURES.map((feature) => ( -
-

- {feature.title} -

-

{feature.body}

-
- ))} +
+ + {(feature) => ( +
+

+ {feature.title} +

+

{feature.body}

+
+ )} +
diff --git a/src/routes/gaze/privacy.tsx b/src/routes/gaze/privacy.tsx index 57045cc..1c709d9 100644 --- a/src/routes/gaze/privacy.tsx +++ b/src/routes/gaze/privacy.tsx @@ -15,6 +15,7 @@ */ import { A } from "@solidjs/router"; import { PageHead } from "~/components/PageHead"; +import SubdomainHeader from "~/components/SubdomainHeader"; export default function GazePrivacyPolicy() { return ( @@ -23,9 +24,10 @@ export default function GazePrivacyPolicy() { title="Privacy Policy" description="Privacy policy for Gaze, a macOS eye health reminder app." /> +
Gaze's Privacy Policy
-
Last Updated: February 9, 2026
+
Last Updated: July 23, 2026
Welcome to Gaze ('We', 'Us', 'Our'). Your privacy is important to us. This privacy policy will help you diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 07ded4f..b2b8532 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,8 +1,55 @@ +import { Switch, Match, type JSX } from "solid-js"; import { PageHead } from "~/components/PageHead"; import { DarkModeToggle } from "~/components/DarkModeToggle"; import { Typewriter } from "~/components/Typewriter"; +import { useSite } from "~/context/SiteContext"; +import NessaLanding from "./nessa"; +import LineageLanding from "./lineage"; +import GazeLanding from "./gaze"; +import InputHaloLanding from "./inputhalo"; -export default function Home() { +/** + * Root route handler. + * + * SolidStart's file router cannot match on host, so `vercel.json` host + * rewrites (`nessa.freno.me/(.*) → /nessa/$1`) map each subdomain onto its + * `src/routes//*` file route in production. The vinxi dev server + * ignores `vercel.json`, however — so in dev a subdomain root (`nessa.localhost/`) + * would otherwise fall through to this `index.tsx` and render Mike's personal + * page with only the sidebar changing. + * + * The AGENTS.md-sanctioned remedy is to branch on the resolved `Site` (via + * the `useSite()` accessor from `src/lib/site-context.ts`, NOT raw host + * sifting) and render the matching subdomain landing component here. This + * makes the subdomain roots render their unique landing pages in dev *and* + * acts as a production safety net should a Vercel rewrite ever fail to fire. + * In production the vercel rewrite still wins — `nessa.freno.me/` → + * `/nessa/index.tsx` directly — so the two paths render the same component: + * `NessaLanding`. + */ +export default function Home(): JSX.Element { + const site = useSite(); + + return ( + }> + + + + + + + + + + + + + + ); +} + +/** The freno.me personal site landing page (default/main host only). */ +function MainHome(): JSX.Element { return ( <> ; + return ( + <> + + + + ); } diff --git a/src/routes/inputhalo/downloads.tsx b/src/routes/inputhalo/downloads.tsx new file mode 100644 index 0000000..9d03f65 --- /dev/null +++ b/src/routes/inputhalo/downloads.tsx @@ -0,0 +1,134 @@ +/** + * InputHalo per-subdomain downloads page — `inputhalo.freno.me/downloads`. + * + * Public browser path `/downloads`; vercel.json host rewrites serve this + * from the internal `/inputhalo/*` route prefix while keeping the URL clean. + * + * Mirrors the download surface already exposed on the InputHalo landing page: + * - Direct signed-S3 DMG download via `downloads.getDownloadUrl({ asset_name: "inputhalo" })`. + * - macOS App Store listing link (paid variant — currently a placeholder URL). + */ +import { A } from "@solidjs/router"; +import { createSignal } from "solid-js"; +import { PageHead } from "~/components/PageHead"; +import SubdomainHeader from "~/components/SubdomainHeader"; +import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark"; +import Button from "~/components/ui/Button"; +import { useDarkMode } from "~/context/darkMode"; +import { useSite } from "~/context/SiteContext"; +import { api } from "~/lib/api"; +import { + INPUTHALO_APP_STORE_URL, + INPUTHALO_ICON_DARK, + INPUTHALO_ICON_DEFAULT, + INPUTHALO_MIN_SYSTEM_VERSION, + performInputHaloDownload +} from "~/routes/inputhalo/download"; + +export default function InputHaloDownloadsPage() { + const site = useSite(); + const { isDark } = useDarkMode(); + const [loading, setLoading] = createSignal(false); + + const iconSrc = () => + isDark() ? INPUTHALO_ICON_DARK : INPUTHALO_ICON_DEFAULT; + + const download = () => { + if (loading()) return; + setLoading(true); + performInputHaloDownload( + (input) => api.downloads.getDownloadUrl.query(input), + (url) => { + window.location.href = url; + }, + () => { + alert("Failed to initiate download. Please try again."); + } + ).finally(() => setLoading(false)); + }; + + return ( + <> + + + + +
+
+ + ); +} diff --git a/src/routes/inputhalo/index.tsx b/src/routes/inputhalo/index.tsx index f0084be..1f6bed5 100644 --- a/src/routes/inputhalo/index.tsx +++ b/src/routes/inputhalo/index.tsx @@ -1,29 +1,23 @@ /** - * InputHalo landing page — net-new marketing page for inputhalo.freno.me - * (task 06). + * InputHalo landing page — net-new marketing page for inputhalo.freno.me. * - * Routes: - * - vercel.json rewrites `inputhalo.freno.me/(.*)` → `/inputhalo/$1`, so - * this `src/routes/inputhalo/index.tsx` serves the subdomain root. + * InputHalo is a polished macOS menu bar app that visualizes mouse and + * keyboard input on screen: keyboard indicators, cursor halos, click ripples, + * scroll indicators, and sensitive-input detection. It is built with SwiftUI + * and distributed via direct DMG (Sparkle auto-updates) with a paid App Store + * variant planned. * - * Sections: - * - Hero: app icon (dark/light variant via `useDarkMode`), title, tagline - * - Feature highlights (real-time tracking, menu bar, customizable, native) - * - Download: inline macOS DMG button (tRPC → signed S3 URL) + App Store link - * - * The download orchestration lives in the pure, unit-tested - * `./download.ts` helper — this component is a thin wrapper that supplies the - * real tRPC client + `window.location.href` redirect and the loading/error - * UX. The pattern mirrors `downloads.tsx` and the site-aware PageHead / - * nav-config split. + * The download orchestration stays in the testable pure `./download.ts` module; + * this component is a thin wrapper around it plus the landing-page UX. */ -import { PageHead } from "~/components/PageHead"; -import Button from "~/components/ui/Button"; -import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore"; -import { glitchText } from "~/lib/client-utils"; -import { useDarkMode } from "~/context/darkMode"; +import { For, createSignal } from "solid-js"; import { A } from "@solidjs/router"; -import { createSignal, onMount, onCleanup } from "solid-js"; +import { PageHead } from "~/components/PageHead"; +import SubdomainHeader from "~/components/SubdomainHeader"; +import Button from "~/components/ui/Button"; +import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark"; +import { useDarkMode } from "~/context/darkMode"; +import { useSite } from "~/context/SiteContext"; import { api } from "~/lib/api"; import { INPUTHALO_APP_STORE_URL, @@ -33,9 +27,55 @@ import { performInputHaloDownload } from "./download"; +const FEATURES = [ + { + title: "Keyboard indicator overlay", + body: "Display the keys you press in a clean, configurable on-screen overlay — perfect for demos and tutorials." + }, + { + title: "Mouse halo & click ripples", + body: "Add a subtle halo around your cursor and visual click feedback so viewers never lose track of your pointer." + }, + { + title: "Scroll indicators", + body: "Show scroll direction and intensity in real time, useful for screencasts and accessibility demos." + }, + { + title: "Sensitive input detection", + body: "InputHalo automatically hides visual feedback when you type into password or secure fields." + }, + { + title: "Fully customizable", + body: "Choose colors, sizes, animations, position, and behavior to match your setup and brand." + }, + { + title: "Native macOS menu bar app", + body: "Built with SwiftUI, runs quietly in the menu bar, and uses system accessibility APIs responsibly." + } +] as const; + +const USE_CASES = [ + { + title: "Streamers & creators", + body: "Let your audience see exactly what you're clicking and typing without cluttering your scene." + }, + { + title: "Presenters & educators", + body: "Make keyboard shortcuts and cursor movement crystal clear during demos and recordings." + }, + { + title: "Developers", + body: "Show input interactions in bug reports, design reviews, and pair-programming sessions." + }, + { + title: "Accessibility", + body: "Visualize inputs to make workflows easier to follow for users who benefit from clear feedback." + } +] as const; + export default function InputHaloLanding() { + const site = useSite(); const { isDark } = useDarkMode(); - const [titleText, setTitleText] = createSignal("InputHalo"); const [loading, setLoading] = createSignal(false); const download = () => { @@ -52,142 +92,196 @@ export default function InputHaloLanding() { ).finally(() => setLoading(false)); }; - onMount(() => { - const interval = glitchText(titleText(), setTitleText); - onCleanup(() => clearInterval(interval)); - }); - - const iconSrc = () => (isDark() ? INPUTHALO_ICON_DARK : INPUTHALO_ICON_DEFAULT); + const iconSrc = () => + isDark() ? INPUTHALO_ICON_DARK : INPUTHALO_ICON_DEFAULT; + const brandColor = () => site().brandColor; return ( <> -
- {/* Subtle scanline effect */} -
+ + +
+ {/* Soft pink halos in the background */} +
); } diff --git a/src/routes/inputhalo/privacy.tsx b/src/routes/inputhalo/privacy.tsx index 595a41c..c82d31d 100644 --- a/src/routes/inputhalo/privacy.tsx +++ b/src/routes/inputhalo/privacy.tsx @@ -24,6 +24,7 @@ */ import { A } from "@solidjs/router"; import { PageHead } from "~/components/PageHead"; +import SubdomainHeader from "~/components/SubdomainHeader"; export default function InputHaloPrivacyPolicy() { return ( @@ -32,15 +33,15 @@ export default function InputHaloPrivacyPolicy() { title="Privacy Policy" description="Privacy policy for InputHalo, a macOS menu bar productivity app." /> +
InputHalo's Privacy Policy
-
Last Updated: February 9, 2026
+
Last Updated: July 23, 2026
Welcome to InputHalo ('We', 'Us', - 'Our'). Your privacy is important to us. This privacy - policy will help you understand our policies and procedures related - to the collection, use, and storage of personal information from our - users. + 'Our'). Your privacy is important to us. This privacy policy + will help you understand our policies and procedures related to the + collection, use, and storage of personal information from our users.
    @@ -52,17 +53,17 @@ export default function InputHaloPrivacyPolicy() {
    (a) Collection of Personal Data:
    {" "} InputHalo is designed with privacy as a core principle. We currently do not collect, store, or share any personal - information from our users. The app runs entirely on your - device as a menu bar utility and does not require any account - creation or data transmission to external servers. + information from our users. The app runs entirely on your device + as a menu bar utility and does not require any account creation + or data transmission to external servers.
    (b) Local-Only Settings:
    Any preferences, configuration, or cached state InputHalo maintains - is stored locally on your device using standard macOS - mechanisms (such as the user defaults system). This data never - leaves your device and is not transmitted to us or to any - third-party service. + is stored locally on your device using standard macOS mechanisms + (such as the user defaults system). This data never leaves your + device and is not transmitted to us or to any third-party + service.
    (c) Future Data Collection:
    We may in diff --git a/src/routes/lineage/contact.tsx b/src/routes/lineage/contact.tsx index 2b593bd..1a0a0ba 100644 --- a/src/routes/lineage/contact.tsx +++ b/src/routes/lineage/contact.tsx @@ -1,5 +1,5 @@ import { ContactForm } from "~/components/ContactForm"; -import { LineageContactQuestions } from "~/routes/contact"; +import SubdomainHeader from "~/components/SubdomainHeader"; /** * Life and Lineage contact page (`lineage.freno.me/contact`). @@ -9,9 +9,9 @@ import { LineageContactQuestions } from "~/routes/contact"; * — is derived from `useSite()` inside the component via * `CONTACT_CONTEXT.lineage`. * - * Renders the Life-and-Lineage FAQ accordion (re-imported from the main-site - * contact route so the canonical definition lives in one place) above the - * form — the lineage subdomain is the natural home for product support Q&A. + * Subdomain contact pages intentionally render only the shared contact form — + * no Life-and-Lineage FAQ accordion and no main-site disclaimer subline + * (those remain exclusive to the main `freno.me/contact` page). * * vercel.json rewrites `lineage.freno.me/*` → the internal `/lineage/*` route * prefix; the browser URL stays `lineage.freno.me/contact`. @@ -22,8 +22,9 @@ import { LineageContactQuestions } from "~/routes/contact"; */ export default function LineageContactPage() { return ( - - - + <> + + + ); } diff --git a/src/routes/lineage/deletion.tsx b/src/routes/lineage/deletion.tsx index 9d9f996..a8b2a15 100644 --- a/src/routes/lineage/deletion.tsx +++ b/src/routes/lineage/deletion.tsx @@ -33,6 +33,7 @@ * the form posts to the correct tRPC mutation (Lineage-branded email). */ import { PageHead } from "~/components/PageHead"; +import SubdomainHeader from "~/components/SubdomainHeader"; import DeletionForm from "~/components/DeletionForm"; import { DELETION_PRODUCT_KEY, @@ -45,6 +46,7 @@ export default function LineageDeletionPage() { return ( <> +
    diff --git a/src/routes/lineage/downloads.tsx b/src/routes/lineage/downloads.tsx index cf77924..41ac32a 100644 --- a/src/routes/lineage/downloads.tsx +++ b/src/routes/lineage/downloads.tsx @@ -31,6 +31,7 @@ import { A } from "@solidjs/router"; import { createSignal, onMount, onCleanup } from "solid-js"; import { PageHead } from "~/components/PageHead"; +import SubdomainHeader from "~/components/SubdomainHeader"; import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore"; import Button from "~/components/ui/Button"; import { glitchText } from "~/lib/client-utils"; @@ -71,10 +72,9 @@ export default function LineageDownloadsPage() { return ( <> - + + +
    {/* Subtle scanline effect — consistent with the unified downloads page. */} @@ -131,7 +131,7 @@ export default function LineageDownloadsPage() {
    {/* Secondary CTA → landing page */} -

    +

    ` reads `useSite()` → the lineage `titleSuffix` - * (` | Life and Lineage`) + canonical `https://lineage.freno.me/` are - * derived automatically (task 02); we pass only the base title here. - * - The Google Play badge links to the **public browser path** `/downloads` - * (subdomain-relative), which vercel rewrites to `/lineage/downloads` - * (task 11). The App Store link is an absolute external URL, unchanged. - * - `SimpleParallax` reuses the existing Cave parallax background — a - * fitting "dark fantasy" ambience — and continues to work under the - * subdomain because assets are served from the shared `/public` root. - * - * Auth: this page performs NO auth — Lineage's mobile JWT (`LINEAGE_JWT_SECRET`) - * is for the mobile app's API calls, not the web landing page. + * This intentionally preserves the original marketing-page structure: a single + * full-viewport `SimpleParallax` Cave background with the app icon, title, + * tagline, and store badges centered on screen. No sidebars, no scrollable + * feature sections, and no extra content — the parallax effect depends on the + * page being exactly one viewport tall. The persistent `SubdomainHeader` + * sits sticky above the parallax backdrop so navigation is reachable on every + * subdomain page. */ import { A } from "@solidjs/router"; import { PageHead } from "~/components/PageHead"; +import SubdomainHeader from "~/components/SubdomainHeader"; import SimpleParallax from "~/components/SimpleParallax"; import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark"; -import { For } from "solid-js"; import { APP_STORE_URL, DOWNLOADS_HREF, APP_ICON_SRC, GOOGLE_PLAY_BADGE_SRC, - SCREENSHOT_ASSETS, - PAGE_META, - FEATURES + PAGE_META } from "~/routes/lineage/landing-content"; export default function LineageLandingPage() { return ( <> +

    - {/* Hero */} - Life and Lineage App Icon -

    +
    + Life and Lineage App Icon +
    +

    Life and Lineage

    -

    A dark fantasy adventure

    +

    A dark fantasy adventure

    - {/* Store badges — App Store (external) + Google Play → /downloads */} -
    + - - {/* Feature highlights */} -
    -

    - Why players return -

    -
    - - {(feature) => ( -
    -

    {feature.title}

    -

    - {feature.description} -

    -
    - )} -
    -
    -
    - - {/* Game art / screenshots strip */} -
    -

    - glimpses of the world -

    -
    -
    - Life and Lineage home - Life and Lineage shops -
    -
    -
    - - {/* Secondary CTA → per-subdomain downloads page */} -

    - Prefer a direct download?{" "} - - Visit the downloads page - - . -

    diff --git a/src/routes/lineage/privacy.tsx b/src/routes/lineage/privacy.tsx index e83b434..d1f6986 100644 --- a/src/routes/lineage/privacy.tsx +++ b/src/routes/lineage/privacy.tsx @@ -17,6 +17,7 @@ */ import { A } from "@solidjs/router"; import { PageHead } from "~/components/PageHead"; +import SubdomainHeader from "~/components/SubdomainHeader"; export default function LineagePrivacyPolicy() { return ( @@ -25,6 +26,7 @@ export default function LineagePrivacyPolicy() { title="Privacy Policy" description="Privacy policy for Life and Lineage mobile game, outlining data collection, usage, and user rights." /> +
    Life and Lineage's Privacy Policy
    Last Updated: October 22, 2024
    @@ -52,10 +54,7 @@ export default function LineagePrivacyPolicy() {
    (b) Data Removal:
    Users can request the removal of all information related to them by visiting{" "} - + this page {" "} and filling out the provided form. diff --git a/src/routes/nessa/contact.tsx b/src/routes/nessa/contact.tsx index 903f2e6..edbe4f0 100644 --- a/src/routes/nessa/contact.tsx +++ b/src/routes/nessa/contact.tsx @@ -1,4 +1,5 @@ import { ContactForm } from "~/components/ContactForm"; +import SubdomainHeader from "~/components/SubdomainHeader"; /** * Nessa contact page (`nessa.freno.me/contact`). @@ -17,5 +18,10 @@ import { ContactForm } from "~/components/ContactForm"; * `[Nessa] Contact Request`. */ export default function NessaContactPage() { - return ; + return ( + <> + + + + ); } diff --git a/src/routes/nessa/content.ts b/src/routes/nessa/content.ts new file mode 100644 index 0000000..c0d4cf2 --- /dev/null +++ b/src/routes/nessa/content.ts @@ -0,0 +1,193 @@ +/** + * Pure content + metadata for the Nessa subdomain landing page. + * + * Sourced from the real product positioning doc in + * `~/code/Nessa/plans/2026-03-16-marketing-strategy-launch-positioning.md` + * and the profitability plan (`nessa_profitability_plan_2026-03-09.md`). + * + * Intentionally imports NOTHING from solid-js / @solidjs / + * @solidjs/meta so it can be unit-tested with `bun:test` and referenced from + * the route render layer without dragging the router into pure tests. + */ + +export const TAGLINE = "The fitness app that puts you first." as const; +export const SUBTITLE = + "Tired of Strava's paywalls and price hikes? Track, train, and connect — without the paywall." as const; + +export const ICON_DEFAULT = + "/Nessa Exports/Nessa-iOS-Default-1024x1024.png" as const; +export const ICON_DARK = "/Nessa Exports/Nessa-iOS-Dark-1024x1024.png" as const; + +export const SCREENSHOTS = { + home: { + src: "/Nessa Exports/01-home-tab.png", + alt: "Nessa home dashboard with activity metrics" + }, + segments: { + src: "/Nessa Exports/40-segment-list.png", + alt: "Segment leaderboards and KOM/QOM contenders" + }, + clubs: { + src: "/Nessa Exports/09-clubs-list.png", + alt: "Clubs and social feed" + }, + workoutSummary: { + src: "/Nessa Exports/38-workout-summary.png", + alt: "Workout summary and training log" + }, + plans: { + src: "/Nessa Exports/04-plans-tab-strength.png", + alt: "AI-powered training plans and structured workouts" + }, + appleHealth: { + src: "/Nessa Exports/29-apple-health.png", + alt: "Apple Health integration" + } +} as const; + +export interface Feature { + title: string; + description: string; + free: boolean; +} + +export const FEATURES: readonly Feature[] = [ + { + title: "Track every sport", + description: + "Activity tracking for running, cycling, swimming, and 8+ sports with full GPS, heart-rate, and duration metrics.", + free: true + }, + { + title: "Segment leaderboards — free forever", + description: + "Create segments and compete on leaderboards without paying a subscription. Nessa keeps the features other apps gate behind paywalls free.", + free: true + }, + { + title: "Clubs & community challenges", + description: + "Join clubs, run monthly challenges, post to the social feed, and cheer friends on with kudos and comments.", + free: true + }, + { + title: "Native Apple Watch companion", + description: + "Start, follow, and finish workouts from your wrist. Built from the ground up for the Apple ecosystem with Apple Health integration.", + free: true + }, + { + title: "Route planning & offline maps", + description: + "Plan routes with turn-by-turn navigation and download maps so you stay on track when coverage drops.", + free: false + }, + { + title: "AI training plans", + description: + "Get personalized training plans shaped around your goals, schedule, and fitness history.", + free: false + } +] as const; + +export interface PricingTier { + key: "free" | "plus" | "pro"; + header: string; + price: string; + badge?: string; + headline: string; + features: readonly string[]; + cta: string; +} + +export const PRICING: readonly PricingTier[] = [ + { + key: "free", + header: "Everything You Need", + price: "$0 — Always Free", + headline: + "Most fitness apps charge for the basics. We don't. Track your activities, compete on segments, and connect with friends — completely free.", + features: [ + "Activity tracking for 8+ sports", + "Segment creation + leaderboards", + "Social feed, kudos & comments", + "Training log & activity history", + "Customizable heart-rate zones", + "Clubs & community challenges", + "Apple Watch companion app", + "Apple Health integration" + ], + cta: "Get Started Free" + }, + { + key: "plus", + header: "Take It Further", + price: "$4.99/month or $49.99/year", + badge: "17% savings with annual", + headline: + "Plan your routes, go offline, and dive deeper into your performance data. Everything you need to train smarter.", + features: [ + "Route planning with turn-by-turn navigation", + "Offline maps for areas without coverage", + "Advanced segment analytics", + "Personal heatmaps showing all your adventures" + ], + cta: "Start Free Trial" + }, + { + key: "pro", + header: "Train Smarter", + price: "$9.99/month or $99.99/year", + badge: "17% savings with annual", + headline: + "AI-powered training plans, advanced analytics, and premium challenges. For athletes who mean business.", + features: [ + "AI-powered personalized training plans", + "Premium challenges with rewards", + "Fitness & freshness tracking", + "Matched activities for route comparison", + "Priority customer support" + ], + cta: "Start Free Trial" + } +] as const; + +export interface ComparisonRow { + feature: string; + strava: string; + nessa: string; +} + +export const COMPARISON: readonly ComparisonRow[] = [ + { + feature: "Segment leaderboards", + strava: "Paywalled", + nessa: "Free forever" + }, + { feature: "Privacy", strava: "Server-side data", nessa: "On-device first" }, + { feature: "Premium price", strava: "$23.99/mo", nessa: "From $4.99/mo" }, + { + feature: "Apple Watch", + strava: "Companion app", + nessa: "Native experience" + } +] as const; + +export const WHY_NESSA = [ + { + title: "Segment leaderboards free forever", + body: "Strava's most complained-about paywall is included in Nessa's free tier." + }, + { + title: "Half the price of Strava", + body: "Premium tiers at 50–60% of Strava's cost, with no surprise paywalls." + }, + { + title: "Privacy-first", + body: "Your fitness data stays on your device. No data mining, no targeted ads." + }, + { + title: "Built for Apple Watch", + body: "A native watch experience, not an afterthought. Apple Health included." + } +] as const; diff --git a/src/routes/nessa/deletion.tsx b/src/routes/nessa/deletion.tsx index 0dd6c7d..68c1f18 100644 --- a/src/routes/nessa/deletion.tsx +++ b/src/routes/nessa/deletion.tsx @@ -24,6 +24,7 @@ * Acceptance: `nessa.localhost:3000/deletion` renders the deletion form. */ import { PageHead } from "~/components/PageHead"; +import SubdomainHeader from "~/components/SubdomainHeader"; import DeletionForm from "~/components/DeletionForm"; import { DELETION_PRODUCT_KEY, @@ -36,6 +37,7 @@ export default function NessaDeletionPage() { return ( <> +
    diff --git a/src/routes/nessa/index.tsx b/src/routes/nessa/index.tsx index 53ecb27..9544c32 100644 --- a/src/routes/nessa/index.tsx +++ b/src/routes/nessa/index.tsx @@ -1,298 +1,358 @@ -import { Show, For, type JSX } from "solid-js"; -import { PageHead } from "~/components/PageHead"; -import { useSite } from "~/context/SiteContext"; -import { useDarkMode } from "~/context/darkMode"; -import { NESSA_LANDING_META } from "./meta"; - /** - * Nessa landing page (task 07). + * Nessa landing page. * - * Net-new branded marketing home for `nessa.freno.me`. Served at the public - * URL `/` (vercel.json rewrites `nessa.freno.me/*` → the internal `/nessa/*` - * route prefix — see `src/lib/site-context.ts` + `vercel.json`). + * Serves `nessa.freno.me/` (and falls back from `src/routes/index.tsx`'s + * `useSite()` branch in dev). Reflects Nessa's actual product: a + * privacy-first Strava-alternative fitness app with segment leaderboards, + * clubs, challenges, Apple Watch support, and Free / Plus / Pro pricing tiers. * - * Design contract: - * - Public — NO freno.me web-auth UI (Nessa authenticates via Clerk; the - * subdomain nav in `Bars.tsx` already omits the web-auth widgets). - * - Site-aware metadata via `` — title suffix, canonical, and OG - * image all derive from `useSite()` (`nessa` site config). - * - Themed via `useDarkMode()` + the nessa `brandColor` (#cba6f7) accent. - * - Feature copy is derived from the real server-side capabilities in - * `nessaCommunityRouter` (clubs, challenges, social feed, events) — not - * fabricated. Nessa has no app-store presence or downloadable artifacts - * yet, so the CTA is "Coming soon" rather than a fake download link. - * - * Acceptance: `nessa.localhost:3000/` renders the landing page with hero, - * feature highlights, and a CTA; `` and a `` - * containing "Nessa" are emitted by task-01 SSR + task-02 PageHead. + * Content is sourced from `~/code/Nessa/plans/2026-03-16-marketing-strategy-launch-positioning.md` + * and kept in a pure `./content.ts` module so the acceptance matrix is + * testable without spinning up the router. */ +import { For, Show } from "solid-js"; +import { PageHead } from "~/components/PageHead"; +import SubdomainHeader from "~/components/SubdomainHeader"; +import { useDarkMode } from "~/context/darkMode"; +import { useSite } from "~/context/SiteContext"; +import { A } from "@solidjs/router"; +import { NESSA_LANDING_META } from "./meta"; +import { + TAGLINE, + SUBTITLE, + ICON_DEFAULT, + ICON_DARK, + SCREENSHOTS, + FEATURES, + PRICING, + COMPARISON, + WHY_NESSA +} from "./content"; -/** Inline SVG icon set — kept local to the page (no shared icon key needed). */ -function FeatureIcon(props: { name: string }): JSX.Element { - const common = (viewBox: string, path: JSX.Element) => ( +/** Small SVG checkmark used in pricing cards. */ +function CheckIcon() { + return ( <svg xmlns="http://www.w3.org/2000/svg" - height={28} - width={28} - viewBox={viewBox} - class="h-full w-full" + viewBox="0 0 24 24" + class="mt-0.5 h-5 w-5 shrink-0" fill="none" stroke="currentColor" - stroke-width={2} + stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" > - {path} + <path d="M20 6 9 17l-5-5" /> </svg> ); - switch (props.name) { - case "clubs": - return common( - "0 0 24 24", - <> - <circle cx={9} cy={8} r={3} /> - <circle cx={17} cy={10} r={2.5} /> - <path d="M3 21c0-3.314 2.686-6 6-6s6 2.686 6 6" /> - <path d="M15 21c0-2.21 1.343-4 3-4s3 1.79 3 4" /> - </> - ); - case "challenges": - return common( - "0 0 24 24", - <> - <path d="M12 2 3 7v6c0 4.97 3.806 8.74 9 9 5.194-.26 9-4.03 9-9V7l-9-5Z" /> - <path d="m9 12 2 2 4-4" /> - </> - ); - case "social": - return common( - "0 0 24 24", - <> - <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5Z" /> - </> - ); - case "events": - return common( - "0 0 24 24", - <> - <rect x={3} y={4} width={18} height={18} rx={2} /> - <path d="M16 2v4M8 2v4M3 10h18" /> - </> - ); - default: - return <></>; - } } -/** A single feature highlight card. */ -function FeatureCard(props: { - icon: string; - title: string; - description: string; -}): JSX.Element { - return ( - <div class="border-surface0 bg-surface0/30 hover:bg-surface0/50 flex flex-col gap-3 rounded-2xl border-2 p-6 transition-colors duration-200"> - <div - class="flex h-14 w-14 items-center justify-center rounded-xl" - style={{ color: "var(--brand-color, #cba6f7)" }} - > - <FeatureIcon name={props.icon} /> - </div> - <h3 class="text-xl font-semibold">{props.title}</h3> - <p class="text-text/80 text-base leading-relaxed">{props.description}</p> - </div> - ); -} - -interface Feature { - icon: string; - title: string; - description: string; -} - -/** - * Feature set derived from `nessaCommunityRouter` procedures: - * - clubs.{list,get,create,…} → "Clubs" - * - challenges.{list,get,…} → "Challenges" - * - social.{feed,getPost,likes,…} → "Social Feed" - * - events.{list,create,rsvp,…} → "Events" - */ -const FEATURES: Feature[] = [ - { - icon: "clubs", - title: "Clubs", - description: - "Create and join clubs around shared interests. Public or private, with member rosters, rules, and rich profiles that keep your community organized." - }, - { - icon: "challenges", - title: "Challenges", - description: - "Run goal-driven challenges with start and end dates, track progress, and celebrate completions. Members stay motivated with live participation." - }, - { - icon: "social", - title: "Social Feed", - description: - "Share posts inside your clubs, react with likes, and reply in threaded comments. A social layer that lives right where your community already gathers." - }, - { - icon: "events", - title: "Events", - description: - "Organize events and let members RSVP — going, maybe, or not going. Coordinate meetups and activities without leaving the club." - } -]; - export default function NessaLanding() { const site = useSite(); const { isDark } = useDarkMode(); + const iconSrc = () => (isDark() ? ICON_DARK : ICON_DEFAULT); + const brandColor = () => site().brandColor; + return ( <> <PageHead {...NESSA_LANDING_META} /> + <SubdomainHeader /> + <main class="relative min-h-screen w-full overflow-x-hidden" - // Drive the per-site brand accent via a CSS custom property so child - // elements (feature icons, CTA) can reference `var(--brand-color)` - // without re-reading the site accessor on every render. - style={{ "--brand-color": site().brandColor }} + style={{ "--brand-color": brandColor() }} > - {/* - * Soft brand-tinted backdrop. Two layered radial gradients give the - * page depth on both light and dark themes without competing with the - * content. Fixed so it stays put while the page scrolls. - */} + {/* Soft brand-tinted backdrop */} <div class="pointer-events-none fixed inset-0 z-0" aria-hidden="true" style={{ background: isDark() - ? `radial-gradient(60% 50% at 50% 0%, ${site().brandColor}22 0%, transparent 70%), radial-gradient(50% 40% at 80% 100%, ${site().brandColor}1a 0%, transparent 70%)` - : `radial-gradient(60% 50% at 50% 0%, ${site().brandColor}18 0%, transparent 70%), radial-gradient(50% 40% at 80% 100%, ${site().brandColor}12 0%, transparent 70%)` + ? `radial-gradient(60% 50% at 50% 0%, ${brandColor()}22 0%, transparent 70%), radial-gradient(50% 40% at 80% 100%, ${brandColor()}1a 0%, transparent 70%)` + : `radial-gradient(60% 50% at 50% 0%, ${brandColor()}18 0%, transparent 70%), radial-gradient(50% 40% at 80% 100%, ${brandColor()}12 0%, transparent 70%)` }} /> - {/* ───────────────────────── Hero ───────────────────────── */} - <section class="relative z-10 flex flex-col items-center px-4 pb-16 pt-24 text-center md:pt-32"> - {/* Brand mark — a CSS gradient circle standing in for the icon - until Nessa ships a real icon asset. */} - <div - class="mb-8 flex h-24 w-24 items-center justify-center rounded-[1.75rem] text-5xl font-black text-white shadow-lg md:h-28 md:w-28" - style={{ - background: `linear-gradient(135deg, ${site().brandColor} 0%, var(--color-mauve, #cba6f7) 100%)` - }} - aria-hidden="true" - > - N + {/* ─── Hero ───────────────────────────────────────────────── */} + <section class="relative z-10 flex flex-col items-center px-4 pt-24 pb-16 text-center md:pt-32"> + <div class="mb-8 flex h-28 w-28 items-center justify-center rounded-[1.75rem] shadow-2xl md:h-32 md:w-32"> + <img + src={iconSrc()} + alt="Nessa app icon" + width={128} + height={128} + class="h-full w-full rounded-[1.75rem] object-cover" + /> </div> - <h1 class="text-5xl font-bold tracking-tight md:text-7xl">Nessa</h1> - + <h1 class="max-w-3xl text-4xl font-bold tracking-tight md:text-6xl"> + {TAGLINE} + </h1> <p class="text-text/85 mt-4 max-w-2xl text-lg md:text-2xl"> - Build community. Run challenges. Stay connected. + {SUBTITLE} </p> - <p class="text-text/70 mt-3 max-w-xl text-base md:text-lg"> - A home for your clubs — bring members together with challenges, a - social feed, and events, all in one place. - </p> - - {/* - * CTA — Nessa authenticates via Clerk and runs entirely server-side; - * there is no public app-store listing or downloadable artifact yet, - * so this is a "Coming soon" affordance rather than a fabricated - * download button. (Per task notes: don't fabricate download links.) - */} <div class="mt-10 flex flex-col items-center gap-4 sm:flex-row"> - <button - type="button" - disabled - class="cursor-not-allowed rounded-full px-8 py-3 text-base font-semibold text-white opacity-95 shadow-md transition-transform" - style={{ background: site().brandColor }} - title="Nessa is coming soon" + <A + href="/contact" + class="rounded-full px-8 py-3 text-base font-semibold text-white shadow-md transition-transform hover:scale-[1.02] active:scale-95" + style={{ background: brandColor() }} > - Coming soon - </button> - <a + Join the waitlist + </A> + <A href="/contact" class="border-surface2 hover:bg-surface0/40 rounded-full border-2 px-8 py-3 text-base font-semibold transition-colors" > - Get in touch - </a> + Request beta access + </A> </div> - <p class="text-text/50 mt-4 text-sm"> - Nessa is in active development. Reach out to learn more. + <p class="text-text/60 mt-4 text-sm"> + Launching soon on the App Store. </p> </section> - {/* ─────────────────────── Features ─────────────────────── */} + {/* ─── Free-tier feature highlights ───────────────────────── */} <section class="relative z-10 px-4 py-16" id="features"> <div class="mx-auto max-w-6xl"> <div class="mb-12 text-center"> <h2 class="text-3xl font-bold md:text-4xl"> - Everything your club needs + Everything you need, free forever </h2> <p class="text-text/70 mx-auto mt-3 max-w-2xl text-base md:text-lg"> - Nessa brings the tools communities actually use into one - shared space — no more juggling chats, spreadsheets, and - half-built trackers. + Nessa gives away the features other apps lock behind + subscriptions — because your workouts shouldn't cost extra. </p> </div> - <div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4"> + <div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"> <For each={FEATURES}> - {(feature) => <FeatureCard {...feature} />} + {(feature) => ( + <div class="border-surface0 bg-surface0/30 hover:bg-surface0/50 flex flex-col gap-3 rounded-2xl border-2 p-6 transition-colors"> + <div class="flex items-center justify-between"> + <h3 class="text-xl font-semibold">{feature.title}</h3> + <Show when={feature.free}> + <span + class="rounded-full px-2 py-0.5 text-xs font-semibold text-white" + style={{ background: brandColor() }} + > + free + </span> + </Show> + </div> + <p class="text-text/80 text-base leading-relaxed"> + {feature.description} + </p> + </div> + )} </For> </div> </div> </section> - {/* ──────────────────── Secondary CTA band ──────────────────── */} - <section class="relative z-10 px-4 py-20"> - <div class="mx-auto max-w-4xl text-center"> - <h2 class="text-3xl font-bold md:text-4xl"> - Ready to grow your community? - </h2> - <p class="text-text/70 mx-auto mt-3 max-w-xl text-base md:text-lg"> - Nessa is being built for clubs that want structure without the - friction. We'll share more as it takes shape. - </p> - <div class="mt-8 flex flex-col items-center justify-center gap-4 sm:flex-row"> - <a - href="/contact" - class="rounded-full px-8 py-3 text-base font-semibold text-white shadow-md transition-transform hover:scale-[1.02] active:scale-95" - style={{ background: site().brandColor }} - > - Contact us - </a> - <a - href="/privacy" - class="text-text/80 hover:text-text underline-offset-4 hover:underline" - > - Privacy policy - </a> + {/* ─── Pricing tiers ───────────────────────────────────────── */} + <section class="relative z-10 px-4 py-16"> + <div class="mx-auto max-w-6xl"> + <div class="mb-12 text-center"> + <h2 class="text-3xl font-bold md:text-4xl"> + Premium features, half the price of Strava + </h2> + <p class="text-text/70 mx-auto mt-3 max-w-2xl text-base md:text-lg"> + Choose the plan that fits your training. + </p> + </div> + + <div class="grid grid-cols-1 gap-6 lg:grid-cols-3"> + <For each={PRICING}> + {(tier) => { + const highlighted = tier.key === "plus"; + return ( + <div + class="border-surface0 bg-base/80 flex flex-col rounded-2xl border-2 p-6 backdrop-blur-sm" + classList={{ + "scale-[1.02]": highlighted + }} + style={{ + "border-color": highlighted ? brandColor() : undefined + }} + > + <div class="mb-4"> + <h3 class="text-xl font-semibold">{tier.header}</h3> + <div class="mt-1 text-2xl font-bold">{tier.price}</div> + <Show when={tier.badge}> + <div + class="mt-1 inline-block rounded-full px-2 py-0.5 text-xs font-semibold text-white" + style={{ background: brandColor() }} + > + {tier.badge} + </div> + </Show> + </div> + + <p class="text-text/80 mb-6 leading-relaxed"> + {tier.headline} + </p> + + <ul class="mb-8 flex flex-col gap-3"> + <For each={tier.features}> + {(item) => ( + <li class="flex items-start gap-3 text-sm"> + <CheckIcon /> + <span>{item}</span> + </li> + )} + </For> + </ul> + + <A + href="/contact" + class="mt-auto w-full rounded-full py-3 text-center text-base font-semibold transition-transform active:scale-95" + classList={{ + "text-white": highlighted, + "border-surface2 border-2 hover:bg-surface0/40": + !highlighted + }} + style={{ + background: highlighted ? brandColor() : undefined + }} + > + {tier.cta} + </A> + </div> + ); + }} + </For> </div> </div> </section> - {/* ───────────────────────── Footer ───────────────────────── */} + {/* ─── Comparison vs Strava ─────────────────────────────────── */} + <section class="relative z-10 px-4 py-16"> + <div class="mx-auto max-w-4xl"> + <h2 class="text-center text-3xl font-bold md:text-4xl"> + See how we compare + </h2> + <p class="text-text/70 mt-3 text-center text-base md:text-lg"> + Get more for less with Nessa. + </p> + + <div class="border-surface0 mt-8 overflow-hidden rounded-2xl border-2"> + <div class="bg-surface0/60 grid grid-cols-3 px-6 py-4 text-sm font-semibold"> + <span>Feature</span> + <span class="text-center">Strava</span> + <span class="text-center" style={{ color: brandColor() }}> + Nessa + </span> + </div> + <For each={COMPARISON}> + {(row, idx) => ( + <div + class="grid grid-cols-3 px-6 py-4 text-sm" + classList={{ + "bg-surface0/20": idx() % 2 === 1 + }} + > + <span>{row.feature}</span> + <span class="text-text/70 text-center">{row.strava}</span> + <span + class="text-center font-medium" + style={{ color: brandColor() }} + > + {row.nessa} + </span> + </div> + )} + </For> + </div> + </div> + </section> + + {/* ─── Why Nessa ───────────────────────────────────────────── */} + <section class="relative z-10 px-4 py-16"> + <div class="mx-auto max-w-6xl"> + <h2 class="mb-10 text-center text-3xl font-bold md:text-4xl"> + Why athletes are switching + </h2> + <div class="grid grid-cols-1 gap-6 sm:grid-cols-2"> + <For each={WHY_NESSA}> + {(item) => ( + <div class="border-surface0 bg-surface0/30 rounded-2xl border-2 p-6"> + <h3 class="mb-2 text-lg font-semibold">{item.title}</h3> + <p class="text-text/80">{item.body}</p> + </div> + )} + </For> + </div> + </div> + </section> + + {/* ─── Screenshots ─────────────────────────────────────────── */} + <section class="relative z-10 px-4 py-16"> + <div class="mx-auto max-w-6xl"> + <h2 class="mb-10 text-center text-3xl font-bold md:text-4xl"> + Built for your whole training life + </h2> + <div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"> + <For each={Object.values(SCREENSHOTS)}> + {(shot) => ( + <div class="border-surface0 overflow-hidden rounded-2xl border-2"> + <img + src={shot.src} + alt={shot.alt} + class="h-auto w-full object-cover" + loading="lazy" + /> + </div> + )} + </For> + </div> + </div> + </section> + + {/* ─── Final CTA ───────────────────────────────────────────── */} + <section class="relative z-10 px-4 py-20"> + <div + class="mx-auto max-w-4xl rounded-3xl px-8 py-16 text-center text-white" + style={{ background: brandColor() }} + > + <h2 class="text-3xl font-bold md:text-4xl"> + Ready to put your fitness first? + </h2> + <p class="mx-auto mt-3 max-w-xl text-base text-white/90 md:text-lg"> + Join the waitlist and be the first to know when Nessa lands on the + App Store. + </p> + <div class="mt-8 flex flex-col items-center justify-center gap-4 sm:flex-row"> + <A + href="/contact" + class="rounded-full bg-white px-8 py-3 text-base font-semibold transition-transform hover:scale-[1.02] active:scale-95" + style={{ color: brandColor() }} + > + Join the waitlist + </A> + <A + href="/privacy" + class="text-white/90 underline-offset-4 hover:underline" + > + Privacy policy + </A> + </div> + </div> + </section> + + {/* ─── Footer ───────────────────────────────────────────────── */} <footer class="border-surface0 relative z-10 border-t px-4 py-10"> - <div class="mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 text-sm text-text/60 sm:flex-row"> - <Show - when={site().subdomain} - fallback={<span>{site().displayName}</span>} - > - <span>{site().displayName}</span> - </Show> - <a + <div class="text-text/60 mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 text-sm sm:flex-row"> + <span>{site().displayName}</span> + <A href="https://freno.me" class="hover:text-text underline-offset-4 hover:underline" > freno.me - </a> + </A> </div> </footer> </main> diff --git a/src/routes/nessa/meta.test.ts b/src/routes/nessa/meta.test.ts index f513a59..5223385 100644 --- a/src/routes/nessa/meta.test.ts +++ b/src/routes/nessa/meta.test.ts @@ -1,14 +1,11 @@ /** - * Unit tests for the Nessa landing page metadata (task 07). + * Unit tests for the Nessa landing page metadata. * - * Asserts the task-spec unit criterion: "PageHead on nessa site produces - * correct title suffix and canonical." Mirrors the `page-head-meta.ts` - * testability pattern — `nessa/meta.ts` is a pure module (no solid-js / - * @solidjs/router / @solidjs/meta imports) so `bun:test` can resolve it. - * - * The resolved values are produced by the (task-02) `resolvePageHeadMeta` - * helper over `NESSA_LANDING_META` + `SITE_CONFIG.nessa` + the browser - * pathname `/` — exactly what `<PageHead>` emits on `nessa.freno.me/`. + * Mirrors the `page-head-meta.ts` testability pattern — `nessa/meta.ts` is a + * pure module (no solid-js / @solidjs/router / @solidjs/meta imports) so + * `bun:test` can resolve it. Asserts the metadata matches Nessa's actual + * product positioning as a privacy-first fitness / Strava-alternative app + * (per `~/code/Nessa/plans/2026-03-16-marketing-strategy-launch-positioning.md`). */ import { describe, it, expect } from "bun:test"; import { resolvePageHeadMeta } from "~/components/page-head-meta"; @@ -17,35 +14,71 @@ import { NESSA_LANDING_META } from "./meta"; describe("Nessa landing page — PageHead metadata", () => { it("title composes to 'Nessa | Nessa' (base title + nessa titleSuffix)", () => { - const meta = resolvePageHeadMeta(NESSA_LANDING_META, SITE_CONFIG.nessa, "/"); + const meta = resolvePageHeadMeta( + NESSA_LANDING_META, + SITE_CONFIG.nessa, + "/" + ); expect(meta.title).toBe("Nessa | Nessa"); }); it("title contains the substring 'Nessa'", () => { - const meta = resolvePageHeadMeta(NESSA_LANDING_META, SITE_CONFIG.nessa, "/"); + const meta = resolvePageHeadMeta( + NESSA_LANDING_META, + SITE_CONFIG.nessa, + "/" + ); expect(meta.title).toContain("Nessa"); }); it("canonical is https://nessa.freno.me/ for the landing route", () => { - const meta = resolvePageHeadMeta(NESSA_LANDING_META, SITE_CONFIG.nessa, "/"); + const meta = resolvePageHeadMeta( + NESSA_LANDING_META, + SITE_CONFIG.nessa, + "/" + ); expect(meta.canonical).toBe("https://nessa.freno.me/"); }); it("ogImage falls back to the nessa site default", () => { - const meta = resolvePageHeadMeta(NESSA_LANDING_META, SITE_CONFIG.nessa, "/"); + const meta = resolvePageHeadMeta( + NESSA_LANDING_META, + SITE_CONFIG.nessa, + "/" + ); expect(meta.ogImage).toBe(SITE_CONFIG.nessa.ogDefaultImage); }); it("ogTitle uses the explicit marketing copy, not the bare title", () => { - const meta = resolvePageHeadMeta(NESSA_LANDING_META, SITE_CONFIG.nessa, "/"); + const meta = resolvePageHeadMeta( + NESSA_LANDING_META, + SITE_CONFIG.nessa, + "/" + ); expect(meta.ogTitle).toBe(NESSA_LANDING_META.ogTitle); expect(meta.ogTitle).not.toBe("Nessa"); }); - it("description mentions community capabilities", () => { - const meta = resolvePageHeadMeta(NESSA_LANDING_META, SITE_CONFIG.nessa, "/"); + it("description positions Nessa as a fitness app with community features", () => { + const meta = resolvePageHeadMeta( + NESSA_LANDING_META, + SITE_CONFIG.nessa, + "/" + ); expect(meta.description).toBe(NESSA_LANDING_META.description); + expect(meta.description?.toLowerCase()).toContain("fitness"); + expect(meta.description?.toLowerCase()).toContain("segment"); expect(meta.description?.toLowerCase()).toContain("community"); expect(meta.description?.toLowerCase()).toContain("challenges"); }); + + it("ogDescription mentions free leaderboards and Strava comparison", () => { + const meta = resolvePageHeadMeta( + NESSA_LANDING_META, + SITE_CONFIG.nessa, + "/" + ); + expect(meta.ogDescription?.toLowerCase()).toContain("leaderboards"); + expect(meta.ogDescription?.toLowerCase()).toContain("strava"); + }); }); diff --git a/src/routes/nessa/meta.ts b/src/routes/nessa/meta.ts index 7e719c3..e3bacd2 100644 --- a/src/routes/nessa/meta.ts +++ b/src/routes/nessa/meta.ts @@ -1,23 +1,16 @@ /** - * Deterministic `PageHead` metadata for the Nessa landing page (task 07). + * Deterministic `PageHead` metadata for the Nessa landing page. * * Pure module — imports NOTHING from solid-js / @solidjs/router / @solidjs/meta — * so the landing-page metadata can be unit-tested in `bun:test` without * spinning up the router / MetaProvider / DOM, mirroring the * `page-head-meta.ts` / `nav-config.ts` testability pattern. * - * `index.tsx` imports `NESSA_LANDING_META` and feeds it to `<PageHead …/>`; - * `meta.test.ts` asserts the *resolved* title / canonical / OG values against - * the site-aware `resolvePageHeadMeta` helper (task 02) for the `nessa` site. - * - * Nessa-specific metadata contract: - * - `<title>` resolves to `"Nessa | Nessa"` (props.title + nessa.titleSuffix). - * - canonical resolves to `https://nessa.freno.me/` (auto-derived from - * `site.domain` + the browser pathname `/` — the vercel.json host rewrite - * targets the internal `/nessa` prefix but leaves the public URL clean). - * - `og:image` falls back to the site's `ogDefaultImage` (`/nessa/og-default.png`). - * - description emphasizes Nessa's community capabilities (clubs, challenges, - * social feed, events) — derived from `nessaCommunityRouter`, not fabricated. + * Nessa is positioned as a privacy-first fitness app and Strava alternative + * (per `~/code/Nessa/plans/2026-03-16-marketing-strategy-launch-positioning.md`). + * The description still mentions community features (clubs, challenges) because + * those are real free-tier capabilities, but it now leads with the product's + * actual purpose: fitness tracking. */ import type { PageHeadProps } from "~/components/page-head-meta"; @@ -32,8 +25,8 @@ import type { PageHeadProps } from "~/components/page-head-meta"; export const NESSA_LANDING_META: PageHeadProps = { title: "Nessa", description: - "Nessa is a community platform for building clubs, running challenges, sharing in a social feed, and organizing events.", - ogTitle: "Nessa — Build community. Run challenges. Stay connected.", + "Nessa is the fitness app that puts you first. Track running, cycling, swimming and more; compete on free segment leaderboards; and connect with friends through clubs, community challenges, and a social feed — all while keeping your data on your device.", + ogTitle: "Nessa — The fitness app that puts you first", ogDescription: - "Nessa brings clubs, challenges, social posts, and events together in one community platform." + "A privacy-first fitness app with segment leaderboards free forever, social clubs, community challenges, Apple Watch support, and premium tiers for less than Strava." }; diff --git a/src/routes/nessa/privacy.tsx b/src/routes/nessa/privacy.tsx index b2a2cbf..da2cde6 100644 --- a/src/routes/nessa/privacy.tsx +++ b/src/routes/nessa/privacy.tsx @@ -30,6 +30,7 @@ */ import { A } from "@solidjs/router"; import { PageHead } from "~/components/PageHead"; +import SubdomainHeader from "~/components/SubdomainHeader"; export default function NessaPrivacyPolicy() { return ( @@ -38,9 +39,10 @@ export default function NessaPrivacyPolicy() { title="Privacy Policy" description="Privacy policy for Nessa, a community platform for clubs, challenges, and social features." /> + <SubdomainHeader /> <div class="min-h-screen px-[8vw] py-[10vh]"> <div class="py-4 text-xl">Nessa's Privacy Policy</div> - <div class="py-2">Last Updated: February 9, 2026</div> + <div class="py-2">Last Updated: July 23, 2026</div> <div class="py-2"> Welcome to Nessa ('We', 'Us', 'Our'). Your privacy is important to us. This privacy policy will help you @@ -86,18 +88,14 @@ export default function NessaPrivacyPolicy() { <div class="-ml-6">(d) Data Removal:</div> You can request the removal of your Nessa account and all associated content by contacting us{" "} - <A - href="/contact" - class="text-blue hover-underline-animation" - > + <A href="/contact" class="text-blue hover-underline-animation"> here </A> - . On receipt of your request we will remove your account - record, your club memberships, your challenges and - participation data, and your posts, comments, and likes from the - Nessa database, and we will request that Clerk delete the - corresponding user record. A short grace period may apply while - the deletion is processed. + . On receipt of your request we will remove your account record, + your club memberships, your challenges and participation data, + and your posts, comments, and likes from the Nessa database, and + we will request that Clerk delete the corresponding user record. + A short grace period may apply while the deletion is processed. </div> </div> </div> @@ -120,13 +118,13 @@ export default function NessaPrivacyPolicy() { </div> <div class="pb-2"> <div class="-ml-6">(b) Clerk as Identity Provider:</div> Your - authentication credentials (such as your password, if you use - an email/password sign-in) are handled exclusively by Clerk - under Clerk's own privacy policy. Nessa never receives, - stores, or transmits your password. When you sign in, Nessa - receives a session token from Clerk that lets us identify you; - this token is verified on each request and is not persisted in - our database. + authentication credentials (such as your password, if you use an + email/password sign-in) are handled exclusively by Clerk under + Clerk's own privacy policy. Nessa never receives, stores, + or transmits your password. When you sign in, Nessa receives a + session token from Clerk that lets us identify you; this token + is verified on each request and is not persisted in our + database. </div> </div> </div> diff --git a/src/server/api/routers/misc.ts b/src/server/api/routers/misc.ts index b3cc0c0..b5e91f7 100644 --- a/src/server/api/routers/misc.ts +++ b/src/server/api/routers/misc.ts @@ -1,4 +1,9 @@ -import { createTRPCRouter, publicProcedure, protectedProcedure, csrfProtectedProcedure } from "../utils"; +import { + createTRPCRouter, + publicProcedure, + protectedProcedure, + csrfProtectedProcedure +} from "../utils"; import { z } from "zod"; import { S3Client, @@ -42,7 +47,7 @@ export function sanitizeS3PathComponent(value: string): string { // Strip path traversal characters and normalize whitespace return value .replace(/\s+/g, "-") - .replace(/[\/\\]/g, "-") + .replace(/[/\\]/g, "-") .replace(/\.\./g, "") .replace(/[^a-zA-Z0-9_-]/g, "") .replace(/-+/g, "-") @@ -70,15 +75,17 @@ export function assertS3KeyOwnership(key: string, userId: string | null): void { // Pure helpers live in `./deletion-email.ts` (env-free) so they can be unit- // tested in `bun:test` without a populated `.env`. Re-exported here for the // tRPC mutation below + for callers that already import from `misc`. -export { +// Import into local scope FIRST — `sendDeletionRequestEmail` below uses +// these names directly. A bare `export { ... } from` re-export does NOT make +// the bindings available locally, which caused a ReferenceError that crashed +// the entire tRPC router (503 on every /api/trpc call). +import { DELETION_PRODUCT_SCHEMA, deletionCookieName, deletionEmailContent } from "./deletion-email"; -export type { - DeletionProduct, - DeletionEmailContent -} from "./deletion-email"; +export { DELETION_PRODUCT_SCHEMA, deletionCookieName, deletionEmailContent }; +export type { DeletionProduct, DeletionEmailContent } from "./deletion-email"; const assets: Record<string, string> = { "shapes-with-abigail": "shapes-with-abigail.apk", @@ -378,10 +385,13 @@ export const miscRouter = createTRPCRouter({ ); if (!turnstileValid) { - console.error("Turnstile verification failed for contact form submission"); + console.error( + "Turnstile verification failed for contact form submission" + ); throw new TRPCError({ code: "FORBIDDEN", - message: "Security verification failed. Please refresh the page and try again." + message: + "Security verification failed. Please refresh the page and try again." }); } diff --git a/src/server/site-context-server.ts b/src/server/site-context-server.ts index 32717d3..21a272e 100644 --- a/src/server/site-context-server.ts +++ b/src/server/site-context-server.ts @@ -14,6 +14,7 @@ import type { APIEvent } from "@solidjs/start/server"; import type { H3Event } from "vinxi/http"; import { resolveSiteFromHost, + resolveSiteFromPath, type Site, MAIN_SITE } from "~/lib/site-context"; @@ -53,7 +54,9 @@ function hostFromEventLike(event: ServerSiteEvent | unknown): string | null { try { const nodeReq = ( event as { - nativeEvent?: { node?: { req?: { headers?: Record<string, string | string[]> } } }; + nativeEvent?: { + node?: { req?: { headers?: Record<string, string | string[]> } }; + }; } | null )?.nativeEvent?.node?.req; const raw = nodeReq?.headers?.host; @@ -78,16 +81,76 @@ function hostFromEventLike(event: ServerSiteEvent | unknown): string | null { return null; } +/** + * Best-effort extraction of the URL pathname from any request-shaped event. + * + * SolidStart's FetchEvent exposes `event.request.url` (a full URL string); + * nitro/H3 exposes the raw Node request URL via `event.nativeEvent.node.req.url`. + * We try both so this works for API route handlers, the SSR document handler, + * and tRPC procedures operating on the underlying `H3Event`. + */ +function pathFromEventLike(event: ServerSiteEvent | unknown): string | null { + // 1) Web FetchEvent / APIEvent / PageEvent → standard Request URL. + try { + const req = (event as { request?: Request } | null)?.request; + const url = req?.url; + if (url) return new URL(url).pathname; + } catch { + /* noop */ + } + + // 2) vinxi/nitro H3Event → raw Node request URL. + try { + const nodeReq = ( + event as { + nativeEvent?: { node?: { req?: { url?: string } } }; + } | null + )?.nativeEvent?.node?.req; + const raw = nodeReq?.url; + if (typeof raw === "string" && raw) { + // Node request URLs may be path-only (`/nessa/contact`) or full URLs. + return raw.startsWith("/") ? raw : new URL(raw).pathname; + } + } catch { + /* noop */ + } + + // 3) Some H3 shapes expose `event.node.req` directly. + try { + const nodeReq = ( + event as { + node?: { req?: { url?: string } }; + } | null + )?.node?.req; + const raw = nodeReq?.url; + if (typeof raw === "string" && raw) { + return raw.startsWith("/") ? raw : new URL(raw).pathname; + } + } catch { + /* noop */ + } + + return null; +} + /** * Resolve the active `Site` from a SolidStart APIEvent / PageEvent or a * vinxi/nitro H3Event. Accepts the structural {@link ServerSiteEvent} shape, * so the SSR document handler can pass its `PageEvent` directly. Falls back - * to `main` if no host can be determined. + * to `main` if no host or path can be determined. */ export function getSiteFromEvent( event: APIEvent | H3Event | ServerSiteEvent ): Site { - return resolveSiteFromHost(hostFromEventLike(event)); + const hostResult = resolveSiteFromHost(hostFromEventLike(event)); + // Subdomain host (incl. `*.localhost` dev) → authoritative, use it. + if (hostResult.id !== "main") return hostResult; + + // Host resolved to main — fall back to the URL path prefix. On localhost + // the vercel.json host rewrites don't run, so `localhost:3000/nessa/contact` + // has host `localhost` (→ main) but path `/nessa/contact` (→ nessa). + // Without this, every subdomain page in dev renders the main-site nav. + return resolveSiteFromPath(pathFromEventLike(event)) ?? hostResult; } /** @@ -95,7 +158,9 @@ export function getSiteFromEvent( * functions that receive a `Request` directly). Falls back to `main`. */ export function getSiteFromRequest(request: Request): Site { - return resolveSiteFromHost(request.headers.get("host")); + const hostResult = resolveSiteFromHost(request.headers.get("host")); + if (hostResult.id !== "main") return hostResult; + return resolveSiteFromPath(new URL(request.url).pathname) ?? hostResult; } export { MAIN_SITE };