meta: task ref cleanup
This commit is contained in:
@@ -4,7 +4,7 @@ import SubdomainHeader from "~/components/SubdomainHeader";
|
||||
/**
|
||||
* Nessa contact page (`nessa.freno.me/contact`).
|
||||
*
|
||||
* Thin wrapper over the shared `<ContactForm>` (task 09). Site awareness —
|
||||
* Thin wrapper over the shared `<ContactForm>`. Site awareness —
|
||||
* subject prefix `[Nessa]`, recipient label, heading, and PageHead metadata —
|
||||
* is derived from `useSite()` inside the component via
|
||||
* `CONTACT_CONTEXT.nessa`, so this route needs no explicit props.
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
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;
|
||||
"Track, train, and connect — without the paywall." as const;
|
||||
|
||||
export const ICON_DEFAULT =
|
||||
"/Nessa Exports/Nessa-iOS-Default-1024x1024.png" as const;
|
||||
@@ -152,35 +152,21 @@ export const PRICING: readonly PricingTier[] = [
|
||||
}
|
||||
] 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"
|
||||
}
|
||||
export const COMPARISON: readonly { feature: string; nessa: string }[] = [
|
||||
{ feature: "Segment leaderboards", nessa: "Free forever" },
|
||||
{ feature: "Privacy", nessa: "On-device first" },
|
||||
{ feature: "Premium price", nessa: "From $4.99/mo" },
|
||||
{ feature: "Apple Watch", 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."
|
||||
body: "The features other apps gate behind a subscription are 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: "Affordable premium",
|
||||
body: "Premium tiers start at $4.99/mo with no surprise paywalls."
|
||||
},
|
||||
{
|
||||
title: "Privacy-first",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Unit tests for the Nessa per-subdomain account-deletion page content
|
||||
* (task 11).
|
||||
* (see `./deletion.tsx`).
|
||||
*
|
||||
* Asserts against pure constants exported from `deletion-content.ts` — no
|
||||
* solid-js / router / DOM. Covers the task-11 acceptance matrix for the
|
||||
* solid-js / router / DOM. Covers the acceptance matrix for the
|
||||
* Nessa deletion flow:
|
||||
* - Product discriminator is `"nessa"` (selects Nessa-branded email).
|
||||
* - Cooldown cookie name is Nessa-specific + matches the server-side
|
||||
@@ -26,21 +26,21 @@ import {
|
||||
describe("Nessa deletion — assessment outcome", () => {
|
||||
it("defines a product discriminator (deletion flow IS implemented)", () => {
|
||||
// Nessa stores user data (nessa.ts: users, workouts, workoutPlans, … +
|
||||
// nessa-community.ts: clubs, clubMemberships). Per task 11 spec step 5,
|
||||
// nessa-community.ts: clubs, clubMemberships).
|
||||
// a deletion flow IS needed — this page provides it.
|
||||
expect(typeof DELETION_PRODUCT_KEY).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Nessa deletion — product discriminator", () => {
|
||||
it("is \"nessa\" (selects Nessa-branded email)", () => {
|
||||
it('is "nessa" (selects Nessa-branded email)', () => {
|
||||
expect(DELETION_PRODUCT_KEY).toBe("nessa");
|
||||
});
|
||||
|
||||
it("is accepted by the server-side product schema", () => {
|
||||
expect(DELETION_PRODUCT_SCHEMA.safeParse(DELETION_PRODUCT_KEY).success).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
DELETION_PRODUCT_SCHEMA.safeParse(DELETION_PRODUCT_KEY).success
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,10 +49,8 @@ describe("Nessa deletion — cooldown cookie", () => {
|
||||
expect(DELETION_COOKIE_NAME).toBe("nessaDeletionRequestSent");
|
||||
});
|
||||
|
||||
it("matches the server-side deletionCookieName(\"nessa\")", () => {
|
||||
expect(DELETION_COOKIE_NAME).toBe(
|
||||
deletionCookieName(DELETION_PRODUCT_KEY)
|
||||
);
|
||||
it('matches the server-side deletionCookieName("nessa")', () => {
|
||||
expect(DELETION_COOKIE_NAME).toBe(deletionCookieName(DELETION_PRODUCT_KEY));
|
||||
});
|
||||
|
||||
it("does NOT collide with the Lineage cooldown cookie", () => {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* Pure content + metadata for the Nessa per-subdomain account-deletion page
|
||||
* (task 11).
|
||||
* (see `./deletion.tsx`).
|
||||
*
|
||||
* Mirrors the `lineage/deletion-content.ts` pattern: imports NOTHING from
|
||||
* solid-js / @solidjs/router / @solidjs/meta so the constants here can be
|
||||
* unit-tested in `bun:test` without spinning up the router / MetaProvider.
|
||||
*
|
||||
* Nessa deletion assessment (see task 11 spec, step 5):
|
||||
* Nessa deletion assessment:
|
||||
* - Nessa DOES store user data. `src/server/api/routers/nessa.ts` defines
|
||||
* per-user tables (`users`, `authProviders`, `workouts`, `workoutPlans`,
|
||||
* `planExercises`, `planSets`, `routePoints`, `exerciseLibrary`) backed
|
||||
@@ -24,7 +24,7 @@
|
||||
* scope for the subdomain-routing feature); the email-request flow gives
|
||||
* users a real, immediate deletion path today.
|
||||
*
|
||||
* Cross-task contracts:
|
||||
* Contracts:
|
||||
* - `DELETION_PRODUCT_KEY = "nessa"` selects Nessa branding + the
|
||||
* `nessaDeletionRequestSent` cooldown cookie (server-side
|
||||
* `deletionCookieName("nessa")`).
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Nessa per-subdomain account-deletion page — `nessa.freno.me/deletion`
|
||||
* (task 11).
|
||||
* (see `./deletion-content.ts`).
|
||||
*
|
||||
* Served at the public browser path `/deletion` (vercel.json host rewrites
|
||||
* `nessa.freno.me/*` → the internal `/nessa/*` route prefix, leaving the
|
||||
* browser URL clean — task 02 canonical rule).
|
||||
* browser URL clean.
|
||||
*
|
||||
* Nessa deletion assessment (see `./deletion-content.ts` for the full
|
||||
* rationale): Nessa stores user data (`users`, `workouts`, `workoutPlans`,
|
||||
@@ -19,7 +19,7 @@
|
||||
* request is email-based (the requester may be locked out of their Clerk
|
||||
* session), NOT an authenticated self-delete. The nav-config does NOT list
|
||||
* a Nessa deletion link by default, so this page is reachable by direct URL
|
||||
* + from the Nessa privacy policy (task-provided).
|
||||
* + from the Nessa privacy policy.
|
||||
*
|
||||
* Acceptance: `nessa.localhost:3000/deletion` renders the deletion form.
|
||||
*/
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* 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,
|
||||
* privacy-first fitness app with segment leaderboards,
|
||||
* clubs, challenges, Apple Watch support, and Free / Plus / Pro pricing tiers.
|
||||
*
|
||||
* Content is sourced from `~/code/Nessa/plans/2026-03-16-marketing-strategy-launch-positioning.md`
|
||||
@@ -16,6 +16,7 @@ import SubdomainHeader from "~/components/SubdomainHeader";
|
||||
import { useDarkMode } from "~/context/darkMode";
|
||||
import { useSite } from "~/context/SiteContext";
|
||||
import { A } from "@solidjs/router";
|
||||
import { buildMainSiteUrl } from "~/lib/site-context";
|
||||
import { NESSA_LANDING_META } from "./meta";
|
||||
import {
|
||||
TAGLINE,
|
||||
@@ -158,7 +159,7 @@ export default function NessaLanding() {
|
||||
<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
|
||||
Premium features, affordable pricing
|
||||
</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.
|
||||
@@ -229,20 +230,20 @@ export default function NessaLanding() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── Comparison vs Strava ─────────────────────────────────── */}
|
||||
{/* ─── Feature highlights ─────────────────────────────────── */}
|
||||
<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
|
||||
What you get with Nessa
|
||||
</h2>
|
||||
<p class="text-text/70 mt-3 text-center text-base md:text-lg">
|
||||
Get more for less with Nessa.
|
||||
Free features other apps charge for, plus affordable premium
|
||||
tiers.
|
||||
</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">
|
||||
<div class="bg-surface0/60 grid grid-cols-2 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>
|
||||
@@ -250,13 +251,12 @@ export default function NessaLanding() {
|
||||
<For each={COMPARISON}>
|
||||
{(row, idx) => (
|
||||
<div
|
||||
class="grid grid-cols-3 px-6 py-4 text-sm"
|
||||
class="grid grid-cols-2 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() }}
|
||||
@@ -348,7 +348,7 @@ export default function NessaLanding() {
|
||||
<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"
|
||||
href={buildMainSiteUrl()}
|
||||
class="hover:text-text underline-offset-4 hover:underline"
|
||||
>
|
||||
freno.me
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 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
|
||||
* product positioning as a privacy-first fitness app
|
||||
* (per `~/code/Nessa/plans/2026-03-16-marketing-strategy-launch-positioning.md`).
|
||||
*/
|
||||
import { describe, it, expect } from "bun:test";
|
||||
@@ -72,13 +72,13 @@ describe("Nessa landing page — PageHead metadata", () => {
|
||||
expect(meta.description?.toLowerCase()).toContain("challenges");
|
||||
});
|
||||
|
||||
it("ogDescription mentions free leaderboards and Strava comparison", () => {
|
||||
it("ogDescription mentions free leaderboards and affordable pricing", () => {
|
||||
const meta = resolvePageHeadMeta(
|
||||
NESSA_LANDING_META,
|
||||
SITE_CONFIG.nessa,
|
||||
"/"
|
||||
);
|
||||
expect(meta.ogDescription?.toLowerCase()).toContain("leaderboards");
|
||||
expect(meta.ogDescription?.toLowerCase()).toContain("strava");
|
||||
expect(meta.ogDescription?.toLowerCase()).toContain("affordable");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* spinning up the router / MetaProvider / DOM, mirroring the
|
||||
* `page-head-meta.ts` / `nav-config.ts` testability pattern.
|
||||
*
|
||||
* Nessa is positioned as a privacy-first fitness app and Strava alternative
|
||||
* Nessa is positioned as a privacy-first fitness app
|
||||
* (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
|
||||
@@ -28,5 +28,5 @@ export const NESSA_LANDING_META: PageHeadProps = {
|
||||
"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:
|
||||
"A privacy-first fitness app with segment leaderboards free forever, social clubs, community challenges, Apple Watch support, and premium tiers for less than Strava."
|
||||
"A privacy-first fitness app with segment leaderboards free forever, social clubs, community challenges, Apple Watch support, and affordable premium tiers."
|
||||
};
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* Nessa privacy policy — `nessa.freno.me/privacy` (task 10).
|
||||
* Nessa privacy policy — `nessa.freno.me/privacy`.
|
||||
*
|
||||
* Net-new privacy policy for the Nessa subdomain. Modeled on the Life and
|
||||
* Lineage policy (the template for products with user accounts, per the task
|
||||
* notes) but scoped to Nessa's real data practices:
|
||||
* Lineage policy (the template for products with user accounts) but scoped to Nessa's real data practices:
|
||||
*
|
||||
* - Authentication: user accounts are managed by Clerk
|
||||
* (`src/server/nessa-auth.ts` verifies Clerk session JWTs via the Clerk
|
||||
@@ -17,13 +16,13 @@
|
||||
* (`NessaConnectionFactory` in `src/server/db-connections.ts`), separate
|
||||
* from the freno.me main DB and the Lineage DB.
|
||||
*
|
||||
* PageHead is site-aware (task 02): only the base title is supplied; the
|
||||
* PageHead is site-aware: only the base title is supplied; the
|
||||
* ` | Nessa` suffix, `https://nessa.freno.me/privacy` canonical, and OG image
|
||||
* are derived automatically. Internal links use public subdomain-relative
|
||||
* paths (`/contact`) consistent with nav-config.ts and page-head-meta.ts.
|
||||
*
|
||||
* Nessa does not yet ship a dedicated account-deletion form route; per the
|
||||
* task notes ("reference the deletion flow if Nessa has user accounts"),
|
||||
* notes ("reference the deletion flow if Nessa has user accounts"),
|
||||
* account/data deletion is initiated by contacting us — Clerk user records
|
||||
* and the associated Nessa community content are then purged manually until a
|
||||
* self-serve flow is built.
|
||||
|
||||
Reference in New Issue
Block a user