fix: product subdomain cleanup
This commit is contained in:
@@ -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
193
src/routes/nessa/content.ts
Normal 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 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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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."
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user