FRE-1670: publish /terms and the support address on the Nessa site
nessa.freno.me/terms was a live 404 although AppSettings.termsOfServiceURL links it from Settings > Legal and the paywall. The document existed only on an unmerged branch and had no host-dispatch route, so it could not serve on the subdomain. Carry the Terms of Use page, add the /terms dispatcher, and publish nessa-support@freno.me on terms, privacy, contact and deletion. - src/routes/terms.tsx: host-aware dispatch for nessa; other sites 404. - src/routes/nessa/terms.tsx: the Terms page; its contact section names the support address. - privacy: add the controller clause and the support address. - contact/deletion: show the support address; deletion also accepts a request by email. - subdomain-dispatch.test.ts: list directories before filtering to .tsx files. The old predicate only ever received file names, so it asserted over an empty list and missed the missing /terms dispatch. - support-contact.test.ts: guard that every contact route renders the one address the app shows. App side unchanged: termsOfServiceURL stays https://nessa.freno.me/terms.
This commit is contained in:
@@ -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 (
|
||||
<>
|
||||
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
|
||||
<SubdomainHeader />
|
||||
<ContactForm />
|
||||
<ContactForm>
|
||||
<p class="mt-8 text-center text-sm opacity-80">
|
||||
You can also email us directly at{" "}
|
||||
<a
|
||||
href={`mailto:${SUPPORT_EMAIL}`}
|
||||
class="text-blue hover-underline-animation"
|
||||
>
|
||||
{SUPPORT_EMAIL}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</ContactForm>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
</div>
|
||||
|
||||
<div class="text-text mt-4 w-full justify-center">
|
||||
Prefer email? Send your deletion request from the address on your
|
||||
Nessa account to{" "}
|
||||
<a
|
||||
href={`mailto:${SUPPORT_EMAIL}?subject=Account%20Deletion%20Request`}
|
||||
class="text-blue hover-underline-animation"
|
||||
>
|
||||
{SUPPORT_EMAIL}
|
||||
</a>
|
||||
; the same {DELETION_GRACE_PERIOD_LABEL} grace period applies.
|
||||
</div>
|
||||
|
||||
<DeletionForm
|
||||
product={DELETION_PRODUCT_KEY}
|
||||
cookieName={DELETION_COOKIE_NAME}
|
||||
|
||||
@@ -30,6 +30,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 NessaPrivacyPolicy() {
|
||||
return (
|
||||
@@ -174,13 +175,27 @@ export default function NessaPrivacyPolicy() {
|
||||
<span class="-ml-4 pr-2">5.</span> Contact Us
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
<div class="-ml-6">(a) Reaching Out:</div> If there are any
|
||||
questions or comments regarding this privacy policy, you can
|
||||
contact us{" "}
|
||||
<A href="/contact" class="text-blue hover-underline-animation">
|
||||
here
|
||||
</A>
|
||||
.
|
||||
<div class="-ml-6">(a) Data Controller:</div> 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.
|
||||
<div class="pt-2 pb-2">
|
||||
<div class="-ml-6">(b) Reaching Out:</div> 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{" "}
|
||||
<A href="/contact" class="text-blue hover-underline-animation">
|
||||
through the contact form
|
||||
</A>{" "}
|
||||
or email{" "}
|
||||
<a
|
||||
href={`mailto:${SUPPORT_EMAIL}`}
|
||||
class="text-blue hover-underline-animation"
|
||||
>
|
||||
{SUPPORT_EMAIL}
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ol>
|
||||
|
||||
36
src/routes/nessa/support-contact.test.ts
Normal file
36
src/routes/nessa/support-contact.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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() {
|
||||
<SubdomainHeader />
|
||||
<div class="min-h-screen px-[8vw] py-[10vh]">
|
||||
<div class="py-4 text-xl">Nessa's Terms of Use</div>
|
||||
<div class="py-2">Last Updated: November 7, 2026</div>
|
||||
<div class="py-2">Last Updated: November 9, 2026</div>
|
||||
<div class="py-2">
|
||||
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{" "}
|
||||
<A href="/contact" class="text-blue hover-underline-animation">
|
||||
here
|
||||
</A>
|
||||
through the contact form
|
||||
</A>{" "}
|
||||
or email{" "}
|
||||
<a
|
||||
href={`mailto:${SUPPORT_EMAIL}`}
|
||||
class="text-blue hover-underline-animation"
|
||||
>
|
||||
{SUPPORT_EMAIL}
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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/<name>.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)
|
||||
|
||||
30
src/routes/terms.tsx
Normal file
30
src/routes/terms.tsx
Normal file
@@ -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 (
|
||||
<Switch fallback={<NotFound />}>
|
||||
<Match when={site().id === "nessa"}>
|
||||
<NessaTermsOfUse />
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user