Terms 2
CI / ci (push) Canceled after 0s

Policies section

New settings: two new dropdowns are under Policies in Manage Business:
Name shown on Terms & Privacy: NextChair, My business name, or NextChair and my business name.
Contact email on Terms & Privacy: NextChair (info@next-chair.co.uk) or My business email.
Fallbacks: both default to NextChair. If you pick the business email but haven’t set one, the form warns you and the pages fall back to the NextChair address.
Where the name appears: the choice changes the name in the header link, footer, “we/us” definition, privacy controller line and intro on both pages.
Business email: it replaces the address in the complaints section and the privacy contact lines.
Database change: this needed a new migration, 20261017120000_add_legal_display_settings. Existing businesses default to NextChair, so nothing changes for them. Run prisma migrate deploy on your server.

Terms and placeholders

Sections 15 (Governing law) and 16 (Contact us) are removed from terms/page.tsx, so the Terms now have 14 sections.
The [LEGAL ENTITY NAME] and [REGISTERED ADDRESS] placeholders are gone, along with the unused governing-law setting. The contact email is now info@next-chair.co.uk in legal-config.ts.

Login page

sign-in-form.tsx now shows “By continuing, you agree to our Terms of Service and Privacy Policy”, with both as links.
This commit is contained in:
Stefan Lazic committed 2026-10-17 10:38:07 +01:00
1 parent 05ea83e059
commit 766a0808ab
14 files changed
+400 -175

No files matched your search

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE `BusinessSettings` ADD COLUMN `legalContactEmail` ENUM('NEXTCHAIR', 'BUSINESS') NOT NULL DEFAULT 'NEXTCHAIR',
ADD COLUMN `legalNameDisplay` ENUM('NEXTCHAIR', 'BUSINESS', 'BOTH') NOT NULL DEFAULT 'NEXTCHAIR';
+19
View File
@@ -223,6 +223,17 @@ enum CalendarProvider {
GOOGLE GOOGLE
} }
enum LegalNameDisplay {
NEXTCHAIR
BUSINESS
BOTH
}
enum LegalContactEmail {
NEXTCHAIR
BUSINESS
}
/// Per-appointment sync outcome, not the connection's overall health (see /// Per-appointment sync outcome, not the connection's overall health (see
/// StaffCalendarConnection.lastSyncError for that). /// StaffCalendarConnection.lastSyncError for that).
enum CalendarSyncStatus { enum CalendarSyncStatus {
@@ -403,6 +414,14 @@ model BusinessSettings {
bookingTerms String? @db.Text bookingTerms String? @db.Text
privacyPolicyUrl String? privacyPolicyUrl String?
/// Who the public Terms of Service / Privacy Policy pages name as "we" —
/// see src/features/legal/legal-identity.ts. NEXTCHAIR is the default so
/// existing businesses keep today's wording.
legalNameDisplay LegalNameDisplay @default(NEXTCHAIR)
/// Which inbox those pages give for legal/privacy requests. BUSINESS uses
/// Business.email and falls back to NextChair's when that is empty.
legalContactEmail LegalContactEmail @default(NEXTCHAIR)
/// Phase 9 — Google reviews visibility policy, applied on top of the /// Phase 9 — Google reviews visibility policy, applied on top of the
/// per-review Review.isHidden flag. Null minRating = no floor. /// per-review Review.isHidden flag. Null minRating = no floor.
reviewsMinRating Int? reviewsMinRating Int?
+10 -8
View File
@@ -1,21 +1,23 @@
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { LEGAL } from "@/features/legal/legal-config"; import { getLegalIdentity } from "@/features/legal/legal-identity";
/** /**
* Shell for the public legal pages (/terms, /privacy). Deliberately static * Shell for the public legal pages (/terms, /privacy). Readable by guests,
* and independent of any business's branding — these documents are the * signed-in users and (via the proxy's install-exempt list) before
* platform operator's, and must stay readable by guests, signed-in users * installation completes — getLegalIdentity() falls back to NextChair then.
* and (via the proxy's install-exempt list) before installation completes.
*/ */
export default function LegalLayout({ children }: { children: React.ReactNode }) { export const dynamic = "force-dynamic";
export default async function LegalLayout({ children }: { children: React.ReactNode }) {
const { name } = await getLegalIdentity();
return ( return (
<div className="bg-stone flex min-h-screen flex-col"> <div className="bg-stone flex min-h-screen flex-col">
<header className="border-line border-b bg-white"> <header className="border-line border-b bg-white">
<div className="mx-auto flex max-w-3xl items-center justify-between px-6 py-4"> <div className="mx-auto flex max-w-3xl items-center justify-between px-6 py-4">
<Link href="/" className="text-ink flex items-center gap-2 text-sm font-bold"> <Link href="/" className="text-ink flex items-center gap-2 text-sm font-bold">
<ArrowLeft className="h-4 w-4" aria-hidden="true" /> <ArrowLeft className="h-4 w-4" aria-hidden="true" />
Back to {LEGAL.productName} Back to {name}
</Link> </Link>
<nav aria-label="Legal" className="text-muted flex gap-5 text-sm"> <nav aria-label="Legal" className="text-muted flex gap-5 text-sm">
<Link href="/terms" className="hover:text-ink"> <Link href="/terms" className="hover:text-ink">
@@ -31,7 +33,7 @@ export default function LegalLayout({ children }: { children: React.ReactNode })
{children} {children}
</main> </main>
<footer className="border-line text-muted border-t px-6 py-6 text-center text-xs"> <footer className="border-line text-muted border-t px-6 py-6 text-center text-xs">
© {new Date().getFullYear()} {LEGAL.legalName} © {new Date().getFullYear()} {name}
</footer> </footer>
</div> </div>
); );
+91 -63
View File
@@ -1,34 +1,40 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { LegalDocument, type LegalSectionDef } from "@/features/legal/components/legal-document"; import { LegalDocument, type LegalSectionDef } from "@/features/legal/components/legal-document";
import { LEGAL } from "@/features/legal/legal-config"; import { getLegalIdentity } from "@/features/legal/legal-identity";
import type { LegalIdentity } from "@/features/legal/resolve-legal-identity";
export const metadata: Metadata = { title: "Privacy Policy" }; export const metadata: Metadata = { title: "Privacy Policy" };
// Names the business per its Policies settings, so it must be read per request.
export const dynamic = "force-dynamic";
const sections: LegalSectionDef[] = [ function buildSections({ name, email }: LegalIdentity): LegalSectionDef[] {
return [
{ {
id: "who-we-are", id: "who-we-are",
title: "Who is responsible for your data", title: "Who is responsible for your data",
body: ( body: (
<> <>
<p> <p>
{LEGAL.productName} is a booking platform used by many independent Businesses. Two parties handle your data, in This booking platform is used by independent Businesses. Two parties handle your data,
different roles: in different roles:
</p> </p>
<ul> <ul>
<li> <li>
<strong>The Business you book with</strong> decides why and how your booking details are used for your appointments — <strong>The Business you book with</strong> decides why and how your booking details
it is an independent <em>controller</em> of that data. It sees your name, contact details and booking history, and may are used for your appointments — it is an independent <em>controller</em> of that
keep notes about you. data. It sees your name, contact details and booking history, and may keep notes about
you.
</li> </li>
<li> <li>
<strong>{LEGAL.legalName}</strong> ({LEGAL.address}) runs the Platform. For your account, sign-in and the running and <strong>The operator of the Platform</strong> ({name}, &ldquo;we&rdquo;). For your
security of the Platform, we are a controller. For the booking data a Business keeps in the Platform, we act as its{" "} account, sign-in and the running and security of the Platform, we are a controller.
For the booking data a Business keeps in the Platform, we act as its{" "}
<em>processor</em>, on its instructions. <em>processor</em>, on its instructions.
</li> </li>
</ul> </ul>
<p> <p>
Contact us about privacy at <a href={`mailto:${LEGAL.contactEmail}`}>{LEGAL.contactEmail}</a>. Contact us about privacy at <a href={`mailto:${email}`}>{email}</a>.
</p> </p>
</> </>
), ),
@@ -53,18 +59,26 @@ const sections: LegalSectionDef[] = [
<ul> <ul>
<li>Appointment history and status (confirmed, cancelled, completed, no-show).</li> <li>Appointment history and status (confirmed, cancelled, completed, no-show).</li>
<li>Loyalty points and rewards, if the Business runs a loyalty programme.</li> <li>Loyalty points and rewards, if the Business runs a loyalty programme.</li>
<li>Notes a Business keeps about its clients to deliver the service (for example, preferred style).</li> <li>
<li>Sign-in codes (short-lived and stored in hashed form) and session information that keeps you signed in.</li> Notes a Business keeps about its clients to deliver the service (for example,
<li>A push-notification address for your device, if you switch on push notifications.</li> preferred style).
</li>
<li>
Sign-in codes (short-lived and stored in hashed form) and session information that
keeps you signed in.
</li>
<li>
A push-notification address for your device, if you switch on push notifications.
</li>
</ul> </ul>
<p> <p>
<strong>For Staff and owners:</strong> work schedule, services offered and, if connected, the Google Calendar account used <strong>For Staff and owners:</strong> work schedule, services offered and, if
to sync appointments. connected, the Google Calendar account used to sync appointments.
</p> </p>
<p> <p>
<strong>Clients added by a Business:</strong> a Business may add or import its existing clients (name, contact details, <strong>Clients added by a Business:</strong> a Business may add or import its existing
history) into the Platform. In that case the Business is responsible for having the right to do so and for telling those clients (name, contact details, history) into the Platform. In that case the Business is
people. responsible for having the right to do so and for telling those people.
</p> </p>
</> </>
), ),
@@ -75,25 +89,26 @@ const sections: LegalSectionDef[] = [
body: ( body: (
<ul> <ul>
<li> <li>
<strong>To provide the booking service</strong> — create and manage appointments, show availability and sign you in. <strong>To provide the booking service</strong> — create and manage appointments, show
availability and sign you in.
<em> Basis:</em> performing our contract with you. <em> Basis:</em> performing our contract with you.
</li> </li>
<li> <li>
<strong>To send booking messages</strong> — confirmations, reminders, changes and cancellations by email, push, or a <strong>To send booking messages</strong> — confirmations, reminders, changes and
WhatsApp message from the Business. <em>Basis:</em> performing the contract, and legitimate interest in reducing missed cancellations by email, push, or a WhatsApp message from the Business. <em>Basis:</em>{" "}
appointments. performing the contract, and legitimate interest in reducing missed appointments.
</li> </li>
<li> <li>
<strong>To keep the Platform safe</strong> — prevent abuse, fraud and unauthorised access, and fix errors.{" "} <strong>To keep the Platform safe</strong> — prevent abuse, fraud and unauthorised
<em>Basis:</em> legitimate interest. access, and fix errors. <em>Basis:</em> legitimate interest.
</li> </li>
<li> <li>
<strong>To meet legal obligations</strong> — such as accounting, or responding to lawful requests. <em>Basis:</em> legal <strong>To meet legal obligations</strong> — such as accounting, or responding to lawful
obligation. requests. <em>Basis:</em> legal obligation.
</li> </li>
<li> <li>
<strong>Optional features</strong> — such as push notifications or Google Calendar sync. <em>Basis:</em> your consent, <strong>Optional features</strong> — such as push notifications or Google Calendar sync.{" "}
which you can withdraw at any time. <em>Basis:</em> your consent, which you can withdraw at any time.
</li> </li>
</ul> </ul>
), ),
@@ -105,19 +120,21 @@ const sections: LegalSectionDef[] = [
<> <>
<ul> <ul>
<li> <li>
<strong>The Business you book with</strong>, and its Staff, who need your details to serve you. <strong>The Business you book with</strong>, and its Staff, who need your details to
serve you.
</li> </li>
<li> <li>
<strong>Service providers</strong> that run the Platform for us, such as hosting, email delivery and push-notification <strong>Service providers</strong> that run the Platform for us, such as hosting,
services, bound to use the data only for that purpose. email delivery and push-notification services, bound to use the data only for that
purpose.
</li> </li>
<li> <li>
<strong>Google</strong>, if a Staff member or owner connects Google Calendar — appointment details are written to their <strong>Google</strong>, if a Staff member or owner connects Google Calendar —
calendar. appointment details are written to their calendar.
</li> </li>
<li> <li>
<strong>WhatsApp</strong>, if a Business messages you there — your phone number and the message are handled by WhatsApp <strong>WhatsApp</strong>, if a Business messages you there — your phone number and
under its own terms. the message are handled by WhatsApp under its own terms.
</li> </li>
<li> <li>
<strong>Authorities</strong>, where the law requires it. <strong>Authorities</strong>, where the law requires it.
@@ -135,15 +152,18 @@ const sections: LegalSectionDef[] = [
<p>We use only what the Platform needs to work:</p> <p>We use only what the Platform needs to work:</p>
<ul> <ul>
<li> <li>
<strong>Session cookie</strong> — keeps you signed in. Without it you could not use your account. <strong>Session cookie</strong> — keeps you signed in. Without it you could not use
your account.
</li> </li>
<li> <li>
<strong>Local storage and the app cache</strong> — remember small settings and let the installable app load offline. <strong>Local storage and the app cache</strong> — remember small settings and let the
installable app load offline.
</li> </li>
</ul> </ul>
<p> <p>
These are strictly necessary and do not need consent. We do not use advertising or tracking cookies. If we ever add These are strictly necessary and do not need consent. We do not use advertising or
analytics, we will update this policy and ask for your consent where the law requires it. tracking cookies. If we ever add analytics, we will update this policy and ask for your
consent where the law requires it.
</p> </p>
</> </>
), ),
@@ -154,13 +174,13 @@ const sections: LegalSectionDef[] = [
body: ( body: (
<> <>
<p> <p>
We keep your account and booking data while your account is active and the Business needs it to serve you. Sign-in codes We keep your account and booking data while your account is active and the Business
expire within minutes and are cleared soon after. needs it to serve you. Sign-in codes expire within minutes and are cleared soon after.
</p> </p>
<p> <p>
When you ask us to delete your account we remove or anonymise your data, except what we must keep by law (for example, When you ask us to delete your account we remove or anonymise your data, except what we
records needed for accounting or to resolve a dispute). A Business may keep its own records of past appointments must keep by law (for example, records needed for accounting or to resolve a dispute). A
according to its legal duties. Business may keep its own records of past appointments according to its legal duties.
</p> </p>
</> </>
), ),
@@ -170,9 +190,9 @@ const sections: LegalSectionDef[] = [
title: "Security", title: "Security",
body: ( body: (
<p> <p>
We protect your data with encrypted connections, access controls limited to what each role needs, and hashed sign-in codes. We protect your data with encrypted connections, access controls limited to what each role
No system is perfectly secure; if a breach affecting you occurs we will notify you and the relevant authority as the law needs, and hashed sign-in codes. No system is perfectly secure; if a breach affecting you
requires. occurs we will notify you and the relevant authority as the law requires.
</p> </p>
), ),
}, },
@@ -181,9 +201,9 @@ const sections: LegalSectionDef[] = [
title: "International transfers", title: "International transfers",
body: ( body: (
<p> <p>
Some of our providers (for example Google or WhatsApp, when you use those features) may process data outside your Some of our providers (for example Google or WhatsApp, when you use those features) may
country. Where they do, we rely on appropriate safeguards such as standard contractual clauses, as required by applicable process data outside your country. Where they do, we rely on appropriate safeguards such
data protection law. as standard contractual clauses, as required by applicable data protection law.
</p> </p>
), ),
}, },
@@ -192,19 +212,23 @@ const sections: LegalSectionDef[] = [
title: "Your rights", title: "Your rights",
body: ( body: (
<> <>
<p>Depending on where you live (including under the GDPR and equivalent laws), you can:</p> <p>
Depending on where you live (including under the GDPR and equivalent laws), you can:
</p>
<ul> <ul>
<li>access the personal data we hold about you;</li> <li>access the personal data we hold about you;</li>
<li>correct data that is wrong or incomplete;</li> <li>correct data that is wrong or incomplete;</li>
<li>ask us to delete your data;</li> <li>ask us to delete your data;</li>
<li>restrict or object to certain uses, including those based on legitimate interest;</li> <li>
restrict or object to certain uses, including those based on legitimate interest;
</li>
<li>receive your data in a portable format;</li> <li>receive your data in a portable format;</li>
<li>withdraw consent at any time, where we rely on it.</li> <li>withdraw consent at any time, where we rely on it.</li>
</ul> </ul>
<p> <p>
Email <a href={`mailto:${LEGAL.contactEmail}`}>{LEGAL.contactEmail}</a> to exercise any of these. For data a Business Email <a href={`mailto:${email}`}>{email}</a> to exercise any of these. For data a
controls, we may pass your request to that Business. We will reply within one month. You also have the right to complain Business controls, we may pass your request to that Business. We will reply within one
to your local data protection authority. month. You also have the right to complain to your local data protection authority.
</p> </p>
</> </>
), ),
@@ -214,8 +238,8 @@ const sections: LegalSectionDef[] = [
title: "Children", title: "Children",
body: ( body: (
<p> <p>
The Platform is not directed at children under 16, and we do not knowingly collect their data. If you believe a child has The Platform is not directed at children under 16, and we do not knowingly collect their
given us personal data, contact us and we will delete it. data. If you believe a child has given us personal data, contact us and we will delete it.
</p> </p>
), ),
}, },
@@ -224,24 +248,28 @@ const sections: LegalSectionDef[] = [
title: "Changes to this policy", title: "Changes to this policy",
body: ( body: (
<p> <p>
We will post any update here with a new &ldquo;last updated&rdquo; date and tell you about significant changes through the We will post any update here with a new &ldquo;last updated&rdquo; date and tell you about
Platform or by email. See also our <Link href="/terms">Terms of Service</Link>. significant changes through the Platform or by email. See also our{" "}
<Link href="/terms">Terms of Service</Link>.
</p> </p>
), ),
}, },
]; ];
}
export default function PrivacyPage() { export default async function PrivacyPage() {
const { name, email } = await getLegalIdentity();
return ( return (
<LegalDocument <LegalDocument
title="Privacy Policy" title="Privacy Policy"
intro={ intro={
<p> <p>
This policy explains what personal data {LEGAL.productName} and the Businesses that use it collect when you book an This policy explains what personal data {name} and the Businesses that use the Platform
appointment, why, and the choices you have. We have tried to keep it plain and specific. collect when you book an appointment, why, and the choices you have. We have tried to keep
it plain and specific.
</p> </p>
} }
sections={sections} sections={buildSections({ name, email })}
/> />
); );
} }
+102 -90
View File
@@ -1,37 +1,42 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { LegalDocument, type LegalSectionDef } from "@/features/legal/components/legal-document"; import { LegalDocument, type LegalSectionDef } from "@/features/legal/components/legal-document";
import { LEGAL } from "@/features/legal/legal-config"; import { getLegalIdentity } from "@/features/legal/legal-identity";
import type { LegalIdentity } from "@/features/legal/resolve-legal-identity";
export const metadata: Metadata = { title: "Terms of Service" }; export const metadata: Metadata = { title: "Terms of Service" };
// Names the business per its Policies settings, so it must be read per request.
export const dynamic = "force-dynamic";
const sections: LegalSectionDef[] = [ function buildSections({ name, email }: LegalIdentity): LegalSectionDef[] {
return [
{ {
id: "definitions", id: "definitions",
title: "Definitions", title: "Definitions",
body: ( body: (
<ul> <ul>
<li> <li>
<strong>&ldquo;{LEGAL.productName}&rdquo;, &ldquo;we&rdquo;, &ldquo;us&rdquo;</strong> — {LEGAL.legalName}, {LEGAL.address}. <strong>&ldquo;we&rdquo;, &ldquo;us&rdquo;, &ldquo;our&rdquo;</strong> — {name}.
</li> </li>
<li> <li>
<strong>&ldquo;Platform&rdquo;</strong> — the {LEGAL.productName} website, web app and installable app, through which <strong>&ldquo;Platform&rdquo;</strong> — the website, web app and installable app
appointments are booked and managed. through which appointments are booked and managed.
</li> </li>
<li> <li>
<strong>&ldquo;Business&rdquo;</strong> — a salon, barbershop or other service provider that uses the Platform to take <strong>&ldquo;Business&rdquo;</strong> — a salon, barbershop or other service provider
bookings. that uses the Platform to take bookings.
</li> </li>
<li> <li>
<strong>&ldquo;Staff&rdquo;</strong> — people who work for a Business and use the Platform to manage schedules and <strong>&ldquo;Staff&rdquo;</strong> — people who work for a Business and use the
appointments. Platform to manage schedules and appointments.
</li> </li>
<li> <li>
<strong>&ldquo;Customer&rdquo;, &ldquo;you&rdquo;</strong> — anyone who books an appointment through the Platform, with or <strong>&ldquo;Customer&rdquo;, &ldquo;you&rdquo;</strong> — anyone who books an
without an account. appointment through the Platform, with or without an account.
</li> </li>
<li> <li>
<strong>&ldquo;Services&rdquo;</strong> — the haircuts, treatments and other services a Business offers. <strong>&ldquo;Services&rdquo;</strong> — the haircuts, treatments and other services a
Business offers.
</li> </li>
</ul> </ul>
), ),
@@ -42,29 +47,32 @@ const sections: LegalSectionDef[] = [
body: ( body: (
<> <>
<p> <p>
These Terms govern your use of the Platform. By creating an account, booking an appointment or otherwise using the These Terms govern your use of the Platform. By creating an account, booking an
Platform, you agree to them. If you do not agree, please do not use the Platform. appointment or otherwise using the Platform, you agree to them. If you do not agree,
please do not use the Platform.
</p> </p>
<p> <p>
You must be at least 16 years old to create an account or book on your own behalf. If you are younger, a parent or You must be at least 16 years old to create an account or book on your own behalf. If
guardian must book for you. you are younger, a parent or guardian must book for you.
</p> </p>
</> </>
), ),
}, },
{ {
id: "our-role", id: "our-role",
title: "What NextChair does — and doesn’t do", title: "What we do — and don’t do",
body: ( body: (
<> <>
<p> <p>
{LEGAL.productName} provides the software that connects Customers with Businesses: viewing services and availability, We provide the software that connects Customers with Businesses: viewing services and
booking and managing appointments, receiving reminders and, where a Business enables it, collecting loyalty points. availability, booking and managing appointments, receiving reminders and, where a
Business enables it, collecting loyalty points.
</p> </p>
<p> <p>
<strong>We are not the provider of the Services.</strong> Each appointment is an agreement between you and the Business. <strong>We are not the provider of the Services.</strong> Each appointment is an
The Business is responsible for the Services, their quality, their pricing, and any payment taken in person. We are an agreement between you and the Business. The Business is responsible for the Services,
intermediary and are not a party to that agreement. their quality, their pricing, and any payment taken in person. We are an intermediary
and are not a party to that agreement.
</p> </p>
</> </>
), ),
@@ -75,13 +83,22 @@ const sections: LegalSectionDef[] = [
body: ( body: (
<> <>
<p> <p>
You can book as a guest where a Business allows it, or create an account. We sign you in with a one-time code sent to You can book as a guest where a Business allows it, or create an account. We sign you in
your email address, so there is no password to remember — but you must keep access to that email address secure. with a one-time code sent to your email address, so there is no password to remember —
but you must keep access to that email address secure.
</p> </p>
<ul> <ul>
<li>Give accurate contact details so the Business can reach you about your appointment.</li> <li>
<li>You are responsible for activity under your account. Tell us promptly if you think it has been accessed without permission.</li> Give accurate contact details so the Business can reach you about your appointment.
<li>One person, one account. Do not share your account or create accounts for others without their knowledge.</li> </li>
<li>
You are responsible for activity under your account. Tell us promptly if you think it
has been accessed without permission.
</li>
<li>
One person, one account. Do not share your account or create accounts for others
without their knowledge.
</li>
</ul> </ul>
</> </>
), ),
@@ -92,20 +109,22 @@ const sections: LegalSectionDef[] = [
body: ( body: (
<> <>
<p> <p>
A booking is confirmed when the Platform shows it as confirmed or you receive a confirmation. Some Businesses approve A booking is confirmed when the Platform shows it as confirmed or you receive a
bookings manually; in that case the booking is pending until they accept it. confirmation. Some Businesses approve bookings manually; in that case the booking is
pending until they accept it.
</p> </p>
<ul> <ul>
<li> <li>
Each Business sets its own cancellation window, no-show policy and any deposits or fees. These are shown during booking Each Business sets its own cancellation window, no-show policy and any deposits or
and apply to your appointment. fees. These are shown during booking and apply to your appointment.
</li> </li>
<li> <li>
If you cannot attend, cancel or reschedule as early as you can, through the Platform or by contacting the Business. If you cannot attend, cancel or reschedule as early as you can, through the Platform
or by contacting the Business.
</li> </li>
<li> <li>
A Business may need to change or cancel an appointment (for example due to illness). They will contact you and offer a A Business may need to change or cancel an appointment (for example due to illness).
new time or a cancellation where possible. They will contact you and offer a new time or a cancellation where possible.
</li> </li>
</ul> </ul>
</> </>
@@ -116,9 +135,10 @@ const sections: LegalSectionDef[] = [
title: "Prices and payments", title: "Prices and payments",
body: ( body: (
<p> <p>
Prices are set by each Business and shown at booking. Unless the Platform explicitly says otherwise, payment is made to the Prices are set by each Business and shown at booking. Unless the Platform explicitly says
Business directly, at the appointment. We do not process payments for Services and are not responsible for refunds or otherwise, payment is made to the Business directly, at the appointment. We do not process
payment disputes with a Business. payments for Services and are not responsible for refunds or payment disputes with a
Business.
</p> </p>
), ),
}, },
@@ -128,12 +148,14 @@ const sections: LegalSectionDef[] = [
body: ( body: (
<> <>
<p> <p>
To reduce missed appointments, the Platform and the Business may send you booking confirmations and reminders by email, To reduce missed appointments, the Platform and the Business may send you booking
push notification, or a message the Business sends you on WhatsApp using the phone number you provided. confirmations and reminders by email, push notification, or a message the Business sends
you on WhatsApp using the phone number you provided.
</p> </p>
<p> <p>
These are service messages about your booking. You can turn off push notifications in your device or account settings. These are service messages about your booking. You can turn off push notifications in
Messages sent by a Business through WhatsApp are governed by WhatsApp’s own terms. your device or account settings. Messages sent by a Business through WhatsApp are
governed by WhatsApp’s own terms.
</p> </p>
</> </>
), ),
@@ -148,7 +170,10 @@ const sections: LegalSectionDef[] = [
<li>make false, spam or repeated bookings you do not intend to keep;</li> <li>make false, spam or repeated bookings you do not intend to keep;</li>
<li>harass, threaten or abuse Staff, Businesses or other Customers;</li> <li>harass, threaten or abuse Staff, Businesses or other Customers;</li>
<li>attempt to access accounts, data or systems that are not yours;</li> <li>attempt to access accounts, data or systems that are not yours;</li>
<li>interfere with the Platform, probe it for vulnerabilities without our permission, or use bots or scrapers on it;</li> <li>
interfere with the Platform, probe it for vulnerabilities without our permission, or
use bots or scrapers on it;
</li>
<li>use the Platform for anything unlawful.</li> <li>use the Platform for anything unlawful.</li>
</ul> </ul>
<p>We may suspend or close accounts that break these rules.</p> <p>We may suspend or close accounts that break these rules.</p>
@@ -160,9 +185,10 @@ const sections: LegalSectionDef[] = [
title: "Reviews and content", title: "Reviews and content",
body: ( body: (
<p> <p>
Where the Platform lets you leave feedback or upload content such as a profile picture, you keep ownership of it but give Where the Platform lets you leave feedback or upload content such as a profile picture,
the Business and us a non-exclusive, worldwide licence to display it on the Platform for as long as it is there. It must be you keep ownership of it but give the Business and us a non-exclusive, worldwide licence
honest, lawful and not infringe anyone’s rights. We may remove content that breaks these Terms. to display it on the Platform for as long as it is there. It must be honest, lawful and
not infringe anyone’s rights. We may remove content that breaks these Terms.
</p> </p>
), ),
}, },
@@ -171,8 +197,9 @@ const sections: LegalSectionDef[] = [
title: "Our intellectual property", title: "Our intellectual property",
body: ( body: (
<p> <p>
The Platform, including its software, design, name and logo, belongs to us or our licensors. You may use it for its The Platform, including its software, design, name and logo, belongs to us or our
intended purpose, but you may not copy, modify, resell or reverse-engineer it, except as the law allows. licensors. You may use it for its intended purpose, but you may not copy, modify, resell
or reverse-engineer it, except as the law allows.
</p> </p>
), ),
}, },
@@ -182,14 +209,16 @@ const sections: LegalSectionDef[] = [
body: ( body: (
<> <>
<p> <p>
We work to keep the Platform running and accurate, but it is provided &ldquo;as is&rdquo; and we cannot promise it will We work to keep the Platform running and accurate, but it is provided &ldquo;as
always be available or error-free. Maintenance and outages happen. is&rdquo; and we cannot promise it will always be available or error-free. Maintenance
and outages happen.
</p> </p>
<p> <p>
To the extent the law allows, we are not liable for the Services a Business provides, for a Business’s actions or To the extent the law allows, we are not liable for the Services a Business provides,
omissions, or for indirect or consequential losses. Nothing in these Terms limits liability that cannot be limited by law, for a Business’s actions or omissions, or for indirect or consequential losses. Nothing
including for death or personal injury caused by negligence, or for fraud. Your statutory consumer rights are not in these Terms limits liability that cannot be limited by law, including for death or
affected. personal injury caused by negligence, or for fraud. Your statutory consumer rights are
not affected.
</p> </p>
</> </>
), ),
@@ -199,7 +228,8 @@ const sections: LegalSectionDef[] = [
title: "Your personal data", title: "Your personal data",
body: ( body: (
<p> <p>
How we and Businesses handle your personal data is explained in our <Link href="/privacy">Privacy Policy</Link>. How we and Businesses handle your personal data is explained in our{" "}
<Link href="/privacy">Privacy Policy</Link>.
</p> </p>
), ),
}, },
@@ -209,11 +239,12 @@ const sections: LegalSectionDef[] = [
body: ( body: (
<> <>
<p> <p>
<strong>About a Service:</strong> raise it with the Business first — they are responsible for the Service. <strong>About a Service:</strong> raise it with the Business first — they are
responsible for the Service.
</p> </p>
<p> <p>
<strong>About the Platform:</strong> email <a href={`mailto:${LEGAL.contactEmail}`}>{LEGAL.contactEmail}</a> with what <strong>About the Platform:</strong> email <a href={`mailto:${email}`}>{email}</a> with
happened and, if relevant, the date and Business. We aim to reply within 14 days. what happened and, if relevant, the date and Business. We aim to reply within 14 days.
</p> </p>
</> </>
), ),
@@ -224,54 +255,35 @@ const sections: LegalSectionDef[] = [
body: ( body: (
<> <>
<p> <p>
We may update these Terms, for example when we add features or the law changes. We will post the new version here with a We may update these Terms, for example when we add features or the law changes. We will
new &ldquo;last updated&rdquo; date and, for significant changes, tell you through the Platform or by email. If you keep post the new version here with a new &ldquo;last updated&rdquo; date and, for
using the Platform after the change, you accept the updated Terms; if you do not agree, you can stop using it and delete significant changes, tell you through the Platform or by email. If you keep using the
your account. Platform after the change, you accept the updated Terms; if you do not agree, you can
stop using it and delete your account.
</p> </p>
<p> <p>
You can stop using the Platform at any time. We may suspend or end your access if you break these Terms or if we are You can stop using the Platform at any time. We may suspend or end your access if you
required to by law. break these Terms or if we are required to by law.
</p> </p>
</> </>
), ),
}, },
{
id: "law",
title: "Governing law",
body: (
<p>
These Terms are governed by the law of {LEGAL.governingLaw}. This does not remove any mandatory consumer protections you
have in the country where you live.
</p>
),
},
{
id: "contact",
title: "Contact us",
body: (
<p>
{LEGAL.legalName}
<br />
{LEGAL.address}
<br />
<a href={`mailto:${LEGAL.contactEmail}`}>{LEGAL.contactEmail}</a>
</p>
),
},
]; ];
}
export default function TermsPage() { export default async function TermsPage() {
const identity = await getLegalIdentity();
return ( return (
<LegalDocument <LegalDocument
title="Terms of Service" title="Terms of Service"
intro={ intro={
<p> <p>
Welcome to {LEGAL.productName}. These Terms explain the rules for using our booking platform, whether you book Welcome to {identity.name}. These Terms explain the rules for using our booking platform,
appointments as a Customer or work for a Business. Please read them — they are written to be readable. whether you book appointments as a Customer or work for a Business. Please read them —
they are written to be readable.
</p> </p>
} }
sections={sections} sections={buildSections(identity)}
/> />
); );
} }
@@ -56,6 +56,8 @@ export default async function OwnerBusinessDetailsPage() {
lateArrivalPolicy: business.settings?.lateArrivalPolicy ?? "", lateArrivalPolicy: business.settings?.lateArrivalPolicy ?? "",
bookingTerms: business.settings?.bookingTerms ?? "", bookingTerms: business.settings?.bookingTerms ?? "",
privacyPolicyUrl: business.settings?.privacyPolicyUrl ?? "", privacyPolicyUrl: business.settings?.privacyPolicyUrl ?? "",
legalNameDisplay: business.settings?.legalNameDisplay ?? "NEXTCHAIR",
legalContactEmail: business.settings?.legalContactEmail ?? "NEXTCHAIR",
}} }}
/> />
</SettingsPageShell> </SettingsPageShell>
@@ -117,6 +117,18 @@ export function SignInForm() {
Create one Create one
</Link> </Link>
</p> </p>
<p className="text-muted mt-6 text-center text-xs leading-relaxed">
By continuing, you agree to our{" "}
<Link href="/terms" className="underline">
Terms of Service
</Link>{" "}
and{" "}
<Link href="/privacy" className="underline">
Privacy Policy
</Link>
.
</p>
</> </>
)} )}
</div> </div>
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { LegalContactEmail, LegalNameDisplay } from "@/generated/prisma/enums";
import { LEGAL } from "../legal-config";
import { resolveLegalIdentity } from "../resolve-legal-identity";
const base = {
businessName: "Cut & Co",
businessEmail: "hello@cutandco.example",
nameDisplay: LegalNameDisplay.NEXTCHAIR,
contactEmail: LegalContactEmail.NEXTCHAIR,
};
describe("resolveLegalIdentity", () => {
it("defaults to NextChair's name and email", () => {
expect(resolveLegalIdentity(base)).toEqual({ name: "NextChair", email: LEGAL.contactEmail });
});
it("can show the business name instead of NextChair", () => {
expect(resolveLegalIdentity({ ...base, nameDisplay: LegalNameDisplay.BUSINESS }).name).toBe(
"Cut & Co",
);
});
it("can show both names together", () => {
expect(resolveLegalIdentity({ ...base, nameDisplay: LegalNameDisplay.BOTH }).name).toBe(
"NextChair and Cut & Co",
);
});
it("can show the business email instead of NextChair's", () => {
expect(resolveLegalIdentity({ ...base, contactEmail: LegalContactEmail.BUSINESS }).email).toBe(
"hello@cutandco.example",
);
});
it("falls back to NextChair when the business has no name or email to show", () => {
expect(
resolveLegalIdentity({
businessName: null,
businessEmail: " ",
nameDisplay: LegalNameDisplay.BOTH,
contactEmail: LegalContactEmail.BUSINESS,
}),
).toEqual({ name: "NextChair", email: LEGAL.contactEmail });
});
});
@@ -27,7 +27,7 @@ export function LegalDocument({
<p className="text-muted mt-2 text-sm">Last updated {LEGAL.lastUpdated}</p> <p className="text-muted mt-2 text-sm">Last updated {LEGAL.lastUpdated}</p>
<div className="text-ink mt-6 space-y-3 leading-relaxed">{intro}</div> <div className="text-ink mt-6 space-y-3 leading-relaxed">{intro}</div>
<nav aria-label="Contents" className="border-line mt-8 rounded-tile border bg-white p-5"> <nav aria-label="Contents" className="border-line rounded-tile mt-8 border bg-white p-5">
<p className="text-ink mb-3 text-sm font-bold">Contents</p> <p className="text-ink mb-3 text-sm font-bold">Contents</p>
<ol className="text-muted grid gap-x-8 gap-y-1.5 text-sm sm:grid-cols-2"> <ol className="text-muted grid gap-x-8 gap-y-1.5 text-sm sm:grid-cols-2">
{sections.map((section, i) => ( {sections.map((section, i) => (
@@ -46,7 +46,7 @@ export function LegalDocument({
<h2 className="text-ink mb-3 text-xl font-bold tracking-tight"> <h2 className="text-ink mb-3 text-xl font-bold tracking-tight">
{i + 1}. {section.title} {i + 1}. {section.title}
</h2> </h2>
<div className="text-ink space-y-3 leading-relaxed [&_a]:text-accent-foreground [&_a]:underline [&_li]:pl-1 [&_ul]:list-disc [&_ul]:space-y-1.5 [&_ul]:pl-6"> <div className="text-ink [&_a]:text-accent-foreground space-y-3 leading-relaxed [&_a]:underline [&_li]:pl-1 [&_ul]:list-disc [&_ul]:space-y-1.5 [&_ul]:pl-6">
{section.body} {section.body}
</div> </div>
</section> </section>
+4 -12
View File
@@ -1,19 +1,11 @@
/** /**
* Single source of truth for the facts the Terms of Service and Privacy * Platform-level facts the Terms of Service and Privacy Policy state. Which
* Policy pages state about the operator. Every value marked TODO is a * of these a business actually shows is controlled per business in
* placeholder — fill it in (and have the documents reviewed by a lawyer) * Manage Business → Policies — see legal-identity.ts.
* before launch.
*/ */
export const LEGAL = { export const LEGAL = {
productName: "NextChair", productName: "NextChair",
/** TODO: registered legal entity name. */ contactEmail: "info@next-chair.co.uk",
legalName: "NextChair [LEGAL ENTITY NAME]",
/** TODO: registered address. */
address: "[REGISTERED ADDRESS]",
/** TODO: monitored inbox for legal and privacy requests. */
contactEmail: "legal@nextchair.example",
/** TODO: jurisdiction whose law governs the Terms and whose courts hear disputes. */
governingLaw: "[COUNTRY]",
/** Bump whenever either document changes materially. */ /** Bump whenever either document changes materially. */
lastUpdated: "17 October 2026", lastUpdated: "17 October 2026",
} as const; } as const;
+31
View File
@@ -0,0 +1,31 @@
import { cache } from "react";
import { LegalContactEmail, LegalNameDisplay } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db/prisma";
import { LEGAL } from "./legal-config";
import { resolveLegalIdentity, type LegalIdentity } from "./resolve-legal-identity";
/**
* Identity for the public legal pages. Never throws: before installation
* (no business row) or with an unreachable database it falls back to the
* NextChair defaults, the same way getBranding() does.
*/
export const getLegalIdentity = cache(async (): Promise<LegalIdentity> => {
try {
const business = await prisma.business.findFirst({
orderBy: { createdAt: "asc" },
select: {
name: true,
email: true,
settings: { select: { legalNameDisplay: true, legalContactEmail: true } },
},
});
return resolveLegalIdentity({
businessName: business?.name ?? null,
businessEmail: business?.email ?? null,
nameDisplay: business?.settings?.legalNameDisplay ?? LegalNameDisplay.NEXTCHAIR,
contactEmail: business?.settings?.legalContactEmail ?? LegalContactEmail.NEXTCHAIR,
});
} catch {
return { name: LEGAL.productName, email: LEGAL.contactEmail };
}
});
@@ -0,0 +1,33 @@
import { LegalContactEmail, LegalNameDisplay } from "@/generated/prisma/enums";
import { LEGAL } from "./legal-config";
export interface LegalIdentity {
/** The party named as "we" — NextChair, the business, or both. */
name: string;
/** Where privacy and legal requests go. */
email: string;
}
/** Pure so the Policies form and the pages agree on one set of rules. */
export function resolveLegalIdentity(input: {
businessName: string | null;
businessEmail: string | null;
nameDisplay: LegalNameDisplay;
contactEmail: LegalContactEmail;
}): LegalIdentity {
const businessName = input.businessName?.trim() || null;
const businessEmail = input.businessEmail?.trim() || null;
let name: string = LEGAL.productName;
if (businessName) {
if (input.nameDisplay === LegalNameDisplay.BUSINESS) name = businessName;
else if (input.nameDisplay === LegalNameDisplay.BOTH)
name = `${LEGAL.productName} and ${businessName}`;
}
const email =
input.contactEmail === LegalContactEmail.BUSINESS && businessEmail
? businessEmail
: LEGAL.contactEmail;
return { name, email };
}
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select"; import { Select } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { LegalContactEmail, LegalNameDisplay } from "@/generated/prisma/enums";
import { MediaPicker } from "@/features/media/components/media-picker"; import { MediaPicker } from "@/features/media/components/media-picker";
import { updateBusinessProfileAction, updateBusinessSettingsAction } from "../actions/business"; import { updateBusinessProfileAction, updateBusinessSettingsAction } from "../actions/business";
@@ -39,6 +40,8 @@ export interface BusinessDetailsInitial {
lateArrivalPolicy: string; lateArrivalPolicy: string;
bookingTerms: string; bookingTerms: string;
privacyPolicyUrl: string; privacyPolicyUrl: string;
legalNameDisplay: LegalNameDisplay;
legalContactEmail: LegalContactEmail;
} }
export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsInitial }) { export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsInitial }) {
@@ -93,6 +96,8 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
lateArrivalPolicy: form.lateArrivalPolicy || undefined, lateArrivalPolicy: form.lateArrivalPolicy || undefined,
bookingTerms: form.bookingTerms || undefined, bookingTerms: form.bookingTerms || undefined,
privacyPolicyUrl: form.privacyPolicyUrl || undefined, privacyPolicyUrl: form.privacyPolicyUrl || undefined,
legalNameDisplay: form.legalNameDisplay,
legalContactEmail: form.legalContactEmail,
}); });
if (!settingsResult.success) { if (!settingsResult.success) {
setError(settingsResult.error ?? "Couldn't save business policies."); setError(settingsResult.error ?? "Couldn't save business policies.");
@@ -227,6 +232,41 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
value={form.privacyPolicyUrl} value={form.privacyPolicyUrl}
onChange={(e) => set("privacyPolicyUrl", e.target.value)} onChange={(e) => set("privacyPolicyUrl", e.target.value)}
/> />
<div className="border-line space-y-4 border-t pt-4">
<p className="text-muted ml-1 text-xs leading-relaxed">
Your site&apos;s{" "}
<a href="/terms" target="_blank" rel="noreferrer" className="underline">
Terms of Service
</a>{" "}
and{" "}
<a href="/privacy" target="_blank" rel="noreferrer" className="underline">
Privacy Policy
</a>{" "}
pages say who is responsible and where to send requests. Choose what they show.
</p>
<Select
label="Name shown on Terms & Privacy"
value={form.legalNameDisplay}
onChange={(e) => set("legalNameDisplay", e.target.value as LegalNameDisplay)}
>
<option value={LegalNameDisplay.NEXTCHAIR}>NextChair</option>
<option value={LegalNameDisplay.BUSINESS}>My business name</option>
<option value={LegalNameDisplay.BOTH}>NextChair and my business name</option>
</Select>
<Select
label="Contact email on Terms & Privacy"
value={form.legalContactEmail}
onChange={(e) => set("legalContactEmail", e.target.value as LegalContactEmail)}
>
<option value={LegalContactEmail.NEXTCHAIR}>NextChair (info@next-chair.co.uk)</option>
<option value={LegalContactEmail.BUSINESS}>My business email</option>
</Select>
{form.legalContactEmail === LegalContactEmail.BUSINESS && !form.email.trim() && (
<p className="text-warning-foreground ml-1 text-xs">
Add an Email under Contact &amp; Address — until then the NextChair address is shown.
</p>
)}
</div>
</div> </div>
</section> </section>
</div> </div>
@@ -1,6 +1,7 @@
import { Prisma } from "@/generated/prisma/client"; import { Prisma } from "@/generated/prisma/client";
import type { PrismaClient } from "@/generated/prisma/client"; import type { PrismaClient } from "@/generated/prisma/client";
import { Weekday } from "@/generated/prisma/enums"; import { Weekday } from "@/generated/prisma/enums";
import type { LegalContactEmail, LegalNameDisplay } from "@/generated/prisma/enums";
import { calendarDateOf, minutesFromMidnight, weekdayOf } from "@/lib/time/business-time"; import { calendarDateOf, minutesFromMidnight, weekdayOf } from "@/lib/time/business-time";
import { fail, ok } from "@/lib/validation-result"; import { fail, ok } from "@/lib/validation-result";
import type { ValidationResult } from "@/lib/validation-result"; import type { ValidationResult } from "@/lib/validation-result";
@@ -65,6 +66,10 @@ export interface BusinessSettingsInput {
lateArrivalPolicy?: string; lateArrivalPolicy?: string;
bookingTerms?: string; bookingTerms?: string;
privacyPolicyUrl?: string; privacyPolicyUrl?: string;
/** Who the public Terms/Privacy pages name as "we" — see src/features/legal/legal-identity.ts. */
legalNameDisplay?: LegalNameDisplay;
/** Whose inbox those pages give for legal/privacy requests. */
legalContactEmail?: LegalContactEmail;
/** Reviews visibility policy — see src/services/reviews. Applied on top of each Review's own isHidden flag. */ /** Reviews visibility policy — see src/services/reviews. Applied on top of each Review's own isHidden flag. */
reviewsMinRating?: number | null; reviewsMinRating?: number | null;
reviewsExcludeRatingOnly?: boolean; reviewsExcludeRatingOnly?: boolean;