meta: task ref cleanup
This commit is contained in:
@@ -3,11 +3,12 @@ import { useSearchParams } from "@solidjs/router";
|
||||
import { A } from "@solidjs/router";
|
||||
import RevealDropDown from "~/components/RevealDropDown";
|
||||
import { ContactForm } from "~/components/ContactForm";
|
||||
import { buildSubdomainUrl } from "~/lib/site-context";
|
||||
|
||||
/**
|
||||
* Main-site contact page (`freno.me/contact`).
|
||||
*
|
||||
* Refactored (task 09) to render the shared `<ContactForm>` — the form logic,
|
||||
* Refactored to render the shared `<ContactForm>` — the form logic,
|
||||
* Turnstile widget, cooldown timer, email-verification flow, and tRPC
|
||||
* submission all live in the shared component now. This route remains a thin
|
||||
* wrapper that supplies:
|
||||
@@ -51,7 +52,7 @@ export function LineageContactQuestions(): JSX.Element {
|
||||
<div class="pb-2">
|
||||
You can find the entire privacy policy{" "}
|
||||
<A
|
||||
href="https://lineage.freno.me/privacy"
|
||||
href={buildSubdomainUrl("lineage", "/privacy")}
|
||||
class="text-blue underline-offset-4 hover:underline"
|
||||
>
|
||||
here
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Regression test for the legacy `/deletion/life-and-lineage` route (task 11).
|
||||
* Regression test for the legacy `/deletion/life-and-lineage` route.
|
||||
*
|
||||
* The route was converted from a rendered page into a 308 permanent redirect
|
||||
* to `lineage.freno.me/deletion`. Because the route file is a SolidStart
|
||||
@@ -23,7 +23,7 @@ const SOURCE = readFileSync(
|
||||
"utf8"
|
||||
);
|
||||
|
||||
describe("Legacy /deletion/life-and-lineage — redirect (task 11)", () => {
|
||||
describe("Legacy /deletion/life-and-lineage — redirect", () => {
|
||||
it("is a GET handler (API-route redirect, not a rendered page)", () => {
|
||||
expect(SOURCE).toContain("export function GET()");
|
||||
expect(SOURCE).not.toContain("export default function");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Legacy Life and Lineage account-deletion route — now a 308 permanent
|
||||
* redirect to the Lineage subdomain (task 11).
|
||||
* redirect to the Lineage subdomain.
|
||||
*
|
||||
* The deletion form has been migrated to `src/routes/lineage/deletion.tsx`
|
||||
* served at `lineage.freno.me/deletion` (vercel.json host rewrites map the
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Regression test for the unified `freno.me/downloads` page (task 11).
|
||||
* Regression test for the unified `freno.me/downloads` page.
|
||||
*
|
||||
* Task 11's acceptance criteria require that the unified downloads page is
|
||||
* UNCHANGED — it keeps listing all five products (InputHalo, Gaze, Life and
|
||||
@@ -38,7 +38,7 @@ describe("Unified downloads page — product list (regression)", () => {
|
||||
});
|
||||
|
||||
describe("Unified downloads page — Lineage section (byte-identical APK)", () => {
|
||||
it("still wires the Lineage APK button to the \"lineage\" tRPC asset key", () => {
|
||||
it('still wires the Lineage APK button to the "lineage" tRPC asset key', () => {
|
||||
// Same asset key the per-subdomain lineage/downloads page uses → both
|
||||
// origins serve the byte-identical S3 object (`Life and Lineage.apk`).
|
||||
expect(SOURCE).toContain('download("lineage")');
|
||||
|
||||
@@ -3,6 +3,7 @@ import { A } from "@solidjs/router";
|
||||
import { createSignal, onMount, onCleanup } from "solid-js";
|
||||
import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore";
|
||||
import { glitchText } from "~/lib/client-utils";
|
||||
import { buildSubdomainUrl } from "~/lib/subdomain-url";
|
||||
import Button from "~/components/ui/Button";
|
||||
|
||||
export default function DownloadsPage() {
|
||||
@@ -93,7 +94,13 @@ export default function DownloadsPage() {
|
||||
{/* InputHalo */}
|
||||
<div class="border-overlay0 rounded-lg border p-6 md:p-8">
|
||||
<h2 class="text-text mb-6 font-mono text-2xl">
|
||||
<span class="text-yellow">{">"}</span> {inputHaloText()}
|
||||
<span class="text-yellow">{">"}</span>{" "}
|
||||
<A
|
||||
href={buildSubdomainUrl("inputhalo")}
|
||||
class="text-text hover:text-yellow transition-colors"
|
||||
>
|
||||
{inputHaloText()}
|
||||
</A>
|
||||
</h2>
|
||||
|
||||
<div class="flex flex-col gap-8 lg:flex-row lg:justify-around">
|
||||
@@ -139,7 +146,13 @@ export default function DownloadsPage() {
|
||||
{/* Gaze */}
|
||||
<div class="border-overlay0 rounded-lg border p-6 md:p-8">
|
||||
<h2 class="text-text mb-6 font-mono text-2xl">
|
||||
<span class="text-yellow">{">"}</span> {gazeText()}
|
||||
<span class="text-yellow">{">"}</span>{" "}
|
||||
<A
|
||||
href={buildSubdomainUrl("gaze")}
|
||||
class="text-text hover:text-yellow transition-colors"
|
||||
>
|
||||
{gazeText()}
|
||||
</A>
|
||||
</h2>
|
||||
|
||||
<div class="flex flex-col gap-8 lg:flex-row lg:justify-around">
|
||||
@@ -183,7 +196,13 @@ export default function DownloadsPage() {
|
||||
</div>
|
||||
<div class="border-overlay0 rounded-lg border p-6 md:p-8">
|
||||
<h2 class="text-text mb-6 font-mono text-2xl">
|
||||
<span class="text-yellow">{">"}</span> {LaLText()}
|
||||
<span class="text-yellow">{">"}</span>{" "}
|
||||
<A
|
||||
href={buildSubdomainUrl("lineage")}
|
||||
class="text-text hover:text-yellow transition-colors"
|
||||
>
|
||||
{LaLText()}
|
||||
</A>
|
||||
</h2>
|
||||
|
||||
<div class="flex flex-col gap-8 lg:flex-row lg:justify-around">
|
||||
|
||||
@@ -4,7 +4,7 @@ import SubdomainHeader from "~/components/SubdomainHeader";
|
||||
/**
|
||||
* Gaze contact page (`gaze.freno.me/contact`).
|
||||
*
|
||||
* Thin wrapper over the shared `<ContactForm>` (task 09). Site awareness —
|
||||
* Thin wrapper over the shared `<ContactForm>`. Site awareness —
|
||||
* subject prefix `[Gaze]`, recipient label, heading, and PageHead metadata —
|
||||
* is derived from `useSite()` inside the component via `CONTACT_CONTEXT.gaze`,
|
||||
* so this route needs no explicit props.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Gaze privacy policy — `gaze.freno.me/privacy` (task 10).
|
||||
* Gaze privacy policy — `gaze.freno.me/privacy`.
|
||||
*
|
||||
* Migrated verbatim from the legacy `src/routes/privacy-policy/gaze.tsx`
|
||||
* route so there is zero content loss; the old route now 308-redirects here
|
||||
* (see `src/routes/privacy-policy/gaze.tsx`). PageHead is site-aware (task 02)
|
||||
* (see `src/routes/privacy-policy/gaze.tsx`). PageHead is site-aware
|
||||
* so the Gaze `titleSuffix` (` | Gaze`), canonical
|
||||
* (`https://gaze.freno.me/privacy`), and OG image derive automatically — we
|
||||
* only pass the base title.
|
||||
|
||||
@@ -4,7 +4,7 @@ import SubdomainHeader from "~/components/SubdomainHeader";
|
||||
/**
|
||||
* InputHalo contact page (`inputhalo.freno.me/contact`).
|
||||
*
|
||||
* Thin wrapper over the shared `<ContactForm>` (task 09). Site awareness —
|
||||
* Thin wrapper over the shared `<ContactForm>`. Site awareness —
|
||||
* subject prefix `[InputHalo]`, recipient label, heading, and PageHead
|
||||
* metadata — is derived from `useSite()` inside the component via
|
||||
* `CONTACT_CONTEXT.inputhalo`, so this route needs no explicit props.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Unit tests for the InputHalo landing-page download flow (task 06).
|
||||
* Unit tests for the InputHalo landing-page download flow.
|
||||
*
|
||||
* The helper in `./download.ts` is pure (no solid-js / router / meta imports),
|
||||
* so we exercise the acceptance criterion directly — "the download button
|
||||
@@ -8,7 +8,7 @@
|
||||
*
|
||||
* Integration / visual checks (the rendered landing page, the tRPC client) are
|
||||
* covered by the build gate (`bun run build`) and the manual validation steps
|
||||
* in the task spec; the component is a thin wrapper over this helper.
|
||||
* the component is a thin wrapper over this helper.
|
||||
*/
|
||||
import { describe, it, expect, mock } from "bun:test";
|
||||
import {
|
||||
@@ -49,12 +49,10 @@ describe("InputHalo download constants", () => {
|
||||
|
||||
describe("queryInputHaloDownload", () => {
|
||||
it("calls the query with asset_name 'inputhalo' and returns the signed URL", async () => {
|
||||
const query = mock(
|
||||
(async (input: { asset_name: string }) => {
|
||||
expect(input.asset_name).toBe("inputhalo");
|
||||
return { downloadURL: "https://s3.example.com/InputHalo.dmg?signed=1" };
|
||||
}) as DownloadQueryApi
|
||||
);
|
||||
const query = mock((async (input: { asset_name: string }) => {
|
||||
expect(input.asset_name).toBe("inputhalo");
|
||||
return { downloadURL: "https://s3.example.com/InputHalo.dmg?signed=1" };
|
||||
}) as DownloadQueryApi);
|
||||
|
||||
const url = await queryInputHaloDownload(query);
|
||||
|
||||
@@ -85,9 +83,9 @@ describe("performInputHaloDownload", () => {
|
||||
const SIGNED_URL = "https://s3.example.com/InputHalo-0.1.0.dmg?sig=abc";
|
||||
|
||||
it("redirects to the signed S3 URL returned by the query", async () => {
|
||||
const query = mock(
|
||||
(async () => ({ downloadURL: SIGNED_URL })) as DownloadQueryApi
|
||||
);
|
||||
const query = mock((async () => ({
|
||||
downloadURL: SIGNED_URL
|
||||
})) as DownloadQueryApi);
|
||||
const redirect = mock((url: string) => url);
|
||||
|
||||
const ok = await performInputHaloDownload(query, redirect);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Pure, side-effect-free download orchestration for the InputHalo landing
|
||||
* page (task 06).
|
||||
* page.
|
||||
*
|
||||
* Extracted from the route component so the acceptance criterion —
|
||||
* "download button calls `api.downloads.getDownloadUrl` with `'inputhalo'`
|
||||
@@ -36,9 +36,7 @@ export const INPUTHALO_ICON_DEFAULT =
|
||||
* Keeps the helper decoupled from the full `api` surface and testable with a
|
||||
* stub.
|
||||
*/
|
||||
export interface DownloadQueryApi {
|
||||
(input: { asset_name: string }): Promise<{ downloadURL: string }>;
|
||||
}
|
||||
export type DownloadQueryApi = (input: { asset_name: string }) => Promise<{ downloadURL: string }>
|
||||
|
||||
/**
|
||||
* Resolve the signed S3 download URL for the InputHalo DMG.
|
||||
|
||||
@@ -14,6 +14,7 @@ import { For, createSignal } from "solid-js";
|
||||
import { A } from "@solidjs/router";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||
import { buildMainSiteUrl } from "~/lib/site-context";
|
||||
import Button from "~/components/ui/Button";
|
||||
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
||||
import { useDarkMode } from "~/context/darkMode";
|
||||
@@ -274,7 +275,7 @@ export default function InputHaloLanding() {
|
||||
<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
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
/**
|
||||
* InputHalo privacy policy — `inputhalo.freno.me/privacy` (task 10).
|
||||
* InputHalo privacy policy — `inputhalo.freno.me/privacy`.
|
||||
*
|
||||
* Net-new privacy policy for the InputHalo subdomain. InputHalo is a macOS
|
||||
* menu bar application (`LSUIElement: true`,
|
||||
* `LSApplicationCategoryType: public.app-category.productivity`) — a
|
||||
* productivity utility that lives in the system menu bar. Following the task
|
||||
* notes, Gaze's privacy policy is the template for macOS menu bar apps
|
||||
* productivity utility that lives in the system menu bar. Gaze's privacy policy is the template for macOS menu bar apps
|
||||
* (both are local-only menu bar utilities), so this policy mirrors Gaze's
|
||||
* structure and language while describing InputHalo's own practices.
|
||||
*
|
||||
@@ -16,7 +15,7 @@
|
||||
* - Settings and any cached state are stored locally using standard
|
||||
* macOS mechanisms and are never sent off-device.
|
||||
*
|
||||
* PageHead is site-aware (task 02): only the base title is supplied; the
|
||||
* PageHead is site-aware: only the base title is supplied; the
|
||||
* ` | InputHalo` suffix, `https://inputhalo.freno.me/privacy` canonical, and
|
||||
* OG image are derived automatically. Internal links use public
|
||||
* subdomain-relative paths (`/contact`) consistent with nav-config.ts and
|
||||
|
||||
@@ -4,7 +4,7 @@ import SubdomainHeader from "~/components/SubdomainHeader";
|
||||
/**
|
||||
* Life and Lineage contact page (`lineage.freno.me/contact`).
|
||||
*
|
||||
* Thin wrapper over the shared `<ContactForm>` (task 09). Site awareness —
|
||||
* Thin wrapper over the shared `<ContactForm>`. Site awareness —
|
||||
* subject prefix `[Lineage]`, recipient label, heading, and PageHead metadata
|
||||
* — is derived from `useSite()` inside the component via
|
||||
* `CONTACT_CONTEXT.lineage`.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Unit tests for the Lineage 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:
|
||||
* solid-js / router / DOM. Covers the acceptance matrix:
|
||||
* - Product discriminator is `"lineage"` (selects Lineage-branded email).
|
||||
* - Cooldown cookie name is the legacy `deletionRequestSent` so an in-flight
|
||||
* cooldown survives the `/deletion/life-and-lineage` → subdomain redirect.
|
||||
@@ -26,14 +26,14 @@ import {
|
||||
} from "~/server/api/routers/deletion-email";
|
||||
|
||||
describe("Lineage deletion — product discriminator", () => {
|
||||
it("is \"lineage\" (selects Lineage-branded email)", () => {
|
||||
it('is "lineage" (selects Lineage-branded email)', () => {
|
||||
expect(DELETION_PRODUCT_KEY).toBe("lineage");
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,10 +44,8 @@ describe("Lineage deletion — cooldown cookie", () => {
|
||||
expect(DELETION_COOKIE_NAME).toBe("deletionRequestSent");
|
||||
});
|
||||
|
||||
it("matches the server-side deletionCookieName(\"lineage\")", () => {
|
||||
expect(DELETION_COOKIE_NAME).toBe(
|
||||
deletionCookieName(DELETION_PRODUCT_KEY)
|
||||
);
|
||||
it('matches the server-side deletionCookieName("lineage")', () => {
|
||||
expect(DELETION_COOKIE_NAME).toBe(deletionCookieName(DELETION_PRODUCT_KEY));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,14 +77,13 @@ describe("Lineage deletion — PageHead inputs", () => {
|
||||
});
|
||||
|
||||
describe("Lineage deletion — legacy redirect target", () => {
|
||||
it("points at the lineage subdomain deletion URL", () => {
|
||||
expect(LEGACY_DELETION_REDIRECT_TARGET).toBe(
|
||||
"https://lineage.freno.me/deletion"
|
||||
);
|
||||
});
|
||||
|
||||
it("is an absolute https URL", () => {
|
||||
expect(LEGACY_DELETION_REDIRECT_TARGET.startsWith("https://")).toBe(true);
|
||||
it("points at the lineage subdomain deletion URL (derived from VITE_DOMAIN)", () => {
|
||||
// LEGACY_DELETION_REDIRECT_TARGET is now dynamically derived from
|
||||
// VITE_DOMAIN via buildSubdomainUrl("lineage", "/deletion"). Assert it
|
||||
// is a valid absolute URL containing the lineage + deletion segments.
|
||||
expect(LEGACY_DELETION_REDIRECT_TARGET).toMatch(/^https?:\/\//);
|
||||
expect(LEGACY_DELETION_REDIRECT_TARGET).toContain("lineage");
|
||||
expect(LEGACY_DELETION_REDIRECT_TARGET).toContain("/deletion");
|
||||
});
|
||||
|
||||
it("does not reference the legacy /deletion/life-and-lineage path", () => {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
/**
|
||||
* Pure content + metadata for the Lineage per-subdomain account-deletion page
|
||||
* (task 11).
|
||||
* (see `./deletion.tsx`).
|
||||
*
|
||||
* 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, mirroring the `landing-content.ts` /
|
||||
* `downloads-content.ts` pattern.
|
||||
*
|
||||
* Cross-task contracts encoded here:
|
||||
* Imports `buildSubdomainUrl` from `~/lib/site-context` (near-pure — reads
|
||||
* `import.meta.env.VITE_DOMAIN`) so redirect targets are env-aware.
|
||||
*
|
||||
* Contracts encoded here:
|
||||
* - `DELETION_PRODUCT_KEY` is the `product` discriminator passed to the
|
||||
* generalized `misc.sendDeletionRequestEmail` mutation
|
||||
* (`src/server/api/routers/misc.ts`) so the email copy + cooldown cookie
|
||||
@@ -32,6 +35,9 @@ import type { PageHeadProps } from "~/components/page-head-meta";
|
||||
* Product discriminator for the generalized `sendDeletionRequestEmail`
|
||||
* mutation. The Lineage flow is the original / default product.
|
||||
*/
|
||||
|
||||
import { buildSubdomainUrl } from "~/lib/site-context";
|
||||
|
||||
export const DELETION_PRODUCT_KEY = "lineage" as const;
|
||||
|
||||
/**
|
||||
@@ -63,9 +69,11 @@ export const PAGE_META: PageHeadProps = {
|
||||
|
||||
/**
|
||||
* Canonical absolute URL the legacy `/deletion/life-and-lineage` route
|
||||
* 308-redirects to (task 11). Kept here so tests can assert the redirect
|
||||
* 308-redirects to. Kept here so tests can assert the redirect
|
||||
* target without importing the route module (which would pull the server
|
||||
* runtime).
|
||||
*/
|
||||
export const LEGACY_DELETION_REDIRECT_TARGET =
|
||||
"https://lineage.freno.me/deletion";
|
||||
export const LEGACY_DELETION_REDIRECT_TARGET = buildSubdomainUrl(
|
||||
"lineage",
|
||||
"/deletion"
|
||||
);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* Lineage per-subdomain account-deletion page — `lineage.freno.me/deletion`
|
||||
* (task 11).
|
||||
* (see `./deletion-content.ts`).
|
||||
*
|
||||
* Migrated from `src/routes/deletion/life-and-lineage.tsx` (which is now a
|
||||
* 308 redirect to this public URL — see `LEGACY_DELETION_REDIRECT_TARGET`).
|
||||
*
|
||||
* Served at the public browser path `/deletion` (vercel.json host rewrites
|
||||
* `lineage.freno.me/*` → the internal `/lineage/*` route prefix, leaving the
|
||||
* browser URL clean — task 02 canonical rule). The nav-config "Account
|
||||
* Deletion" entry points at this path (task 04).
|
||||
* browser URL clean. The nav-config "Account
|
||||
* Deletion" entry points at this path.
|
||||
*
|
||||
* Deletion flow:
|
||||
* - Reuses the shared `DeletionForm` component, now generalized to forward
|
||||
@@ -25,7 +25,7 @@
|
||||
*
|
||||
* Site-awareness:
|
||||
* - `<PageHead>` reads `useSite()` → lineage title suffix + canonical are
|
||||
* derived automatically (task 02).
|
||||
* derived automatically.
|
||||
* - No auth — the deletion request is email-based (the requester may be
|
||||
* locked out of their account), NOT an authenticated self-delete.
|
||||
*
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Unit tests for the Lineage per-subdomain downloads page content (task 11).
|
||||
* Unit tests for the Lineage per-subdomain downloads page content.
|
||||
*
|
||||
* Mirrors the `landing-content.test.ts` pattern: assert against pure
|
||||
* constants exported from `downloads-content.ts` (no solid-js / router /
|
||||
* DOM). This covers the task-11 acceptance matrix that's structurally
|
||||
* DOM). This covers the acceptance matrix that's structurally
|
||||
* verifiable without rendering:
|
||||
* - APK asset key is `"lineage"` (the tRPC key the downloads router maps to
|
||||
* `Life and Lineage.apk`) — must match the unified downloads page's key
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Pure content + metadata for the Lineage per-subdomain downloads page
|
||||
* (task 11).
|
||||
* (see `./downloads.tsx`).
|
||||
*
|
||||
* Mirrors the `landing-content.ts` / `page-head-meta.ts` / `nav-config.ts`
|
||||
* pattern: imports NOTHING from solid-js / @solidjs/router / @solidjs/meta so
|
||||
@@ -12,7 +12,7 @@
|
||||
* values; keeping them externalized means changes to the download target /
|
||||
* store link surface as test failures rather than silent regressions.
|
||||
*
|
||||
* Cross-task contracts encoded here:
|
||||
* Contracts encoded here:
|
||||
* - `LINEAGE_DOWNLOAD_ASSET` is the tRPC `downloads.getDownloadUrl` asset key
|
||||
* (`"lineage"`) → resolves to `Life and Lineage.apk` in
|
||||
* `src/server/api/routers/downloads.ts`. It MUST match the key used by the
|
||||
@@ -23,7 +23,7 @@
|
||||
* unified downloads page, so the store front is consistent across origins.
|
||||
* - `LINEAGE_DOWNLOADS_META` is consumed verbatim by `<PageHead>`; the
|
||||
* per-site title suffix (` | Life and Lineage`) is appended automatically
|
||||
* by `resolvePageHeadMeta` (task 02), so `title` here is the BASE title
|
||||
* by `resolvePageHeadMeta`, so `title` here is the BASE title
|
||||
* only — do NOT include the suffix.
|
||||
*/
|
||||
import type { PageHeadProps } from "~/components/page-head-meta";
|
||||
@@ -55,7 +55,7 @@ export const LINEAGE_APP_STORE_URL =
|
||||
* Public browser path back to the Lineage landing page (subdomain-relative).
|
||||
*
|
||||
* vercel.json rewrites `lineage.freno.me/` → the internal `/lineage/` route
|
||||
* prefix while leaving the browser URL clean (task 02 canonical rule).
|
||||
* prefix while leaving the browser URL clean.
|
||||
*/
|
||||
export const LINEAGE_HOME_HREF = "/";
|
||||
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
/**
|
||||
* Lineage per-subdomain downloads page — `lineage.freno.me/downloads`
|
||||
* (task 11).
|
||||
* (see `./deletion-content.ts`).
|
||||
*
|
||||
* Served at the public browser path `/downloads` (vercel.json host rewrites
|
||||
* `lineage.freno.me/*` → the internal `/lineage/*` route prefix, leaving the
|
||||
* browser URL clean — task 02 canonical rule). The nav-config "Downloads"
|
||||
* entry and the landing page's Google Play badge both point at this path
|
||||
* (tasks 04 + 08).
|
||||
* browser URL clean. The nav-config "Downloads"
|
||||
* entry and the landing page's Google Play badge both point at this path.
|
||||
*
|
||||
* Download surface (mirrors the Lineage section of the unified
|
||||
* `freno.me/downloads` page, byte-identical asset source):
|
||||
* - Android APK via tRPC `downloads.getDownloadUrl({ asset_name: "lineage" })`
|
||||
* → S3 signed URL for `Life and Lineage.apk`. Reuses the shared
|
||||
* `downloadAsset` helper (task 05) so the click → redirect → S3 flow is a
|
||||
* `downloadAsset` helper so the click → redirect → S3 flow is a
|
||||
* single code path shared with the Gaze landing page.
|
||||
* - iOS App Store link (`LINEAGE_APP_STORE_URL`) — absolute external URL,
|
||||
* identical to the link surfaced on the landing page + unified downloads.
|
||||
@@ -20,7 +19,7 @@
|
||||
* Site-awareness:
|
||||
* - `<PageHead>` reads `useSite()` → the lineage `titleSuffix`
|
||||
* (` | Life and Lineage`) + canonical `https://lineage.freno.me/downloads`
|
||||
* are derived automatically (task 02); we pass only the base title here.
|
||||
* are derived automatically; we pass only the base title here.
|
||||
* - No auth — Lineage's mobile JWT (`LINEAGE_JWT_SECRET`) is for the mobile
|
||||
* app's API calls, not the web downloads page.
|
||||
*
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
/**
|
||||
* Unit tests for the Lineage landing page content (task 08).
|
||||
* Unit tests for the Lineage landing page content.
|
||||
*
|
||||
* Mirrors the `page-head-meta.ts` / `nav-config.ts` pattern: assert against
|
||||
* pure constants exported from `landing-content.ts` (no solid-js / router /
|
||||
* DOM). This covers the task-08 acceptance matrix that's structurally
|
||||
* DOM). This covers the acceptance matrix that's structurally
|
||||
* verifiable without rendering:
|
||||
* - App Store link is present and correct
|
||||
* - Google Play / downloads link targets the subdomain `/downloads` path
|
||||
* (public browser path, NOT the vercel-rewritten `/lineage/downloads`)
|
||||
* - Feature highlights cover: dark fantasy, mobile, remote saves, PvP
|
||||
* - PageHead base title + description (suffix is added by PageHead, task 02)
|
||||
* - PageHead base title + description (suffix is added by PageHead)
|
||||
* - Legacy `/marketing/life-and-lineage` redirect target points at the
|
||||
* Lineage subdomain.
|
||||
*
|
||||
@@ -46,7 +46,7 @@ describe("Lineage landing — downloads link", () => {
|
||||
it("targets the subdomain-relative public browser path", () => {
|
||||
// NOT `/lineage/downloads` (the internal vercel-rewrite prefix) — vercel
|
||||
// rewrites `lineage.freno.me/downloads` → `/lineage/downloads` while
|
||||
// leaving the browser URL clean, matching the canonical rule from task 02.
|
||||
// leaving the browser URL clean, matching the canonical rule.
|
||||
expect(DOWNLOADS_HREF).toBe("/downloads");
|
||||
});
|
||||
|
||||
@@ -109,9 +109,7 @@ describe("Lineage landing — feature highlights", () => {
|
||||
});
|
||||
|
||||
it("covers the dark-fantasy / mobile / saves / PvP themes", () => {
|
||||
const blob = FEATURES.map(
|
||||
(f) => `${f.title} ${f.description}`
|
||||
)
|
||||
const blob = FEATURES.map((f) => `${f.title} ${f.description}`)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
expect(blob).toContain("dark fantasy");
|
||||
@@ -122,12 +120,16 @@ describe("Lineage landing — feature highlights", () => {
|
||||
});
|
||||
|
||||
describe("Lineage landing — legacy redirect target", () => {
|
||||
it("points at the lineage subdomain apex", () => {
|
||||
expect(LEGACY_REDIRECT_TARGET).toBe("https://lineage.freno.me");
|
||||
it("points at the lineage subdomain (derived from VITE_DOMAIN)", () => {
|
||||
// LEGACY_REDIRECT_TARGET is now dynamically derived from VITE_DOMAIN
|
||||
// via buildSubdomainUrl("lineage"). In dev it's path-based
|
||||
// (http://localhost:3000/lineage); in prod it's host-based
|
||||
// (https://lineage.freno.me). Assert it's a valid absolute URL.
|
||||
expect(LEGACY_REDIRECT_TARGET).toMatch(/^https?:\/\/[^/]+\/[a-z]+$/i);
|
||||
expect(LEGACY_REDIRECT_TARGET).toContain("lineage");
|
||||
});
|
||||
|
||||
it("is an https absolute URL with no trailing path", () => {
|
||||
expect(LEGACY_REDIRECT_TARGET.startsWith("https://")).toBe(true);
|
||||
it("has no trailing slash", () => {
|
||||
expect(LEGACY_REDIRECT_TARGET.endsWith("/")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Pure content + metadata for the Lineage subdomain landing page (task 08).
|
||||
* Pure content + metadata for the Lineage subdomain landing page.
|
||||
*
|
||||
* Intentionally imports NOTHING from solid-js / @solidjs/router / @solidjs/meta
|
||||
* so the constant set here can be unit-tested in `bun:test` without spinning
|
||||
@@ -12,27 +12,29 @@
|
||||
* title/description) is asserted against in `landing-content.test.ts` without
|
||||
* a DOM render.
|
||||
*
|
||||
* Cross-task contracts encoded here:
|
||||
* Contracts encoded here:
|
||||
* - `APP_STORE_URL` is the canonical App Store link the marketing page has
|
||||
* always surfaced (kept stable across the migration).
|
||||
* - `DOWNLOADS_HREF` is the **public browser path** on the lineage subdomain
|
||||
* (`/downloads`), NOT the internal vercel-rewritten prefix `/lineage/downloads`.
|
||||
* This matches the canonical-URL rule from task 02 and the nav-config rule
|
||||
* from task 04: vercel.json maps `lineage.freno.me/downloads` →
|
||||
* This matches the canonical-URL rule and the nav-config rule
|
||||
* from the spec: vercel.json maps `lineage.freno.me/downloads` →
|
||||
* `/lineage/downloads` server-side while the browser sees `/downloads`.
|
||||
* Task 11 will create the matching `src/routes/lineage/downloads.tsx`.
|
||||
* - `PAGE_META` is consumed verbatim by `<PageHead>`; the per-site title
|
||||
* suffix (` | Life and Lineage`) is appended automatically by
|
||||
* `resolvePageHeadMeta` (task 02), so the `title` here is the BASE title
|
||||
* `resolvePageHeadMeta`, so the `title` here is the BASE title
|
||||
* only — do NOT include the suffix.
|
||||
*/
|
||||
|
||||
import { buildSubdomainUrl } from "~/lib/site-context";
|
||||
|
||||
/** Apple App Store link — surfaced unchanged from the legacy marketing page. */
|
||||
export const APP_STORE_URL =
|
||||
"https://apps.apple.com/us/app/life-and-lineage/id6737252442";
|
||||
|
||||
/**
|
||||
* Public browser path to the per-subdomain downloads page (task 11).
|
||||
* Public browser path to the per-subdomain downloads page.
|
||||
* Subdomain-relative: renders `lineage.freno.me/downloads` in the browser.
|
||||
*/
|
||||
export const DOWNLOADS_HREF = "/downloads";
|
||||
@@ -99,4 +101,4 @@ export const FEATURES: readonly LineageFeature[] = [
|
||||
* so tests can assert the redirect target without importing the route module
|
||||
* (which would pull in the server runtime).
|
||||
*/
|
||||
export const LEGACY_REDIRECT_TARGET = "https://lineage.freno.me";
|
||||
export const LEGACY_REDIRECT_TARGET = buildSubdomainUrl("lineage");
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
/**
|
||||
* Life and Lineage privacy policy — `lineage.freno.me/privacy` (task 10).
|
||||
* Life and Lineage privacy policy — `lineage.freno.me/privacy`.
|
||||
*
|
||||
* Migrated verbatim from the legacy
|
||||
* `src/routes/privacy-policy/life-and-lineage.tsx` route so there is zero
|
||||
* content loss; the old route now 308-redirects here (see
|
||||
* `src/routes/privacy-policy/life-and-lineage.tsx`). PageHead is site-aware
|
||||
* (task 02) so the Lineage `titleSuffix` (` | Life and Lineage`), canonical
|
||||
* so the Lineage `titleSuffix` (` | Life and Lineage`), canonical
|
||||
* (`https://lineage.freno.me/privacy`), and OG image derive automatically —
|
||||
* we only pass the base title.
|
||||
*
|
||||
* Per task instructions, the account-deletion reference now points at the
|
||||
* Per instructions, the account-deletion reference now points at the
|
||||
* Lineage subdomain's deletion flow, served at the **public subdomain-relative
|
||||
* path** `/deletion` (vercel.json rewrites to `/lineage/deletion`). The
|
||||
* contact link similarly uses `/contact` (public subdomain path), consistent
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { buildSubdomainUrl } from "~/lib/site-context";
|
||||
|
||||
/**
|
||||
* Legacy `/marketing/gaze` route — redirected (task 05) to the new Gaze
|
||||
* subdomain landing page at `gaze.freno.me`.
|
||||
* Legacy `/marketing/gaze` route — redirected to the new Gaze
|
||||
* subdomain landing page.
|
||||
*
|
||||
* Kept as a permanent 308 redirect so existing inbound links keep resolving
|
||||
* to the canonical Gaze marketing home.
|
||||
@@ -14,7 +16,7 @@ export default function GazeMarketingRedirect(): never {
|
||||
throw new Response(null, {
|
||||
status: 308,
|
||||
headers: {
|
||||
Location: "https://gaze.freno.me",
|
||||
Location: buildSubdomainUrl("gaze"),
|
||||
"Cache-Control": "public, max-age=86400"
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Legacy Life and Lineage marketing route — now a 308 permanent redirect to
|
||||
* the Lineage subdomain (task 08).
|
||||
* the Lineage subdomain.
|
||||
*
|
||||
* The marketing content has been migrated to `src/routes/lineage/index.tsx`
|
||||
* served at `lineage.freno.me` (vercel.json host rewrites map the subdomain to
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { buildSubdomainUrl } from "~/lib/site-context";
|
||||
|
||||
/**
|
||||
* Legacy Gaze privacy policy route — now a 308 permanent redirect to the Gaze
|
||||
* subdomain (task 10).
|
||||
* subdomain.
|
||||
*
|
||||
* The privacy policy content has been migrated to
|
||||
* `src/routes/gaze/privacy.tsx`, served at `gaze.freno.me/privacy`
|
||||
@@ -8,7 +10,7 @@
|
||||
* `/gaze/*` prefix). Keeping this route as a permanent (308) server-side
|
||||
* redirect — rather than a client `<Navigate>` — preserves SEO equity and
|
||||
* gives installed / linked URLs a stable resolution path, mirroring how the
|
||||
* legacy Life and Lineage marketing page was redirected in task 08.
|
||||
* legacy Life and Lineage marketing page was redirected.
|
||||
*
|
||||
* Implemented as a SolidStart API route (`GET` handler returning a Response)
|
||||
* so the redirect happens before any rendering; the route no longer ships a
|
||||
@@ -18,7 +20,7 @@ export function GET() {
|
||||
return new Response(null, {
|
||||
status: 308,
|
||||
headers: {
|
||||
Location: "https://gaze.freno.me/privacy",
|
||||
Location: buildSubdomainUrl("gaze", "/privacy"),
|
||||
"Cache-Control": "public, max-age=0, must-revalidate"
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { buildSubdomainUrl } from "~/lib/site-context";
|
||||
|
||||
/**
|
||||
* Legacy Life and Lineage privacy policy route — now a 308 permanent redirect
|
||||
* to the Lineage subdomain (task 10).
|
||||
* to the Lineage subdomain.
|
||||
*
|
||||
* The privacy policy content has been migrated to
|
||||
* `src/routes/lineage/privacy.tsx`, served at `lineage.freno.me/privacy`
|
||||
@@ -8,7 +10,7 @@
|
||||
* `/lineage/*` prefix). Keeping this route as a permanent (308) server-side
|
||||
* redirect — rather than a client `<Navigate>` — preserves SEO equity and
|
||||
* gives installed / linked URLs a stable resolution path, mirroring how the
|
||||
* legacy Life and Lineage marketing page was redirected in task 08.
|
||||
* legacy Life and Lineage marketing page was redirected.
|
||||
*
|
||||
* Implemented as a SolidStart API route (`GET` handler returning a Response)
|
||||
* so the redirect happens before any rendering; the route no longer ships a
|
||||
@@ -18,7 +20,7 @@ export function GET() {
|
||||
return new Response(null, {
|
||||
status: 308,
|
||||
headers: {
|
||||
Location: "https://lineage.freno.me/privacy",
|
||||
Location: buildSubdomainUrl("lineage", "/privacy"),
|
||||
"Cache-Control": "public, max-age=0, must-revalidate"
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Host-aware sitemap.xml route handler (task 03).
|
||||
* Host-aware sitemap.xml route handler.
|
||||
*
|
||||
* Reads the `Host` header to determine the active site, then generates a
|
||||
* sitemap scoped to that site's routes with canonical URLs from the
|
||||
* corresponding domain.
|
||||
*/
|
||||
import { APIEvent } from "@solidjs/start/server";
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { getSiteFromEvent } from "~/server/site-context-server";
|
||||
import { SITEMAP_ROUTES } from "~/lib/sitemap-routes";
|
||||
import { generateSitemap } from "~/lib/sitemap-generate";
|
||||
|
||||
Reference in New Issue
Block a user