fix: product subdomain cleanup

This commit is contained in:
2026-07-23 20:52:49 -04:00
parent a6ef4d2955
commit 8e33af43a5
39 changed files with 1597 additions and 630 deletions

View File

@@ -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 <ContactForm />;
return (
<>
<SubdomainHeader />
<ContactForm />
</>
);
}

193
src/routes/nessa/content.ts Normal file
View File

@@ -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 5060% 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;

View File

@@ -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 (
<>
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
<SubdomainHeader />
<div class="pt-20">
<div class="mx-auto p-4 md:p-6 lg:p-12">
<div class="text-text w-full justify-center">

View File

@@ -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 `<PageHead>` — 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; `<html data-site="nessa">` and a `<title>`
* 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&apos;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>

View File

@@ -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");
});
});

View File

@@ -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."
};

View File

@@ -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&apos;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 (&apos;We&apos;, &apos;Us&apos;, &apos;Our&apos;).
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&apos;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&apos;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>