Phase 8
This commit is contained in:
82 files changed
+4903
-287
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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't proceed. An administrator needs to set one and restart the
|
||||
application before installation can begin.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <InstallWizard />;
|
||||
}
|
||||
+31
-21
@@ -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
@@ -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" },
|
||||
|
||||
@@ -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'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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user