From 05ea83e05936c775fda7b91d95f9500c4b9be2f8 Mon Sep 17 00:00:00 2001 From: Stefan Lazic Date: Sat, 17 Oct 2026 09:35:52 +0100 Subject: [PATCH] Fixes fixed super admin showing in client list added a reconnect button to google calendar syncying fixed ui issue with whatsapp undo link --- src/app/(legal)/layout.tsx | 38 +++ src/app/(legal)/privacy/page.tsx | 247 ++++++++++++++++ src/app/(legal)/terms/page.tsx | 277 ++++++++++++++++++ src/app/(staff)/staff/clients/[id]/page.tsx | 22 +- src/app/(staff)/staff/clients/page.tsx | 32 +- .../(staff)/staff/settings/calendar/page.tsx | 8 +- .../auth/components/register-form.tsx | 8 +- src/features/calendar/actions/calendar.ts | 9 +- .../components/calendar-connection-panel.tsx | 21 +- .../legal/components/legal-document.tsx | 57 ++++ src/features/legal/legal-config.ts | 19 ++ .../staff/components/whatsapp-button.tsx | 12 +- .../staff/components/whatsapp-send-link.tsx | 109 ++++--- src/proxy.ts | 2 + .../calendar-sync.service.integration.test.ts | 80 +++++ .../google-calendar.provider.test.ts | 93 ++++++ .../calendar/calendar-sync.service.ts | 40 ++- .../calendar/google-calendar.provider.ts | 63 +++- src/services/calendar/types.ts | 10 +- .../client-directory.integration.test.ts | 107 +++++++ src/services/customer/client-directory.ts | 44 +++ 21 files changed, 1180 insertions(+), 118 deletions(-) create mode 100644 src/app/(legal)/layout.tsx create mode 100644 src/app/(legal)/privacy/page.tsx create mode 100644 src/app/(legal)/terms/page.tsx create mode 100644 src/features/legal/components/legal-document.tsx create mode 100644 src/features/legal/legal-config.ts create mode 100644 src/services/calendar/__tests__/google-calendar.provider.test.ts create mode 100644 src/services/customer/__tests__/client-directory.integration.test.ts create mode 100644 src/services/customer/client-directory.ts diff --git a/src/app/(legal)/layout.tsx b/src/app/(legal)/layout.tsx new file mode 100644 index 0000000..84d9b85 --- /dev/null +++ b/src/app/(legal)/layout.tsx @@ -0,0 +1,38 @@ +import { ArrowLeft } from "lucide-react"; +import Link from "next/link"; +import { LEGAL } from "@/features/legal/legal-config"; + +/** + * Shell for the public legal pages (/terms, /privacy). Deliberately static + * and independent of any business's branding — these documents are the + * platform operator's, and must stay readable by guests, signed-in users + * and (via the proxy's install-exempt list) before installation completes. + */ +export default function LegalLayout({ children }: { children: React.ReactNode }) { + return ( +
+
+
+ +
+
+
+ {children} +
+
+ © {new Date().getFullYear()} {LEGAL.legalName} +
+
+ ); +} diff --git a/src/app/(legal)/privacy/page.tsx b/src/app/(legal)/privacy/page.tsx new file mode 100644 index 0000000..536341d --- /dev/null +++ b/src/app/(legal)/privacy/page.tsx @@ -0,0 +1,247 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { LegalDocument, type LegalSectionDef } from "@/features/legal/components/legal-document"; +import { LEGAL } from "@/features/legal/legal-config"; + +export const metadata: Metadata = { title: "Privacy Policy" }; + +const sections: LegalSectionDef[] = [ + { + id: "who-we-are", + title: "Who is responsible for your data", + body: ( + <> +

+ {LEGAL.productName} is a booking platform used by many independent Businesses. Two parties handle your data, in + different roles: +

+ +

+ Contact us about privacy at {LEGAL.contactEmail}. +

+ + ), + }, + { + id: "data-we-collect", + title: "What we collect", + body: ( + <> +

+ You give us: +

+ +

+ Created while you use the Platform: +

+ +

+ For Staff and owners: work schedule, services offered and, if connected, the Google Calendar account used + to sync appointments. +

+

+ Clients added by a Business: a Business may add or import its existing clients (name, contact details, + history) into the Platform. In that case the Business is responsible for having the right to do so and for telling those + people. +

+ + ), + }, + { + id: "why", + title: "How and why we use it", + body: ( + + ), + }, + { + id: "sharing", + title: "Who we share it with", + body: ( + <> + +

We do not sell your personal data and we do not use it for third-party advertising.

+ + ), + }, + { + id: "cookies", + title: "Cookies and similar technologies", + body: ( + <> +

We use only what the Platform needs to work:

+ +

+ These are strictly necessary and do not need consent. We do not use advertising or tracking cookies. If we ever add + analytics, we will update this policy and ask for your consent where the law requires it. +

+ + ), + }, + { + id: "retention", + title: "How long we keep it", + body: ( + <> +

+ We keep your account and booking data while your account is active and the Business needs it to serve you. Sign-in codes + expire within minutes and are cleared soon after. +

+

+ When you ask us to delete your account we remove or anonymise your data, except what we must keep by law (for example, + records needed for accounting or to resolve a dispute). A Business may keep its own records of past appointments + according to its legal duties. +

+ + ), + }, + { + id: "security", + title: "Security", + body: ( +

+ We protect your data with encrypted connections, access controls limited to what each role needs, and hashed sign-in codes. + No system is perfectly secure; if a breach affecting you occurs we will notify you and the relevant authority as the law + requires. +

+ ), + }, + { + id: "transfers", + title: "International transfers", + body: ( +

+ Some of our providers (for example Google or WhatsApp, when you use those features) may process data outside your + country. Where they do, we rely on appropriate safeguards such as standard contractual clauses, as required by applicable + data protection law. +

+ ), + }, + { + id: "rights", + title: "Your rights", + body: ( + <> +

Depending on where you live (including under the GDPR and equivalent laws), you can:

+ +

+ Email {LEGAL.contactEmail} to exercise any of these. For data a Business + controls, we may pass your request to that Business. We will reply within one month. You also have the right to complain + to your local data protection authority. +

+ + ), + }, + { + id: "children", + title: "Children", + body: ( +

+ The Platform is not directed at children under 16, and we do not knowingly collect their data. If you believe a child has + given us personal data, contact us and we will delete it. +

+ ), + }, + { + id: "changes", + title: "Changes to this policy", + body: ( +

+ We will post any update here with a new “last updated” date and tell you about significant changes through the + Platform or by email. See also our Terms of Service. +

+ ), + }, +]; + +export default function PrivacyPage() { + return ( + + This policy explains what personal data {LEGAL.productName} and the Businesses that use it collect when you book an + appointment, why, and the choices you have. We have tried to keep it plain and specific. +

+ } + sections={sections} + /> + ); +} diff --git a/src/app/(legal)/terms/page.tsx b/src/app/(legal)/terms/page.tsx new file mode 100644 index 0000000..92ceef0 --- /dev/null +++ b/src/app/(legal)/terms/page.tsx @@ -0,0 +1,277 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { LegalDocument, type LegalSectionDef } from "@/features/legal/components/legal-document"; +import { LEGAL } from "@/features/legal/legal-config"; + +export const metadata: Metadata = { title: "Terms of Service" }; + +const sections: LegalSectionDef[] = [ + { + id: "definitions", + title: "Definitions", + body: ( + + ), + }, + { + id: "about", + title: "About these Terms", + body: ( + <> +

+ These Terms govern your use of the Platform. By creating an account, booking an appointment or otherwise using the + Platform, you agree to them. If you do not agree, please do not use the Platform. +

+

+ 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 + guardian must book for you. +

+ + ), + }, + { + id: "our-role", + title: "What NextChair does — and doesn’t do", + body: ( + <> +

+ {LEGAL.productName} provides the software that connects Customers with Businesses: viewing services and availability, + booking and managing appointments, receiving reminders and, where a Business enables it, collecting loyalty points. +

+

+ We are not the provider of the Services. Each appointment is an agreement between you and the Business. + The Business is responsible for the Services, their quality, their pricing, and any payment taken in person. We are an + intermediary and are not a party to that agreement. +

+ + ), + }, + { + id: "accounts", + title: "Your account", + body: ( + <> +

+ 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 + your email address, so there is no password to remember — but you must keep access to that email address secure. +

+ + + ), + }, + { + id: "bookings", + title: "Bookings, changes and cancellations", + body: ( + <> +

+ A booking is confirmed when the Platform shows it as confirmed or you receive a confirmation. Some Businesses approve + bookings manually; in that case the booking is pending until they accept it. +

+ + + ), + }, + { + id: "payments", + title: "Prices and payments", + body: ( +

+ Prices are set by each Business and shown at booking. Unless the Platform explicitly says otherwise, payment is made to the + Business directly, at the appointment. We do not process payments for Services and are not responsible for refunds or + payment disputes with a Business. +

+ ), + }, + { + id: "reminders", + title: "Reminders and notifications", + body: ( + <> +

+ To reduce missed appointments, the Platform and the Business may send you booking confirmations and reminders by email, + push notification, or a message the Business sends you on WhatsApp using the phone number you provided. +

+

+ These are service messages about your booking. You can turn off push notifications in your device or account settings. + Messages sent by a Business through WhatsApp are governed by WhatsApp’s own terms. +

+ + ), + }, + { + id: "acceptable-use", + title: "Acceptable use", + body: ( + <> +

You agree not to:

+ +

We may suspend or close accounts that break these rules.

+ + ), + }, + { + id: "reviews", + title: "Reviews and content", + body: ( +

+ Where the Platform lets you leave feedback or upload content such as a profile picture, you keep ownership of it but give + 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 + honest, lawful and not infringe anyone’s rights. We may remove content that breaks these Terms. +

+ ), + }, + { + id: "ip", + title: "Our intellectual property", + body: ( +

+ The Platform, including its software, design, name and logo, belongs to us or our 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. +

+ ), + }, + { + id: "liability", + title: "Availability and liability", + body: ( + <> +

+ We work to keep the Platform running and accurate, but it is provided “as is” and we cannot promise it will + always be available or error-free. Maintenance and outages happen. +

+

+ To the extent the law allows, we are not liable for the Services a Business provides, for a Business’s actions or + omissions, or for indirect or consequential losses. Nothing in these Terms limits liability that cannot be limited by law, + including for death or personal injury caused by negligence, or for fraud. Your statutory consumer rights are not + affected. +

+ + ), + }, + { + id: "privacy", + title: "Your personal data", + body: ( +

+ How we and Businesses handle your personal data is explained in our Privacy Policy. +

+ ), + }, + { + id: "complaints", + title: "Complaints", + body: ( + <> +

+ About a Service: raise it with the Business first — they are responsible for the Service. +

+

+ About the Platform: email {LEGAL.contactEmail} with what + happened and, if relevant, the date and Business. We aim to reply within 14 days. +

+ + ), + }, + { + id: "changes", + title: "Changes and termination", + body: ( + <> +

+ We may update these Terms, for example when we add features or the law changes. We will post the new version here with a + new “last updated” date and, for significant changes, tell you through the Platform or by email. If you keep + using the Platform after the change, you accept the updated Terms; if you do not agree, you can stop using it and delete + your account. +

+

+ 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 + required to by law. +

+ + ), + }, + { + id: "law", + title: "Governing law", + body: ( +

+ 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. +

+ ), + }, + { + id: "contact", + title: "Contact us", + body: ( +

+ {LEGAL.legalName} +
+ {LEGAL.address} +
+ {LEGAL.contactEmail} +

+ ), + }, +]; + +export default function TermsPage() { + return ( + + Welcome to {LEGAL.productName}. These Terms explain the rules for using our booking platform, whether you book + appointments as a Customer or work for a Business. Please read them — they are written to be readable. +

+ } + sections={sections} + /> + ); +} diff --git a/src/app/(staff)/staff/clients/[id]/page.tsx b/src/app/(staff)/staff/clients/[id]/page.tsx index b1b2f14..324dae7 100644 --- a/src/app/(staff)/staff/clients/[id]/page.tsx +++ b/src/app/(staff)/staff/clients/[id]/page.tsx @@ -11,6 +11,7 @@ import { EmptyState } from "@/components/feedback/empty-state"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { TopNav } from "@/components/navigation/top-nav"; import { prisma } from "@/lib/db/prisma"; +import { clientDirectoryWhere } from "@/services/customer/client-directory"; import { getActingBusinessContext } from "@/lib/business/get-acting-business-context"; import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service"; import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold"; @@ -52,19 +53,12 @@ export default async function StaffCustomerDetailsPage({ }); if (!customer) notFound(); - // "Belongs to this business" mirrors the Clients list query exactly - // (see (staff)/staff/clients/page.tsx) — either a real appointment here, - // or a registered client with no appointment anywhere yet (added via - // /staff/clients/add, or self-registered but never booked). Without the - // second branch, a client visible in the list because of that same - // broadening would 404 the moment staff actually clicked into them — - // confirmed live, this exact gap. - const [appointmentsHereCount, totalAppointmentCount] = await Promise.all([ - prisma.appointment.count({ where: { customerId: id, businessId: context.businessId } }), - prisma.appointment.count({ where: { customerId: id } }), - ]); - const belongsToBusiness = appointmentsHereCount > 0 || (!customer.isGuest && totalAppointmentCount === 0); - if (!belongsToBusiness) notFound(); + // The same rule the Clients list uses (clientDirectoryWhere), asked about this + // one person — so anyone the list shows opens, and anyone it hides (the + // Super Administrator, a barber with no bookings) 404s here too. + const inDirectory = + (await prisma.customerProfile.count({ where: { AND: [{ id }, clientDirectoryWhere(context.businessId)] } })) > 0; + if (!inDirectory) notFound(); const [upcoming, past, cancelled, notesRaw, loyalty] = await Promise.all([ listAppointments(prisma, { @@ -220,7 +214,7 @@ export default async function StaffCustomerDetailsPage({ : undefined } className="min-w-[104px] flex-1" - linkClassName="h-12 w-full justify-center rounded-md px-4 py-0" + buttonClassName="h-12 w-full rounded-md" /> ) : (

Google Calendar sync is not configured on this deployment yet.

diff --git a/src/features/legal/components/legal-document.tsx b/src/features/legal/components/legal-document.tsx new file mode 100644 index 0000000..4bccfa7 --- /dev/null +++ b/src/features/legal/components/legal-document.tsx @@ -0,0 +1,57 @@ +import type { ReactNode } from "react"; +import { LEGAL } from "../legal-config"; + +export interface LegalSectionDef { + id: string; + title: string; + body: ReactNode; +} + +/** + * Shared shell for the Terms of Service and Privacy Policy: title, last + * updated date, a jump-to table of contents, then numbered sections. Pure + * presentation — the wording lives in each page. + */ +export function LegalDocument({ + title, + intro, + sections, +}: { + title: string; + intro: ReactNode; + sections: LegalSectionDef[]; +}) { + return ( +
+

{title}

+

Last updated {LEGAL.lastUpdated}

+
{intro}
+ + + +
+ {sections.map((section, i) => ( +
+

+ {i + 1}. {section.title} +

+
+ {section.body} +
+
+ ))} +
+
+ ); +} diff --git a/src/features/legal/legal-config.ts b/src/features/legal/legal-config.ts new file mode 100644 index 0000000..b4a7985 --- /dev/null +++ b/src/features/legal/legal-config.ts @@ -0,0 +1,19 @@ +/** + * Single source of truth for the facts the Terms of Service and Privacy + * Policy pages state about the operator. Every value marked TODO is a + * placeholder — fill it in (and have the documents reviewed by a lawyer) + * before launch. + */ +export const LEGAL = { + productName: "NextChair", + /** TODO: registered legal entity name. */ + 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. */ + lastUpdated: "17 October 2026", +} as const; diff --git a/src/features/staff/components/whatsapp-button.tsx b/src/features/staff/components/whatsapp-button.tsx index e0161e2..6acfcc7 100644 --- a/src/features/staff/components/whatsapp-button.tsx +++ b/src/features/staff/components/whatsapp-button.tsx @@ -21,7 +21,7 @@ export async function WhatsAppButton({ customer, appointment, className, - linkClassName, + buttonClassName, }: { businessId: string; businessName: string; @@ -36,8 +36,8 @@ export async function WhatsAppButton({ }; /** Wrapper sizing (e.g. flex-1 in a row of buttons). */ className?: string; - /** The link's own shape (height, radius, width). */ - linkClassName?: string; + /** The button's own shape (height, radius, width). */ + buttonClassName?: string; }) { const href = reminderWhatsAppUrl({ country: await getBusinessCountry(businessId), @@ -57,7 +57,7 @@ export async function WhatsAppButton({ timezone={timezone} customerFirstName={customer.firstName} className={className} - linkClassName={linkClassName} + buttonClassName={buttonClassName} /> ); } @@ -69,9 +69,9 @@ export async function WhatsAppButton({ rel="noopener noreferrer" aria-label={`Message ${customer.firstName} on WhatsApp`} className={cn( - "border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold", + "border-line flex items-center justify-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold", className, - linkClassName, + buttonClassName, )} >