diff --git a/src/routes/nessa/contact.tsx b/src/routes/nessa/contact.tsx index 6d2db7f..d06de38 100644 --- a/src/routes/nessa/contact.tsx +++ b/src/routes/nessa/contact.tsx @@ -1,6 +1,7 @@ import { ContactForm } from "~/components/ContactForm"; import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders"; import SubdomainHeader from "~/components/SubdomainHeader"; +import { SUPPORT_EMAIL } from "./content"; /** * Nessa contact page (`nessa.freno.me/contact`). @@ -16,14 +17,27 @@ import SubdomainHeader from "~/components/SubdomainHeader"; * * Acceptance: `nessa.localhost:3000/contact` renders the contact form with * Nessa branding; submissions email `michael@freno.me` with subject - * `[Nessa] Contact Request`. + * `[Nessa] Contact Request`. The support address shown by the app is also + * published here, so the contact route does not depend on the form alone + * (FRE-1670). */ export default function NessaContactPage() { return ( <> - + +

+ You can also email us directly at{" "} + + {SUPPORT_EMAIL} + + . +

+
); } diff --git a/src/routes/nessa/content.ts b/src/routes/nessa/content.ts index c127efb..a4e0df8 100644 --- a/src/routes/nessa/content.ts +++ b/src/routes/nessa/content.ts @@ -14,6 +14,15 @@ export const TAGLINE = "The fitness app that puts you first." as const; export const SUBTITLE = "Track, train, and connect — without the paywall." as const; +/** + * The support address the Nessa app shows (`SubscriptionView` / + * `HelpSupportView`). Every published Nessa document that offers a contact + * route names this same address, so a user who leaves the app to read the + * terms, privacy policy, contact page, or deletion page can still reach us + * (FRE-1670). Keep in step with `nessa-support@freno.me` in the app. + */ +export const SUPPORT_EMAIL = "nessa-support@freno.me" 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; diff --git a/src/routes/nessa/deletion.tsx b/src/routes/nessa/deletion.tsx index cbd0d2e..fd76ed1 100644 --- a/src/routes/nessa/deletion.tsx +++ b/src/routes/nessa/deletion.tsx @@ -33,6 +33,7 @@ import { DELETION_GRACE_PERIOD_LABEL, PAGE_META } from "~/routes/nessa/deletion-content"; +import { SUPPORT_EMAIL } from "./content"; export default function NessaDeletionPage() { return ( @@ -54,6 +55,18 @@ export default function NessaDeletionPage() { removed. No data related to the account is retained in any way. +
+ Prefer email? Send your deletion request from the address on your + Nessa account to{" "} + + {SUPPORT_EMAIL} + + ; the same {DELETION_GRACE_PERIOD_LABEL} grace period applies. +
+ 5. Contact Us
-
(a) Reaching Out:
If there are any - questions or comments regarding this privacy policy, you can - contact us{" "} - - here - - . +
(a) Data Controller:
Nessa is operated by + FrenoCorp. For the purposes of applicable data-protection law, + FrenoCorp is the controller of the personal information described + in this policy. +
+
(b) Reaching Out:
If there are any + questions or comments regarding this privacy policy, or you want + to access, correct, or delete your personal information, you can + contact us{" "} + + through the contact form + {" "} + or email{" "} + + {SUPPORT_EMAIL} + + . +
diff --git a/src/routes/nessa/support-contact.test.ts b/src/routes/nessa/support-contact.test.ts new file mode 100644 index 0000000..880a805 --- /dev/null +++ b/src/routes/nessa/support-contact.test.ts @@ -0,0 +1,36 @@ +/** + * Regression guard for the published Nessa support address (FRE-1670). + * + * The app tells users to mail `nessa-support@freno.me` from the subscription + * screen and Help & Support. Before this guard, no published Nessa page named + * that address: a user who left the app to read the terms, privacy policy, + * contact page, or deletion page found no way to reach support. That is a + * privacy-policy contact gap (GDPR/CCPA-style) and an App Review question + * waiting to happen. + * + * The address lives in one place (`SUPPORT_EMAIL`), and every page that + * offers a contact route must render it. Reading the route sources is + * deliberate: the defect is a page that exists but does not mention the + * address, which no runtime assertion over a single page can observe. + */ +import { describe, it, expect } from "bun:test"; +import { readFileSync } from "fs"; +import { SUPPORT_EMAIL } from "./content"; + +const CONTACT_ROUTE_FILES = [ + "src/routes/nessa/terms.tsx", + "src/routes/nessa/privacy.tsx", + "src/routes/nessa/contact.tsx", + "src/routes/nessa/deletion.tsx" +]; + +describe("Nessa published support address", () => { + it("is the address the app shows", () => { + expect(SUPPORT_EMAIL).toBe("nessa-support@freno.me"); + }); + + it.each(CONTACT_ROUTE_FILES)("%s renders it", (file) => { + const source = readFileSync(file, "utf8"); + expect(source).toContain("SUPPORT_EMAIL"); + }); +}); diff --git a/src/routes/nessa/terms.tsx b/src/routes/nessa/terms.tsx index e4b85f1..a442f92 100644 --- a/src/routes/nessa/terms.tsx +++ b/src/routes/nessa/terms.tsx @@ -26,6 +26,7 @@ import { A } from "@solidjs/router"; import { PageHead } from "~/components/PageHead"; import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders"; import SubdomainHeader from "~/components/SubdomainHeader"; +import { SUPPORT_EMAIL } from "./content"; export default function NessaTermsOfUse() { return ( @@ -38,7 +39,7 @@ export default function NessaTermsOfUse() {
Nessa's Terms of Use
-
Last Updated: November 7, 2026
+
Last Updated: November 9, 2026
Welcome to Nessa ('We', 'Us', 'Our'). These terms of use govern your access to and use of the Nessa app and @@ -434,8 +435,15 @@ export default function NessaTermsOfUse() { questions or comments about these terms, want to report content, or need help with a subscription, you can contact us{" "} - here - + through the contact form + {" "} + or email{" "} + + {SUPPORT_EMAIL} + .
diff --git a/src/routes/subdomain-dispatch.test.ts b/src/routes/subdomain-dispatch.test.ts index a8efd65..f07251f 100644 --- a/src/routes/subdomain-dispatch.test.ts +++ b/src/routes/subdomain-dispatch.test.ts @@ -14,7 +14,10 @@ import { describe, it, expect } from "bun:test"; import { readdirSync, existsSync, statSync } from "fs"; function isRouteFile(name: string): boolean { - return name.endsWith(".tsx") || name.endsWith(".ts"); + // Page routes are components; the `.ts` files beside them are pure content / + // metadata modules (content.ts, meta.ts, *-content.ts) with no default + // export the router can render, so they are not dispatch targets. + return name.endsWith(".tsx"); } function listRouteFiles(dir: string): string[] { @@ -25,6 +28,20 @@ function listRouteFiles(dir: string): string[] { } } +function listDirectories(dir: string): string[] { + try { + return readdirSync(dir).filter((name) => { + try { + return statSync(`${dir}/${name}`).isDirectory(); + } catch { + return false; + } + }); + } catch { + return []; + } +} + function hasDispatchRoute(routeName: string, routesDir: string): boolean { // Check for src/routes/.tsx if (existsSync(`${routesDir}/${routeName}.tsx`)) { @@ -41,14 +58,12 @@ describe("subdomain routing dispatch invariant", () => { const routesDir = "./src/routes"; it("every subdomain route has a top-level dispatch route", () => { - const prefixDirs = listRouteFiles(routesDir) - .filter((name) => { - try { - return statSync(`${routesDir}/${name}`).isDirectory(); - } catch { - return false; - } - }) + // List directory entries before filtering to route files. The previous + // shape filtered with `listRouteFiles` first, which only ever returns + // `.tsx`/`.ts` names, so `statSync(...).isDirectory()` was never true + // and the assertion ran over an empty list — it passed while `/terms` + // had no dispatch file (FRE-1670). + const prefixDirs = listDirectories(routesDir) // Exclude non-product subdomains .filter((name) => ["nessa", "lineage", "gaze", "inputhalo", "nook"].includes(name) diff --git a/src/routes/terms.tsx b/src/routes/terms.tsx new file mode 100644 index 0000000..98a716a --- /dev/null +++ b/src/routes/terms.tsx @@ -0,0 +1,30 @@ +import { Switch, Match } from "solid-js"; +import { useSite } from "~/context/SiteContext"; +import NotFound from "./[...404]"; +import NessaTermsOfUse from "./nessa/terms"; + +/** + * Host-aware terms-of-use route — `/terms`. + * + * The public browser path `/terms` is what the Nessa app links from + * Settings › Legal and from the subscription paywall + * (`AppSettings.termsOfServiceURL`). Production Vercel deployments do not + * apply the `vercel.json` host rewrites (the Nitro Build Output API config + * supersedes them), so the subdomain page needs this top-level dispatch file + * to be reachable — without it `nessa.freno.me/terms` is a 404 even though + * `src/routes/nessa/terms.tsx` exists. + * + * Only Nessa publishes terms of use today. Every other site — and the main + * site — falls back to the 404 handler rather than borrowing another + * product's legal document. + */ +export default function TermsPage() { + const site = useSite(); + return ( + }> + + + + + ); +}