forked from Mike/freno-dev
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 { ContactForm } from "~/components/ContactForm";
|
||||||
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
import { SUPPORT_EMAIL } from "./content";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nessa contact page (`nessa.freno.me/contact`).
|
* 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
|
* Acceptance: `nessa.localhost:3000/contact` renders the contact form with
|
||||||
* Nessa branding; submissions email `michael@freno.me` with subject
|
* 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() {
|
export default function NessaContactPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
|
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
|
||||||
<SubdomainHeader />
|
<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 =
|
export const SUBTITLE =
|
||||||
"Track, train, and connect — without the paywall." as const;
|
"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 =
|
export const ICON_DEFAULT =
|
||||||
"/Nessa Exports/Nessa-iOS-Default-1024x1024.png" as const;
|
"/Nessa Exports/Nessa-iOS-Default-1024x1024.png" as const;
|
||||||
export const ICON_DARK = "/Nessa Exports/Nessa-iOS-Dark-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,
|
DELETION_GRACE_PERIOD_LABEL,
|
||||||
PAGE_META
|
PAGE_META
|
||||||
} from "~/routes/nessa/deletion-content";
|
} from "~/routes/nessa/deletion-content";
|
||||||
|
import { SUPPORT_EMAIL } from "./content";
|
||||||
|
|
||||||
export default function NessaDeletionPage() {
|
export default function NessaDeletionPage() {
|
||||||
return (
|
return (
|
||||||
@@ -54,6 +55,18 @@ export default function NessaDeletionPage() {
|
|||||||
removed. No data related to the account is retained in any way.
|
removed. No data related to the account is retained in any way.
|
||||||
</div>
|
</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
|
<DeletionForm
|
||||||
product={DELETION_PRODUCT_KEY}
|
product={DELETION_PRODUCT_KEY}
|
||||||
cookieName={DELETION_COOKIE_NAME}
|
cookieName={DELETION_COOKIE_NAME}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { A } from "@solidjs/router";
|
|||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
import { SUPPORT_EMAIL } from "./content";
|
||||||
|
|
||||||
export default function NessaPrivacyPolicy() {
|
export default function NessaPrivacyPolicy() {
|
||||||
return (
|
return (
|
||||||
@@ -174,13 +175,27 @@ export default function NessaPrivacyPolicy() {
|
|||||||
<span class="-ml-4 pr-2">5.</span> Contact Us
|
<span class="-ml-4 pr-2">5.</span> Contact Us
|
||||||
</div>
|
</div>
|
||||||
<div class="pb-2 pl-4">
|
<div class="pb-2 pl-4">
|
||||||
<div class="-ml-6">(a) Reaching Out:</div> If there are any
|
<div class="-ml-6">(a) Data Controller:</div> Nessa is operated by
|
||||||
questions or comments regarding this privacy policy, you can
|
FrenoCorp. For the purposes of applicable data-protection law,
|
||||||
contact us{" "}
|
FrenoCorp is the controller of the personal information described
|
||||||
<A href="/contact" class="text-blue hover-underline-animation">
|
in this policy.
|
||||||
here
|
<div class="pt-2 pb-2">
|
||||||
</A>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</ol>
|
</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 { PageHead } from "~/components/PageHead";
|
||||||
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
import { SUPPORT_EMAIL } from "./content";
|
||||||
|
|
||||||
export default function NessaTermsOfUse() {
|
export default function NessaTermsOfUse() {
|
||||||
return (
|
return (
|
||||||
@@ -38,7 +39,7 @@ export default function NessaTermsOfUse() {
|
|||||||
<SubdomainHeader />
|
<SubdomainHeader />
|
||||||
<div class="min-h-screen px-[8vw] py-[10vh]">
|
<div class="min-h-screen px-[8vw] py-[10vh]">
|
||||||
<div class="py-4 text-xl">Nessa's Terms of Use</div>
|
<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">
|
<div class="py-2">
|
||||||
Welcome to Nessa ('We', 'Us', 'Our').
|
Welcome to Nessa ('We', 'Us', 'Our').
|
||||||
These terms of use govern your access to and use of the Nessa app and
|
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,
|
questions or comments about these terms, want to report content,
|
||||||
or need help with a subscription, you can contact us{" "}
|
or need help with a subscription, you can contact us{" "}
|
||||||
<A href="/contact" class="text-blue hover-underline-animation">
|
<A href="/contact" class="text-blue hover-underline-animation">
|
||||||
here
|
through the contact form
|
||||||
</A>
|
</A>{" "}
|
||||||
|
or email{" "}
|
||||||
|
<a
|
||||||
|
href={`mailto:${SUPPORT_EMAIL}`}
|
||||||
|
class="text-blue hover-underline-animation"
|
||||||
|
>
|
||||||
|
{SUPPORT_EMAIL}
|
||||||
|
</a>
|
||||||
.
|
.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ import { describe, it, expect } from "bun:test";
|
|||||||
import { readdirSync, existsSync, statSync } from "fs";
|
import { readdirSync, existsSync, statSync } from "fs";
|
||||||
|
|
||||||
function isRouteFile(name: string): boolean {
|
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[] {
|
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 {
|
function hasDispatchRoute(routeName: string, routesDir: string): boolean {
|
||||||
// Check for src/routes/<name>.tsx
|
// Check for src/routes/<name>.tsx
|
||||||
if (existsSync(`${routesDir}/${routeName}.tsx`)) {
|
if (existsSync(`${routesDir}/${routeName}.tsx`)) {
|
||||||
@@ -41,14 +58,12 @@ describe("subdomain routing dispatch invariant", () => {
|
|||||||
const routesDir = "./src/routes";
|
const routesDir = "./src/routes";
|
||||||
|
|
||||||
it("every subdomain route has a top-level dispatch route", () => {
|
it("every subdomain route has a top-level dispatch route", () => {
|
||||||
const prefixDirs = listRouteFiles(routesDir)
|
// List directory entries before filtering to route files. The previous
|
||||||
.filter((name) => {
|
// shape filtered with `listRouteFiles` first, which only ever returns
|
||||||
try {
|
// `.tsx`/`.ts` names, so `statSync(...).isDirectory()` was never true
|
||||||
return statSync(`${routesDir}/${name}`).isDirectory();
|
// and the assertion ran over an empty list — it passed while `/terms`
|
||||||
} catch {
|
// had no dispatch file (FRE-1670).
|
||||||
return false;
|
const prefixDirs = listDirectories(routesDir)
|
||||||
}
|
|
||||||
})
|
|
||||||
// Exclude non-product subdomains
|
// Exclude non-product subdomains
|
||||||
.filter((name) =>
|
.filter((name) =>
|
||||||
["nessa", "lineage", "gaze", "inputhalo", "nook"].includes(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