This commit is contained in:
Stefan Lazic committed 2026-08-04 09:57:58 +01:00
1 parent 546613e677
commit 4785510144
82 files changed
+4903 -287

No files matched your search

+27 -2
View File
@@ -1,11 +1,36 @@
import { Scissors } from "lucide-react";
import { getBranding } from "@/lib/business/branding";
export default function AuthLayout({ children }: { children: React.ReactNode }) {
/**
* Every auth page (sign-in, register, forgot-password, verify) shares
* this shell, so the business logo is shown once here rather than on
* each page — see the brief's "Authentication Branding" requirement.
* Unreachable pre-install: src/proxy.ts redirects auth routes to
* /install until a business exists, so `getBranding()` never falls back
* to neutral wording here in practice.
*
* Forced dynamic: without this, a production build can statically
* prerender /sign-in and /register with whatever branding (or the
* neutral fallback) happened to be in the database at *build* time,
* baked into static HTML served forever after — a real business's name
* would never actually reach these pages in that deployment. See
* docs/phase-8-summary.md, "Known limitations" for how this was found
* (a Docker build with no live database at build time).
*/
export const dynamic = "force-dynamic";
export default async function AuthLayout({ children }: { children: React.ReactNode }) {
const branding = await getBranding();
return (
<div className="flex min-h-screen flex-col bg-stone p-6">
<div id="main-content" className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center">
<div className="mb-8 flex h-16 w-16 items-center justify-center rounded-[1.25rem] bg-ink text-3xl text-white shadow-xl shadow-ink/20">
<Scissors className="h-7 w-7" aria-hidden="true" />
{branding.logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- external, business-supplied URL.
<img src={branding.logoUrl} alt="" className="h-full w-full rounded-[1.25rem] object-cover" />
) : (
<Scissors className="h-7 w-7" aria-hidden="true" />
)}
</div>
{children}
</div>
+10 -4
View File
@@ -1,16 +1,22 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { RegisterForm } from "@/features/auth/components/register-form";
import { getBranding } from "@/lib/business/branding";
export const metadata: Metadata = {
title: "Create Account",
};
export default function RegisterPage() {
export default async function RegisterPage() {
const branding = await getBranding();
return (
<div>
<h1 className="mb-2 text-3xl font-bold tracking-tight">Create Account</h1>
<p className="mb-10 text-muted">Join to track your appointments and loyalty.</p>
<RegisterForm />
<h1 className="mb-2 text-3xl font-bold tracking-tight">Join {branding.name}</h1>
<p className="mb-10 text-muted">Create an account to track your appointments and loyalty.</p>
{/* useSearchParams in RegisterForm requires a Suspense boundary. */}
<Suspense>
<RegisterForm />
</Suspense>
</div>
);
}
+4 -2
View File
@@ -1,15 +1,17 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { SignInForm } from "@/features/auth/components/sign-in-form";
import { getBranding } from "@/lib/business/branding";
export const metadata: Metadata = {
title: "Sign In",
};
export default function SignInPage() {
export default async function SignInPage() {
const branding = await getBranding();
return (
<div>
<h1 className="mb-2 text-4xl font-bold tracking-tight">Welcome back</h1>
<h1 className="mb-2 text-4xl font-bold tracking-tight">Welcome back to {branding.name}</h1>
<p className="mb-12 leading-relaxed text-muted">
Please enter your credentials to access your appointments.
</p>
+6 -4
View File
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/feedback/empty-state";
import { ProfileCard } from "@/components/ui/profile-card";
import { prisma } from "@/lib/db/prisma";
import { getBranding } from "@/lib/business/branding";
import { SignOutButton } from "@/features/auth/components/sign-out-button";
import { InstallAppButton } from "@/features/pwa/components/install-app-button";
@@ -33,9 +34,10 @@ export default async function CustomerSettingsPage() {
);
}
const customerProfile = await prisma.customerProfile.findUnique({
where: { userId: session.user.id },
});
const [customerProfile, branding] = await Promise.all([
prisma.customerProfile.findUnique({ where: { userId: session.user.id } }),
getBranding(),
]);
return (
<div className="mx-auto max-w-2xl space-y-6 px-6 pt-12">
@@ -65,7 +67,7 @@ export default async function CustomerSettingsPage() {
</div>
<div className="border-line rounded-jumbo border bg-white p-5">
<p className="mb-3 text-sm font-bold">Get the app</p>
<InstallAppButton />
<InstallAppButton businessName={branding.name} />
</div>
<SignOutButton />
</div>
+3 -1
View File
@@ -7,6 +7,7 @@ import { ProfileCard } from "@/components/ui/profile-card";
import { SignOutButton } from "@/features/auth/components/sign-out-button";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getBranding } from "@/lib/business/branding";
import { InstallAppButton } from "@/features/pwa/components/install-app-button";
export const metadata: Metadata = { title: "Owner Settings" };
@@ -26,6 +27,7 @@ export default async function OwnerSettingsPage() {
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManage) redirect("/staff");
const branding = await getBranding();
return (
<div className="space-y-6 px-6 pt-12 pb-12">
@@ -53,7 +55,7 @@ export default async function OwnerSettingsPage() {
<div className="rounded-jumbo border-line border bg-white p-5">
<p className="mb-3 text-sm font-bold">Get the app</p>
<InstallAppButton />
<InstallAppButton businessName={branding.name} />
</div>
<SignOutButton />
+20 -1
View File
@@ -26,10 +26,15 @@ export default async function OwnerStaffDetailPage({ params }: { params: Promise
if (!owner) redirect("/staff");
const { id } = await params;
const [target, roles, allServices] = await Promise.all([
const [target, roles, allServices, calendarConnection] = await Promise.all([
getStaffDetailById(prisma, { staffId: id, businessId: owner.businessId }),
listRolesForBusiness(prisma, owner.businessId),
prisma.service.findMany({ where: { businessId: owner.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
// Status only — never tokens (see the brief's "Owners must not see
// OAuth tokens, refresh tokens, or unrelated private calendar
// events"). Owners can see whether a barber has connected, not
// connect on their behalf.
prisma.staffCalendarConnection.findUnique({ where: { staffId: id }, select: { revokedAt: true, lastSyncError: true } }),
]);
if (!target) notFound();
@@ -72,6 +77,20 @@ export default async function OwnerStaffDetailPage({ params }: { params: Promise
</a>
)}
</div>
<Card className="p-4">
<p className="text-muted mb-1 text-[10px] font-bold tracking-widest uppercase">Google Calendar</p>
<p className="text-sm font-bold">
{!calendarConnection || calendarConnection.revokedAt
? "Not connected"
: calendarConnection.lastSyncError
? "Sync error"
: "Connected"}
</p>
{calendarConnection?.lastSyncError && !calendarConnection.revokedAt && (
<p className="text-danger mt-1 text-xs">{calendarConnection.lastSyncError}</p>
)}
</Card>
</div>
<StaffDetailPanel
@@ -0,0 +1,49 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getBranding } from "@/lib/business/branding";
import { CalendarConnectionPanel } from "@/features/calendar/components/calendar-connection-panel";
export const metadata: Metadata = { title: "Google Calendar" };
export const dynamic = "force-dynamic";
export default async function StaffCalendarSettingsPage() {
const session = await auth();
if (!session?.user?.id) redirect("/sign-in?callbackUrl=/staff/settings/calendar");
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!staffProfile) redirect("/home");
const [connection, branding] = await Promise.all([
prisma.staffCalendarConnection.findUnique({ where: { staffId: staffProfile.id } }),
getBranding(),
]);
const connected = !!connection && !connection.revokedAt;
const configured = !!process.env.GOOGLE_CALENDAR_CLIENT_ID && !!process.env.GOOGLE_CALENDAR_CLIENT_SECRET;
return (
<div className="space-y-6 px-6 pt-12 pb-12">
<TopNav title="Google Calendar" onBack sticky className="bg-stone" />
<p className="text-muted text-sm">
One-way sync: {branding.name} stays the source of truth, and your confirmed appointments are pushed to
Google Calendar. Nothing is ever imported back from Google.
</p>
<CalendarConnectionPanel
configured={configured}
initial={
connected
? {
connected: true,
accountEmail: connection.accountEmail,
selectedCalendarName: connection.selectedCalendarName,
lastSyncAt: connection.lastSyncAt?.toISOString(),
lastSyncError: connection.lastSyncError,
}
: { connected: false }
}
/>
</div>
);
}
+8 -3
View File
@@ -1,11 +1,12 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Bell, CalendarClock, CalendarOff, Clock, ShieldCheck, UserCircle } from "lucide-react";
import { Bell, CalendarClock, CalendarOff, CalendarSync, Clock, ShieldCheck, UserCircle } from "lucide-react";
import { auth } from "@/auth";
import { ProfileCard } from "@/components/ui/profile-card";
import { SignOutButton } from "@/features/auth/components/sign-out-button";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getBranding } from "@/lib/business/branding";
import { InstallAppButton } from "@/features/pwa/components/install-app-button";
const SETTINGS_LINKS = [
@@ -14,6 +15,7 @@ const SETTINGS_LINKS = [
{ href: "/staff/settings/time-off", label: "Time Off", icon: CalendarOff },
{ href: "/staff/settings/profile", label: "Profile", icon: UserCircle },
{ href: "/staff/settings/notifications", label: "Notifications", icon: Bell },
{ href: "/staff/settings/calendar", label: "Google Calendar", icon: CalendarSync },
] as const;
export const metadata: Metadata = { title: "Staff Settings" };
@@ -25,7 +27,10 @@ export default async function StaffSettingsPage() {
// Real permission check, not the templates' unconditional mock "Switch to
// Admin View" button (see docs/implementation-plan.md §3) — only staff
// whose Role actually grants MANAGE_STAFF see a way into the owner area.
const canManageStaff = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
const [canManageStaff, branding] = await Promise.all([
hasPermission(session.user.id, Permission.MANAGE_STAFF),
getBranding(),
]);
return (
<div className="space-y-6 px-6 pt-12">
@@ -55,7 +60,7 @@ export default async function StaffSettingsPage() {
<div className="rounded-jumbo border border-line bg-white p-5">
<p className="mb-3 text-sm font-bold">Get the app</p>
<InstallAppButton />
<InstallAppButton businessName={branding.name} />
</div>
<SignOutButton />
@@ -39,7 +39,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
const ics = generateAppointmentIcs({
appointmentId: appointment.id,
businessName: business?.name ?? "NextChair",
businessName: business?.name ?? "the business",
businessAddress: business?.locations[0]
? `${business.locations[0].addressLine1}, ${business.locations[0].city} ${business.locations[0].postalCode}`
: undefined,
@@ -1,18 +1,11 @@
import { timingSafeEqual } from "node:crypto";
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db/prisma";
import { isInstalledForRequest } from "@/lib/installation/installation-state";
import { secretsMatch } from "@/lib/security/secrets-match";
import { expireOverdueRewards } from "@/services/loyalty/loyalty.service";
import { getEmailProvider, getPushProvider } from "@/services/notifications/providers";
import { processDueNotificationDeliveries } from "@/services/notifications/reminder-worker";
/** Same constant-time convention as src/lib/booking-draft/sign.ts and guest-access.service.ts — a plain `!==` on a secret leaks its length/prefix via timing. */
function secretsMatch(provided: string, expected: string): boolean {
const providedBuffer = Buffer.from(provided);
const expectedBuffer = Buffer.from(expected);
if (providedBuffer.length !== expectedBuffer.length) return false;
return timingSafeEqual(providedBuffer, expectedBuffer);
}
/**
* The external trigger point for the background notification processor —
* see reminder-worker.ts for why this exists instead of an in-process
@@ -47,6 +40,14 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
}
// No business data exists to process before the installer has run — see
// src/lib/installation/installation-state.ts. A 200 with a no-op result
// (not an error) so an external scheduler hitting this on a fixed
// interval before day-one setup doesn't page anyone.
if (!(await isInstalledForRequest())) {
return NextResponse.json({ skipped: "not_installed" });
}
const result = await processDueNotificationDeliveries(prisma, {
emailProvider: getEmailProvider(),
pushProvider: getPushProvider(),
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db/prisma";
import { getInstallationState } from "@/lib/installation/installation-state";
/**
* Docker/orchestrator health check — deliberately never requires
* installation to have completed: a fresh deployment with a reachable,
* migrated database but no business yet is healthy (it should correctly
* show the installer), not unhealthy. Only a database connectivity
* failure returns a non-200 status. Never returns secrets or a stack
* trace — just enough for a health probe to act on.
*/
export async function GET() {
try {
await prisma.$queryRaw`SELECT 1`;
} catch {
return NextResponse.json({ status: "unhealthy", database: "unreachable" }, { status: 503 });
}
const { installed } = await getInstallationState().catch(() => ({ installed: false }));
return NextResponse.json({ status: "healthy", database: "reachable", installed });
}
@@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { createOAuthState } from "@/lib/security/oauth-state";
import { GoogleCalendarProvider } from "@/services/calendar/google-calendar.provider";
function getRedirectUri(): string {
const explicit = process.env.GOOGLE_CALENDAR_REDIRECT_URI;
if (explicit) return explicit;
const appUrl = process.env.NEXT_PUBLIC_APP_URL;
return `${appUrl}/api/staff/calendar/google/callback`;
}
/**
* Starts the Google Calendar connect flow for the signed-in staff
* member's own account — deliberately separate from Google sign-in (see
* docs/phase-8-summary.md): a barber may connect a different Google
* account here than the one they authenticate with. `state` is a signed,
* short-lived, staff-scoped token (see oauth-state.ts) set as an httpOnly
* cookie and re-verified in the callback — CSRF/replay protection without
* server-side session storage.
*/
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.redirect(new URL("/sign-in", process.env.NEXT_PUBLIC_APP_URL));
}
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: session.user.id }, select: { id: true } });
if (!staffProfile) {
return NextResponse.json({ error: "Only staff members can connect a calendar." }, { status: 403 });
}
// Fails closed with a friendly redirect, not a 500 — see the brief's
// "show disabled states rather than crashing" for every optional
// integration. The Settings page itself also hides the Connect button
// when unconfigured (see calendar-connection-panel.tsx), so reaching
// this route unconfigured means a stale link or a direct hit.
if (!process.env.GOOGLE_CALENDAR_CLIENT_ID || !process.env.GOOGLE_CALENDAR_CLIENT_SECRET) {
const url = new URL("/staff/settings/calendar", process.env.NEXT_PUBLIC_APP_URL);
url.searchParams.set("error", "1");
url.searchParams.set("detail", "not_configured");
return NextResponse.redirect(url);
}
const state = createOAuthState(staffProfile.id);
const provider = new GoogleCalendarProvider();
const authorizationUrl = provider.buildAuthorizationUrl({ redirectUri: getRedirectUri(), state });
const response = NextResponse.redirect(authorizationUrl);
response.cookies.set("gcal_oauth_state", state, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 600,
});
return response;
}
@@ -0,0 +1,98 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { encryptToken } from "@/lib/security/token-encryption";
import { verifyOAuthState } from "@/lib/security/oauth-state";
import { GoogleCalendarProvider } from "@/services/calendar/google-calendar.provider";
import { CalendarProviderError } from "@/services/calendar/types";
import { runInitialSync } from "@/services/calendar/calendar-sync.service";
function getRedirectUri(): string {
const explicit = process.env.GOOGLE_CALENDAR_REDIRECT_URI;
if (explicit) return explicit;
const appUrl = process.env.NEXT_PUBLIC_APP_URL;
return `${appUrl}/api/staff/calendar/google/callback`;
}
function settingsRedirect(status: "connected" | "error", detail?: string): NextResponse {
const url = new URL("/staff/settings/calendar", process.env.NEXT_PUBLIC_APP_URL);
url.searchParams.set(status, "1");
if (detail) url.searchParams.set("detail", detail);
const response = NextResponse.redirect(url);
response.cookies.delete("gcal_oauth_state");
return response;
}
/**
* Consent denial, an expired/tampered state, or a state issued for a
* different staff member than the one currently signed in are all
* treated the same way: bounce back to Settings with a generic error —
* never revealing which check failed (same principle as the installation
* token check, see installer.service.ts).
*/
export async function GET(request: Request) {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const returnedState = url.searchParams.get("state");
const consentDenied = url.searchParams.get("error");
const session = await auth();
if (!session?.user?.id) {
return NextResponse.redirect(new URL("/sign-in", process.env.NEXT_PUBLIC_APP_URL));
}
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: session.user.id }, select: { id: true } });
if (!staffProfile) {
return settingsRedirect("error", "not_staff");
}
if (consentDenied) {
return settingsRedirect("error", "consent_denied");
}
const cookieState = request.headers.get("cookie")?.match(/gcal_oauth_state=([^;]+)/)?.[1];
if (!code || !returnedState || !cookieState || returnedState !== cookieState || !verifyOAuthState(cookieState, staffProfile.id)) {
return settingsRedirect("error", "invalid_state");
}
try {
const provider = new GoogleCalendarProvider();
const tokens = await provider.exchangeCodeForTokens({ code, redirectUri: getRedirectUri() });
const calendars = await provider.listWritableCalendars(tokens.accessToken);
const primaryCalendar = calendars.find((c) => c.primary) ?? calendars[0];
if (!primaryCalendar) {
return settingsRedirect("error", "no_writable_calendar");
}
const connection = await prisma.staffCalendarConnection.upsert({
where: { staffId: staffProfile.id },
create: {
staffId: staffProfile.id,
externalAccountId: tokens.externalAccountId,
accountEmail: tokens.accountEmail,
encryptedAccessToken: encryptToken(tokens.accessToken),
encryptedRefreshToken: encryptToken(tokens.refreshToken),
tokenExpiresAt: tokens.expiresAt,
selectedCalendarId: primaryCalendar.id,
selectedCalendarName: primaryCalendar.name,
},
update: {
externalAccountId: tokens.externalAccountId,
accountEmail: tokens.accountEmail,
encryptedAccessToken: encryptToken(tokens.accessToken),
encryptedRefreshToken: encryptToken(tokens.refreshToken),
tokenExpiresAt: tokens.expiresAt,
selectedCalendarId: primaryCalendar.id,
selectedCalendarName: primaryCalendar.name,
revokedAt: null,
lastSyncError: null,
},
});
await runInitialSync(prisma, connection.staffId);
return settingsRedirect("connected");
} catch (error) {
const detail = error instanceof CalendarProviderError ? error.code.toLowerCase() : "unknown";
return settingsRedirect("error", detail);
}
}
+36
View File
@@ -1,6 +1,10 @@
import type { Metadata } from "next";
import { LogIn } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/feedback/empty-state";
import { TopNav } from "@/components/navigation/top-nav";
import { readDraft } from "@/lib/booking-draft/cookie";
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
@@ -10,6 +14,8 @@ import { DetailsForm } from "@/features/booking/components/details-form";
export const metadata: Metadata = { title: "Your Details" };
export const dynamic = "force-dynamic";
const CALLBACK_URL = "/book/details";
export default async function BookDetailsPage() {
const business = await getPrimaryBusiness();
const draft = await readDraft(business.id);
@@ -18,6 +24,36 @@ export default async function BookDetailsPage() {
if (!draft.startAt) redirect("/book/barber-time");
const session = await auth();
// Booking progress up to this point (services, barber, time) is stored
// in the signed draft cookie, which survives navigation to /sign-in or
// /register and back — see src/lib/booking-draft/cookie.ts. Only the
// *details* step itself (and everything after it) requires an account
// when guest booking is disabled; browsing and slot selection never do.
const guestBookingEnabled = business.bookingRule?.allowGuestBooking ?? false;
if (!session?.user && !guestBookingEnabled) {
return (
<div className="pb-6">
<TopNav title="Your Details" onBack={true} sticky className="bg-stone" />
<EmptyState
icon={LogIn}
title="Sign in to complete your booking"
description="Create an account or sign in to confirm your appointment, manage your bookings and keep track of your loyalty rewards."
action={
<div className="flex w-full gap-3">
<Button asChild variant="secondary" className="flex-1">
<Link href={`/sign-in?callbackUrl=${encodeURIComponent(CALLBACK_URL)}`}>Sign In</Link>
</Button>
<Button asChild className="flex-1">
<Link href={`/register?callbackUrl=${encodeURIComponent(CALLBACK_URL)}`}>Create Account</Link>
</Button>
</div>
}
/>
</div>
);
}
let prefill: { firstName: string; lastName: string; email: string; phone: string } | undefined;
if (session?.user?.id) {
+18
View File
@@ -0,0 +1,18 @@
/**
* Deliberately not the (auth)/(customer)/(staff)/(owner) shells — this
* route renders before any business exists, so there is nothing to brand
* with yet (see the brief's "Offline and Error Branding" section: use
* neutral installation wording before install, business branding after).
* A plain neutral mark, not the app's own logo/name, keeps that boundary
* honest rather than showing product branding on the one screen where it
* explicitly shouldn't appear.
*/
export default function InstallLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen flex-col bg-stone p-6">
<div id="main-content" className="mx-auto w-full max-w-lg flex-1 py-10">
{children}
</div>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { redirect } from "next/navigation";
import { getInstallationState } from "@/lib/installation/installation-state";
import { InstallWizard } from "@/features/installation/components/install-wizard";
export const metadata = {
title: "Set Up Your Business",
};
// Must never be statically prerendered — the installation-state check on
// every request is the entire point of this page (see the brief's "do
// not rely on the form being hidden"). Without this, Next could bake a
// single build-time snapshot of `installed` into static HTML and serve
// that forever, regardless of the database's real state afterward.
export const dynamic = "force-dynamic";
export default async function InstallPage() {
const { installed } = await getInstallationState();
// Direct navigation after a completed install — src/proxy.ts already
// redirects here for most cases, but this page enforces the same rule
// itself rather than trusting the proxy alone (see the brief's "do not
// rely on the form being hidden" requirement).
if (installed) {
redirect("/");
}
const tokenConfigured = !!process.env.INSTALLATION_TOKEN;
if (!tokenConfigured) {
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold">Installation is not configured</h1>
<p className="text-muted text-sm">
This deployment has no <code className="rounded bg-white px-1.5 py-0.5 text-xs">INSTALLATION_TOKEN</code>{" "}
environment variable set, so setup can&apos;t proceed. An administrator needs to set one and restart the
application before installation can begin.
</p>
</div>
);
}
return <InstallWizard />;
}
+31 -21
View File
@@ -1,6 +1,7 @@
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import { Providers } from "@/components/providers";
import { getBranding } from "@/lib/business/branding";
import "./globals.css";
const inter = Inter({
@@ -9,28 +10,37 @@ const inter = Inter({
display: "swap",
});
export const metadata: Metadata = {
title: {
default: "NextChair",
template: "%s — NextChair",
},
description:
"Book your next barber appointment in seconds — services, staff and loyalty rewards in one place.",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "NextChair",
},
};
/**
* Dynamic, not a static `export const metadata` — every page's title,
* description, and app-name metadata reflect the installed business, not
* the internal product name (see src/lib/business/branding.ts). Before
* installation completes, `getBranding()` returns neutral wording rather
* than "NextChair" (see that file's own fallback-order comment).
*/
export async function generateMetadata(): Promise<Metadata> {
const branding = await getBranding();
return {
title: { default: branding.name, template: `%s | ${branding.name}` },
description: branding.description || "Book your next appointment in seconds.",
appleWebApp: { capable: true, statusBarStyle: "default", title: branding.shortName },
icons:
branding.faviconUrl || branding.appIconUrl
? { icon: branding.faviconUrl || undefined, apple: branding.appIconUrl || undefined }
: undefined,
};
}
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
viewportFit: "cover",
themeColor: "#111b15",
};
export async function generateViewport(): Promise<Viewport> {
const branding = await getBranding();
return {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
viewportFit: "cover",
themeColor: branding.themeColor,
};
}
export default function RootLayout({
children,
+20 -10
View File
@@ -1,22 +1,32 @@
import type { MetadataRoute } from "next";
import { getBranding } from "@/lib/business/branding";
/**
* Next.js's manifest file convention — auto-served at /manifest.webmanifest
* with the <link rel="manifest"> tag injected automatically, so nothing in
* layout.tsx needs to reference this directly. Single business per
* deployment (see docs/implementation-plan.md), so the name/colors are
* static rather than read from the Business row.
* with the <link rel="manifest"> tag injected automatically. Dynamic as of
* Phase 8: name/description/colors reflect the installed business (see
* src/lib/business/branding.ts) rather than the internal product name.
*
* `icons` intentionally stays pointed at the app's own pre-generated,
* correctly-sized/maskable PWA icon set rather than a business's single
* `appIconUrl` — there's no image-processing pipeline in this app to
* derive the required 192/512/maskable variants from one arbitrary
* uploaded URL (see docs/phase-8-summary.md, "Known limitations"), so
* this is fallback rule #2 ("business name + a neutral generated icon"),
* applied deliberately rather than risking a broken/non-square install
* icon.
*/
export default function manifest(): MetadataRoute.Manifest {
export default async function manifest(): Promise<MetadataRoute.Manifest> {
const branding = await getBranding();
return {
name: "NextChair — Barber Booking",
short_name: "NextChair",
description: "Book your next barber appointment in seconds — services, staff and loyalty rewards in one place.",
name: branding.name,
short_name: branding.shortName,
description: branding.description || "Book your next appointment in seconds.",
start_url: "/home",
scope: "/",
display: "standalone",
background_color: "#f4f6f4",
theme_color: "#111b15",
background_color: branding.backgroundColor,
theme_color: branding.themeColor,
orientation: "portrait-primary",
icons: [
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
+19 -6
View File
@@ -1,16 +1,29 @@
import type { Metadata } from "next";
import { WifiOff } from "lucide-react";
import { getBranding } from "@/lib/business/branding";
export const metadata: Metadata = { title: "You're offline" };
// Forced dynamic so every time the service worker actually (re-)fetches
// this page (at install/activate, while online — see this file's own
// comment below), it gets a fresh render reflecting current branding.
// Without this, a production build could statically bake in whatever
// branding existed at *build* time and serve that forever, even after
// the business's branding changes.
export const dynamic = "force-dynamic";
/**
* The service worker's navigation fallback (see public/sw.js) — served
* from cache when a page request fails with no network. Deliberately
* static: no auth, no data fetching, nothing that could itself require a
* network round-trip, since the whole point is that it still renders with
* zero connectivity. Precached at service-worker install time.
* from cache when a page request fails with no network. No auth, no
* per-request data fetching once it's *serving* offline (the whole point
* is that it still renders with zero connectivity) — but it's still a
* normal server-rendered page whenever the service worker actually
* (re-)precaches it, which only ever happens while online (install/
* activate, see public/sw.js). So it reads business branding like any
* other page; a device that goes offline simply keeps showing whatever
* branding was current the last time it had a connection.
*/
export default function OfflinePage() {
export default async function OfflinePage() {
const branding = await getBranding();
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-stone px-6 text-center">
<div className="bg-ink mb-6 flex h-16 w-16 items-center justify-center rounded-[1.25rem] text-white shadow-xl">
@@ -18,7 +31,7 @@ export default function OfflinePage() {
</div>
<h1 className="mb-2 text-xl font-bold tracking-tight">You&apos;re offline</h1>
<p className="text-muted max-w-xs text-sm">
NextChair needs a connection for booking, schedules, and account details. Reconnect and try again.
{branding.name} needs a connection for booking, schedules, and account details. Reconnect and try again.
</p>
</div>
);
+47 -10
View File
@@ -3,21 +3,49 @@ import Link from "next/link";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { Button } from "@/components/ui/button";
import { getBranding } from "@/lib/business/branding";
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db/prisma";
import { WelcomeInstallCard } from "@/features/pwa/components/welcome-install-card";
/**
* The unauthenticated entry point — always shown at `/` before
* authentication, per the brief's "Welcome routing" requirements.
* Authenticated visitors never see it: they're routed straight to their
* role's home (customer → /home, barber → /staff, owner → /owner) below.
* Unreachable at all before installation completes — src/proxy.ts
* redirects every non-exempt route (including this one) to /install
* until ApplicationInstallation flips to INSTALLED.
*/
export default async function WelcomePage() {
const session = await auth();
if (session?.user) {
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (staffProfile) {
const canManageStaff = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
redirect(canManageStaff ? "/owner" : "/staff");
}
redirect("/home");
}
const [branding, business] = await Promise.all([getBranding(), getPrimaryBusiness()]);
const guestBookingEnabled = business.bookingRule?.allowGuestBooking ?? false;
return (
<div id="main-content" className="flex min-h-screen flex-col justify-end bg-ink px-8 pb-16 text-white">
<div className="mb-8 flex h-16 w-16 items-center justify-center rounded-2xl bg-white text-ink">
<Scissors className="h-7 w-7" aria-hidden="true" />
{branding.logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- external, business-supplied URL; not a build-time-known local asset for next/image.
<img src={branding.logoUrl} alt="" className="h-full w-full rounded-2xl object-cover" />
) : (
<Scissors className="h-7 w-7" aria-hidden="true" />
)}
</div>
<h1 className="mb-4 text-5xl font-bold tracking-tight">NextChair</h1>
<h1 className="mb-4 text-5xl font-bold tracking-tight">{branding.name}</h1>
<p className="mb-12 max-w-xs text-lg text-white/70">
Precision grooming at your fingertips. Book your next visit in seconds.
Book your next visit in seconds — services, staff and loyalty rewards in one place.
</p>
<div className="space-y-4">
@@ -32,15 +60,24 @@ export default async function WelcomePage() {
>
<Link href="/sign-in">Sign In</Link>
</Button>
{guestBookingEnabled && (
<Button
asChild
variant="ghost"
size="lg"
className="w-full text-white/70 hover:bg-white/10 hover:text-white"
>
<Link href="/book">Continue as Guest</Link>
</Button>
)}
<Button asChild variant="ghost" size="lg" className="w-full text-white/70 hover:bg-white/10 hover:text-white">
<Link href="/home">Browse Services</Link>
</Button>
</div>
{/*
The templates' "Continue as Guest" goes straight to the business
home/booking flow (template 20 → 15). That flow isn't built yet
(Phase 1: Customer Booking), so this button isn't shown rather than
linking somewhere that doesn't work yet — see
docs/implementation-plan.md §16 for the phase it belongs to.
*/}
<div className="mt-6">
<WelcomeInstallCard businessName={branding.name} />
</div>
</div>
);
}