From 503258c2ba82c43f62969807e51c1ce5c1714584 Mon Sep 17 00:00:00 2001 From: Grant Fowler Date: Thu, 23 Jul 2026 22:32:44 -0400 Subject: [PATCH] Add Privacy Policy and Terms of Service scaffolds App Store Connect and the Play Data Safety form both require a reachable policy URL before a build can be submitted, and Stripe expects terms at checkout. Neither existed anywhere. These live here rather than in the app because the URL is a compliance artifact third parties open on their own schedule -- it must not depend on a Vercel deploy being healthy, and legal text changes on a different cadence than the product. The routes, layout, links, and publish machinery are complete; the section BODIES ARE DELIBERATELY EMPTY. Each section carries a note describing what it must cover, derived from the data flows that actually exist in the product (label photos to Anthropic for OCR, numbers to Twilio, billing to Stripe, the 60-day pre-checkout trial, the consent obligation that sits with the store). Counsel writes the prose. Publishing is all-or-nothing and enforced at build time: a document with any unwritten section, or without an effective date, throws rather than shipping counsel notes as operative policy text. src/legal-docs.mjs is the single source of truth for publish state, so the sitemap exclusion derives from the same flag instead of a second hand-maintained list that could drift. Until published, both pages render a draft banner, emit noindex, and stay out of the sitemap. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01FxVqvypw6Y1GmeVKr44XgR --- astro.config.mjs | 17 ++- src/layouts/Base.astro | 5 +- src/layouts/Legal.astro | 259 ++++++++++++++++++++++++++++++++++++++++ src/legal-docs.mjs | 26 ++++ src/pages/index.astro | 26 +++- src/pages/privacy.astro | 73 +++++++++++ src/pages/terms.astro | 84 +++++++++++++ 7 files changed, 482 insertions(+), 8 deletions(-) create mode 100644 src/layouts/Legal.astro create mode 100644 src/legal-docs.mjs create mode 100644 src/pages/privacy.astro create mode 100644 src/pages/terms.astro diff --git a/astro.config.mjs b/astro.config.mjs index 50c768f..703b1a0 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,10 +1,25 @@ import { defineConfig } from 'astro/config'; import tailwind from '@astrojs/tailwind'; import sitemap from '@astrojs/sitemap'; +import { draftPaths } from './src/legal-docs.mjs'; + +/** + * Draft legal pages must not be advertised in the sitemap — a sitemap entry and + * a `noindex` tag are contradictory signals. Derived from the same publish flags + * the pages render from, so the two cannot drift: previously this was a second + * hand-maintained list, and forgetting either side silently produced a live + * policy missing from the sitemap, or a noindexed draft advertised in it. + */ +const DRAFT_PAGES = draftPaths(); // Custom domain deploy (samplifypro.com). If you ever move off the custom domain // to a Pages project subpath, set `base: '/samplify-web/'` and drop public/CNAME. export default defineConfig({ site: 'https://samplifypro.com', - integrations: [tailwind(), sitemap()], + integrations: [ + tailwind(), + sitemap({ + filter: (page) => !DRAFT_PAGES.some((p) => new URL(page).pathname === p), + }), + ], }); diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index bcdec05..13d4e17 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -2,9 +2,11 @@ export interface Props { title: string; description: string; + /** Keep the page out of search results and sitemaps (unpublished legal drafts). */ + noindex?: boolean; } -const { title, description } = Astro.props; +const { title, description, noindex = false } = Astro.props; const canonical = new URL(Astro.url.pathname, Astro.site); --- @@ -17,6 +19,7 @@ const canonical = new URL(Astro.url.pathname, Astro.site); + {noindex && } diff --git a/src/layouts/Legal.astro b/src/layouts/Legal.astro new file mode 100644 index 0000000..5dc40ea --- /dev/null +++ b/src/layouts/Legal.astro @@ -0,0 +1,259 @@ +--- +/** + * Shared shell for the legal documents (/privacy, /terms). + * + * These pages exist because App Store Connect and the Play Data Safety form both + * require a reachable policy URL before a build can be submitted, and Stripe + * expects terms at checkout. They live on the marketing site rather than in the + * app so the URL keeps resolving independently of a product deploy — third + * parties check these on their schedule, not ours. + * + * The routes and links are wired; the BODIES ARE DELIBERATELY EMPTY. Each + * section carries a `note` describing what it has to cover, derived from the + * data flows that actually exist in ../app (capture path, SMS engine, billing + * rail, storage policies) — guidance for whoever writes the copy, never + * customer-facing text. + * + * To publish: write EVERY section body, then set `published: true` and an + * `effective` date in src/legal-docs.mjs (the single source of truth, which the + * sitemap exclusion is also derived from). While unpublished the page renders a + * loud draft banner and is marked noindex; publishing with any section still + * unwritten fails the build rather than shipping notes as policy text. + */ +import Base from './Base.astro'; +import Mark from '../components/Mark.astro'; + +export interface Section { + title: string; + /** + * What this section must cover. Rendered in place of the body while the + * document is unpublished; publishing with any body still missing is a build + * error, so a note can never reach a reader as operative policy text. + */ + note: string; + /** The operative policy copy, as HTML. Undefined until counsel supplies it. */ + body?: string; +} + +export interface Props { + title: string; + summary: string; + /** ISO date (YYYY-MM-DD) the document takes effect; null until published. */ + effective: string | null; + published: boolean; + sections: Section[]; +} + +const { title, summary, effective, published, sections } = Astro.props; + +/** + * Publishing is all-or-nothing, enforced at build time. + * + * `published` used to gate only the draft banner and the noindex tag, while the + * per-section fallback keyed off `body` independently. Writing 9 of 11 sections + * and flipping the flag therefore shipped an indexable, banner-free policy whose + * remaining sections rendered their internal counsel notes as operative prose. + * Failing the deploy is the only reliable guard — a half-published legal + * document must never reach an app-store reviewer. + */ +const missing = sections.filter((s) => !s.body).map((s) => s.title); +if (published && missing.length > 0) { + throw new Error( + `${title}: cannot publish with ${missing.length} unwritten section(s) — ${missing.join(', ')}. ` + + `Write every section body, or set published: false in src/legal-docs.mjs.` + ); +} +if (published && !effective) { + throw new Error(`${title}: cannot publish without an effective date — set it in src/legal-docs.mjs.`); +} + +const effectiveLabel = effective + ? new Date(`${effective}T00:00:00Z`).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: 'UTC', + }) + : 'Not yet in effect'; + +const email = 'hello@samplifypro.com'; +const slug = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); +--- + + +
+
+ + + + Samplify + + + + Back to site + +
+ +
+

{title}

+

{summary}

+

+ Effective {effectiveLabel} +

+ + {/* role="note", not "alert": alert is a live region for content injected + after load, and screen-reader handling of a load-time alert is + inconsistent. This is the one signal telling a non-sighted reader the + document is not operative, so it is labelled instead. */} + { + !published && ( +
+

+ Draft — not a published policy +

+

+ This document has no operative text yet. The headings below are a skeleton of what it + needs to cover; the copy is still to be written and reviewed by counsel. Do not submit + this URL to an app store listing, a billing flow, or any customer-facing surface until + it is complete. +

+
+ ) + } + + + +
+ { + sections.map((s, i) => ( +
+ {String(i + 1).padStart(2, '0')} +
+

{s.title}

+ {s.body ? ( + +
+ )) + } +
+
+ + +
+ + + diff --git a/src/legal-docs.mjs b/src/legal-docs.mjs new file mode 100644 index 0000000..4fc5ed7 --- /dev/null +++ b/src/legal-docs.mjs @@ -0,0 +1,26 @@ +/** + * Publish state for the legal documents — the SINGLE source of truth. + * + * Imported by both the pages (which render from it) and astro.config.mjs (which + * derives the sitemap exclusion from it), so the two can never disagree. Before + * this existed, the sitemap kept its own list and the two drifted silently in + * both directions: a live policy missing from the sitemap, or a noindexed draft + * advertised in it. + * + * To publish a document: write every section body in its .astro page, then set + * `published: true` and an `effective` date here. Nothing else to remember — + * the build fails loudly if the sections are not all written (see Legal.astro). + * + * Keys are the built URL paths, trailing slash included, to match what + * @astrojs/sitemap passes to its filter. + */ +export const LEGAL_DOCS = { + '/privacy/': { published: false, effective: null }, + '/terms/': { published: false, effective: null }, +}; + +/** Paths that must stay out of the sitemap because they are still drafts. */ +export const draftPaths = () => + Object.entries(LEGAL_DOCS) + .filter(([, doc]) => !doc.published) + .map(([path]) => path); diff --git a/src/pages/index.astro b/src/pages/index.astro index 3a6a135..7b862cb 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -96,12 +96,26 @@ const stages = [ diff --git a/src/pages/privacy.astro b/src/pages/privacy.astro new file mode 100644 index 0000000..45f5a47 --- /dev/null +++ b/src/pages/privacy.astro @@ -0,0 +1,73 @@ +--- +import Legal from '../layouts/Legal.astro'; +import type { Section } from '../layouts/Legal.astro'; +import { LEGAL_DOCS } from '../legal-docs.mjs'; + +/** + * Publish state lives in src/legal-docs.mjs so the sitemap exclusion derives + * from the same flag. Publishing with any section body still unwritten is a + * build error — see Legal.astro. This URL is what goes into App Store Connect + * and the Play Data Safety form. + */ +const { published, effective } = LEGAL_DOCS['/privacy/']; + +/** + * Section skeleton. Every note below describes a data flow that actually exists + * in the product — derived by reading the capture path, the SMS engine, the + * billing rail, and the storage policies in ../app, not from a generic template. + */ +const sections: Section[] = [ + { + title: 'Who we are', + note: 'Identify the operating entity (Fugitive Labs, LLC, DBA "Samplify Pro" — this is the identity submitted to and approved by Twilio toll-free verification, so it must match exactly), its jurisdiction, and a contact address. Explain the two-audience structure: flooring retailers are the customers who hold accounts, and their showroom customers are people whose contact details flow through the product without ever having an account.', + }, + { + title: 'Information we collect', + note: 'Four distinct buckets. (1) Store account data: name, email, password, role, plus the business details collected during onboarding — legal name, business type, EIN, street address, and website. (2) Photographs of flooring sample labels captured by reps, stored as image objects. (3) Showroom-customer data entered by reps or received in replies: mobile number, first and last name, optional email, text-message consent state, and the full two-way message history. (4) Product and usage telemetry: app events, cold-start timings, scan counts, and pipeline stage transitions.', + }, + { + title: 'How we use information', + note: 'Cover: running optical character recognition on label photographs to identify products; creating and advancing orders through the sales pipeline; sending the automated qualification text sequence and routing replies to the store; producing the owner dashboard, rep scorecards, and weekly digest email; billing and usage metering; and product analytics. Be explicit that label photographs are transmitted to a third-party model provider for OCR — that is the least obvious flow and the one most worth disclosing plainly.', + }, + { + title: 'Text messaging', + note: 'This section carries real compliance weight — it is the substance the carriers verified during toll-free verification. Cover: consent is collected in person by a store representative as a separate, optional, affirmative step and is never bundled with taking a sample home; message frequency and that message and data rates may apply; STOP, START, and HELP keyword handling; and that an opt-out cannot be reversed by the store. Cross-reference the per-store opt-in disclosure the app serves at /sms-optin.', + }, + { + title: 'Service providers', + note: 'Name every sub-processor that receives data and state what each one gets: Supabase (database, authentication, image storage), Anthropic (label photographs submitted for OCR), Twilio (mobile numbers and message bodies for SMS delivery), Stripe (store billing and payment data), Resend (email addresses for invitations, digests, and transactional mail), Expo (device push tokens), and Vercel (application hosting and request logs). State whether any of them may use the data for their own purposes.', + }, + { + title: 'Data retention and deletion', + note: 'State how long each category is kept. Describe the account-deletion behavior the product actually implements: a rep who deletes their account has their sign-in disabled and their name and device push token scrubbed, while the orders and scans they created remain with the store as its business records. Explain separately what happens to a store\'s data when it cancels, and how a store requests full deletion.', + }, + { + title: 'Your choices and rights', + note: 'Cover how a store owner and a rep each exercise access, correction, and deletion. Address separately how a showroom customer whose number is in the system makes a request, given that they have no account and no way to sign in — this is the gap most policies handle badly. Then address whichever state privacy regimes apply based on where stores operate.', + }, + { + title: 'Security', + note: 'Describe the controls without overstating them. Accurate as implemented today: per-tenant isolation enforced at the database layer rather than in application code, encryption in transit, scoped access credentials, and label images served through short-lived signed URLs rather than public links.', + }, + { + title: "Children's privacy", + note: 'Standard statement that the service is not directed to children and is intended for business use by flooring retailers and their staff.', + }, + { + title: 'Changes to this policy', + note: 'How material changes are communicated to stores and when they take effect.', + }, + { + title: 'Contact us', + note: 'A monitored address for privacy requests. This needs to be a real, watched inbox before launch — it is the address app-store reviewers and customers will actually use.', + }, +]; +--- + + diff --git a/src/pages/terms.astro b/src/pages/terms.astro new file mode 100644 index 0000000..2251e48 --- /dev/null +++ b/src/pages/terms.astro @@ -0,0 +1,84 @@ +--- +import Legal from '../layouts/Legal.astro'; +import type { Section } from '../layouts/Legal.astro'; +import { LEGAL_DOCS } from '../legal-docs.mjs'; + +/** + * Publish state lives in src/legal-docs.mjs so the sitemap exclusion derives + * from the same flag. Publishing with any section body still unwritten is a + * build error — see Legal.astro. Stripe expects a reachable terms URL. + */ +const { published, effective } = LEGAL_DOCS['/terms/']; + +/** + * Section skeleton. The notes reflect how the product actually behaves — + * the 60-day pre-checkout trial, the plan allowances, the consent obligation + * that sits with the store, and the shared-responsibility split on SMS. + */ +const sections: Section[] = [ + { + title: 'Agreement to these terms', + note: 'Identify the contracting entity (Fugitive Labs, LLC), state that use of the service constitutes acceptance, and note that the person accepting must have authority to bind the store.', + }, + { + title: 'The service', + note: 'Describe what Samplify does in operative terms: a mobile app for capturing flooring sample checkouts, automated SMS follow-up to the store\'s customers, and a web dashboard reporting the resulting pipeline. Reserve the right to change functionality.', + }, + { + title: 'Accounts and eligibility', + note: 'Business use only. Cover the two roles the product implements — owners, who hold billing and settings authority, and reps, who are invited by an owner. State that the store is responsible for its reps\' activity and for removing access when someone leaves.', + }, + { + title: 'Your responsibilities for customer consent', + note: 'The most important section in this document, and the one that protects you. Make it unambiguous that the store — not Samplify — is responsible for obtaining valid, affirmative, documented consent from each customer before texting them, and for the accuracy of the numbers it enters. Reference the required separate, optional, non-bundled consent step. State the consequences of violating it, including suspension.', + }, + { + title: 'Fees, trial, and billing', + note: 'Reflect the implemented model: a 60-day free trial that begins before any payment method is collected, then a monthly subscription. Cover plan allowances and what happens when a store exceeds them, billing through Stripe, renewal, failed payment and the past-due state, and the cancellation and refund policy.', + }, + { + title: 'Acceptable use', + note: 'Prohibit texting people who have not consented, uploading unlawful content, reselling or sharing access, probing or circumventing tenant isolation, and automated or scripted use of the capture endpoints. Note that per-rep rate limits exist and that automation trips them.', + }, + { + title: 'Data ownership', + note: 'State that the store owns its own business data — orders, scans, customers, and messages — and that Samplify holds a license to process it in order to run the service. Address aggregated and de-identified use if you intend to do it, and say so plainly if you do.', + }, + { + title: 'Third-party services', + note: 'Note that the service depends on third parties (carriers, Twilio, Stripe, Anthropic, Supabase, Apple, Google) and disclaim responsibility for their outages, message delivery failures, and carrier filtering — SMS delivery is genuinely not guaranteeable, and this should say so.', + }, + { + title: 'Disclaimers', + note: 'Service provided as-is. Specifically disclaim warranties around OCR accuracy and message deliverability, both of which are probabilistic in practice and will occasionally be wrong.', + }, + { + title: 'Limitation of liability', + note: 'Standard cap and exclusion of consequential damages. Counsel should set the cap relative to fees paid.', + }, + { + title: 'Termination', + note: 'How either side ends the agreement, what notice is given, what happens to store data afterward and for how long it can be exported, and the grounds for immediate suspension (most relevantly, an SMS consent violation).', + }, + { + title: 'Changes to these terms', + note: 'How changes are communicated and when continued use constitutes acceptance.', + }, + { + title: 'Governing law and disputes', + note: 'Jurisdiction and venue — North Carolina, unless counsel advises otherwise. Include the dispute-resolution posture, and any arbitration or class-waiver terms if you want them.', + }, + { + title: 'Contact us', + note: 'A monitored address for legal and contractual notices.', + }, +]; +--- + +