Initial PHP rebuild of the app
This commit is contained in:
690 files changed
+33944
-12046
No files matched your search
@@ -1,47 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { signIn } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { acceptInvitation, validateInvitationToken } from "@/services/staff/staff-invitation.service";
|
||||
import { acceptInvitationSchema } from "@/features/auth/schemas";
|
||||
|
||||
export interface AcceptInvitationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string[] | undefined>;
|
||||
}
|
||||
|
||||
/** Public — the invited person has no account/session yet. Mirrors registerAction's shape: create the credential, then sign in immediately, same as a fresh registration. */
|
||||
export async function acceptInvitationAction(token: string, formData: FormData): Promise<AcceptInvitationResult> {
|
||||
const parsed = acceptInvitationSchema.safeParse({
|
||||
password: formData.get("password"),
|
||||
confirmPassword: formData.get("confirmPassword"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: "Please fix the highlighted fields.", fieldErrors: parsed.error.flatten().fieldErrors };
|
||||
}
|
||||
|
||||
const check = await validateInvitationToken(prisma, token);
|
||||
if (!check.valid) {
|
||||
const message =
|
||||
check.reason === "EXPIRED"
|
||||
? "This invitation link has expired. Ask the business owner to send you a new one."
|
||||
: check.reason === "ALREADY_ACCEPTED"
|
||||
? "This invitation has already been used — try signing in instead."
|
||||
: "This invitation link isn't valid.";
|
||||
return { success: false, error: message };
|
||||
}
|
||||
|
||||
const result = await acceptInvitation(prisma, { token, password: parsed.data.password });
|
||||
if (!result.success) {
|
||||
return { success: false, error: "Couldn't accept this invitation. Please try again." };
|
||||
}
|
||||
|
||||
await signIn("credentials", {
|
||||
email: check.invitation.staff.user.email,
|
||||
password: parsed.data.password,
|
||||
redirectTo: "/staff",
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import bcrypt from "bcryptjs";
|
||||
import { signIn } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { registerSchema } from "@/features/auth/schemas";
|
||||
import { checkRateLimit } from "@/lib/security/rate-limit";
|
||||
import { getRequestIp } from "@/lib/security/request-ip";
|
||||
import { safeRedirectPath } from "@/lib/utils/safe-redirect";
|
||||
|
||||
export interface RegisterResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
/** Per-field errors, keyed by field name, for the form to surface inline. */
|
||||
fieldErrors?: Record<string, string[] | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a User + CustomerProfile in one transaction, then signs the user
|
||||
* straight in — no email-verification gate yet (that needs the
|
||||
* Notifications system, which is out of scope for Phase 2; see
|
||||
* docs/implementation-plan.md §15, Question 3-adjacent).
|
||||
*/
|
||||
export async function registerAction(formData: FormData): Promise<RegisterResult> {
|
||||
const ip = await getRequestIp();
|
||||
const { allowed } = checkRateLimit(`register:${ip}`, { maxAttempts: 5, windowMs: 60 * 60 * 1000 });
|
||||
if (!allowed) {
|
||||
return { success: false, error: "Too many attempts. Please try again later." };
|
||||
}
|
||||
|
||||
const parsed = registerSchema.safeParse({
|
||||
firstName: formData.get("firstName"),
|
||||
lastName: formData.get("lastName"),
|
||||
email: formData.get("email"),
|
||||
phone: formData.get("phone"),
|
||||
password: formData.get("password"),
|
||||
confirmPassword: formData.get("confirmPassword"),
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Please fix the highlighted fields.",
|
||||
fieldErrors: parsed.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
|
||||
const { firstName, lastName, email, phone, password } = parsed.data;
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email } });
|
||||
if (existing) {
|
||||
return {
|
||||
success: false,
|
||||
error: "An account with this email already exists.",
|
||||
fieldErrors: { email: ["An account with this email already exists."] },
|
||||
};
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
phone: phone || null,
|
||||
name: `${firstName} ${lastName}`,
|
||||
passwordHash,
|
||||
customerProfile: {
|
||||
create: {
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
phone: phone || null,
|
||||
isGuest: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const redirectTo = safeRedirectPath(formData.get("callbackUrl")?.toString(), "/home");
|
||||
await signIn("credentials", { email, password, redirectTo });
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { acceptInvitationAction } from "@/features/auth/actions/accept-invitation";
|
||||
import { acceptInvitationSchema } from "@/features/auth/schemas";
|
||||
import type { AcceptInvitationInput } from "@/features/auth/schemas";
|
||||
|
||||
export function AcceptInvitationForm({ token, businessName }: { token: string; businessName: string }) {
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<AcceptInvitationInput>({ resolver: zodResolver(acceptInvitationSchema) });
|
||||
|
||||
const onSubmit = async (data: AcceptInvitationInput) => {
|
||||
setFormError(null);
|
||||
const formData = new FormData();
|
||||
formData.set("password", data.password);
|
||||
formData.set("confirmPassword", data.confirmPassword);
|
||||
|
||||
const result = await acceptInvitationAction(token, formData);
|
||||
if (!result.success) {
|
||||
setFormError(result.error ?? "Something went wrong. Please try again.");
|
||||
if (result.fieldErrors) {
|
||||
for (const [field, messages] of Object.entries(result.fieldErrors)) {
|
||||
if (messages?.[0]) setError(field as keyof AcceptInvitationInput, { message: messages[0] });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
|
||||
<p className="text-muted text-sm">
|
||||
You've been invited to join <span className="text-ink font-bold">{businessName}</span>. Set a password
|
||||
to finish setting up your account.
|
||||
</p>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
error={errors.password?.message}
|
||||
{...register("password")}
|
||||
/>
|
||||
<Input
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
error={errors.confirmPassword?.message}
|
||||
{...register("confirmPassword")}
|
||||
/>
|
||||
{formError && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" className="mt-4 w-full" isLoading={isSubmitting}>
|
||||
Set Password & Sign In
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, Scissors } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
/**
|
||||
* The shared (auth) layout's top-left slot — the business logo by default,
|
||||
* or a back button to the welcome screen on Sign In and Register, the two
|
||||
* auth pages reached directly from Welcome's own buttons (so "back" has an
|
||||
* obvious, single destination there). forgot-password/verify are reached
|
||||
* from elsewhere in the auth flow instead, where a logo reads better than
|
||||
* an ambiguous "back". A small Client Component rather than making the
|
||||
* whole layout client-side, since `usePathname()` is the only piece that
|
||||
* needs the browser.
|
||||
*/
|
||||
export function AuthHeader({ logoUrl }: { logoUrl: string | null }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
if (pathname === "/register" || pathname === "/sign-in") {
|
||||
return (
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="Back to welcome"
|
||||
className="mb-8 flex h-12 w-12 items-center justify-center rounded-full border border-line text-ink hover:bg-white"
|
||||
>
|
||||
<ArrowLeft className="h-6 w-6" aria-hidden="true" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto mb-8 flex items-center justify-center text-3xl text-white">
|
||||
{logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- external, business-supplied URL.
|
||||
<img src={logoUrl} alt="" className="h-40 w-40 object-cover" />
|
||||
) : (
|
||||
<Scissors className="h-7 w-7" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { registerAction } from "@/features/auth/actions/register";
|
||||
import { registerSchema, type RegisterInput } from "@/features/auth/schemas";
|
||||
import { safeRedirectPath } from "@/lib/utils/safe-redirect";
|
||||
|
||||
export function RegisterForm() {
|
||||
const searchParams = useSearchParams();
|
||||
const callbackUrl = safeRedirectPath(searchParams.get("callbackUrl"), "/home");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<RegisterInput>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: RegisterInput) => {
|
||||
setFormError(null);
|
||||
const formData = new FormData();
|
||||
Object.entries(data).forEach(([key, value]) => formData.set(key, value ?? ""));
|
||||
formData.set("callbackUrl", callbackUrl);
|
||||
|
||||
const result = await registerAction(formData);
|
||||
|
||||
// A successful call redirects server-side and never resolves here with
|
||||
// success: true in practice, but we still handle it for completeness.
|
||||
if (!result.success) {
|
||||
setFormError(result.error ?? "Something went wrong. Please try again.");
|
||||
if (result.fieldErrors) {
|
||||
for (const [field, messages] of Object.entries(result.fieldErrors)) {
|
||||
if (messages?.[0]) {
|
||||
setError(field as keyof RegisterInput, { message: messages[0] });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="First Name"
|
||||
placeholder="Alex"
|
||||
error={errors.firstName?.message}
|
||||
{...register("firstName")}
|
||||
/>
|
||||
<Input
|
||||
label="Last Name"
|
||||
placeholder="Chen"
|
||||
error={errors.lastName?.message}
|
||||
{...register("lastName")}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Email Address"
|
||||
type="email"
|
||||
placeholder="name@example.com"
|
||||
error={errors.email?.message}
|
||||
{...register("email")}
|
||||
/>
|
||||
<Input
|
||||
label="Phone Number (optional)"
|
||||
type="tel"
|
||||
placeholder="+44 7700 900000"
|
||||
error={errors.phone?.message}
|
||||
{...register("phone")}
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
error={errors.password?.message}
|
||||
{...register("password")}
|
||||
/>
|
||||
<Input
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
error={errors.confirmPassword?.message}
|
||||
{...register("confirmPassword")}
|
||||
/>
|
||||
|
||||
{formError && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-muted px-1 text-[10px] leading-relaxed">
|
||||
By creating an account, you agree to our{" "}
|
||||
<a href="#" className="underline">
|
||||
Terms of Service
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a href="#" className="underline">
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
<Button type="submit" className="mt-4 w-full" isLoading={isSubmitting}>
|
||||
Sign Up
|
||||
</Button>
|
||||
|
||||
<p className="text-muted mt-10 text-center text-sm">
|
||||
Already have an account?{" "}
|
||||
<Link href={`/sign-in?callbackUrl=${encodeURIComponent(callbackUrl)}`} className="border-ink text-ink border-b font-bold">
|
||||
Log in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Mail, Lock } from "lucide-react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { signInSchema, type SignInInput } from "@/features/auth/schemas";
|
||||
import { safeRedirectPath } from "@/lib/utils/safe-redirect";
|
||||
|
||||
export function SignInForm() {
|
||||
const searchParams = useSearchParams();
|
||||
// "/" rather than a hardcoded "/home" — the role isn't known client-side
|
||||
// before sign-in completes, so a bare sign-in (no callbackUrl) routes
|
||||
// through the root welcome page's own post-auth redirect, which already
|
||||
// knows how to send a barber/owner/super admin to their Calendar tab
|
||||
// instead of the customer home. See src/app/page.tsx.
|
||||
const callbackUrl = safeRedirectPath(searchParams.get("callbackUrl"), "/");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<SignInInput>({
|
||||
resolver: zodResolver(signInSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: SignInInput) => {
|
||||
setFormError(null);
|
||||
const result = await signIn("credentials", { ...data, redirect: false, callbackUrl });
|
||||
if (result?.error) {
|
||||
setFormError("Incorrect email or password.");
|
||||
return;
|
||||
}
|
||||
window.location.assign(result?.url ?? callbackUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
|
||||
<Input
|
||||
label="Email Address"
|
||||
type="email"
|
||||
icon={<Mail className="h-4 w-4" aria-hidden="true" />}
|
||||
placeholder="name@example.com"
|
||||
error={errors.email?.message}
|
||||
{...register("email")}
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
id="password"
|
||||
type="password"
|
||||
icon={<Lock className="h-4 w-4" aria-hidden="true" />}
|
||||
placeholder="••••••••"
|
||||
error={errors.password?.message}
|
||||
{...register("password")}
|
||||
/>
|
||||
{/* Password reset needs the Notifications system (email delivery)
|
||||
to actually send a reset link — out of scope for this phase.
|
||||
The "Forgot Password" template (8) is a design reference for
|
||||
when that lands; no dead link to it here in the meantime. */}
|
||||
|
||||
{formError && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="mt-2 w-full" isLoading={isSubmitting}>
|
||||
Sign In
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-10">
|
||||
<div className="relative mb-8 flex items-center justify-center">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="border-line w-full border-t" />
|
||||
</div>
|
||||
<span className="bg-stone text-muted relative px-4 text-[10px] font-bold tracking-widest uppercase">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="w-full tracking-normal normal-case"
|
||||
onClick={() => signIn("google", { callbackUrl })}
|
||||
>
|
||||
Continue with Google
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-muted mt-10 text-center text-sm">
|
||||
Don't have an account?{" "}
|
||||
<Link href={`/register?callbackUrl=${encodeURIComponent(callbackUrl)}`} className="border-ink text-ink border-b-2 font-bold">
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { LogOut } from "lucide-react";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function SignOutButton() {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="text-danger hover:bg-danger/10 w-full"
|
||||
onClick={() => signOut({ callbackUrl: "/" })}
|
||||
>
|
||||
<LogOut className="h-4 w-4" aria-hidden="true" />
|
||||
Log Out
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Single source of truth for auth form shapes — shared between the client
|
||||
* form (react-hook-form + zodResolver) and the server action / Credentials
|
||||
* `authorize()` callback, so validation never drifts between the two.
|
||||
*/
|
||||
|
||||
export const signInSchema = z.object({
|
||||
email: z.email("Enter a valid email address"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
});
|
||||
export type SignInInput = z.infer<typeof signInSchema>;
|
||||
|
||||
export const registerSchema = z
|
||||
.object({
|
||||
firstName: z.string().trim().min(1, "First name is required"),
|
||||
lastName: z.string().trim().min(1, "Last name is required"),
|
||||
email: z.email("Enter a valid email address"),
|
||||
phone: z.string().trim().optional().or(z.literal("")),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
export type RegisterInput = z.infer<typeof registerSchema>;
|
||||
|
||||
export const acceptInvitationSchema = z
|
||||
.object({
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
export type AcceptInvitationInput = z.infer<typeof acceptInvitationSchema>;
|
||||
@@ -1,104 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PermissionDeniedError, assertCanAccessAppointment, canAccessAppointment } from "../authorization";
|
||||
|
||||
/**
|
||||
* The registered-customer branch never touches the database (it's a plain
|
||||
* userId comparison), so it's tested here in isolation. The guest-token
|
||||
* branch's real hash/expiry/revocation behaviour is covered against a real
|
||||
* database in customer-booking.integration.test.ts — this file only checks
|
||||
* that authorization.ts calls into validateGuestAccessToken correctly and
|
||||
* respects its result.
|
||||
*/
|
||||
vi.mock("@/services/scheduling/guest-access.service", () => ({
|
||||
validateGuestAccessToken: vi.fn(),
|
||||
}));
|
||||
|
||||
const { validateGuestAccessToken } = await import("@/services/scheduling/guest-access.service");
|
||||
const mockValidate = vi.mocked(validateGuestAccessToken);
|
||||
|
||||
beforeEach(() => {
|
||||
mockValidate.mockClear();
|
||||
});
|
||||
|
||||
// Not a real PrismaClient — the mocked guest-access service never touches it.
|
||||
const prisma = {} as never;
|
||||
|
||||
describe("assertCanAccessAppointment", () => {
|
||||
it("allows a registered customer to access their own appointment", async () => {
|
||||
await expect(
|
||||
assertCanAccessAppointment(prisma, { id: "a1", customer: { userId: "user_1" } }, { sessionUserId: "user_1" }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("denies a different signed-in user from accessing a registered customer's appointment", async () => {
|
||||
await expect(
|
||||
assertCanAccessAppointment(prisma, { id: "a1", customer: { userId: "user_1" } }, { sessionUserId: "user_2" }),
|
||||
).rejects.toThrow(PermissionDeniedError);
|
||||
});
|
||||
|
||||
it("denies an unauthenticated caller from accessing a registered customer's appointment", async () => {
|
||||
await expect(
|
||||
assertCanAccessAppointment(prisma, { id: "a1", customer: { userId: "user_1" } }, { sessionUserId: undefined }),
|
||||
).rejects.toThrow(PermissionDeniedError);
|
||||
});
|
||||
|
||||
it("never even calls the guest-token validator for a registered customer's appointment", async () => {
|
||||
mockValidate.mockClear();
|
||||
await assertCanAccessAppointment(
|
||||
prisma,
|
||||
{ id: "a1", customer: { userId: "user_1" } },
|
||||
{ sessionUserId: "user_1" },
|
||||
).catch(() => {});
|
||||
expect(mockValidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a guest appointment when the guest-token validator confirms the token", async () => {
|
||||
mockValidate.mockResolvedValueOnce(true);
|
||||
await expect(
|
||||
assertCanAccessAppointment(
|
||||
prisma,
|
||||
{ id: "a1", customer: { userId: null } },
|
||||
{ sessionUserId: undefined, guestToken: "correct-token" },
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
expect(mockValidate).toHaveBeenCalledWith(prisma, { appointmentId: "a1", rawToken: "correct-token" });
|
||||
});
|
||||
|
||||
it("denies a guest appointment when no token is supplied at all — the appointment id is no longer sufficient", async () => {
|
||||
await expect(
|
||||
assertCanAccessAppointment(prisma, { id: "a1", customer: { userId: null } }, { sessionUserId: undefined }),
|
||||
).rejects.toThrow(PermissionDeniedError);
|
||||
expect(mockValidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("denies a guest appointment when the guest-token validator rejects the token", async () => {
|
||||
mockValidate.mockResolvedValueOnce(false);
|
||||
await expect(
|
||||
assertCanAccessAppointment(
|
||||
prisma,
|
||||
{ id: "a1", customer: { userId: null } },
|
||||
{ sessionUserId: undefined, guestToken: "wrong-token" },
|
||||
),
|
||||
).rejects.toThrow(PermissionDeniedError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canAccessAppointment", () => {
|
||||
it("mirrors assertCanAccessAppointment as a boolean", async () => {
|
||||
expect(
|
||||
await canAccessAppointment(prisma, { id: "a1", customer: { userId: "user_1" } }, { sessionUserId: "user_1" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await canAccessAppointment(prisma, { id: "a1", customer: { userId: "user_1" } }, { sessionUserId: "user_2" }),
|
||||
).toBe(false);
|
||||
|
||||
mockValidate.mockResolvedValueOnce(true);
|
||||
expect(
|
||||
await canAccessAppointment(
|
||||
prisma,
|
||||
{ id: "a1", customer: { userId: null } },
|
||||
{ sessionUserId: undefined, guestToken: "correct-token" },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,524 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time";
|
||||
import type { CalendarDate } from "@/lib/time/business-time";
|
||||
import { cancelBooking, createBooking, rescheduleBooking } from "@/services/scheduling/booking.service";
|
||||
import { listAppointments } from "@/services/scheduling/appointment.service";
|
||||
import { revokeGuestAccessToken } from "@/services/scheduling/guest-access.service";
|
||||
import {
|
||||
cleanupTestBusiness,
|
||||
createTestBusiness,
|
||||
createTestCustomer,
|
||||
createTestGuestCustomer,
|
||||
createTestStaff,
|
||||
prisma,
|
||||
} from "@/services/scheduling/__tests__/fixtures";
|
||||
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||
import { resolveCustomerForBooking } from "@/services/customer/customer.service";
|
||||
import { assertCanAccessAppointment, canAccessAppointment, PermissionDeniedError } from "@/features/booking/authorization";
|
||||
import { getFirstFriendlyMessage } from "@/features/booking/error-messages";
|
||||
|
||||
/**
|
||||
* Covers the customer-facing booking journeys added in Phase 4 — guest
|
||||
* identity resolution, ownership enforcement, and the "book again"
|
||||
* repricing rule — against a real database. Deliberately exercises the
|
||||
* domain services directly (CustomerService, BookingService,
|
||||
* AppointmentService, authorization.ts), the same layer Phase 3's suite
|
||||
* tests: the Server Actions above them are thin orchestration (collect
|
||||
* input, call these, map errors), verified instead by the Playwright
|
||||
* journeys.
|
||||
*/
|
||||
|
||||
function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate {
|
||||
let date = addDays(todayIn(timezone), minDaysAhead);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (weekdayOf(date) === target) return date;
|
||||
date = addDays(date, 1);
|
||||
}
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
describe("customer booking flows (integration)", () => {
|
||||
let business: TestBusiness;
|
||||
let monday: CalendarDate;
|
||||
let guestProfileIds: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
business = await createTestBusiness();
|
||||
monday = nextWeekday(business.timezone, Weekday.MONDAY);
|
||||
guestProfileIds = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (guestProfileIds.length > 0) {
|
||||
await prisma.customerProfile.deleteMany({ where: { id: { in: guestProfileIds } } }).catch(() => {});
|
||||
}
|
||||
await cleanupTestBusiness(business);
|
||||
});
|
||||
|
||||
describe("guest booking", () => {
|
||||
it("creates a new guest CustomerProfile and books under it", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const guest = { firstName: "Jordan", lastName: "Blake", email: "Jordan.Blake@Example.com", phone: "+44 7700 900111" };
|
||||
|
||||
const resolved = await resolveCustomerForBooking(prisma, { businessId: business.businessId, guest });
|
||||
guestProfileIds.push(resolved.customerId);
|
||||
expect(resolved.isNewProfile).toBe(true);
|
||||
|
||||
const result = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId: resolved.customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
expect(result.appointment.customer.isGuest).toBe(true);
|
||||
expect(result.appointment.customer.userId).toBeNull();
|
||||
// Stored lowercased even though the caller typed mixed case.
|
||||
expect(result.appointment.customer.email).toBe("jordan.blake@example.com");
|
||||
});
|
||||
|
||||
it("reuses an existing guest profile when email and phone both match, without overwriting its stored name", async () => {
|
||||
const existing = await createTestGuestCustomer({ email: "sam.reed@example.com", phone: "+44 7700 900222" });
|
||||
guestProfileIds.push(existing.customerId);
|
||||
|
||||
const resolved = await resolveCustomerForBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
// Different casing on the email, but same identifiers — should still match.
|
||||
guest: { firstName: "Samuel", lastName: "Reed", email: "SAM.REED@example.com", phone: existing.phone },
|
||||
});
|
||||
|
||||
expect(resolved.isNewProfile).toBe(false);
|
||||
expect(resolved.customerId).toBe(existing.customerId);
|
||||
|
||||
const profile = await prisma.customerProfile.findUniqueOrThrow({ where: { id: existing.customerId } });
|
||||
expect(profile.firstName).toBe("Guest"); // unchanged — reuse never overwrites on match
|
||||
});
|
||||
|
||||
it("never merges two guests who share only one identifier — creates a separate profile instead", async () => {
|
||||
const existing = await createTestGuestCustomer({ email: "shared.inbox@example.com", phone: "+44 7700 900333" });
|
||||
guestProfileIds.push(existing.customerId);
|
||||
|
||||
// Same email (shared household inbox), different phone — must not be treated as the same person.
|
||||
const resolved = await resolveCustomerForBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
guest: { firstName: "Different", lastName: "Person", email: existing.email, phone: "+44 7700 900444" },
|
||||
});
|
||||
guestProfileIds.push(resolved.customerId);
|
||||
|
||||
expect(resolved.isNewProfile).toBe(true);
|
||||
expect(resolved.customerId).not.toBe(existing.customerId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("authenticated booking", () => {
|
||||
it("books under the caller's own profile and is idempotent across repeated calls", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId, userId } = await createTestCustomer(business);
|
||||
|
||||
const resolvedAgain = await resolveCustomerForBooking(prisma, { businessId: business.businessId, userId });
|
||||
expect(resolvedAgain.isNewProfile).toBe(false);
|
||||
expect(resolvedAgain.customerId).toBe(customerId);
|
||||
|
||||
const result = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
expect(result.appointment.customer.userId).toBe(userId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multi-service, specific-barber booking", () => {
|
||||
it("books both selected services with one specific barber", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
|
||||
const result = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId, business.beardTrimServiceId],
|
||||
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
expect(result.appointment.staffId).toBe(staffId);
|
||||
expect(result.appointment.services.map((s) => s.serviceId).sort()).toEqual(
|
||||
[business.haircutServiceId, business.beardTrimServiceId].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("any-barber booking", () => {
|
||||
it("auto-assigns whichever eligible barber is less busy that day", async () => {
|
||||
const { staffId: busyStaffId } = await createTestStaff(business);
|
||||
const { staffId: freeStaffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
|
||||
// Give the first barber an existing appointment that day so the second is strictly less busy.
|
||||
const existing = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId: busyStaffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 9 * 60, business.timezone),
|
||||
});
|
||||
expect(existing.success).toBe(true);
|
||||
|
||||
const anyBarber = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
// staffId omitted — "any available barber"
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 11 * 60, business.timezone),
|
||||
});
|
||||
|
||||
expect(anyBarber.success).toBe(true);
|
||||
if (!anyBarber.success) return;
|
||||
expect(anyBarber.appointment.staffId).toBe(freeStaffId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("slot becomes unavailable before confirmation", () => {
|
||||
it("rejects the second confirmation with a customer-friendly message once someone else has taken the slot", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId: customerAId } = await createTestCustomer(business);
|
||||
const { customerId: customerBId } = await createTestCustomer(business);
|
||||
const startAt = wallClockToInstant(monday, 10 * 60, business.timezone);
|
||||
|
||||
// Customer B confirms first, while customer A's draft still points at the same slot.
|
||||
const first = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId: customerBId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt,
|
||||
});
|
||||
expect(first.success).toBe(true);
|
||||
|
||||
const second = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId: customerAId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt,
|
||||
});
|
||||
|
||||
expect(second.success).toBe(false);
|
||||
if (second.success) return;
|
||||
expect(getFirstFriendlyMessage(second.validation)).toBe(
|
||||
"That time was just booked by someone else — please choose another.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("customer appointment retrieval", () => {
|
||||
it("only returns the requesting customer's own appointments, filtered by status", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId: ownCustomerId } = await createTestCustomer(business);
|
||||
const { customerId: otherCustomerId } = await createTestCustomer(business);
|
||||
|
||||
const confirmed = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId: ownCustomerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 9 * 60, business.timezone),
|
||||
});
|
||||
expect(confirmed.success).toBe(true);
|
||||
|
||||
const othersAppointment = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId: otherCustomerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 11 * 60, business.timezone),
|
||||
});
|
||||
expect(othersAppointment.success).toBe(true);
|
||||
|
||||
const upcoming = await listAppointments(prisma, {
|
||||
customerId: ownCustomerId,
|
||||
statuses: ["PENDING", "CONFIRMED"],
|
||||
});
|
||||
expect(upcoming).toHaveLength(1);
|
||||
expect(upcoming[0]!.customerId).toBe(ownCustomerId);
|
||||
|
||||
const cancelledList = await listAppointments(prisma, { customerId: ownCustomerId, statuses: ["CANCELLED"] });
|
||||
expect(cancelledList).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancellation", () => {
|
||||
it("a guest can cancel their own booking without a cancelledByUserId", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const guest = await createTestGuestCustomer();
|
||||
guestProfileIds.push(guest.customerId);
|
||||
|
||||
const created = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId: guest.customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||
});
|
||||
if (!created.success) throw new Error("setup failed");
|
||||
|
||||
const result = await cancelBooking(prisma, { appointmentId: created.appointment.id, reason: "Can't make it" });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.appointment?.status).toBe("CANCELLED");
|
||||
expect(result.appointment?.cancelledByUserId).toBeNull();
|
||||
expect(result.appointment?.cancellationReason).toBe("Can't make it");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rescheduling", () => {
|
||||
it("supports moving to a different barber, not just a different time", async () => {
|
||||
const { staffId: originalStaffId } = await createTestStaff(business);
|
||||
const { staffId: newStaffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
|
||||
const created = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId: originalStaffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||
});
|
||||
if (!created.success) throw new Error("setup failed");
|
||||
|
||||
const rescheduled = await rescheduleBooking(prisma, {
|
||||
appointmentId: created.appointment.id,
|
||||
staffId: newStaffId,
|
||||
startAt: wallClockToInstant(monday, 14 * 60, business.timezone),
|
||||
});
|
||||
|
||||
expect(rescheduled.success).toBe(true);
|
||||
if (!rescheduled.success) return;
|
||||
expect(rescheduled.appointment.staffId).toBe(newStaffId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("book again", () => {
|
||||
it("reprices at the current service price, never the old completed appointment's snapshot", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
|
||||
const original = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId], // £20 at the time of fixtures.ts
|
||||
startAt: wallClockToInstant(monday, 9 * 60, business.timezone),
|
||||
});
|
||||
if (!original.success) throw new Error("setup failed");
|
||||
await prisma.appointment.update({ where: { id: original.appointment.id }, data: { status: "COMPLETED" } });
|
||||
expect(Number(original.appointment.services[0]!.priceAtBooking)).toBe(20);
|
||||
|
||||
// Price goes up before the customer books again.
|
||||
await prisma.service.update({ where: { id: business.haircutServiceId }, data: { price: 35 } });
|
||||
|
||||
const tuesday = addDays(monday, 1);
|
||||
const rebooked = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(tuesday, 9 * 60, business.timezone),
|
||||
});
|
||||
|
||||
expect(rebooked.success).toBe(true);
|
||||
if (!rebooked.success) return;
|
||||
// The new booking snapshots today's price, not the £20 from the completed appointment.
|
||||
expect(Number(rebooked.appointment.services[0]!.priceAtBooking)).toBe(35);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ownership enforcement", () => {
|
||||
it("a registered customer's appointment is only accessible to that exact session user", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId, userId: ownerUserId } = await createTestCustomer(business);
|
||||
const { userId: strangerUserId } = await createTestCustomer(business);
|
||||
|
||||
const created = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||
});
|
||||
if (!created.success) throw new Error("setup failed");
|
||||
|
||||
await expect(
|
||||
assertCanAccessAppointment(prisma, created.appointment, { sessionUserId: ownerUserId }),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
assertCanAccessAppointment(prisma, created.appointment, { sessionUserId: strangerUserId }),
|
||||
).rejects.toThrow(PermissionDeniedError);
|
||||
await expect(
|
||||
assertCanAccessAppointment(prisma, created.appointment, { sessionUserId: undefined }),
|
||||
).rejects.toThrow(PermissionDeniedError);
|
||||
expect(await canAccessAppointment(prisma, created.appointment, { sessionUserId: strangerUserId })).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat the appointment id alone as permission — an unrelated session cannot claim it", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId, userId: ownerUserId } = await createTestCustomer(business);
|
||||
|
||||
const created = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||
});
|
||||
if (!created.success) throw new Error("setup failed");
|
||||
|
||||
expect(created.appointment.customer.userId).toBe(ownerUserId);
|
||||
const randomOtherId = "some-other-user-id-that-is-not-the-owner";
|
||||
expect(await canAccessAppointment(prisma, created.appointment, { sessionUserId: randomOtherId })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("guest access tokens (Phase 7 — see guest-access.service.ts)", () => {
|
||||
it("a guest booking issues a token, and only the correct token grants access", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const guest = await createTestGuestCustomer();
|
||||
guestProfileIds.push(guest.customerId);
|
||||
|
||||
const created = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId: guest.customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||
});
|
||||
if (!created.success) throw new Error("setup failed");
|
||||
|
||||
expect(created.guestAccessToken).toBeTruthy();
|
||||
expect(created.appointment.customer.userId).toBeNull();
|
||||
|
||||
await expect(
|
||||
assertCanAccessAppointment(prisma, created.appointment, { guestToken: created.guestAccessToken }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
// The bare appointment id is no longer sufficient — the exact gap this closes.
|
||||
await expect(
|
||||
assertCanAccessAppointment(prisma, created.appointment, { guestToken: undefined }),
|
||||
).rejects.toThrow(PermissionDeniedError);
|
||||
await expect(
|
||||
assertCanAccessAppointment(prisma, created.appointment, { guestToken: "totally-wrong-token" }),
|
||||
).rejects.toThrow(PermissionDeniedError);
|
||||
});
|
||||
|
||||
it("a token scoped to one guest appointment does not grant access to another", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const guest = await createTestGuestCustomer();
|
||||
guestProfileIds.push(guest.customerId);
|
||||
|
||||
const first = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId: guest.customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||
});
|
||||
const second = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId: guest.customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 12 * 60, business.timezone),
|
||||
});
|
||||
if (!first.success || !second.success) throw new Error("setup failed");
|
||||
expect(first.guestAccessToken).not.toBe(second.guestAccessToken);
|
||||
|
||||
await expect(
|
||||
assertCanAccessAppointment(prisma, second.appointment, { guestToken: first.guestAccessToken }),
|
||||
).rejects.toThrow(PermissionDeniedError);
|
||||
});
|
||||
|
||||
it("an expired or revoked token is rejected even though it's otherwise correct", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const guest = await createTestGuestCustomer();
|
||||
guestProfileIds.push(guest.customerId);
|
||||
|
||||
const created = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId: guest.customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||
});
|
||||
if (!created.success) throw new Error("setup failed");
|
||||
|
||||
await prisma.guestAppointmentAccessToken.update({
|
||||
where: { appointmentId: created.appointment.id },
|
||||
data: { expiresAt: new Date(Date.now() - 1000) },
|
||||
});
|
||||
await expect(
|
||||
assertCanAccessAppointment(prisma, created.appointment, { guestToken: created.guestAccessToken }),
|
||||
).rejects.toThrow(PermissionDeniedError);
|
||||
|
||||
await prisma.guestAppointmentAccessToken.update({
|
||||
where: { appointmentId: created.appointment.id },
|
||||
data: { expiresAt: new Date(Date.now() + 1000 * 60 * 60), revokedAt: null },
|
||||
});
|
||||
await expect(
|
||||
assertCanAccessAppointment(prisma, created.appointment, { guestToken: created.guestAccessToken }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await revokeGuestAccessToken(prisma, created.appointment.id);
|
||||
await expect(
|
||||
assertCanAccessAppointment(prisma, created.appointment, { guestToken: created.guestAccessToken }),
|
||||
).rejects.toThrow(PermissionDeniedError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("guest data isolation", () => {
|
||||
it("keeps two guests' profiles and appointments completely separate", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const guestA = await createTestGuestCustomer();
|
||||
const guestB = await createTestGuestCustomer();
|
||||
guestProfileIds.push(guestA.customerId, guestB.customerId);
|
||||
|
||||
const apptA = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId: guestA.customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 9 * 60, business.timezone),
|
||||
});
|
||||
const apptB = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId: guestB.customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 11 * 60, business.timezone),
|
||||
});
|
||||
expect(apptA.success).toBe(true);
|
||||
expect(apptB.success).toBe(true);
|
||||
|
||||
const guestAAppointments = await listAppointments(prisma, { customerId: guestA.customerId });
|
||||
expect(guestAAppointments).toHaveLength(1);
|
||||
expect(guestAAppointments[0]!.customerId).toBe(guestA.customerId);
|
||||
|
||||
// Resolving guest A's own details again must never return guest B's profile.
|
||||
const resolvedA = await resolveCustomerForBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
guest: { firstName: "Guest", lastName: "Customer", email: guestA.email, phone: guestA.phone },
|
||||
});
|
||||
expect(resolvedA.customerId).toBe(guestA.customerId);
|
||||
expect(resolvedA.customerId).not.toBe(guestB.customerId);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BOOKING_VALIDATION_ERROR_CODES } from "@/services/scheduling/types";
|
||||
import { getFirstFriendlyMessage, getFriendlyMessage, getFriendlyValidationMessages } from "../error-messages";
|
||||
|
||||
describe("getFriendlyMessage", () => {
|
||||
it("has a specific, non-empty message for every code the domain layer can produce", () => {
|
||||
for (const code of BOOKING_VALIDATION_ERROR_CODES) {
|
||||
const message = getFriendlyMessage(code);
|
||||
expect(message.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("never leaks the raw code into the message", () => {
|
||||
for (const code of BOOKING_VALIDATION_ERROR_CODES) {
|
||||
expect(getFriendlyMessage(code)).not.toContain(code);
|
||||
}
|
||||
});
|
||||
|
||||
it("gives SLOT_UNAVAILABLE a message that doesn't sound like an internal error", () => {
|
||||
expect(getFriendlyMessage("SLOT_UNAVAILABLE").toLowerCase()).toContain("booked");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFriendlyValidationMessages", () => {
|
||||
it("maps every issue in a validation result", () => {
|
||||
const messages = getFriendlyValidationMessages({
|
||||
valid: false,
|
||||
issues: [
|
||||
{ code: "STAFF_ON_BREAK", message: "internal" },
|
||||
{ code: "BELOW_MIN_NOTICE", message: "internal" },
|
||||
],
|
||||
});
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toBe(getFriendlyMessage("STAFF_ON_BREAK"));
|
||||
expect(messages[1]).toBe(getFriendlyMessage("BELOW_MIN_NOTICE"));
|
||||
});
|
||||
|
||||
it("returns an empty array for a valid result", () => {
|
||||
expect(getFriendlyValidationMessages({ valid: true, issues: [] })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFirstFriendlyMessage", () => {
|
||||
it("returns the first issue's friendly message", () => {
|
||||
const message = getFirstFriendlyMessage({
|
||||
valid: false,
|
||||
issues: [
|
||||
{ code: "BUSINESS_CLOSED", message: "internal" },
|
||||
{ code: "STAFF_ON_LEAVE", message: "internal" },
|
||||
],
|
||||
});
|
||||
expect(message).toBe(getFriendlyMessage("BUSINESS_CLOSED"));
|
||||
});
|
||||
|
||||
it("falls back gracefully when there are no issues", () => {
|
||||
expect(getFirstFriendlyMessage({ valid: true, issues: [] })).toMatch(/went wrong/i);
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import * as AppointmentService from "@/services/scheduling/appointment.service";
|
||||
|
||||
export async function getAppointmentAction(appointmentId: string) {
|
||||
return AppointmentService.getAppointmentById(prisma, appointmentId);
|
||||
}
|
||||
|
||||
export async function listAppointmentsAction(params: Parameters<typeof AppointmentService.listAppointments>[1]) {
|
||||
return AppointmentService.listAppointments(prisma, params);
|
||||
}
|
||||
|
||||
// Next's Server Actions compiler statically analyses "use server" files for
|
||||
// exported `async function` declarations — a `const` bound to a factory's
|
||||
// return value (however trivial) isn't recognized as a callable action, so
|
||||
// each one below is a real function declaration even though the bodies
|
||||
// are identical apart from which AppointmentService transition they call.
|
||||
|
||||
export async function checkInAction(appointmentId: string) {
|
||||
const result = await AppointmentService.checkIn(prisma, appointmentId);
|
||||
if (result.success) revalidatePath("/dev/scheduling-test");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function startAppointmentAction(appointmentId: string) {
|
||||
const result = await AppointmentService.start(prisma, appointmentId);
|
||||
if (result.success) revalidatePath("/dev/scheduling-test");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function completeAppointmentAction(appointmentId: string) {
|
||||
const result = await AppointmentService.complete(prisma, appointmentId);
|
||||
if (result.success) revalidatePath("/dev/scheduling-test");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function markNoShowAction(appointmentId: string) {
|
||||
const result = await AppointmentService.markNoShow(prisma, appointmentId);
|
||||
if (result.success) revalidatePath("/dev/scheduling-test");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function confirmAppointmentAction(appointmentId: string) {
|
||||
const result = await AppointmentService.confirm(prisma, appointmentId);
|
||||
if (result.success) revalidatePath("/dev/scheduling-test");
|
||||
return result;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import * as AvailabilityService from "@/services/scheduling/availability.service";
|
||||
import { fail } from "@/services/scheduling/types";
|
||||
import {
|
||||
getAvailableBarbersSchema,
|
||||
getAvailableDatesSchema,
|
||||
getAvailableServicesSchema,
|
||||
getAvailableTimeSlotsSchema,
|
||||
} from "../schemas";
|
||||
|
||||
/**
|
||||
* Thin wrappers over AvailabilityService — no business logic here, only
|
||||
* input parsing and the client↔server boundary. See
|
||||
* docs/implementation-plan.md §11 ("Server Actions... orchestrate the
|
||||
* appropriate domain services").
|
||||
*/
|
||||
|
||||
export async function getAvailableServicesAction(input: unknown) {
|
||||
const parsed = getAvailableServicesSchema.safeParse(input);
|
||||
if (!parsed.success) return { services: [], validation: fail({ code: "INVALID_INPUT", message: parsed.error.message }) };
|
||||
const services = await AvailabilityService.getAvailableServices(prisma, parsed.data);
|
||||
return { services };
|
||||
}
|
||||
|
||||
export async function getAvailableBarbersAction(input: unknown) {
|
||||
const parsed = getAvailableBarbersSchema.safeParse(input);
|
||||
if (!parsed.success) return { staff: [], validation: fail({ code: "INVALID_INPUT", message: parsed.error.message }) };
|
||||
const staff = await AvailabilityService.getAvailableStaff(prisma, parsed.data);
|
||||
return { staff };
|
||||
}
|
||||
|
||||
export async function getAvailableDatesAction(input: unknown) {
|
||||
const parsed = getAvailableDatesSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { dates: [], validation: fail({ code: "INVALID_INPUT", message: parsed.error.message }) };
|
||||
}
|
||||
return AvailabilityService.getAvailableDates(prisma, parsed.data);
|
||||
}
|
||||
|
||||
export async function getAvailableTimeSlotsAction(input: unknown) {
|
||||
const parsed = getAvailableTimeSlotsSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { slots: [], validation: fail({ code: "INVALID_INPUT", message: parsed.error.message }) };
|
||||
}
|
||||
return AvailabilityService.getAvailableTimeSlots(prisma, parsed.data);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import * as BookingService from "@/services/scheduling/booking.service";
|
||||
import { fail } from "@/services/scheduling/types";
|
||||
import { cancelBookingSchema, createBookingSchema, rescheduleBookingSchema, updateBookingNotesSchema } from "../schemas";
|
||||
|
||||
export async function createBookingAction(input: unknown) {
|
||||
const parsed = createBookingSchema.safeParse(input);
|
||||
if (!parsed.success) return { success: false as const, validation: fail({ code: "INVALID_INPUT", message: parsed.error.message }) };
|
||||
|
||||
const result = await BookingService.createBooking(prisma, {
|
||||
...parsed.data,
|
||||
startAt: new Date(parsed.data.startAt),
|
||||
});
|
||||
if (result.success) revalidatePath("/dev/scheduling-test");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function rescheduleBookingAction(input: unknown) {
|
||||
const parsed = rescheduleBookingSchema.safeParse(input);
|
||||
if (!parsed.success) return { success: false as const, validation: fail({ code: "INVALID_INPUT", message: parsed.error.message }) };
|
||||
|
||||
const result = await BookingService.rescheduleBooking(prisma, {
|
||||
...parsed.data,
|
||||
startAt: new Date(parsed.data.startAt),
|
||||
});
|
||||
if (result.success) revalidatePath("/dev/scheduling-test");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function cancelBookingAction(input: unknown) {
|
||||
const parsed = cancelBookingSchema.safeParse(input);
|
||||
if (!parsed.success) return { success: false as const, validation: fail({ code: "INVALID_INPUT", message: parsed.error.message }) };
|
||||
|
||||
const result = await BookingService.cancelBooking(prisma, parsed.data);
|
||||
if (result.success) revalidatePath("/dev/scheduling-test");
|
||||
return result;
|
||||
}
|
||||
|
||||
/** "Update booking" — notes only. Time/staff changes go through reschedule (a distinct, re-validated workflow), not this. */
|
||||
export async function updateBookingNotesAction(input: unknown) {
|
||||
const parsed = updateBookingNotesSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { success: false as const, validation: fail({ code: "INVALID_INPUT", message: parsed.error.message }) };
|
||||
}
|
||||
|
||||
const appointment = await prisma.appointment.update({
|
||||
where: { id: parsed.data.appointmentId },
|
||||
data: { notes: parsed.data.notes },
|
||||
include: { services: { include: { service: true } }, staff: { include: { user: true } }, customer: true },
|
||||
});
|
||||
revalidatePath("/dev/scheduling-test");
|
||||
return { success: true as const, appointment };
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { clearDraft, readDraft, writeLastBookingReference } from "@/lib/booking-draft/cookie";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { checkRateLimit } from "@/lib/security/rate-limit";
|
||||
import { getRequestIp } from "@/lib/security/request-ip";
|
||||
import { resolveCustomerForBooking } from "@/services/customer/customer.service";
|
||||
import { createBooking } from "@/services/scheduling/booking.service";
|
||||
import { syncAppointmentSafely } from "@/services/calendar/calendar-sync.service";
|
||||
import { getFirstFriendlyMessage } from "../error-messages";
|
||||
|
||||
export interface ConfirmBookingResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The only action that actually creates an appointment. Resolves *which*
|
||||
* customer this is via CustomerService (never trusts a client-supplied
|
||||
* customerId — see docs/phase-4-summary.md, "Security"), then hands off
|
||||
* entirely to BookingService.createBooking, which re-validates the slot
|
||||
* inside a transaction before committing — this action does not decide
|
||||
* whether the slot is actually available.
|
||||
*/
|
||||
export async function confirmBookingAction(businessId: string): Promise<ConfirmBookingResult> {
|
||||
const draft = await readDraft(businessId);
|
||||
|
||||
if (draft.serviceIds.length === 0 || !draft.startAt) {
|
||||
return { success: false, error: "Your booking details are incomplete — please start again." };
|
||||
}
|
||||
if (!draft.policyAcknowledged) {
|
||||
return { success: false, error: "Please acknowledge the cancellation policy to continue." };
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
|
||||
let customerId: string;
|
||||
try {
|
||||
if (session?.user?.id) {
|
||||
const resolved = await resolveCustomerForBooking(prisma, { businessId, userId: session.user.id });
|
||||
customerId = resolved.customerId;
|
||||
} else {
|
||||
if (!draft.guest) {
|
||||
return { success: false, error: "Please provide your contact details first." };
|
||||
}
|
||||
// Authoritative guest-booking gate — the UI already hides the
|
||||
// guest path when disabled (see /book/details), but this is the
|
||||
// one place that actually creates an Appointment row, so it's
|
||||
// re-checked here regardless of how the request arrived (a direct
|
||||
// Server Action call, a stale draft cookie started before the
|
||||
// owner disabled guest booking mid-flow, etc.).
|
||||
const bookingRule = await prisma.bookingRule.findUnique({ where: { businessId }, select: { allowGuestBooking: true } });
|
||||
if (!bookingRule?.allowGuestBooking) {
|
||||
return { success: false, error: "Guest booking is currently unavailable. Please sign in or create an account to continue." };
|
||||
}
|
||||
// Registered customers already have a rate-limited signin gate on
|
||||
// their account; a guest has none, so this is the one place spam
|
||||
// bookings can otherwise be generated with no account at all.
|
||||
const ip = await getRequestIp();
|
||||
const { allowed } = checkRateLimit(`guest-booking:${ip}`, { maxAttempts: 8, windowMs: 60 * 60 * 1000 });
|
||||
if (!allowed) {
|
||||
return { success: false, error: "Too many booking attempts. Please try again later." };
|
||||
}
|
||||
const resolved = await resolveCustomerForBooking(prisma, { businessId, guest: draft.guest });
|
||||
customerId = resolved.customerId;
|
||||
}
|
||||
} catch {
|
||||
return { success: false, error: "We couldn't confirm your details. Please try again." };
|
||||
}
|
||||
|
||||
const result = await createBooking(prisma, {
|
||||
businessId,
|
||||
customerId,
|
||||
serviceIds: draft.serviceIds,
|
||||
staffId: draft.staffId,
|
||||
startAt: new Date(draft.startAt),
|
||||
notes: draft.notes,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: getFirstFriendlyMessage(result.validation) };
|
||||
}
|
||||
|
||||
// A no-op when the appointment is PENDING (requires approval) — see
|
||||
// calendar-sync.service.ts's own policy comment. Never blocks or fails
|
||||
// this action either way.
|
||||
await syncAppointmentSafely(prisma, result.appointment.id);
|
||||
|
||||
await writeLastBookingReference({
|
||||
appointmentId: result.appointment.id,
|
||||
guestAccessToken: result.guestAccessToken,
|
||||
});
|
||||
await clearDraft();
|
||||
redirect("/book/confirmation");
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
||||
import { cancelBooking, rescheduleBooking } from "@/services/scheduling/booking.service";
|
||||
import { syncAppointmentSafely } from "@/services/calendar/calendar-sync.service";
|
||||
import { assertCanAccessAppointment, PermissionDeniedError } from "../authorization";
|
||||
import { getFirstFriendlyMessage } from "../error-messages";
|
||||
import { customerCancelSchema, customerRescheduleSchema } from "../schemas";
|
||||
|
||||
/**
|
||||
* Every mutation here re-checks ownership against the current session
|
||||
* before touching anything — never trusts that "the client only shows
|
||||
* this button for your own appointments" is actually true, since the
|
||||
* action can be called directly regardless of what the UI renders.
|
||||
*/
|
||||
|
||||
export interface CustomerAppointmentActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
wasLateCancellation?: boolean;
|
||||
}
|
||||
|
||||
export async function cancelCustomerAppointmentAction(input: unknown): Promise<CustomerAppointmentActionResult> {
|
||||
const parsed = customerCancelSchema.safeParse(input);
|
||||
if (!parsed.success) return { success: false, error: "Please try again." };
|
||||
|
||||
const appointment = await getAppointmentById(prisma, parsed.data.appointmentId);
|
||||
if (!appointment) return { success: false, error: "We couldn't find that appointment." };
|
||||
|
||||
const session = await auth();
|
||||
try {
|
||||
await assertCanAccessAppointment(prisma, appointment, {
|
||||
sessionUserId: session?.user?.id,
|
||||
guestToken: parsed.data.guestToken,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) return { success: false, error: error.message };
|
||||
throw error;
|
||||
}
|
||||
|
||||
const result = await cancelBooking(prisma, {
|
||||
appointmentId: parsed.data.appointmentId,
|
||||
cancelledByUserId: session?.user?.id,
|
||||
reason: parsed.data.reason,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: getFirstFriendlyMessage(result.validation) };
|
||||
}
|
||||
|
||||
await syncAppointmentSafely(prisma, parsed.data.appointmentId);
|
||||
|
||||
// Deliberately not revalidating here (unlike reschedule) — every affected
|
||||
// page is already `dynamic = "force-dynamic"`, so revalidatePath buys no
|
||||
// extra freshness, and doing it immediately would drop the cancelled
|
||||
// appointment out of the Upcoming list mid-render, unmounting this very
|
||||
// dialog before the customer can read the late-cancellation notice or
|
||||
// click "Done". The dialog's own onClose already calls router.refresh().
|
||||
return { success: true, wasLateCancellation: result.wasLateCancellation };
|
||||
}
|
||||
|
||||
export async function rescheduleCustomerAppointmentAction(input: unknown): Promise<CustomerAppointmentActionResult> {
|
||||
const parsed = customerRescheduleSchema.safeParse(input);
|
||||
if (!parsed.success) return { success: false, error: "Please choose a valid time." };
|
||||
|
||||
const appointment = await getAppointmentById(prisma, parsed.data.appointmentId);
|
||||
if (!appointment) return { success: false, error: "We couldn't find that appointment." };
|
||||
|
||||
const session = await auth();
|
||||
try {
|
||||
await assertCanAccessAppointment(prisma, appointment, {
|
||||
sessionUserId: session?.user?.id,
|
||||
guestToken: parsed.data.guestToken,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) return { success: false, error: error.message };
|
||||
throw error;
|
||||
}
|
||||
|
||||
const result = await rescheduleBooking(prisma, {
|
||||
appointmentId: parsed.data.appointmentId,
|
||||
staffId: parsed.data.staffId,
|
||||
startAt: new Date(parsed.data.startAt),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: getFirstFriendlyMessage(result.validation) };
|
||||
}
|
||||
|
||||
await syncAppointmentSafely(prisma, parsed.data.appointmentId);
|
||||
|
||||
revalidatePath("/appointments");
|
||||
revalidatePath(`/appointments/${parsed.data.appointmentId}`);
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { clearDraft, readDraft, writeDraft } from "@/lib/booking-draft/cookie";
|
||||
import { guestDetailsSchema } from "@/lib/booking-draft/schema";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
||||
import { validateBookingSlot } from "@/services/scheduling/availability.service";
|
||||
import { assertCanAccessAppointment, PermissionDeniedError } from "../authorization";
|
||||
import { getFirstFriendlyMessage } from "../error-messages";
|
||||
import {
|
||||
preselectServiceSchema,
|
||||
saveBarberTimeStepSchema,
|
||||
saveDetailsStepSchema,
|
||||
saveServicesStepSchema,
|
||||
startRebookSchema,
|
||||
} from "../schemas";
|
||||
|
||||
/**
|
||||
* Step-by-step booking draft actions. Each one: parses input, calls a
|
||||
* domain service where a real check is needed (never re-implements one),
|
||||
* persists the relevant slice of the draft, and redirects to the next
|
||||
* step. See src/lib/booking-draft for the storage mechanism.
|
||||
*/
|
||||
|
||||
export interface StepActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string[] | undefined>;
|
||||
}
|
||||
|
||||
export async function saveServicesStepAction(input: unknown): Promise<StepActionResult> {
|
||||
const parsed = saveServicesStepSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: "Please select at least one service.", fieldErrors: parsed.error.flatten().fieldErrors };
|
||||
}
|
||||
|
||||
const draft = await readDraft(parsed.data.businessId);
|
||||
const previousServiceIds = [...draft.serviceIds].sort().join(",");
|
||||
const nextServiceIds = [...parsed.data.serviceIds].sort().join(",");
|
||||
const servicesChanged = previousServiceIds !== nextServiceIds;
|
||||
|
||||
await writeDraft({
|
||||
...draft,
|
||||
businessId: parsed.data.businessId,
|
||||
serviceIds: parsed.data.serviceIds,
|
||||
// Changing the service selection can invalidate a previously chosen
|
||||
// slot (different duration), so clear it — but only if it actually
|
||||
// changed, so re-confirming the same services without edits doesn't
|
||||
// lose progress for no reason.
|
||||
...(servicesChanged ? { staffId: undefined, date: undefined, startAt: undefined } : {}),
|
||||
});
|
||||
|
||||
redirect("/book/barber-time");
|
||||
}
|
||||
|
||||
/**
|
||||
* Home's Services tab "Book" button (see BookServiceButton) — adds one
|
||||
* service to the existing booking draft and sends the customer straight
|
||||
* to /book with it already selected, per the follow-up brief's "Home
|
||||
* Service Book Preselection" requirement. Only ever receives a service
|
||||
* id from the client; everything else (does it exist, is it active, is
|
||||
* it online-bookable, which business it belongs to) is re-read from the
|
||||
* database here, never trusted from the caller — same discipline as
|
||||
* every other draft action in this file.
|
||||
*
|
||||
* Reuses the exact same "only clear staff/date/time if the service
|
||||
* selection actually changed" rule saveServicesStepAction already uses
|
||||
* above, rather than inventing a second one.
|
||||
*/
|
||||
export async function preselectServiceAction(
|
||||
input: unknown,
|
||||
): Promise<{ success: false; error: string } | never> {
|
||||
const parsed = preselectServiceSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: "That service couldn't be selected." };
|
||||
}
|
||||
|
||||
const service = await prisma.service.findUnique({ where: { id: parsed.data.serviceId } });
|
||||
if (!service || !service.isActive || !service.allowOnlineBooking) {
|
||||
return { success: false, error: "This service isn't available for online booking right now." };
|
||||
}
|
||||
|
||||
const draft = await readDraft(service.businessId);
|
||||
const alreadySelected = draft.serviceIds.includes(service.id);
|
||||
const nextServiceIds = alreadySelected ? draft.serviceIds : [...draft.serviceIds, service.id];
|
||||
|
||||
await writeDraft({
|
||||
...draft,
|
||||
businessId: service.businessId,
|
||||
serviceIds: nextServiceIds,
|
||||
// Adding a genuinely new service can change total duration/eligible
|
||||
// barbers, invalidating a previously chosen slot — clear it. Re-clicking
|
||||
// Book for a service that's already selected changes nothing, so leave
|
||||
// any in-progress barber/date/time alone.
|
||||
...(alreadySelected ? {} : { staffId: undefined, date: undefined, startAt: undefined }),
|
||||
});
|
||||
|
||||
redirect("/book");
|
||||
}
|
||||
|
||||
export async function saveBarberTimeStepAction(input: unknown): Promise<StepActionResult> {
|
||||
const parsed = saveBarberTimeStepSchema.safeParse(input);
|
||||
if (!parsed.success) return { success: false, error: "Please choose a valid time." };
|
||||
|
||||
const draft = await readDraft(parsed.data.businessId);
|
||||
if (draft.serviceIds.length === 0) {
|
||||
return { success: false, error: "Please select a service first." };
|
||||
}
|
||||
|
||||
// Only re-validate here when a specific barber was chosen — "any
|
||||
// available barber" is resolved to a specific one by BookingService
|
||||
// itself at confirm time (see booking.service.ts), and re-implementing
|
||||
// that resolution here just to pre-validate would duplicate its logic,
|
||||
// which the brief explicitly prohibits.
|
||||
if (parsed.data.staffId) {
|
||||
const { validation } = await validateBookingSlot(prisma, {
|
||||
businessId: parsed.data.businessId,
|
||||
serviceIds: draft.serviceIds,
|
||||
staffId: parsed.data.staffId,
|
||||
startAt: new Date(parsed.data.startAt),
|
||||
});
|
||||
if (!validation.valid) {
|
||||
return { success: false, error: getFirstFriendlyMessage(validation) };
|
||||
}
|
||||
}
|
||||
|
||||
await writeDraft({ ...draft, staffId: parsed.data.staffId, date: parsed.data.date, startAt: parsed.data.startAt });
|
||||
redirect("/book/details");
|
||||
}
|
||||
|
||||
export async function saveDetailsStepAction(input: unknown): Promise<StepActionResult> {
|
||||
const parsed = saveDetailsStepSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: "Please check the highlighted fields.", fieldErrors: parsed.error.flatten().fieldErrors };
|
||||
}
|
||||
|
||||
const draft = await readDraft(parsed.data.businessId);
|
||||
if (!draft.startAt) {
|
||||
return { success: false, error: "Please choose a date and time first." };
|
||||
}
|
||||
if (!parsed.data.policyAcknowledged) {
|
||||
return { success: false, error: "Please acknowledge the cancellation policy to continue." };
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
// Same authoritative check confirmBookingAction re-runs at the final
|
||||
// commit — rejected here too so a direct call to this action (not
|
||||
// just the confirm step) can't bypass a disabled guest-booking rule.
|
||||
const bookingRule = await prisma.bookingRule.findUnique({
|
||||
where: { businessId: parsed.data.businessId },
|
||||
select: { allowGuestBooking: true },
|
||||
});
|
||||
if (!bookingRule?.allowGuestBooking) {
|
||||
return { success: false, error: "Please sign in or create an account to continue." };
|
||||
}
|
||||
const guestParsed = guestDetailsSchema.safeParse({
|
||||
firstName: parsed.data.firstName,
|
||||
lastName: parsed.data.lastName,
|
||||
email: parsed.data.email,
|
||||
phone: parsed.data.phone,
|
||||
});
|
||||
if (!guestParsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Please fill in your contact details.",
|
||||
fieldErrors: guestParsed.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
await writeDraft({
|
||||
...draft,
|
||||
guest: guestParsed.data,
|
||||
notes: parsed.data.notes,
|
||||
policyAcknowledged: true,
|
||||
});
|
||||
} else {
|
||||
await writeDraft({
|
||||
...draft,
|
||||
guest: undefined,
|
||||
notes: parsed.data.notes,
|
||||
policyAcknowledged: true,
|
||||
});
|
||||
}
|
||||
|
||||
redirect("/book/review");
|
||||
}
|
||||
|
||||
export async function startRebookAction(input: unknown): Promise<void> {
|
||||
const parsed = startRebookSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
redirect("/appointments");
|
||||
}
|
||||
|
||||
const appointment = await getAppointmentById(prisma, parsed.data.appointmentId);
|
||||
if (!appointment) {
|
||||
redirect("/appointments");
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
try {
|
||||
await assertCanAccessAppointment(prisma, appointment, {
|
||||
sessionUserId: session?.user?.id,
|
||||
guestToken: parsed.data.guestToken,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
redirect("/appointments");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Only preselect services that are still active — an inactive service
|
||||
// can't be booked again, and the customer picks a fresh replacement
|
||||
// instead of silently having it dropped with no explanation.
|
||||
const activeServiceIds = appointment.services.filter((line) => line.service.isActive).map((line) => line.serviceId);
|
||||
|
||||
// Only preselect the original barber if they're still active and bookable online.
|
||||
const staffId = appointment.staff.status === "ACTIVE" && appointment.staff.isBookableOnline ? appointment.staffId : undefined;
|
||||
|
||||
await clearDraft();
|
||||
await writeDraft({
|
||||
businessId: appointment.businessId,
|
||||
serviceIds: activeServiceIds,
|
||||
staffId,
|
||||
policyAcknowledged: false,
|
||||
rebookFromAppointmentId: appointment.id,
|
||||
});
|
||||
|
||||
redirect("/book");
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { validateGuestAccessToken } from "@/services/scheduling/guest-access.service";
|
||||
|
||||
/**
|
||||
* Customer-facing ownership checks. Kept in the feature/action layer, not
|
||||
* the domain services — AppointmentService/BookingService are also used
|
||||
* by (future) staff/owner tooling, where an entirely different
|
||||
* permission rule applies ("any staff member in this business"). This
|
||||
* file is specifically "can THIS customer touch THIS appointment", so it
|
||||
* lives next to the customer-facing actions that need it, per Phase 2's
|
||||
* "permission enforcement happens again at the server-action layer"
|
||||
* principle.
|
||||
*/
|
||||
|
||||
export class PermissionDeniedError extends Error {
|
||||
constructor(message = "You don't have permission to access this appointment.") {
|
||||
super(message);
|
||||
this.name = "PermissionDeniedError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface OwnableAppointment {
|
||||
id: string;
|
||||
customer: { userId: string | null };
|
||||
}
|
||||
|
||||
export interface AppointmentAccess {
|
||||
sessionUserId?: string | undefined;
|
||||
/** Raw token from the guest's booking-confirmation link — see guest-access.service.ts. Ignored for a registered customer's appointment. */
|
||||
guestToken?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ownership rule:
|
||||
* - A REGISTERED customer's appointment (customer.userId is set) always
|
||||
* requires the requesting session to match that exact user. An
|
||||
* appointment id alone is never sufficient — see the Phase 4 brief's
|
||||
* explicit "Appointment IDs are not treated as permission".
|
||||
* - A GUEST appointment (customer.userId is null) requires the current,
|
||||
* unexpired, unrevoked access token for THIS exact appointment (see
|
||||
* GuestAppointmentAccessToken in schema.prisma and
|
||||
* guest-access.service.ts). Closes the Phase 4 gap where the
|
||||
* appointment's own id — unguessable but never actually secret — was
|
||||
* the only credential (see docs/phase-4-summary.md and
|
||||
* docs/phase-7-summary.md).
|
||||
*/
|
||||
export async function assertCanAccessAppointment(
|
||||
prisma: PrismaClient,
|
||||
appointment: OwnableAppointment,
|
||||
access: AppointmentAccess,
|
||||
): Promise<void> {
|
||||
if (appointment.customer.userId) {
|
||||
if (appointment.customer.userId !== access.sessionUserId) throw new PermissionDeniedError();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!access.guestToken ||
|
||||
!(await validateGuestAccessToken(prisma, { appointmentId: appointment.id, rawToken: access.guestToken }))
|
||||
) {
|
||||
throw new PermissionDeniedError();
|
||||
}
|
||||
}
|
||||
|
||||
export async function canAccessAppointment(
|
||||
prisma: PrismaClient,
|
||||
appointment: OwnableAppointment,
|
||||
access: AppointmentAccess,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await assertCanAccessAppointment(prisma, appointment, access);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MonthCalendar } from "@/components/ui/month-calendar";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { EmptyState } from "@/components/feedback/empty-state";
|
||||
import { CardSkeleton } from "@/components/feedback/skeleton";
|
||||
import { CalendarX2 } from "lucide-react";
|
||||
import {
|
||||
getAvailableBarbersAction,
|
||||
getAvailableDatesAction,
|
||||
getAvailableTimeSlotsAction,
|
||||
} from "../actions/availability";
|
||||
|
||||
interface Staff {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
interface TimeSlot {
|
||||
startMinutes: number;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
staffOptions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Local calendar-date components, not `.toISOString().slice(0, 10)` — that
|
||||
* converts to UTC first, which silently shifts the date back a day for
|
||||
* anyone in a timezone ahead of UTC (e.g. the business's own Europe/London
|
||||
* during BST). `date` here is always local midnight from MonthCalendar's
|
||||
* own `new Date(year, month, day)`, so reading the local parts back out is
|
||||
* the correct inverse.
|
||||
*/
|
||||
function toYMD(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The interactive barber/date/time core, shared between the booking flow
|
||||
* (/book/barber-time) and rescheduling (/appointments/[id]/reschedule) —
|
||||
* per the Phase 4 brief's explicit instruction to reuse this selection UI
|
||||
* rather than building it twice. All availability comes from the Phase 3
|
||||
* AvailabilityService via the existing Server Actions; nothing here
|
||||
* computes a slot itself.
|
||||
*/
|
||||
export function BarberDateTimePicker(props: {
|
||||
businessId: string;
|
||||
businessTimezone: string;
|
||||
serviceIds: string[];
|
||||
initialStaffId?: string;
|
||||
initialDate?: string;
|
||||
onContinue: (selection: {
|
||||
staffId?: string;
|
||||
date: string;
|
||||
startAt: string;
|
||||
}) => Promise<{ success: boolean; error?: string }>;
|
||||
continueLabel?: string;
|
||||
}) {
|
||||
const {
|
||||
businessId,
|
||||
businessTimezone,
|
||||
serviceIds,
|
||||
initialStaffId,
|
||||
initialDate,
|
||||
onContinue,
|
||||
continueLabel = "Continue",
|
||||
} = props;
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const [staffOptions, setStaffOptions] = useState<Staff[]>([]);
|
||||
const [selectedStaffId, setSelectedStaffId] = useState<string>(initialStaffId ?? "any");
|
||||
const [month, setMonth] = useState(() =>
|
||||
initialDate ? new Date(`${initialDate}T00:00:00`) : new Date(),
|
||||
);
|
||||
const [availableDates, setAvailableDates] = useState<Set<string>>(new Set());
|
||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(
|
||||
initialDate ? new Date(`${initialDate}T00:00:00`) : undefined,
|
||||
);
|
||||
const [slots, setSlots] = useState<TimeSlot[]>([]);
|
||||
const [selectedSlot, setSelectedSlot] = useState<TimeSlot | undefined>();
|
||||
const [slotsPending, startSlotsTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getAvailableBarbersAction({ businessId, serviceIds }).then((res) => setStaffOptions(res.staff));
|
||||
}, [businessId, serviceIds]);
|
||||
|
||||
useEffect(() => {
|
||||
const from = new Date(month.getFullYear(), month.getMonth(), 1);
|
||||
const to = new Date(month.getFullYear(), month.getMonth() + 1, 0);
|
||||
getAvailableDatesAction({
|
||||
businessId,
|
||||
serviceIds,
|
||||
staffId: selectedStaffId === "any" ? undefined : selectedStaffId,
|
||||
fromDate: toYMD(from),
|
||||
toDate: toYMD(to),
|
||||
}).then((res) => setAvailableDates(new Set(res.dates)));
|
||||
}, [businessId, serviceIds, month, selectedStaffId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedDate) return;
|
||||
// The fetch (and every state update it produces) runs inside the
|
||||
// transition callback, after an `await` — not synchronously at the
|
||||
// top of the effect body — so this stays clear of the
|
||||
// set-state-in-effect pitfall while still giving us a pending flag.
|
||||
startSlotsTransition(async () => {
|
||||
const res = await getAvailableTimeSlotsAction({
|
||||
businessId,
|
||||
serviceIds,
|
||||
staffId: selectedStaffId === "any" ? undefined : selectedStaffId,
|
||||
date: toYMD(selectedDate),
|
||||
});
|
||||
setSlots(res.slots);
|
||||
setSelectedSlot(undefined);
|
||||
});
|
||||
}, [businessId, serviceIds, selectedDate, selectedStaffId]);
|
||||
const effectiveSlots = !selectedDate ? EMPTY_SLOTS : slots;
|
||||
|
||||
const handleContinue = () => {
|
||||
if (!selectedDate || !selectedSlot) return;
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await onContinue({
|
||||
staffId: selectedStaffId === "any" ? undefined : selectedStaffId,
|
||||
date: toYMD(selectedDate),
|
||||
startAt: selectedSlot.startAt,
|
||||
});
|
||||
if (!result.success) setError(result.error ?? "Please choose a different time.");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pb-32">
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<section className="px-6">
|
||||
<h2 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">Barber</h2>
|
||||
<Select
|
||||
value={selectedStaffId}
|
||||
onChange={(e) => {
|
||||
setSelectedStaffId(e.target.value);
|
||||
setSelectedDate(undefined);
|
||||
}}
|
||||
>
|
||||
<option value="any">Anyone available</option>
|
||||
{staffOptions.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</section>
|
||||
|
||||
<section className="px-6">
|
||||
<h2 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">Date</h2>
|
||||
<MonthCalendar
|
||||
month={month}
|
||||
onMonthChange={setMonth}
|
||||
selectedDate={selectedDate}
|
||||
onSelectDate={setSelectedDate}
|
||||
isDateDisabled={(date) => !availableDates.has(toYMD(date))}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{selectedDate && (
|
||||
<section className="px-6">
|
||||
<h2 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">Time</h2>
|
||||
{slotsPending ? (
|
||||
<CardSkeleton />
|
||||
) : effectiveSlots.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={CalendarX2}
|
||||
title="No times available"
|
||||
description="Try a different date or barber."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{effectiveSlots.map((slot) => (
|
||||
<button
|
||||
key={slot.startAt}
|
||||
type="button"
|
||||
onClick={() => setSelectedSlot(slot)}
|
||||
aria-pressed={selectedSlot?.startAt === slot.startAt}
|
||||
className={
|
||||
selectedSlot?.startAt === slot.startAt
|
||||
? "border-accent bg-accent/10 rounded-xl border-2 px-5 py-3 text-sm font-bold"
|
||||
: "border-line hover:border-ink rounded-xl border bg-white px-5 py-3 text-sm font-medium"
|
||||
}
|
||||
>
|
||||
{new Date(slot.startAt).toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: businessTimezone,
|
||||
})}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-line fixed inset-x-0 bottom-0 z-40 border-t bg-white/95 px-6 py-4 backdrop-blur">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
{error && (
|
||||
<p role="alert" className="text-danger mb-3 text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleContinue}
|
||||
isLoading={isPending}
|
||||
disabled={!selectedSlot}
|
||||
>
|
||||
{continueLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_SLOTS: TimeSlot[] = [];
|
||||
@@ -1,29 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { startRebookAction } from "../actions/draft";
|
||||
|
||||
export function BookAgainButton({
|
||||
appointmentId,
|
||||
guestToken,
|
||||
className,
|
||||
}: {
|
||||
appointmentId: string;
|
||||
guestToken?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className={className}
|
||||
isLoading={isPending}
|
||||
onClick={() => startTransition(() => startRebookAction({ appointmentId, guestToken }))}
|
||||
>
|
||||
Book Again
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { preselectServiceAction } from "../actions/draft";
|
||||
|
||||
/**
|
||||
* Home's Services tab "Book" button — preselects this service in the
|
||||
* booking draft (see preselectServiceAction) before sending the customer
|
||||
* to /book, instead of the plain `<Link href="/book">` it used to be.
|
||||
* `preselectServiceAction` redirects on success; this only ever has
|
||||
* something to render when it instead returns a friendly error (an
|
||||
* inactive/removed service, or one no longer online-bookable).
|
||||
*/
|
||||
export function BookServiceButton({ serviceId }: { serviceId: string }) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleClick = () => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await preselectServiceAction({ serviceId });
|
||||
if (result && !result.success) setError(result.error);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Button type="button" size="sm" isLoading={isPending} onClick={handleClick}>
|
||||
Book
|
||||
</Button>
|
||||
{error && (
|
||||
<p role="alert" className="text-danger max-w-[10rem] text-right text-[10px] font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cancelCustomerAppointmentAction } from "../actions/customer-appointments";
|
||||
|
||||
export function CancelAppointmentDialog({
|
||||
appointmentId,
|
||||
guestToken,
|
||||
className,
|
||||
}: {
|
||||
appointmentId: string;
|
||||
guestToken?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<{ wasLateCancellation?: boolean } | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
|
||||
const handleConfirm = () => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const res = await cancelCustomerAppointmentAction({
|
||||
appointmentId,
|
||||
reason: reason || undefined,
|
||||
guestToken,
|
||||
});
|
||||
if (!res.success) {
|
||||
setError(res.error ?? "Something went wrong. Please try again.");
|
||||
return;
|
||||
}
|
||||
setResult({ wasLateCancellation: res.wasLateCancellation });
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
setResult(null);
|
||||
setReason("");
|
||||
setError(null);
|
||||
if (result) router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="destructive" size="sm" className={className} onClick={() => setOpen(true)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title={result ? "Appointment Cancelled" : "Cancel Appointment?"}
|
||||
>
|
||||
{result ? (
|
||||
<div className="space-y-4">
|
||||
<p role="status" className="text-muted text-sm">
|
||||
Your appointment has been cancelled. It won't be deleted from your history.
|
||||
</p>
|
||||
{result.wasLateCancellation && (
|
||||
<p className="border-warning/30 bg-warning/10 text-warning-foreground rounded-lg border p-3 text-xs">
|
||||
This was a late cancellation, outside the free-cancellation window. The business may
|
||||
apply a late-cancellation policy.
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" onClick={handleClose}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted text-sm">
|
||||
This can't be undone. You can add an optional reason below.
|
||||
</p>
|
||||
<Textarea
|
||||
placeholder="Reason (optional)"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={handleClose}>
|
||||
Keep it
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
onClick={handleConfirm}
|
||||
isLoading={isPending}
|
||||
>
|
||||
Cancel it
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { confirmBookingAction } from "../actions/confirm";
|
||||
|
||||
export function ConfirmBookingButton({ businessId }: { businessId: string }) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleConfirm = () => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await confirmBookingAction(businessId);
|
||||
if (!result.success) setError(result.error ?? "Something went wrong. Please try again.");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && (
|
||||
<p role="alert" className="text-danger mb-3 text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" onClick={handleConfirm} isLoading={isPending}>
|
||||
Confirm Booking
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState, useTransition } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { saveDetailsStepAction } from "../actions/draft";
|
||||
|
||||
const guestFormSchema = z.object({
|
||||
firstName: z.string().trim().min(1, "First name is required"),
|
||||
lastName: z.string().trim().min(1, "Last name is required"),
|
||||
email: z.email("Enter a valid email address"),
|
||||
phone: z.string().trim().min(1, "Phone number is required"),
|
||||
notes: z.string().max(2000).optional(),
|
||||
policyAcknowledged: z.literal(true, { error: "Please acknowledge the cancellation policy" }),
|
||||
});
|
||||
|
||||
const authenticatedFormSchema = z.object({
|
||||
notes: z.string().max(2000).optional(),
|
||||
policyAcknowledged: z.literal(true, { error: "Please acknowledge the cancellation policy" }),
|
||||
});
|
||||
|
||||
export function DetailsForm(props: {
|
||||
businessId: string;
|
||||
cancellationPolicy?: string;
|
||||
isAuthenticated: boolean;
|
||||
prefill?: { firstName: string; lastName: string; email: string; phone: string };
|
||||
initialNotes?: string;
|
||||
}) {
|
||||
const { businessId, cancellationPolicy, isAuthenticated, prefill, initialNotes } = props;
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const schema = isAuthenticated ? authenticatedFormSchema : guestFormSchema;
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} = useForm<any>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
firstName: prefill?.firstName ?? "",
|
||||
lastName: prefill?.lastName ?? "",
|
||||
email: prefill?.email ?? "",
|
||||
phone: prefill?.phone ?? "",
|
||||
notes: initialNotes ?? "",
|
||||
policyAcknowledged: false,
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const onSubmit = (data: any) => {
|
||||
setServerError(null);
|
||||
startTransition(async () => {
|
||||
const result = await saveDetailsStepAction({ businessId, ...data });
|
||||
if (!result.success) {
|
||||
setServerError(result.error ?? "Please check the highlighted fields.");
|
||||
if (result.fieldErrors) {
|
||||
for (const [field, messages] of Object.entries(result.fieldErrors)) {
|
||||
if (messages?.[0]) setError(field, { message: messages[0] });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
noValidate
|
||||
className="mx-auto max-w-2xl space-y-6 px-6 py-6"
|
||||
>
|
||||
{!isAuthenticated && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="First Name"
|
||||
error={errors.firstName?.message as string}
|
||||
{...register("firstName")}
|
||||
/>
|
||||
<Input
|
||||
label="Last Name"
|
||||
error={errors.lastName?.message as string}
|
||||
{...register("lastName")}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Email Address"
|
||||
type="email"
|
||||
error={errors.email?.message as string}
|
||||
{...register("email")}
|
||||
/>
|
||||
<Input
|
||||
label="Phone Number"
|
||||
type="tel"
|
||||
error={errors.phone?.message as string}
|
||||
{...register("phone")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAuthenticated && prefill && (
|
||||
<div className="rounded-md border-line border bg-white p-5">
|
||||
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">Booking as</p>
|
||||
<p className="mt-1 text-sm font-bold">
|
||||
{prefill.firstName} {prefill.lastName}
|
||||
</p>
|
||||
<p className="text-muted text-xs">
|
||||
{prefill.email}
|
||||
{prefill.phone ? ` · ${prefill.phone}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
label="Booking Notes (optional)"
|
||||
placeholder="e.g. skin sensitivity, specific style requests…"
|
||||
error={errors.notes?.message as string}
|
||||
{...register("notes")}
|
||||
/>
|
||||
|
||||
{cancellationPolicy && (
|
||||
<div className="border-line text-muted rounded-xl border bg-white p-4 text-xs">
|
||||
{cancellationPolicy}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Checkbox label="I acknowledge the cancellation policy" {...register("policyAcknowledged")} />
|
||||
{errors.policyAcknowledged && (
|
||||
<p role="alert" className="text-danger -mt-4 text-xs font-medium">
|
||||
{errors.policyAcknowledged.message as string}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{serverError && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{serverError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" isLoading={isPending}>
|
||||
Continue to Review
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, Check } from "lucide-react";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import type { AvailableService } from "@/services/scheduling/availability.service";
|
||||
|
||||
export function ServiceSelector(props: {
|
||||
services: AvailableService[];
|
||||
categoryNames: Record<string, string>;
|
||||
categoryOrder: string[];
|
||||
initialSelectedServiceIds: string[];
|
||||
rebooking: boolean;
|
||||
/** Injected rather than imported directly (same pattern as BarberDateTimePicker's onContinue) — the customer flow and the Phase 5 staff "book for customer" flow each write to their own draft cookie via a different Server Action, see docs/phase-5-summary.md. */
|
||||
onContinue: (serviceIds: string[]) => Promise<{ success: boolean; error?: string }>;
|
||||
}) {
|
||||
const {
|
||||
services,
|
||||
categoryNames,
|
||||
categoryOrder,
|
||||
initialSelectedServiceIds,
|
||||
rebooking,
|
||||
onContinue,
|
||||
} = props;
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>(initialSelectedServiceIds);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const byCategory = new Map<string, AvailableService[]>();
|
||||
for (const service of services) {
|
||||
const list = byCategory.get(service.categoryId) ?? [];
|
||||
list.push(service);
|
||||
byCategory.set(service.categoryId, list);
|
||||
}
|
||||
const orderedIds = [...categoryOrder, ...byCategory.keys()].filter(
|
||||
(id, i, arr) => arr.indexOf(id) === i,
|
||||
);
|
||||
return orderedIds
|
||||
.map((id) => ({
|
||||
id,
|
||||
name: categoryNames[id] ?? "Services",
|
||||
services: byCategory.get(id) ?? [],
|
||||
}))
|
||||
.filter((g) => g.services.length > 0);
|
||||
}, [services, categoryNames, categoryOrder]);
|
||||
|
||||
const selectedServices = services.filter((s) => selectedIds.includes(s.id));
|
||||
const totalDuration = selectedServices.reduce((sum, s) => sum + s.durationMinutes, 0);
|
||||
const totalPrice = selectedServices.reduce((sum, s) => sum + Number(s.price), 0);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]));
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await onContinue(selectedIds);
|
||||
if (!result.success) setError(result.error ?? "Please select at least one service.");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pb-32">
|
||||
<TopNav title="Select Services" onBack={true} sticky className="bg-stone" />
|
||||
|
||||
{rebooking && (
|
||||
<div className="border-accent/30 bg-accent/10 text-ink mx-6 mt-4 rounded-xl border p-3 text-xs">
|
||||
Booking again — we've preselected your previous services. Prices and availability are
|
||||
current, not from your last visit.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="mx-auto max-w-2xl space-y-8 px-6 py-6">
|
||||
{grouped.map((group) => (
|
||||
<section key={group.id}>
|
||||
<h2 className="mb-3 text-sm font-bold">{group.name}</h2>
|
||||
<div className="divide-line rounded-md border-line divide-y border bg-white">
|
||||
{group.services.map((service) => {
|
||||
const selected = selectedIds.includes(service.id);
|
||||
return (
|
||||
<button
|
||||
key={service.id}
|
||||
type="button"
|
||||
onClick={() => toggle(service.id)}
|
||||
aria-pressed={selected}
|
||||
className="flex w-full items-center justify-between gap-4 px-5 py-4 text-left"
|
||||
>
|
||||
{service.imageUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide
|
||||
<img src={service.imageUrl} alt="" className="bg-stone h-12 w-12 shrink-0 rounded-xl object-cover" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-medium">{service.name}</h3>
|
||||
<p className="text-muted mt-1 text-xs">{service.durationMinutes}m</p>
|
||||
{service.description && (
|
||||
<p className="text-muted mt-1 text-xs line-clamp-2">{service.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-bold">£{Number(service.price).toFixed(2)}</span>
|
||||
<span
|
||||
className={
|
||||
selected
|
||||
? "bg-accent flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-white"
|
||||
: "border-line text-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-full border"
|
||||
}
|
||||
>
|
||||
{selected ? (
|
||||
<Check className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{grouped.length === 0 && (
|
||||
<p className="text-muted text-sm">No services are available online right now.</p>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<div className="border-line fixed inset-x-0 bottom-0 z-40 border-t bg-white/95 px-6 py-4 backdrop-blur">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
{error && (
|
||||
<p role="alert" className="text-danger mb-3 text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-muted text-xs font-bold tracking-widest uppercase">
|
||||
{selectedServices.length} selected · {totalDuration}m
|
||||
</p>
|
||||
<p className="text-xl font-semibold">£{totalPrice.toFixed(2)}</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleContinue}
|
||||
isLoading={isPending}
|
||||
disabled={selectedIds.length === 0}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { BookingValidationErrorCode, ValidationResult } from "@/services/scheduling/types";
|
||||
|
||||
/**
|
||||
* Maps the typed error codes the Phase 3 services already return into
|
||||
* customer-friendly copy. The only place raw codes get translated —
|
||||
* every booking-flow page imports from here rather than inlining its own
|
||||
* message strings, so the wording only needs to change in one place.
|
||||
*/
|
||||
const FRIENDLY_MESSAGES: Record<BookingValidationErrorCode, string> = {
|
||||
INVALID_INPUT: "Something about that wasn't quite right. Please try again.",
|
||||
NO_SERVICES_SELECTED: "Please select at least one service.",
|
||||
SERVICES_SPAN_MULTIPLE_BUSINESSES: "One or more selected services aren't available here.",
|
||||
STAFF_CANNOT_PERFORM_SERVICE: "This barber doesn't offer one of the selected services.",
|
||||
BUSINESS_CLOSED: "The business is closed at this time — please choose another slot.",
|
||||
STAFF_NOT_WORKING: "This barber isn't working at that time — please choose another slot or barber.",
|
||||
STAFF_ON_BREAK: "This barber is on a break at that time — please choose another slot.",
|
||||
STAFF_ON_LEAVE: "This barber is away on that date — please choose another barber or date.",
|
||||
SLOT_UNAVAILABLE: "That time was just booked by someone else — please choose another.",
|
||||
INSUFFICIENT_DURATION:
|
||||
"There isn't enough time left that day for the selected services — try an earlier time or fewer services.",
|
||||
BELOW_MIN_NOTICE: "This time is too soon — please choose a later time.",
|
||||
BEYOND_MAX_ADVANCE: "This date is too far ahead to book yet — please choose a nearer date.",
|
||||
BUSINESS_DAILY_LIMIT_REACHED: "This business is fully booked that day — please choose another date.",
|
||||
STAFF_DAILY_LIMIT_REACHED: "This barber is fully booked that day — please choose another barber or date.",
|
||||
APPOINTMENT_NOT_FOUND: "We couldn't find that appointment.",
|
||||
INVALID_STATUS_TRANSITION: "This appointment can no longer be changed.",
|
||||
STAFF_NOT_FOUND: "We couldn't find that barber.",
|
||||
CUSTOMER_NOT_FOUND: "We couldn't find your customer details.",
|
||||
BUSINESS_NOT_FOUND: "We couldn't find this business.",
|
||||
};
|
||||
|
||||
const FALLBACK_MESSAGE = "Something went wrong with this booking. Please try again.";
|
||||
|
||||
export function getFriendlyMessage(code: BookingValidationErrorCode): string {
|
||||
return FRIENDLY_MESSAGES[code] ?? FALLBACK_MESSAGE;
|
||||
}
|
||||
|
||||
export function getFriendlyValidationMessages(validation: ValidationResult): string[] {
|
||||
if (validation.issues.length === 0) return [];
|
||||
return validation.issues.map((issue) => getFriendlyMessage(issue.code));
|
||||
}
|
||||
|
||||
export function getFirstFriendlyMessage(validation: ValidationResult): string {
|
||||
const [first] = validation.issues;
|
||||
return first ? getFriendlyMessage(first.code) : FALLBACK_MESSAGE;
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Input shapes for the booking Server Actions. Kept separate from the
|
||||
* scheduling services' own TS types deliberately — these describe what
|
||||
* crosses the client→server boundary as JSON (dates as ISO strings, no
|
||||
* Date objects), the services describe the domain.
|
||||
*/
|
||||
|
||||
export const getAvailableServicesSchema = z.object({
|
||||
businessId: z.string(),
|
||||
categoryId: z.string().optional(),
|
||||
staffId: z.string().optional(),
|
||||
});
|
||||
|
||||
/** Home's Services tab "Book" button — see preselectServiceAction. Only a service id ever crosses this boundary; price/duration/eligibility are always re-read from the database server-side, never trusted from the client. */
|
||||
export const preselectServiceSchema = z.object({
|
||||
serviceId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const getAvailableBarbersSchema = z.object({
|
||||
businessId: z.string(),
|
||||
serviceIds: z.array(z.string()).min(1),
|
||||
});
|
||||
|
||||
export const getAvailableDatesSchema = z.object({
|
||||
businessId: z.string(),
|
||||
serviceIds: z.array(z.string()).min(1),
|
||||
staffId: z.string().optional(),
|
||||
fromDate: z.string(),
|
||||
toDate: z.string(),
|
||||
});
|
||||
|
||||
export const getAvailableTimeSlotsSchema = z.object({
|
||||
businessId: z.string(),
|
||||
serviceIds: z.array(z.string()).min(1),
|
||||
staffId: z.string().optional(),
|
||||
date: z.string(),
|
||||
});
|
||||
|
||||
export const createBookingSchema = z.object({
|
||||
businessId: z.string(),
|
||||
customerId: z.string(),
|
||||
serviceIds: z.array(z.string()).min(1),
|
||||
staffId: z.string().optional(),
|
||||
startAt: z.iso.datetime(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
export const rescheduleBookingSchema = z.object({
|
||||
appointmentId: z.string(),
|
||||
staffId: z.string().optional(),
|
||||
startAt: z.iso.datetime(),
|
||||
});
|
||||
|
||||
export const cancelBookingSchema = z.object({
|
||||
appointmentId: z.string(),
|
||||
cancelledByUserId: z.string().optional(),
|
||||
reason: z.string().optional(),
|
||||
});
|
||||
|
||||
export const updateBookingNotesSchema = z.object({
|
||||
appointmentId: z.string(),
|
||||
notes: z.string().max(2000),
|
||||
});
|
||||
|
||||
// ── Customer booking-flow draft steps ────────────────────────────────────
|
||||
|
||||
export const saveServicesStepSchema = z.object({
|
||||
businessId: z.string(),
|
||||
serviceIds: z.array(z.string()).min(1, "Select at least one service"),
|
||||
});
|
||||
|
||||
export const saveBarberTimeStepSchema = z.object({
|
||||
businessId: z.string(),
|
||||
staffId: z.string().optional(),
|
||||
date: z.string(),
|
||||
startAt: z.iso.datetime(),
|
||||
});
|
||||
|
||||
export const saveDetailsStepSchema = z.object({
|
||||
businessId: z.string(),
|
||||
firstName: z.string().trim().min(1, "First name is required").optional(),
|
||||
lastName: z.string().trim().min(1, "Last name is required").optional(),
|
||||
email: z.email("Enter a valid email address").optional(),
|
||||
phone: z.string().trim().min(1, "Phone number is required").optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
policyAcknowledged: z.boolean(),
|
||||
});
|
||||
|
||||
export const startRebookSchema = z.object({
|
||||
appointmentId: z.string(),
|
||||
guestToken: z.string().optional(),
|
||||
});
|
||||
|
||||
export const customerCancelSchema = z.object({
|
||||
appointmentId: z.string(),
|
||||
reason: z.string().max(500).optional(),
|
||||
guestToken: z.string().optional(),
|
||||
});
|
||||
|
||||
export const customerRescheduleSchema = z.object({
|
||||
appointmentId: z.string(),
|
||||
staffId: z.string().optional(),
|
||||
startAt: z.iso.datetime(),
|
||||
guestToken: z.string().optional(),
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { decryptToken, encryptToken } from "@/lib/security/token-encryption";
|
||||
import { GoogleCalendarProvider } from "@/services/calendar/google-calendar.provider";
|
||||
import { CalendarProviderError } from "@/services/calendar/types";
|
||||
import type { RemoteCalendarInfo } from "@/services/calendar/types";
|
||||
import { runInitialSync } from "@/services/calendar/calendar-sync.service";
|
||||
|
||||
async function requireStaffId(): Promise<string> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error("Not signed in.");
|
||||
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: session.user.id }, select: { id: true } });
|
||||
if (!staffProfile) throw new Error("Not a staff member.");
|
||||
return staffProfile.id;
|
||||
}
|
||||
|
||||
export interface CalendarActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function disconnectCalendarAction(): Promise<CalendarActionResult> {
|
||||
const staffId = await requireStaffId();
|
||||
const connection = await prisma.staffCalendarConnection.findUnique({ where: { staffId } });
|
||||
if (!connection) return { success: true };
|
||||
|
||||
// Best-effort revoke, then always clear locally regardless of whether
|
||||
// Google's revoke call succeeded — see the brief's "Revoke credentials
|
||||
// where possible" (not "must succeed before disconnecting").
|
||||
const provider = new GoogleCalendarProvider();
|
||||
await provider.revoke(decryptToken(connection.encryptedRefreshToken)).catch(() => {});
|
||||
|
||||
// Existing AppointmentCalendarEvent rows and their remote events are
|
||||
// deliberately left alone — see the brief's "Leave existing events,
|
||||
// stop managing them." Only the connection itself is removed, so future
|
||||
// appointment changes have nothing left to sync against.
|
||||
await prisma.staffCalendarConnection.update({
|
||||
where: { staffId },
|
||||
data: { revokedAt: new Date(), encryptedAccessToken: null, tokenExpiresAt: null },
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function syncNowAction(): Promise<CalendarActionResult> {
|
||||
const staffId = await requireStaffId();
|
||||
const connection = await prisma.staffCalendarConnection.findUnique({ where: { staffId } });
|
||||
if (!connection || connection.revokedAt) {
|
||||
return { success: false, error: "Connect Google Calendar first." };
|
||||
}
|
||||
await runInitialSync(prisma, staffId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export interface ListCalendarsResult {
|
||||
success: boolean;
|
||||
calendars?: RemoteCalendarInfo[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function listAvailableCalendarsAction(): Promise<ListCalendarsResult> {
|
||||
const staffId = await requireStaffId();
|
||||
const connection = await prisma.staffCalendarConnection.findUnique({ where: { staffId } });
|
||||
if (!connection || connection.revokedAt) {
|
||||
return { success: false, error: "Connect Google Calendar first." };
|
||||
}
|
||||
|
||||
const provider = new GoogleCalendarProvider();
|
||||
try {
|
||||
let accessToken = connection.encryptedAccessToken ? decryptToken(connection.encryptedAccessToken) : null;
|
||||
const expired = !connection.tokenExpiresAt || connection.tokenExpiresAt.getTime() - Date.now() < 60_000;
|
||||
if (!accessToken || expired) {
|
||||
const refreshed = await provider.refreshAccessToken(decryptToken(connection.encryptedRefreshToken));
|
||||
accessToken = refreshed.accessToken;
|
||||
await prisma.staffCalendarConnection.update({
|
||||
where: { staffId },
|
||||
data: { encryptedAccessToken: encryptToken(refreshed.accessToken), tokenExpiresAt: refreshed.expiresAt },
|
||||
});
|
||||
}
|
||||
const calendars = await provider.listWritableCalendars(accessToken);
|
||||
return { success: true, calendars };
|
||||
} catch (error) {
|
||||
const message = error instanceof CalendarProviderError ? error.message : "Couldn't load calendars.";
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function selectCalendarAction(calendarId: string, calendarName: string): Promise<CalendarActionResult> {
|
||||
const staffId = await requireStaffId();
|
||||
const connection = await prisma.staffCalendarConnection.findUnique({ where: { staffId } });
|
||||
if (!connection || connection.revokedAt) {
|
||||
return { success: false, error: "Connect Google Calendar first." };
|
||||
}
|
||||
await prisma.staffCalendarConnection.update({
|
||||
where: { staffId },
|
||||
data: { selectedCalendarId: calendarId, selectedCalendarName: calendarName },
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
disconnectCalendarAction,
|
||||
listAvailableCalendarsAction,
|
||||
selectCalendarAction,
|
||||
syncNowAction,
|
||||
} from "../actions/calendar";
|
||||
import type { RemoteCalendarInfo } from "@/services/calendar/types";
|
||||
|
||||
export interface ConnectionState {
|
||||
connected: boolean;
|
||||
accountEmail?: string;
|
||||
selectedCalendarName?: string;
|
||||
lastSyncAt?: string;
|
||||
lastSyncError?: string | null;
|
||||
}
|
||||
|
||||
export function CalendarConnectionPanel({ initial, configured }: { initial: ConnectionState; configured: boolean }) {
|
||||
const [state, setState] = useState(initial);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [calendars, setCalendars] = useState<RemoteCalendarInfo[] | null>(null);
|
||||
|
||||
const sync = () => {
|
||||
setMessage(null);
|
||||
startTransition(async () => {
|
||||
const result = await syncNowAction();
|
||||
setMessage(result.success ? "Sync complete." : (result.error ?? "Sync failed."));
|
||||
});
|
||||
};
|
||||
|
||||
const disconnect = () => {
|
||||
setMessage(null);
|
||||
startTransition(async () => {
|
||||
await disconnectCalendarAction();
|
||||
setState({ connected: false });
|
||||
setMessage("Disconnected. Existing calendar events were left as they are.");
|
||||
});
|
||||
};
|
||||
|
||||
const loadCalendars = () => {
|
||||
setMessage(null);
|
||||
startTransition(async () => {
|
||||
const result = await listAvailableCalendarsAction();
|
||||
if (!result.success) {
|
||||
setMessage(result.error ?? "Couldn't load calendars.");
|
||||
return;
|
||||
}
|
||||
setCalendars(result.calendars ?? []);
|
||||
});
|
||||
};
|
||||
|
||||
const chooseCalendar = (calendar: RemoteCalendarInfo) => {
|
||||
startTransition(async () => {
|
||||
const result = await selectCalendarAction(calendar.id, calendar.name);
|
||||
if (result.success) {
|
||||
setState((prev) => ({ ...prev, selectedCalendarName: calendar.name }));
|
||||
setCalendars(null);
|
||||
setMessage(`Now syncing to "${calendar.name}".`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (!state.connected) {
|
||||
return (
|
||||
<div className="border-line rounded-md border bg-white p-5">
|
||||
<p className="mb-1 text-sm font-bold">Google Calendar</p>
|
||||
<p className="text-muted mb-4 text-xs">
|
||||
Sync your confirmed appointments to your own Google Calendar. This connects a Google account separately
|
||||
from sign-in — you can use a different one if you like.
|
||||
</p>
|
||||
{message && <p className="text-danger mb-3 text-xs font-medium">{message}</p>}
|
||||
{configured ? (
|
||||
<Button asChild size="sm">
|
||||
<a href="/api/staff/calendar/google/authorize">Connect Google Calendar</a>
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-muted text-xs">Google Calendar sync is not configured on this deployment yet.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-line rounded-md border bg-white p-5">
|
||||
<p className="mb-1 text-sm font-bold">Google Calendar</p>
|
||||
<div className="text-muted mb-4 space-y-1 text-xs">
|
||||
<p>Connected as {state.accountEmail}</p>
|
||||
<p>Syncing to “{state.selectedCalendarName}”</p>
|
||||
{state.lastSyncAt && <p>Last synced {new Date(state.lastSyncAt).toLocaleString()}</p>}
|
||||
{state.lastSyncError && <p className="text-danger">Last sync error: {state.lastSyncError}</p>}
|
||||
</div>
|
||||
|
||||
{message && <p className="text-muted mb-3 text-xs font-medium">{message}</p>}
|
||||
|
||||
{calendars ? (
|
||||
<ul className="mb-4 space-y-2">
|
||||
{calendars.map((calendar) => (
|
||||
<li key={calendar.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => chooseCalendar(calendar)}
|
||||
className="border-line w-full rounded-lg border px-3 py-2 text-left text-xs font-bold hover:bg-stone"
|
||||
>
|
||||
{calendar.name} {calendar.primary && "(primary)"}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" isLoading={isPending} onClick={sync}>
|
||||
Sync Now
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" isLoading={isPending} onClick={loadCalendars}>
|
||||
Change Calendar
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" isLoading={isPending} onClick={disconnect}>
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { customerProfileSchema } from "@/features/customer/schemas";
|
||||
import {
|
||||
CUSTOMER_AVATAR_CATEGORY,
|
||||
setCustomerAvatar,
|
||||
updateCustomerProfile,
|
||||
updatePreferredBarber,
|
||||
} from "@/services/customer/customer.service";
|
||||
import { deleteMediaFile, isAllowedMediaMimeType, MEDIA_MAX_UPLOAD_BYTES, storeMediaFile } from "@/lib/media/media-storage";
|
||||
|
||||
export interface CustomerProfileActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
/** Per-field errors, keyed by field name, for the form to surface inline. */
|
||||
fieldErrors?: Record<string, string[] | undefined>;
|
||||
}
|
||||
|
||||
export async function updateCustomerProfileAction(input: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
}): Promise<CustomerProfileActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { success: false, error: "You must be signed in." };
|
||||
|
||||
const parsed = customerProfileSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: "Please fix the highlighted fields.", fieldErrors: parsed.error.flatten().fieldErrors };
|
||||
}
|
||||
|
||||
const result = await updateCustomerProfile(prisma, { userId: session.user.id, ...parsed.data, phone: parsed.data.phone ?? "" });
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error,
|
||||
fieldErrors: result.field ? { [result.field]: [result.error ?? ""] } : undefined,
|
||||
};
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updatePreferredBarberAction(staffId: string | null): Promise<CustomerProfileActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { success: false, error: "You must be signed in." };
|
||||
|
||||
return updatePreferredBarber(prisma, { userId: session.user.id, staffId });
|
||||
}
|
||||
|
||||
export interface CustomerAvatarActionResult {
|
||||
success: boolean;
|
||||
url?: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a customer's own profile photo straight from the device file
|
||||
* picker — no Media Manager/library involved, unlike staff/service/branding
|
||||
* images (see MediaPicker). Stored under its own category so it's never
|
||||
* inserted as a MediaAsset row and therefore never browsable in that
|
||||
* library. Takes effect immediately (writes User.image right away) rather
|
||||
* than waiting for the rest of the profile form's Save, and deletes the
|
||||
* previous photo file once the new one is safely persisted — but only if
|
||||
* that previous file was one we stored (an OAuth-provided avatar URL is
|
||||
* left alone).
|
||||
*/
|
||||
export async function uploadCustomerAvatarAction(formData: FormData): Promise<CustomerAvatarActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { success: false, error: "You must be signed in." };
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) return { success: false, error: "No file provided." };
|
||||
if (!isAllowedMediaMimeType(file.type)) {
|
||||
return { success: false, error: "Unsupported image type — use PNG, JPEG, WEBP, or GIF." };
|
||||
}
|
||||
if (file.size > MEDIA_MAX_UPLOAD_BYTES) {
|
||||
return { success: false, error: `File is too large — max ${Math.floor(MEDIA_MAX_UPLOAD_BYTES / 1_000_000)}MB.` };
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const stored = await storeMediaFile({ buffer, mimeType: file.type, category: CUSTOMER_AVATAR_CATEGORY });
|
||||
|
||||
const { previousUrl } = await setCustomerAvatar(prisma, { userId: session.user.id, url: stored.url });
|
||||
if (previousUrl?.includes(`/${CUSTOMER_AVATAR_CATEGORY}/`)) {
|
||||
await deleteMediaFile(previousUrl);
|
||||
}
|
||||
|
||||
return { success: true, url: stored.url };
|
||||
}
|
||||
|
||||
export async function removeCustomerAvatarAction(): Promise<CustomerAvatarActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { success: false, error: "You must be signed in." };
|
||||
|
||||
const { previousUrl } = await setCustomerAvatar(prisma, { userId: session.user.id, url: null });
|
||||
if (previousUrl?.includes(`/${CUSTOMER_AVATAR_CATEGORY}/`)) {
|
||||
await deleteMediaFile(previousUrl);
|
||||
}
|
||||
|
||||
return { success: true, url: null };
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useTransition } from "react";
|
||||
import { ImageOff, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { removeCustomerAvatarAction, uploadCustomerAvatarAction } from "@/features/customer/actions/profile";
|
||||
|
||||
export interface CustomerAvatarPickerProps {
|
||||
value: string | null;
|
||||
onChange: (url: string | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer's own profile photo — opens the device's native file picker
|
||||
* directly and uploads immediately, unlike MediaPicker (staff/service/
|
||||
* branding images), which opens the shared Media Manager library. Kept
|
||||
* deliberately separate: a customer's photo is personal, not something an
|
||||
* owner should be able to browse/reuse/reassign to something else.
|
||||
*/
|
||||
export function CustomerAvatarPicker({ value, onChange }: CustomerAvatarPickerProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
|
||||
setError(null);
|
||||
const formData = new FormData();
|
||||
formData.set("file", file);
|
||||
startTransition(async () => {
|
||||
const result = await uploadCustomerAvatarAction(formData);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Couldn't upload your photo.");
|
||||
return;
|
||||
}
|
||||
onChange(result.url ?? null);
|
||||
});
|
||||
}
|
||||
|
||||
function handleRemove() {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await removeCustomerAvatarAction();
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Couldn't remove your photo.");
|
||||
return;
|
||||
}
|
||||
onChange(null);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<p className="text-muted mb-2 ml-1 text-[10px] font-bold tracking-widest uppercase">Photo</p>
|
||||
<div className="border-line flex items-center gap-4 rounded-xl border bg-white p-4">
|
||||
<div className="bg-stone flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg">
|
||||
{value ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URLs, same pattern used app-wide
|
||||
<img src={value} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<ImageOff className="text-muted h-5 w-5" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-wrap gap-2">
|
||||
<Button type="button" size="sm" variant="secondary" isLoading={isPending} onClick={() => inputRef.current?.click()}>
|
||||
{value ? "Change" : "Choose image"}
|
||||
</Button>
|
||||
{value && (
|
||||
<Button type="button" size="sm" variant="ghost" disabled={isPending} onClick={handleRemove}>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" /> Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
className="sr-only"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-danger mt-1.5 ml-1 text-xs font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { CustomerAvatarPicker } from "@/features/customer/components/customer-avatar-picker";
|
||||
import { updateCustomerProfileAction } from "@/features/customer/actions/profile";
|
||||
|
||||
export interface CustomerProfileFormInitial {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
export function CustomerProfileForm({ initial }: { initial: CustomerProfileFormInitial }) {
|
||||
const router = useRouter();
|
||||
const [firstName, setFirstName] = useState(initial.firstName);
|
||||
const [lastName, setLastName] = useState(initial.lastName);
|
||||
const [email, setEmail] = useState(initial.email);
|
||||
const [phone, setPhone] = useState(initial.phone);
|
||||
const [image, setImage] = useState(initial.image);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string | undefined>>({});
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleSave = () => {
|
||||
setError(null);
|
||||
setFieldErrors({});
|
||||
setSaved(false);
|
||||
startTransition(async () => {
|
||||
const result = await updateCustomerProfileAction({ firstName, lastName, email, phone });
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Couldn't save your profile.");
|
||||
if (result.fieldErrors) {
|
||||
setFieldErrors(Object.fromEntries(Object.entries(result.fieldErrors).map(([key, value]) => [key, value?.[0]])));
|
||||
}
|
||||
return;
|
||||
}
|
||||
setSaved(true);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CustomerAvatarPicker value={image || null} onChange={(url) => setImage(url ?? "")} />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="First Name"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
error={fieldErrors.firstName}
|
||||
/>
|
||||
<Input label="Last Name" value={lastName} onChange={(e) => setLastName(e.target.value)} error={fieldErrors.lastName} />
|
||||
</div>
|
||||
<Input
|
||||
label="Email Address"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
error={fieldErrors.email}
|
||||
/>
|
||||
<Input
|
||||
label="Phone Number"
|
||||
type="tel"
|
||||
placeholder="+44 7700 900000"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
error={fieldErrors.phone}
|
||||
/>
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{saved && !error && (
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { Check } from "lucide-react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { updatePreferredBarberAction } from "@/features/customer/actions/profile";
|
||||
|
||||
export interface PreferredBarberOption {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
/** Tap-to-select, saves immediately (no separate Save step) — same "pick one" pattern as the Reviews manager's hide/unhide toggle: one action per row, optimistic update, revert on failure. */
|
||||
export function PreferredBarberPicker({
|
||||
staff,
|
||||
initialPreferredId,
|
||||
}: {
|
||||
staff: PreferredBarberOption[];
|
||||
initialPreferredId: string | null;
|
||||
}) {
|
||||
const [preferredId, setPreferredId] = useState(initialPreferredId);
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const select = (id: string | null) => {
|
||||
if (id === preferredId) return;
|
||||
const previous = preferredId;
|
||||
setError(null);
|
||||
setPreferredId(id);
|
||||
setPendingId(id ?? "none");
|
||||
startTransition(async () => {
|
||||
const result = await updatePreferredBarberAction(id);
|
||||
if (!result.success) {
|
||||
setPreferredId(previous);
|
||||
setError(result.error ?? "Couldn't save your preference.");
|
||||
}
|
||||
setPendingId(null);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<ul className="divide-line divide-y rounded-md border border-line bg-white">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => select(null)}
|
||||
disabled={pendingId !== null}
|
||||
className="hover:bg-stone flex w-full items-center gap-3 px-5 py-4 text-left text-sm font-bold disabled:opacity-60"
|
||||
>
|
||||
<div className="bg-stone flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-muted">—</div>
|
||||
<span className="flex-1">No preference</span>
|
||||
{preferredId === null && <Check className="text-accent h-4 w-4" aria-hidden="true" />}
|
||||
</button>
|
||||
</li>
|
||||
{staff.map((barber) => (
|
||||
<li key={barber.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => select(barber.id)}
|
||||
disabled={pendingId !== null}
|
||||
className="hover:bg-stone flex w-full items-center gap-3 px-5 py-4 text-left text-sm font-bold disabled:opacity-60"
|
||||
>
|
||||
{barber.avatarUrl ? (
|
||||
<Image
|
||||
src={barber.avatarUrl}
|
||||
alt=""
|
||||
width={44}
|
||||
height={44}
|
||||
className="h-11 w-11 shrink-0 rounded-full border border-line object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-stone text-muted flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-sm font-bold">
|
||||
{barber.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<span className="flex-1">{barber.name}</span>
|
||||
{preferredId === barber.id && <Check className="text-accent h-4 w-4" aria-hidden="true" />}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Shared between the customer profile-edit form and its server action, same rationale as auth/schemas.ts. */
|
||||
export const customerProfileSchema = z.object({
|
||||
firstName: z.string().trim().min(1, "First name is required"),
|
||||
lastName: z.string().trim().min(1, "Last name is required"),
|
||||
email: z.email("Enter a valid email address"),
|
||||
phone: z.string().trim().optional().or(z.literal("")),
|
||||
});
|
||||
export type CustomerProfileInput = z.infer<typeof customerProfileSchema>;
|
||||
@@ -1,79 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import type { GalleryPlacement } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import {
|
||||
addGalleryImage,
|
||||
deleteGalleryImage,
|
||||
reorderGalleryImages,
|
||||
updateGalleryImage,
|
||||
} from "@/services/gallery/gallery.service";
|
||||
import type { GalleryImageSummary } from "@/services/gallery/gallery.service";
|
||||
|
||||
export interface GalleryActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Gallery/portfolio management is business-content, same as logo/cover — reuses MANAGE_BUSINESS_PROFILE rather than a new permission. */
|
||||
async function requireGalleryManager() {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { businessId: null, error: "You must be signed in." } as const;
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) return { businessId: null, error: error.message } as const;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) return { businessId: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
return { businessId: context.businessId, error: null } as const;
|
||||
}
|
||||
|
||||
export async function addGalleryImageAction(params: {
|
||||
placement: GalleryPlacement;
|
||||
imageUrl: string;
|
||||
category?: string;
|
||||
}): Promise<{ success: true; image: GalleryImageSummary } | { success: false; error: string }> {
|
||||
const { businessId, error } = await requireGalleryManager();
|
||||
if (!businessId) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const image = await addGalleryImage(prisma, { businessId, ...params });
|
||||
return { success: true, image };
|
||||
}
|
||||
|
||||
export async function updateGalleryImageAction(
|
||||
id: string,
|
||||
input: { category?: string | null; caption?: string | null; isActive?: boolean },
|
||||
): Promise<GalleryActionResult> {
|
||||
const { businessId, error } = await requireGalleryManager();
|
||||
if (!businessId) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await updateGalleryImage(prisma, { id, businessId, ...input });
|
||||
if (!result.success) return { success: false, error: "Image not found." };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteGalleryImageAction(id: string): Promise<GalleryActionResult> {
|
||||
const { businessId, error } = await requireGalleryManager();
|
||||
if (!businessId) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await deleteGalleryImage(prisma, { id, businessId });
|
||||
if (!result.success) return { success: false, error: "Image not found." };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function reorderGalleryImagesAction(placement: GalleryPlacement, orderedIds: string[]): Promise<GalleryActionResult> {
|
||||
const { businessId, error } = await requireGalleryManager();
|
||||
if (!businessId) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
await reorderGalleryImages(prisma, { businessId, placement, orderedIds });
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { AtSign, Globe, Mail, Phone, Search, Share2 } from "lucide-react";
|
||||
import { BarberCard } from "@/components/ui/barber-card";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { ServiceCard } from "@/components/ui/service-card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { BookServiceButton } from "@/features/booking/components/book-service-button";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import type { AvailableService } from "@/services/scheduling/availability.service";
|
||||
import type { GalleryImageSummary } from "@/services/gallery/gallery.service";
|
||||
import type { StaffSummary } from "@/types/domain";
|
||||
|
||||
export interface HomeTabsProps {
|
||||
services: AvailableService[];
|
||||
categories: { id: string; name: string }[];
|
||||
portfolioImages: GalleryImageSummary[];
|
||||
description: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
website: string | null;
|
||||
instagramUrl: string | null;
|
||||
facebookUrl: string | null;
|
||||
hoursRows: { label: string; hoursLabel: string }[];
|
||||
staff: (StaffSummary & { isPreferred: boolean })[];
|
||||
mapEmbedSrc: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sticky Services/Portfolio/Details tabs below the Home hero — Phase 9's
|
||||
* Home Screen Redesign (a Reviews tab originally sat between Portfolio and
|
||||
* Details; removed once Google Reviews sync stopped downloading individual
|
||||
* reviews — see reviews.service.ts's header — since it could then only
|
||||
* ever show "No reviews yet"; the aggregate rating/count still show in the
|
||||
* hero above these tabs). All data is pre-fetched server-side by the Home
|
||||
* page and passed in as plain props; tab switching and the Services
|
||||
* search/category filter are pure client-side state over that
|
||||
* already-fetched data, not new network round trips per interaction.
|
||||
*/
|
||||
export function HomeTabs(props: HomeTabsProps) {
|
||||
return (
|
||||
<Tabs defaultValue="services">
|
||||
<TabsList className="bg-white border-t border-b border-line sticky top-0 z-30 -mx-6 rounded-none px-6 py-3">
|
||||
<TabsTrigger value="services">Services</TabsTrigger>
|
||||
<TabsTrigger value="portfolio">Portfolio</TabsTrigger>
|
||||
<TabsTrigger value="details">Details</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="services">
|
||||
<ServicesTab services={props.services} categories={props.categories} />
|
||||
</TabsContent>
|
||||
<TabsContent value="portfolio">
|
||||
<PortfolioTab images={props.portfolioImages} />
|
||||
</TabsContent>
|
||||
<TabsContent value="details">
|
||||
<DetailsTab
|
||||
description={props.description}
|
||||
phone={props.phone}
|
||||
email={props.email}
|
||||
website={props.website}
|
||||
instagramUrl={props.instagramUrl}
|
||||
facebookUrl={props.facebookUrl}
|
||||
hoursRows={props.hoursRows}
|
||||
staff={props.staff}
|
||||
mapEmbedSrc={props.mapEmbedSrc}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
function ServicesTab({
|
||||
services,
|
||||
categories,
|
||||
}: {
|
||||
services: AvailableService[];
|
||||
categories: { id: string; name: string }[];
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return services.filter((s) => {
|
||||
if (activeCategoryId && s.categoryId !== activeCategoryId) return false;
|
||||
if (q && !s.name.toLowerCase().includes(q)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [services, query, activeCategoryId]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const byCategory = new Map<string, AvailableService[]>();
|
||||
for (const service of filtered) {
|
||||
const list = byCategory.get(service.categoryId) ?? [];
|
||||
list.push(service);
|
||||
byCategory.set(service.categoryId, list);
|
||||
}
|
||||
return categories
|
||||
.map((c) => ({ ...c, services: byCategory.get(c.id) ?? [] }))
|
||||
.filter((g) => g.services.length > 0);
|
||||
}, [filtered, categories]);
|
||||
|
||||
const categoriesInUse = useMemo(() => categories.filter((c) => services.some((s) => s.categoryId === c.id)), [categories, services]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="text-muted pointer-events-none absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2" aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search services"
|
||||
className="border-line focus:ring-ink w-full rounded-xl border bg-white py-2.5 pr-4 pl-11 text-sm outline-none focus:ring-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{categoriesInUse.length > 1 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveCategoryId(null)}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
activeCategoryId === null ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{categoriesInUse.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => setActiveCategoryId(c.id)}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
activeCategoryId === c.id ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{grouped.length === 0 ? (
|
||||
<p className="text-muted py-6 text-center text-sm">No services match.</p>
|
||||
) : (
|
||||
grouped.map((group) => (
|
||||
<section key={group.id}>
|
||||
<h3 className="text-muted mb-2 text-xs font-bold tracking-widest uppercase">{group.name}</h3>
|
||||
<Card className="divide-line divide-y px-5">
|
||||
{group.services.map((service) => (
|
||||
<ServiceCard
|
||||
key={service.id}
|
||||
service={{
|
||||
id: service.id,
|
||||
name: service.name,
|
||||
description: service.description,
|
||||
imageUrl: service.imageUrl,
|
||||
durationMinutes: service.durationMinutes,
|
||||
formattedPrice: `£${Number(service.price).toFixed(2)}`,
|
||||
}}
|
||||
action={<BookServiceButton serviceId={service.id} />}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PortfolioTab({ images }: { images: GalleryImageSummary[] }) {
|
||||
const categories = useMemo(
|
||||
() => Array.from(new Set(images.map((i) => i.category).filter((c): c is string => !!c))),
|
||||
[images],
|
||||
);
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||
|
||||
const filtered = activeCategory ? images.filter((i) => i.category === activeCategory) : images;
|
||||
|
||||
if (images.length === 0) {
|
||||
return <p className="text-muted py-10 text-center text-sm">No portfolio images yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{categories.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveCategory(null)}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
activeCategory === null ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{categories.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setActiveCategory(c)}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
activeCategory === c ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{filtered.map((image) => (
|
||||
<div key={image.id} className="bg-stone aspect-square overflow-hidden rounded-xl">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide */}
|
||||
<img src={image.imageUrl} alt={image.caption ?? ""} className="h-full w-full object-cover" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailsTab({
|
||||
description,
|
||||
phone,
|
||||
email,
|
||||
website,
|
||||
instagramUrl,
|
||||
facebookUrl,
|
||||
hoursRows,
|
||||
staff,
|
||||
mapEmbedSrc,
|
||||
}: {
|
||||
description: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
website: string | null;
|
||||
instagramUrl: string | null;
|
||||
facebookUrl: string | null;
|
||||
hoursRows: { label: string; hoursLabel: string }[];
|
||||
staff: (StaffSummary & { isPreferred: boolean })[];
|
||||
mapEmbedSrc: string | null;
|
||||
}) {
|
||||
const hasSocial = website || instagramUrl || facebookUrl;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{mapEmbedSrc && (
|
||||
<section>
|
||||
<div className="bg-stone aspect-[16/9] w-full overflow-hidden rounded-md">
|
||||
<iframe
|
||||
src={mapEmbedSrc}
|
||||
title="Business location map"
|
||||
className="h-full w-full border-0"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{description && (
|
||||
<section>
|
||||
<h3 className="text-muted mb-2 text-xs font-bold tracking-widest uppercase">About Us</h3>
|
||||
<p className="text-muted text-sm leading-relaxed">{description}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{staff.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">Staff</h3>
|
||||
<div className="flex gap-5 overflow-x-auto pb-1">
|
||||
{staff.map((s) => (
|
||||
<BarberCard key={s.id} staff={s} isPreferred={s.isPreferred} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{hoursRows.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">Opening Hours</h3>
|
||||
<Card className="divide-line divide-y px-5">
|
||||
{hoursRows.map((row) => (
|
||||
<div key={row.label} className="flex items-center justify-between py-3 text-sm">
|
||||
<span className="text-muted">{row.label}</span>
|
||||
<span className="font-medium">{row.hoursLabel}</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(hasSocial || phone || email) && (
|
||||
<section>
|
||||
<h3 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">Social & Contact</h3>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{phone && (
|
||||
<a href={`tel:${phone}`} className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold">
|
||||
<Phone className="text-accent h-4 w-4" aria-hidden="true" /> Call
|
||||
</a>
|
||||
)}
|
||||
{email && (
|
||||
<a href={`mailto:${email}`} className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold">
|
||||
<Mail className="text-accent h-4 w-4" aria-hidden="true" /> Email
|
||||
</a>
|
||||
)}
|
||||
{website && (
|
||||
<a
|
||||
href={website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold"
|
||||
>
|
||||
<Globe className="text-accent h-4 w-4" aria-hidden="true" /> Website
|
||||
</a>
|
||||
)}
|
||||
{instagramUrl && (
|
||||
<a
|
||||
href={instagramUrl.startsWith("http") ? instagramUrl : `https://instagram.com/${instagramUrl.replace(/^@/, "")}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold"
|
||||
>
|
||||
<AtSign className="text-accent h-4 w-4" aria-hidden="true" /> Instagram
|
||||
</a>
|
||||
)}
|
||||
{facebookUrl && (
|
||||
<a
|
||||
href={facebookUrl.startsWith("http") ? facebookUrl : `https://${facebookUrl}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold"
|
||||
>
|
||||
<Share2 className="text-accent h-4 w-4" aria-hidden="true" /> Facebook
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { prisma } from "@/services/scheduling/__tests__/fixtures";
|
||||
import { InstallationStatus } from "@/generated/prisma/enums";
|
||||
import { installApplication } from "../installer.service";
|
||||
import type { InstallInput } from "../schema";
|
||||
|
||||
/**
|
||||
* Exercises the real transactional installer against a real MySQL
|
||||
* database — see installer.service.ts's own header for why the
|
||||
* ApplicationInstallation singleton row is the concurrency lock. These
|
||||
* tests own that row exclusively: any pre-existing installation state in
|
||||
* this database is removed in `beforeEach` and never restored, since a
|
||||
* test database is expected to be disposable (see
|
||||
* docs/phase-8-summary.md's testing section) — do not point this at a
|
||||
* real deployment's database.
|
||||
*/
|
||||
|
||||
const INSTALLATION_TOKEN = "integration-test-install-token";
|
||||
|
||||
function buildInput(overrides: Partial<InstallInput> = {}): InstallInput {
|
||||
return {
|
||||
installationToken: INSTALLATION_TOKEN,
|
||||
administrator: {
|
||||
firstName: "Test",
|
||||
lastName: "Owner",
|
||||
email: `install-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`,
|
||||
phone: "",
|
||||
password: "correct-horse-battery",
|
||||
confirmPassword: "correct-horse-battery",
|
||||
},
|
||||
business: {
|
||||
name: "Integration Test Barbershop",
|
||||
shortName: "",
|
||||
description: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
website: "",
|
||||
},
|
||||
location: {
|
||||
addressLine1: "1 Test Street",
|
||||
addressLine2: "",
|
||||
city: "Testville",
|
||||
region: "",
|
||||
postalCode: "T1 1TT",
|
||||
country: "GB",
|
||||
timezone: "Europe/London",
|
||||
currency: "GBP",
|
||||
locale: "en-GB",
|
||||
},
|
||||
hours: (["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"] as const).map((dayOfWeek) => ({
|
||||
dayOfWeek,
|
||||
isClosed: dayOfWeek === "SUNDAY",
|
||||
opensAtMinutes: dayOfWeek === "SUNDAY" ? undefined : 540,
|
||||
closesAtMinutes: dayOfWeek === "SUNDAY" ? undefined : 1080,
|
||||
})),
|
||||
bookingRules: {
|
||||
allowGuestBooking: false,
|
||||
minNoticeMinutes: 120,
|
||||
maxAdvanceDays: 60,
|
||||
cancellationWindowHours: 24,
|
||||
rescheduleWindowHours: 24,
|
||||
slotIntervalMinutes: 30,
|
||||
bufferBeforeMinutes: 0,
|
||||
bufferAfterMinutes: 5,
|
||||
maxAppointmentsPerDay: null,
|
||||
maxAppointmentsPerStaffPerDay: null,
|
||||
customerCanChooseStaff: true,
|
||||
requireApproval: false,
|
||||
},
|
||||
branding: { logoUrl: "", coverImageUrl: "", themeColor: "", backgroundColor: "" },
|
||||
starterServices: [
|
||||
{ categoryName: "Haircuts", name: "Classic Cut", description: "", price: "20.00", durationMinutes: 30, active: true, allowOnlineBooking: true },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function resetInstallationSingleton(): Promise<void> {
|
||||
await prisma.applicationInstallation.deleteMany({ where: { id: "singleton" } });
|
||||
}
|
||||
|
||||
async function cleanupInstalledBusiness(businessId: string | undefined): Promise<void> {
|
||||
if (!businessId) return;
|
||||
const staff = await prisma.staffProfile.findMany({ where: { businessId }, select: { id: true, userId: true } });
|
||||
const userIds = staff.map((s) => s.userId);
|
||||
await prisma.staffService.deleteMany({ where: { staffId: { in: staff.map((s) => s.id) } } });
|
||||
await prisma.staffProfile.deleteMany({ where: { businessId } });
|
||||
await prisma.service.deleteMany({ where: { businessId } });
|
||||
await prisma.serviceCategory.deleteMany({ where: { businessId } });
|
||||
await prisma.rolePermission.deleteMany({ where: { role: { businessId } } });
|
||||
await prisma.role.deleteMany({ where: { businessId } });
|
||||
await prisma.auditLog.deleteMany({ where: { entityId: businessId } });
|
||||
await prisma.loyaltyProgramme.deleteMany({ where: { businessId } });
|
||||
await prisma.bookingRule.deleteMany({ where: { businessId } });
|
||||
await prisma.businessSettings.deleteMany({ where: { businessId } });
|
||||
await prisma.businessHours.deleteMany({ where: { businessId } });
|
||||
await prisma.businessLocation.deleteMany({ where: { businessId } });
|
||||
await prisma.business.deleteMany({ where: { id: businessId } });
|
||||
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
||||
await resetInstallationSingleton();
|
||||
}
|
||||
|
||||
describe("installApplication (integration)", () => {
|
||||
let createdBusinessId: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetInstallationSingleton();
|
||||
createdBusinessId = undefined;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupInstalledBusiness(createdBusinessId);
|
||||
});
|
||||
|
||||
it("rejects an incorrect installation token without creating anything", async () => {
|
||||
const originalToken = process.env.INSTALLATION_TOKEN;
|
||||
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
|
||||
try {
|
||||
const result = await installApplication(prisma, {
|
||||
input: buildInput({ installationToken: "wrong-token" }),
|
||||
installedVersion: "test",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) expect(result.code).toBe("INVALID_TOKEN");
|
||||
|
||||
const business = await prisma.business.findFirst({ where: { name: "Integration Test Barbershop" } });
|
||||
expect(business).toBeNull();
|
||||
const installation = await prisma.applicationInstallation.findUnique({ where: { id: "singleton" } });
|
||||
expect(installation).toBeNull();
|
||||
} finally {
|
||||
process.env.INSTALLATION_TOKEN = originalToken;
|
||||
}
|
||||
});
|
||||
|
||||
it("creates the business, administrator, permissions, booking rules, and starter services transactionally", async () => {
|
||||
const originalToken = process.env.INSTALLATION_TOKEN;
|
||||
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
|
||||
try {
|
||||
const result = await installApplication(prisma, { input: buildInput(), installedVersion: "test" });
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
createdBusinessId = result.businessId;
|
||||
|
||||
const installation = await prisma.applicationInstallation.findUnique({ where: { id: "singleton" } });
|
||||
expect(installation?.status).toBe(InstallationStatus.INSTALLED);
|
||||
expect(installation?.primaryBusinessId).toBe(result.businessId);
|
||||
expect(installation?.initialAdminUserId).toBe(result.userId);
|
||||
|
||||
const business = await prisma.business.findUnique({
|
||||
where: { id: result.businessId },
|
||||
include: { bookingRule: true, locations: true, hours: true, loyaltyProgramme: true },
|
||||
});
|
||||
expect(business?.name).toBe("Integration Test Barbershop");
|
||||
// Guest booking off by default, per the brief — confirmed end to end here.
|
||||
expect(business?.bookingRule?.allowGuestBooking).toBe(false);
|
||||
expect(business?.locations).toHaveLength(1);
|
||||
expect(business?.hours).toHaveLength(7);
|
||||
expect(business?.loyaltyProgramme?.isEnabled).toBe(false);
|
||||
|
||||
const roles = await prisma.role.findMany({ where: { businessId: result.businessId }, include: { permissions: true } });
|
||||
const ownerRole = roles.find((r) => r.name === "Owner");
|
||||
const barberRole = roles.find((r) => r.name === "Barber");
|
||||
expect(ownerRole).toBeDefined();
|
||||
expect(barberRole).toBeDefined();
|
||||
expect(ownerRole!.permissions.length).toBeGreaterThan(barberRole!.permissions.length);
|
||||
|
||||
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: result.userId }, include: { services: true } });
|
||||
expect(staffProfile?.roleId).toBe(ownerRole!.id);
|
||||
// The initial owner is eligible for every starter service — see the brief's "initial owner is service-eligible."
|
||||
expect(staffProfile?.services).toHaveLength(1);
|
||||
|
||||
const services = await prisma.service.findMany({ where: { businessId: result.businessId } });
|
||||
expect(services).toHaveLength(1);
|
||||
expect(services[0]?.name).toBe("Classic Cut");
|
||||
|
||||
const auditLog = await prisma.auditLog.findFirst({ where: { entityId: result.businessId, action: "APPLICATION_INSTALLED" } });
|
||||
expect(auditLog).toBeTruthy();
|
||||
} finally {
|
||||
process.env.INSTALLATION_TOKEN = originalToken;
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses to install a second time once already installed", async () => {
|
||||
const originalToken = process.env.INSTALLATION_TOKEN;
|
||||
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
|
||||
try {
|
||||
const first = await installApplication(prisma, { input: buildInput(), installedVersion: "test" });
|
||||
expect(first.success).toBe(true);
|
||||
if (first.success) createdBusinessId = first.businessId;
|
||||
|
||||
const second = await installApplication(prisma, {
|
||||
input: buildInput({
|
||||
administrator: { ...buildInput().administrator, email: `second-${Date.now()}@example.com` },
|
||||
}),
|
||||
installedVersion: "test",
|
||||
});
|
||||
expect(second.success).toBe(false);
|
||||
if (!second.success) expect(second.code).toBe("ALREADY_INSTALLED");
|
||||
} finally {
|
||||
process.env.INSTALLATION_TOKEN = originalToken;
|
||||
}
|
||||
});
|
||||
|
||||
it("prevents two concurrent installation attempts from both succeeding", async () => {
|
||||
const originalToken = process.env.INSTALLATION_TOKEN;
|
||||
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
|
||||
try {
|
||||
const inputA = buildInput({ administrator: { ...buildInput().administrator, email: `concurrent-a-${Date.now()}@example.com` } });
|
||||
const inputB = buildInput({ administrator: { ...buildInput().administrator, email: `concurrent-b-${Date.now()}@example.com` } });
|
||||
|
||||
const [resultA, resultB] = await Promise.all([
|
||||
installApplication(prisma, { input: inputA, installedVersion: "test" }),
|
||||
installApplication(prisma, { input: inputB, installedVersion: "test" }),
|
||||
]);
|
||||
|
||||
const successes = [resultA, resultB].filter((r) => r.success);
|
||||
expect(successes).toHaveLength(1);
|
||||
const winner = successes[0];
|
||||
if (winner?.success) createdBusinessId = winner.businessId;
|
||||
|
||||
const businessCount = await prisma.business.count({
|
||||
where: { name: "Integration Test Barbershop" },
|
||||
});
|
||||
expect(businessCount).toBe(1);
|
||||
} finally {
|
||||
process.env.INSTALLATION_TOKEN = originalToken;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { signIn } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getRequestIp } from "@/lib/security/request-ip";
|
||||
import { checkRateLimit } from "@/lib/security/rate-limit";
|
||||
import { secretsMatch } from "@/lib/security/secrets-match";
|
||||
import { installApplication } from "../installer.service";
|
||||
import { installSchema } from "../schema";
|
||||
import type { InstallInput } from "../schema";
|
||||
|
||||
const packageVersion = process.env.npm_package_version ?? "1.0.0";
|
||||
|
||||
export interface VerifyTokenResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A cheap, read-only check the wizard's first step calls for immediate
|
||||
* feedback — never reveals *why* a token is wrong, and never on its own
|
||||
* unlocks anything server-side: the real gate is `submitInstallationAction`
|
||||
* re-checking the same token before it writes anything (see
|
||||
* installer.service.ts's own header for why it's checked twice).
|
||||
*/
|
||||
export async function verifyInstallationTokenAction(token: string): Promise<VerifyTokenResult> {
|
||||
const ip = await getRequestIp();
|
||||
const { allowed } = checkRateLimit(`install-token:${ip}`, { maxAttempts: 10, windowMs: 15 * 60 * 1000 });
|
||||
if (!allowed) {
|
||||
return { valid: false, error: "Too many attempts. Please try again later." };
|
||||
}
|
||||
|
||||
const expected = process.env.INSTALLATION_TOKEN;
|
||||
if (!expected) {
|
||||
return { valid: false, error: "Installation is not available on this deployment." };
|
||||
}
|
||||
if (!token || !secretsMatch(token, expected)) {
|
||||
return { valid: false, error: "Incorrect installation token." };
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
export interface SubmitInstallationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string[] | undefined>;
|
||||
}
|
||||
|
||||
export async function submitInstallationAction(input: InstallInput): Promise<SubmitInstallationResult> {
|
||||
const ip = await getRequestIp();
|
||||
const { allowed } = checkRateLimit(`install-submit:${ip}`, { maxAttempts: 10, windowMs: 15 * 60 * 1000 });
|
||||
if (!allowed) {
|
||||
return { success: false, error: "Too many attempts. Please try again later." };
|
||||
}
|
||||
|
||||
const parsed = installSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: "Please fix the highlighted fields.", fieldErrors: parsed.error.flatten().fieldErrors };
|
||||
}
|
||||
|
||||
const result = await installApplication(prisma, { input: parsed.data, installedVersion: packageVersion });
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.message };
|
||||
}
|
||||
|
||||
await signIn("credentials", {
|
||||
email: parsed.data.administrator.email,
|
||||
password: parsed.data.administrator.password,
|
||||
redirectTo: "/owner?installed=1",
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,598 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { submitInstallationAction, verifyInstallationTokenAction } from "../actions/install";
|
||||
import type { InstallInput } from "../schema";
|
||||
|
||||
/**
|
||||
* Holds every wizard step's data as plain React state, not a signed
|
||||
* server-side draft — nothing is validated or written to the database
|
||||
* until the single final `submitInstallationAction` call on Step 9's
|
||||
* confirmation (see installer.service.ts's own header). That means a
|
||||
* page refresh loses progress, a deliberate simplification: this page is
|
||||
* only ever visited once, by the person physically standing up the
|
||||
* deployment, so "refresh loses your place" is a fair trade for not
|
||||
* needing a signed-cookie draft model and its own cleanup story.
|
||||
*/
|
||||
|
||||
const WEEKDAYS = ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"] as const;
|
||||
type WeekdayCode = (typeof WEEKDAYS)[number];
|
||||
const DAY_LABEL: Record<WeekdayCode, string> = {
|
||||
MONDAY: "Monday",
|
||||
TUESDAY: "Tuesday",
|
||||
WEDNESDAY: "Wednesday",
|
||||
THURSDAY: "Thursday",
|
||||
FRIDAY: "Friday",
|
||||
SATURDAY: "Saturday",
|
||||
SUNDAY: "Sunday",
|
||||
};
|
||||
|
||||
interface HoursDayState {
|
||||
dayOfWeek: WeekdayCode;
|
||||
isClosed: boolean;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}
|
||||
|
||||
interface StarterServiceState {
|
||||
key: string;
|
||||
categoryName: string;
|
||||
name: string;
|
||||
description: string;
|
||||
price: string;
|
||||
durationMinutes: number;
|
||||
active: boolean;
|
||||
allowOnlineBooking: boolean;
|
||||
}
|
||||
|
||||
function toMinutes(time: string): number {
|
||||
const [h, m] = time.split(":").map(Number);
|
||||
return (h ?? 0) * 60 + (m ?? 0);
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
"Welcome",
|
||||
"Administrator",
|
||||
"Business Details",
|
||||
"Location & Contact",
|
||||
"Opening Hours",
|
||||
"Booking Settings",
|
||||
"Branding",
|
||||
"Starter Services",
|
||||
"Review",
|
||||
] as const;
|
||||
|
||||
let starterServiceKeySeq = 0;
|
||||
function newStarterServiceKey(): string {
|
||||
starterServiceKeySeq += 1;
|
||||
return `starter-${starterServiceKeySeq}`;
|
||||
}
|
||||
|
||||
const DEFAULT_STARTER_SERVICES: StarterServiceState[] = [
|
||||
{
|
||||
key: newStarterServiceKey(),
|
||||
categoryName: "Haircuts",
|
||||
name: "Classic Haircut",
|
||||
description: "",
|
||||
price: "25.00",
|
||||
durationMinutes: 30,
|
||||
active: true,
|
||||
allowOnlineBooking: true,
|
||||
},
|
||||
{
|
||||
key: newStarterServiceKey(),
|
||||
categoryName: "Beard Services",
|
||||
name: "Beard Trim",
|
||||
description: "",
|
||||
price: "15.00",
|
||||
durationMinutes: 20,
|
||||
active: true,
|
||||
allowOnlineBooking: true,
|
||||
},
|
||||
{
|
||||
key: newStarterServiceKey(),
|
||||
categoryName: "Packages",
|
||||
name: "Haircut & Beard Combo",
|
||||
description: "",
|
||||
price: "35.00",
|
||||
durationMinutes: 45,
|
||||
active: true,
|
||||
allowOnlineBooking: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function InstallWizard() {
|
||||
const [step, setStep] = useState(1);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
// Step 1
|
||||
const [token, setToken] = useState("");
|
||||
const [tokenError, setTokenError] = useState<string | null>(null);
|
||||
|
||||
// Step 2
|
||||
const [admin, setAdmin] = useState({ firstName: "", lastName: "", email: "", phone: "", password: "", confirmPassword: "" });
|
||||
|
||||
// Step 3
|
||||
const [business, setBusiness] = useState({ name: "", shortName: "", description: "", email: "", phone: "", website: "" });
|
||||
|
||||
// Step 4
|
||||
const [location, setLocation] = useState({
|
||||
addressLine1: "",
|
||||
addressLine2: "",
|
||||
city: "",
|
||||
region: "",
|
||||
postalCode: "",
|
||||
country: "GB",
|
||||
timezone: "Europe/London",
|
||||
currency: "GBP",
|
||||
locale: "en-GB",
|
||||
});
|
||||
|
||||
// Step 5
|
||||
const [hours, setHours] = useState<HoursDayState[]>(
|
||||
WEEKDAYS.map((dayOfWeek) => ({
|
||||
dayOfWeek,
|
||||
isClosed: dayOfWeek === "SUNDAY",
|
||||
startTime: "09:00",
|
||||
endTime: "18:00",
|
||||
})),
|
||||
);
|
||||
|
||||
// Step 6 — guest booking defaults OFF, per the brief.
|
||||
const [bookingRules, setBookingRules] = useState({
|
||||
allowGuestBooking: false,
|
||||
minNoticeMinutes: 120,
|
||||
maxAdvanceDays: 60,
|
||||
cancellationWindowHours: 24,
|
||||
rescheduleWindowHours: 24,
|
||||
slotIntervalMinutes: 30,
|
||||
bufferBeforeMinutes: 0,
|
||||
bufferAfterMinutes: 5,
|
||||
maxAppointmentsPerDay: null as number | null,
|
||||
maxAppointmentsPerStaffPerDay: null as number | null,
|
||||
customerCanChooseStaff: true,
|
||||
requireApproval: false,
|
||||
});
|
||||
|
||||
// Step 7
|
||||
const [branding, setBranding] = useState({ logoUrl: "", coverImageUrl: "", themeColor: "", backgroundColor: "" });
|
||||
|
||||
// Step 8
|
||||
const [starterServices, setStarterServices] = useState<StarterServiceState[]>(DEFAULT_STARTER_SERVICES);
|
||||
|
||||
const verifyToken = () => {
|
||||
setTokenError(null);
|
||||
startTransition(async () => {
|
||||
const result = await verifyInstallationTokenAction(token);
|
||||
if (!result.valid) {
|
||||
setTokenError(result.error ?? "Incorrect installation token.");
|
||||
return;
|
||||
}
|
||||
setStep(2);
|
||||
});
|
||||
};
|
||||
|
||||
const updateDay = (dayOfWeek: WeekdayCode, patch: Partial<HoursDayState>) => {
|
||||
setHours((prev) => prev.map((d) => (d.dayOfWeek === dayOfWeek ? { ...d, ...patch } : d)));
|
||||
};
|
||||
const copyMondayToWeekdays = () => {
|
||||
const monday = hours.find((d) => d.dayOfWeek === "MONDAY");
|
||||
if (!monday) return;
|
||||
setHours((prev) =>
|
||||
prev.map((d) =>
|
||||
d.dayOfWeek === "SATURDAY" || d.dayOfWeek === "SUNDAY" || d.dayOfWeek === "MONDAY"
|
||||
? d
|
||||
: { ...d, isClosed: monday.isClosed, startTime: monday.startTime, endTime: monday.endTime },
|
||||
),
|
||||
);
|
||||
};
|
||||
const copyMondayToAll = () => {
|
||||
const monday = hours.find((d) => d.dayOfWeek === "MONDAY");
|
||||
if (!monday) return;
|
||||
setHours((prev) =>
|
||||
prev.map((d) => (d.dayOfWeek === "MONDAY" ? d : { ...d, isClosed: monday.isClosed, startTime: monday.startTime, endTime: monday.endTime })),
|
||||
);
|
||||
};
|
||||
|
||||
const updateStarter = (key: string, patch: Partial<StarterServiceState>) => {
|
||||
setStarterServices((prev) => prev.map((s) => (s.key === key ? { ...s, ...patch } : s)));
|
||||
};
|
||||
const removeStarter = (key: string) => {
|
||||
setStarterServices((prev) => prev.filter((s) => s.key !== key));
|
||||
};
|
||||
const addStarter = () => {
|
||||
setStarterServices((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key: newStarterServiceKey(),
|
||||
categoryName: "Haircuts",
|
||||
name: "",
|
||||
description: "",
|
||||
price: "0.00",
|
||||
durationMinutes: 30,
|
||||
active: true,
|
||||
allowOnlineBooking: true,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
setSubmitError(null);
|
||||
const input: InstallInput = {
|
||||
installationToken: token,
|
||||
administrator: admin,
|
||||
business,
|
||||
location,
|
||||
hours: hours.map((d) => ({
|
||||
dayOfWeek: d.dayOfWeek,
|
||||
isClosed: d.isClosed,
|
||||
...(d.isClosed ? {} : { opensAtMinutes: toMinutes(d.startTime), closesAtMinutes: toMinutes(d.endTime) }),
|
||||
})),
|
||||
bookingRules,
|
||||
branding,
|
||||
starterServices: starterServices
|
||||
.filter((s) => s.name.trim())
|
||||
.map((s) => ({
|
||||
categoryName: s.categoryName,
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
price: s.price,
|
||||
durationMinutes: s.durationMinutes,
|
||||
active: s.active,
|
||||
allowOnlineBooking: s.allowOnlineBooking,
|
||||
})),
|
||||
};
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await submitInstallationAction(input);
|
||||
if (!result.success) {
|
||||
setSubmitError(result.error ?? "Installation failed. Please try again.");
|
||||
}
|
||||
// On success, submitInstallationAction signs the administrator in
|
||||
// and redirects server-side — nothing else to do here.
|
||||
});
|
||||
};
|
||||
|
||||
const canProceedFromStep = (s: number): boolean => {
|
||||
switch (s) {
|
||||
case 2:
|
||||
return (
|
||||
!!admin.firstName.trim() &&
|
||||
!!admin.lastName.trim() &&
|
||||
!!admin.email.trim() &&
|
||||
admin.password.length >= 8 &&
|
||||
admin.password === admin.confirmPassword
|
||||
);
|
||||
case 3:
|
||||
return !!business.name.trim();
|
||||
case 4:
|
||||
return (
|
||||
!!location.addressLine1.trim() &&
|
||||
!!location.city.trim() &&
|
||||
!!location.postalCode.trim() &&
|
||||
!!location.timezone.trim() &&
|
||||
location.currency.trim().length === 3
|
||||
);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">
|
||||
Step {step} of {STEPS.length} — {STEPS[step - 1]}
|
||||
</p>
|
||||
<div className="bg-line mt-2 h-1.5 w-full overflow-hidden rounded-full">
|
||||
<div className="bg-ink h-full rounded-full transition-all" style={{ width: `${(step / STEPS.length) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">Set up your business</h1>
|
||||
<p className="text-muted text-sm">
|
||||
This wizard creates the first administrator account and configures your business — opening hours,
|
||||
booking rules, and branding. It only needs to run once.
|
||||
</p>
|
||||
<Input
|
||||
label="Installation Token"
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
error={tokenError ?? undefined}
|
||||
required
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button className="w-full" isLoading={isPending} disabled={!token} onClick={verifyToken}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-bold">Administrator account</h2>
|
||||
<p className="text-muted text-sm">This becomes the first owner — full permissions, protected from removal.</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input label="First Name" value={admin.firstName} onChange={(e) => setAdmin({ ...admin, firstName: e.target.value })} required />
|
||||
<Input label="Last Name" value={admin.lastName} onChange={(e) => setAdmin({ ...admin, lastName: e.target.value })} required />
|
||||
</div>
|
||||
<Input label="Email Address" type="email" value={admin.email} onChange={(e) => setAdmin({ ...admin, email: e.target.value })} required />
|
||||
<Input label="Phone (optional)" type="tel" value={admin.phone} onChange={(e) => setAdmin({ ...admin, phone: e.target.value })} />
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={admin.password}
|
||||
onChange={(e) => setAdmin({ ...admin, password: e.target.value })}
|
||||
hint="At least 8 characters."
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
value={admin.confirmPassword}
|
||||
onChange={(e) => setAdmin({ ...admin, confirmPassword: e.target.value })}
|
||||
error={admin.confirmPassword && admin.password !== admin.confirmPassword ? "Passwords don't match" : undefined}
|
||||
required
|
||||
/>
|
||||
<StepNav onBack={() => setStep(1)} onNext={() => setStep(3)} nextDisabled={!canProceedFromStep(2)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-bold">Business details</h2>
|
||||
<Input label="Business Name" value={business.name} onChange={(e) => setBusiness({ ...business, name: e.target.value })} required />
|
||||
<Input
|
||||
label="Short Name (optional)"
|
||||
hint="Used where space is tight, e.g. the home-screen icon label."
|
||||
value={business.shortName}
|
||||
onChange={(e) => setBusiness({ ...business, shortName: e.target.value })}
|
||||
/>
|
||||
<Textarea
|
||||
label="Description (optional)"
|
||||
value={business.description}
|
||||
onChange={(e) => setBusiness({ ...business, description: e.target.value })}
|
||||
/>
|
||||
<Input label="Business Email (optional)" type="email" value={business.email} onChange={(e) => setBusiness({ ...business, email: e.target.value })} />
|
||||
<Input label="Phone Number (optional)" type="tel" value={business.phone} onChange={(e) => setBusiness({ ...business, phone: e.target.value })} />
|
||||
<Input label="Website (optional)" value={business.website} onChange={(e) => setBusiness({ ...business, website: e.target.value })} />
|
||||
<StepNav onBack={() => setStep(2)} onNext={() => setStep(4)} nextDisabled={!canProceedFromStep(3)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-bold">Location & contact</h2>
|
||||
<Input label="Address Line 1" value={location.addressLine1} onChange={(e) => setLocation({ ...location, addressLine1: e.target.value })} required />
|
||||
<Input label="Address Line 2 (optional)" value={location.addressLine2} onChange={(e) => setLocation({ ...location, addressLine2: e.target.value })} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input label="Town / City" value={location.city} onChange={(e) => setLocation({ ...location, city: e.target.value })} required />
|
||||
<Input label="County (optional)" value={location.region} onChange={(e) => setLocation({ ...location, region: e.target.value })} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input label="Postcode" value={location.postalCode} onChange={(e) => setLocation({ ...location, postalCode: e.target.value })} required />
|
||||
<Input label="Country" value={location.country} onChange={(e) => setLocation({ ...location, country: e.target.value })} required />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Input label="Timezone" value={location.timezone} onChange={(e) => setLocation({ ...location, timezone: e.target.value })} required />
|
||||
<Input label="Currency" value={location.currency} onChange={(e) => setLocation({ ...location, currency: e.target.value.toUpperCase() })} maxLength={3} required />
|
||||
<Input label="Locale" value={location.locale} onChange={(e) => setLocation({ ...location, locale: e.target.value })} required />
|
||||
</div>
|
||||
<StepNav onBack={() => setStep(3)} onNext={() => setStep(5)} nextDisabled={!canProceedFromStep(4)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 5 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-bold">Opening hours</h2>
|
||||
<div className="flex gap-4">
|
||||
<button type="button" onClick={copyMondayToWeekdays} className="text-accent text-xs font-bold tracking-widest uppercase underline">
|
||||
Copy Monday to weekdays
|
||||
</button>
|
||||
<button type="button" onClick={copyMondayToAll} className="text-accent text-xs font-bold tracking-widest uppercase underline">
|
||||
Copy Monday to all days
|
||||
</button>
|
||||
</div>
|
||||
<div className="divide-line rounded-md border-line divide-y border bg-white">
|
||||
{hours.map((day) => (
|
||||
<div key={day.dayOfWeek} className="flex items-center justify-between gap-4 px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch checked={!day.isClosed} onCheckedChange={(checked) => updateDay(day.dayOfWeek, { isClosed: !checked })} aria-label={`${DAY_LABEL[day.dayOfWeek]} open`} />
|
||||
<p className="text-sm font-bold">{DAY_LABEL[day.dayOfWeek]}</p>
|
||||
</div>
|
||||
{day.isClosed ? (
|
||||
<span className="text-muted text-xs">Closed</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="time" value={day.startTime} onChange={(e) => updateDay(day.dayOfWeek, { startTime: e.target.value })} className="border-line rounded-lg border px-2 py-1.5 text-xs" aria-label={`${DAY_LABEL[day.dayOfWeek]} opening time`} />
|
||||
<span className="text-muted text-xs">to</span>
|
||||
<input type="time" value={day.endTime} onChange={(e) => updateDay(day.dayOfWeek, { endTime: e.target.value })} className="border-line rounded-lg border px-2 py-1.5 text-xs" aria-label={`${DAY_LABEL[day.dayOfWeek]} closing time`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<StepNav onBack={() => setStep(4)} onNext={() => setStep(6)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 6 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-bold">Booking settings</h2>
|
||||
<div className="rounded-md border-line border bg-white p-5">
|
||||
<Switch
|
||||
checked={bookingRules.allowGuestBooking}
|
||||
onCheckedChange={(checked) => setBookingRules({ ...bookingRules, allowGuestBooking: checked })}
|
||||
label="Allow customers to book as guests"
|
||||
description="When enabled, customers can book without creating an account. When disabled, customers must sign in or create an account before confirming their booking. Off is recommended — you can turn this on later in Booking Settings."
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="Minimum Notice (minutes)"
|
||||
type="number"
|
||||
value={bookingRules.minNoticeMinutes}
|
||||
onChange={(e) => setBookingRules({ ...bookingRules, minNoticeMinutes: Number(e.target.value) })}
|
||||
/>
|
||||
<Input
|
||||
label="Max Advance Booking (days)"
|
||||
type="number"
|
||||
value={bookingRules.maxAdvanceDays}
|
||||
onChange={(e) => setBookingRules({ ...bookingRules, maxAdvanceDays: Number(e.target.value) })}
|
||||
/>
|
||||
<Input
|
||||
label="Cancellation Window (hours)"
|
||||
type="number"
|
||||
value={bookingRules.cancellationWindowHours}
|
||||
onChange={(e) => setBookingRules({ ...bookingRules, cancellationWindowHours: Number(e.target.value) })}
|
||||
/>
|
||||
<Input
|
||||
label="Reschedule Window (hours)"
|
||||
type="number"
|
||||
value={bookingRules.rescheduleWindowHours}
|
||||
onChange={(e) => setBookingRules({ ...bookingRules, rescheduleWindowHours: Number(e.target.value) })}
|
||||
/>
|
||||
<Input
|
||||
label="Slot Interval (minutes)"
|
||||
type="number"
|
||||
value={bookingRules.slotIntervalMinutes}
|
||||
onChange={(e) => setBookingRules({ ...bookingRules, slotIntervalMinutes: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-md border-line space-y-3 border bg-white p-5">
|
||||
<Switch
|
||||
checked={bookingRules.customerCanChooseStaff}
|
||||
onCheckedChange={(checked) => setBookingRules({ ...bookingRules, customerCanChooseStaff: checked })}
|
||||
label="Customers can choose their barber"
|
||||
/>
|
||||
<Switch
|
||||
checked={bookingRules.requireApproval}
|
||||
onCheckedChange={(checked) => setBookingRules({ ...bookingRules, requireApproval: checked })}
|
||||
label="New bookings require staff approval"
|
||||
/>
|
||||
</div>
|
||||
<StepNav onBack={() => setStep(5)} onNext={() => setStep(7)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 7 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-bold">Branding</h2>
|
||||
<p className="text-muted text-sm">
|
||||
Optional — every screen in the app uses your business name and these images/colours instead of the
|
||||
product name. You can add or change all of this later in Business Settings.
|
||||
</p>
|
||||
<Input label="Logo URL (optional)" placeholder="https://…" value={branding.logoUrl} onChange={(e) => setBranding({ ...branding, logoUrl: e.target.value })} />
|
||||
<Input label="Cover Image URL (optional)" placeholder="https://…" value={branding.coverImageUrl} onChange={(e) => setBranding({ ...branding, coverImageUrl: e.target.value })} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input label="Theme Colour (optional)" placeholder="#111b15" value={branding.themeColor} onChange={(e) => setBranding({ ...branding, themeColor: e.target.value })} />
|
||||
<Input label="Background Colour (optional)" placeholder="#f4f6f4" value={branding.backgroundColor} onChange={(e) => setBranding({ ...branding, backgroundColor: e.target.value })} />
|
||||
</div>
|
||||
<StepNav onBack={() => setStep(6)} onNext={() => setStep(8)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 8 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-bold">Starter services</h2>
|
||||
<p className="text-muted text-sm">
|
||||
A small editable starting point so your booking page isn't empty on day one. Edit, remove, or add
|
||||
your own before finishing setup.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{starterServices.map((s) => (
|
||||
<div key={s.key} className="rounded-md border-line space-y-3 border bg-white p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">Service</p>
|
||||
<button type="button" onClick={() => removeStarter(s.key)} className="text-danger text-xs font-bold uppercase">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input label="Category" value={s.categoryName} onChange={(e) => updateStarter(s.key, { categoryName: e.target.value })} />
|
||||
<Input label="Service Name" value={s.name} onChange={(e) => updateStarter(s.key, { name: e.target.value })} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input label="Price" value={s.price} onChange={(e) => updateStarter(s.key, { price: e.target.value })} />
|
||||
<Input label="Duration (minutes)" type="number" value={s.durationMinutes} onChange={(e) => updateStarter(s.key, { durationMinutes: Number(e.target.value) })} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="secondary" className="w-full" onClick={addStarter}>
|
||||
Add Another Service
|
||||
</Button>
|
||||
<StepNav onBack={() => setStep(7)} onNext={() => setStep(9)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 9 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-bold">Review</h2>
|
||||
<ReviewSection title="Administrator">
|
||||
{admin.firstName} {admin.lastName} · {admin.email}
|
||||
</ReviewSection>
|
||||
<ReviewSection title="Business">{business.name}</ReviewSection>
|
||||
<ReviewSection title="Location">
|
||||
{location.addressLine1}, {location.city}, {location.postalCode} · {location.timezone} · {location.currency}
|
||||
</ReviewSection>
|
||||
<ReviewSection title="Opening Hours">
|
||||
{hours.filter((d) => !d.isClosed).length} day{hours.filter((d) => !d.isClosed).length === 1 ? "" : "s"} open
|
||||
</ReviewSection>
|
||||
<ReviewSection title="Booking Settings">
|
||||
Guest booking {bookingRules.allowGuestBooking ? "enabled" : "disabled"} · {bookingRules.requireApproval ? "requires approval" : "auto-confirms"}
|
||||
</ReviewSection>
|
||||
<ReviewSection title="Branding">{branding.logoUrl ? "Logo set" : "No logo yet — add one later in Business Settings"}</ReviewSection>
|
||||
<ReviewSection title="Starter Services">
|
||||
{starterServices.filter((s) => s.name.trim()).length} service{starterServices.filter((s) => s.name.trim()).length === 1 ? "" : "s"}
|
||||
</ReviewSection>
|
||||
|
||||
{submitError && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{submitError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={() => setStep(8)} disabled={isPending}>
|
||||
Back
|
||||
</Button>
|
||||
<Button className="flex-1" isLoading={isPending} onClick={submit}>
|
||||
Confirm & Install
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepNav({ onBack, onNext, nextDisabled }: { onBack: () => void; onNext: () => void; nextDisabled?: boolean }) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={onBack}>
|
||||
Back
|
||||
</Button>
|
||||
<Button className="flex-1" onClick={onNext} disabled={nextDisabled}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-md border-line border bg-white p-4">
|
||||
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">{title}</p>
|
||||
<p className="mt-1 text-sm">{children}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import type { PrismaClient, Prisma } from "@/generated/prisma/client";
|
||||
import { InstallationStatus, RewardType } from "@/generated/prisma/enums";
|
||||
import { INSTALLATION_SINGLETON_ID } from "@/lib/installation/installation-state";
|
||||
import { OWNER_PERMISSIONS, BARBER_PERMISSIONS } from "@/lib/roles/default-permissions";
|
||||
import { secretsMatch } from "@/lib/security/secrets-match";
|
||||
import { validateBusinessProfileInput, validateOpeningHoursInput } from "@/services/business/business.service";
|
||||
import { validateBookingRuleInput } from "@/services/scheduling/booking-rule.service";
|
||||
import { validateCategoryInput, validateServiceInput } from "@/services/catalogue/service-catalogue.service";
|
||||
import { fail, ok } from "@/lib/validation-result";
|
||||
import type { ValidationResult } from "@/lib/validation-result";
|
||||
import type { InstallInput } from "./schema";
|
||||
|
||||
export type InstallErrorCode =
|
||||
| "INVALID_TOKEN"
|
||||
| "ALREADY_INSTALLED"
|
||||
| "VALIDATION_FAILED"
|
||||
| "EMAIL_ALREADY_IN_USE"
|
||||
| "INSTALL_FAILED";
|
||||
|
||||
export interface InstallSuccess {
|
||||
success: true;
|
||||
userId: string;
|
||||
businessId: string;
|
||||
}
|
||||
|
||||
export interface InstallFailure {
|
||||
success: false;
|
||||
code: InstallErrorCode;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type InstallResult = InstallSuccess | InstallFailure;
|
||||
|
||||
function slugify(name: string): string {
|
||||
const base = name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
// Appended so a second install attempt with the same business name (a
|
||||
// retried/failed install, or genuinely two identically-named
|
||||
// businesses in dev/test) never collides on Business.slug's unique
|
||||
// constraint.
|
||||
return `${base || "business"}-${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
function validateAllInput(input: InstallInput): ValidationResult<InstallErrorCode> {
|
||||
const businessValidation = validateBusinessProfileInput({
|
||||
name: input.business.name,
|
||||
description: input.business.description || undefined,
|
||||
phone: input.business.phone || undefined,
|
||||
email: input.business.email || undefined,
|
||||
website: input.business.website || undefined,
|
||||
timezone: input.location.timezone,
|
||||
currency: input.location.currency,
|
||||
locale: input.location.locale,
|
||||
});
|
||||
if (!businessValidation.valid) {
|
||||
return fail({ code: "VALIDATION_FAILED", message: businessValidation.issues[0]!.message });
|
||||
}
|
||||
|
||||
if (!input.location.addressLine1.trim() || !input.location.city.trim() || !input.location.postalCode.trim()) {
|
||||
return fail({ code: "VALIDATION_FAILED", message: "Address line 1, city, and postcode are required." });
|
||||
}
|
||||
|
||||
const hoursValidation = validateOpeningHoursInput(input.hours);
|
||||
if (!hoursValidation.valid) {
|
||||
return fail({ code: "VALIDATION_FAILED", message: hoursValidation.issues[0]!.message });
|
||||
}
|
||||
|
||||
const bookingRuleValidation = validateBookingRuleInput(input.bookingRules);
|
||||
if (!bookingRuleValidation.valid) {
|
||||
return fail({ code: "VALIDATION_FAILED", message: bookingRuleValidation.issues[0]!.message });
|
||||
}
|
||||
|
||||
for (const service of input.starterServices) {
|
||||
const categoryValidation = validateCategoryInput({ name: service.categoryName });
|
||||
if (!categoryValidation.valid) {
|
||||
return fail({ code: "VALIDATION_FAILED", message: categoryValidation.issues[0]!.message });
|
||||
}
|
||||
const serviceValidation = validateServiceInput({
|
||||
categoryId: "placeholder", // category is created inside the transaction, before we know its id
|
||||
name: service.name,
|
||||
description: service.description || undefined,
|
||||
durationMinutes: service.durationMinutes,
|
||||
price: service.price,
|
||||
bufferBeforeMinutes: 0,
|
||||
bufferAfterMinutes: 0,
|
||||
allowOnlineBooking: service.allowOnlineBooking,
|
||||
});
|
||||
if (!serviceValidation.valid) {
|
||||
return fail({ code: "VALIDATION_FAILED", message: serviceValidation.issues[0]!.message });
|
||||
}
|
||||
}
|
||||
|
||||
return ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place a fresh deployment goes from "empty database" to "a real,
|
||||
* usable business" — see docs/phase-8-summary.md for the full narrative.
|
||||
* Deliberately separate from prisma/seed.ts (dev/demo fixtures only, never
|
||||
* run against a real deployment) and from every existing owner-facing
|
||||
* service (BusinessService, LoyaltyService, etc., which all assume a
|
||||
* business already exists) — this is the one codepath that creates one.
|
||||
*
|
||||
* Token verification happens twice by design: once cheaply in the
|
||||
* `verifyInstallationTokenAction` Server Action (so the wizard's first
|
||||
* step can give immediate feedback), and again here, as the very first
|
||||
* thing this function does, before any database write — a client that
|
||||
* skips straight to final submission without ever calling the first
|
||||
* check must still be rejected. Neither check ever reveals *why* a token
|
||||
* was wrong.
|
||||
*/
|
||||
export async function installApplication(
|
||||
prisma: PrismaClient,
|
||||
params: { input: InstallInput; installedVersion: string },
|
||||
): Promise<InstallResult> {
|
||||
const expectedToken = process.env.INSTALLATION_TOKEN;
|
||||
if (!expectedToken) {
|
||||
return { success: false, code: "INVALID_TOKEN", message: "Installation is not available." };
|
||||
}
|
||||
if (!secretsMatch(params.input.installationToken, expectedToken)) {
|
||||
return { success: false, code: "INVALID_TOKEN", message: "Installation is not available." };
|
||||
}
|
||||
|
||||
const validation = validateAllInput(params.input);
|
||||
if (!validation.valid) {
|
||||
return { success: false, code: "VALIDATION_FAILED", message: validation.issues[0]!.message };
|
||||
}
|
||||
|
||||
const existingUser = await prisma.user.findUnique({ where: { email: params.input.administrator.email.toLowerCase() } });
|
||||
if (existingUser) {
|
||||
return { success: false, code: "EMAIL_ALREADY_IN_USE", message: "That email is already in use." };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// First statement, and the concurrency lock — see
|
||||
// ApplicationInstallation's own header in prisma/schema.prisma. A
|
||||
// concurrent second install attempt blocks on this same
|
||||
// primary-key insert and then fails with a unique-constraint
|
||||
// violation once this transaction commits (or proceeds cleanly if
|
||||
// this transaction instead rolls back on a later failure).
|
||||
await tx.applicationInstallation.create({
|
||||
data: { id: INSTALLATION_SINGLETON_ID, status: InstallationStatus.INSTALLING },
|
||||
});
|
||||
|
||||
const { administrator, business: businessInput, location, hours, bookingRules, branding, starterServices } =
|
||||
params.input;
|
||||
|
||||
const business = await tx.business.create({
|
||||
data: {
|
||||
slug: slugify(businessInput.name),
|
||||
name: businessInput.name.trim(),
|
||||
shortName: businessInput.shortName?.trim() || null,
|
||||
description: businessInput.description?.trim() || null,
|
||||
logoUrl: branding.logoUrl?.trim() || null,
|
||||
coverImageUrl: branding.coverImageUrl?.trim() || null,
|
||||
themeColor: branding.themeColor?.trim() || null,
|
||||
backgroundColor: branding.backgroundColor?.trim() || null,
|
||||
phone: businessInput.phone?.trim() || null,
|
||||
email: businessInput.email?.trim() || null,
|
||||
website: businessInput.website?.trim() || null,
|
||||
timezone: location.timezone,
|
||||
currency: location.currency.toUpperCase(),
|
||||
locale: location.locale,
|
||||
locations: {
|
||||
create: {
|
||||
addressLine1: location.addressLine1.trim(),
|
||||
addressLine2: location.addressLine2?.trim() || null,
|
||||
city: location.city.trim(),
|
||||
region: location.region?.trim() || null,
|
||||
postalCode: location.postalCode.trim(),
|
||||
country: location.country.trim(),
|
||||
isPrimary: true,
|
||||
},
|
||||
},
|
||||
settings: { create: {} },
|
||||
hours: {
|
||||
create: hours.map((day) => ({
|
||||
dayOfWeek: day.dayOfWeek,
|
||||
isClosed: day.isClosed,
|
||||
opensAtMinutes: day.isClosed ? null : (day.opensAtMinutes ?? null),
|
||||
closesAtMinutes: day.isClosed ? null : (day.closesAtMinutes ?? null),
|
||||
})),
|
||||
},
|
||||
bookingRule: { create: { ...bookingRules } },
|
||||
// Disabled by default — the brief is explicit that installation
|
||||
// must not silently turn loyalty on; the owner opts in later via
|
||||
// /owner/settings/loyalty, same as every other post-install
|
||||
// configuration choice.
|
||||
loyaltyProgramme: {
|
||||
create: { isEnabled: false, stampsRequired: 10, rewardType: RewardType.PERCENTAGE },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [ownerRole] = await Promise.all([
|
||||
tx.role.create({
|
||||
data: {
|
||||
businessId: business.id,
|
||||
name: "Owner",
|
||||
isSystem: true,
|
||||
permissions: { create: OWNER_PERMISSIONS.map((permission) => ({ permission })) },
|
||||
},
|
||||
}),
|
||||
tx.role.create({
|
||||
data: {
|
||||
businessId: business.id,
|
||||
name: "Barber",
|
||||
isSystem: true,
|
||||
permissions: { create: BARBER_PERMISSIONS.map((permission) => ({ permission })) },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const passwordHash = await bcrypt.hash(administrator.password, 12);
|
||||
const user = await tx.user.create({
|
||||
data: {
|
||||
email: administrator.email.toLowerCase(),
|
||||
name: `${administrator.firstName.trim()} ${administrator.lastName.trim()}`,
|
||||
phone: administrator.phone?.trim() || null,
|
||||
passwordHash,
|
||||
emailVerified: new Date(),
|
||||
staffProfile: {
|
||||
create: {
|
||||
businessId: business.id,
|
||||
roleId: ownerRole.id,
|
||||
isBookableOnline: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { staffProfile: true },
|
||||
});
|
||||
const staffId = user.staffProfile!.id;
|
||||
|
||||
// Starter services: one ServiceCategory per distinct categoryName,
|
||||
// created in submission order, then a Service row per entry —
|
||||
// never silently invented data, only what the installer explicitly
|
||||
// reviewed and approved in Step 8 (see install-wizard.tsx).
|
||||
const categoryIdByName = new Map<string, string>();
|
||||
for (const starter of starterServices) {
|
||||
if (!categoryIdByName.has(starter.categoryName)) {
|
||||
const category = await tx.serviceCategory.create({
|
||||
data: { businessId: business.id, name: starter.categoryName },
|
||||
});
|
||||
categoryIdByName.set(starter.categoryName, category.id);
|
||||
}
|
||||
}
|
||||
const createdServiceIds: string[] = [];
|
||||
for (const starter of starterServices) {
|
||||
const categoryId = categoryIdByName.get(starter.categoryName)!;
|
||||
const service = await tx.service.create({
|
||||
data: {
|
||||
businessId: business.id,
|
||||
categoryId,
|
||||
name: starter.name.trim(),
|
||||
description: starter.description?.trim() || null,
|
||||
durationMinutes: starter.durationMinutes,
|
||||
price: starter.price,
|
||||
isActive: starter.active,
|
||||
allowOnlineBooking: starter.allowOnlineBooking,
|
||||
},
|
||||
});
|
||||
createdServiceIds.push(service.id);
|
||||
}
|
||||
if (createdServiceIds.length > 0) {
|
||||
await tx.staffService.createMany({
|
||||
data: createdServiceIds.map((serviceId) => ({ staffId, serviceId })),
|
||||
});
|
||||
}
|
||||
|
||||
await tx.applicationInstallation.update({
|
||||
where: { id: INSTALLATION_SINGLETON_ID },
|
||||
data: {
|
||||
status: InstallationStatus.INSTALLED,
|
||||
installedAt: new Date(),
|
||||
installedVersion: params.installedVersion,
|
||||
initialAdminUserId: user.id,
|
||||
primaryBusinessId: business.id,
|
||||
metadata: { starterServiceCount: createdServiceIds.length } satisfies Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
actorUserId: user.id,
|
||||
action: "APPLICATION_INSTALLED",
|
||||
entityType: "Business",
|
||||
entityId: business.id,
|
||||
metadata: { businessName: business.name } satisfies Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return { userId: user.id, businessId: business.id };
|
||||
});
|
||||
|
||||
return { success: true, userId: result.userId, businessId: result.businessId };
|
||||
} catch (error) {
|
||||
// A concurrent installer won the create() race — see the header
|
||||
// comment above. Anything else is an unexpected failure; either way,
|
||||
// the transaction rolled back and left no partial data (see
|
||||
// ApplicationInstallation's own header for why that's true even for
|
||||
// the lock row itself).
|
||||
const isDuplicateSingleton =
|
||||
typeof error === "object" && error !== null && "code" in error && (error as { code?: string }).code === "P2002";
|
||||
if (isDuplicateSingleton) {
|
||||
return { success: false, code: "ALREADY_INSTALLED", message: "This application has already been installed." };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
code: "INSTALL_FAILED",
|
||||
message: "Installation failed. No changes were saved — you can safely try again.",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Single source of truth for the installation wizard's shape — shared
|
||||
* between the client wizard component and the final Server Action (see
|
||||
* actions/install.ts). Deliberately one flat schema, not one per wizard
|
||||
* step: the wizard itself is client-side-only state (see
|
||||
* install-wizard.tsx's own header for why) and nothing is validated or
|
||||
* written server-side until the single final submission, so there is only
|
||||
* ever one real validation boundary to define.
|
||||
*/
|
||||
|
||||
export const installAdministratorSchema = z
|
||||
.object({
|
||||
firstName: z.string().trim().min(1, "First name is required"),
|
||||
lastName: z.string().trim().min(1, "Last name is required"),
|
||||
email: z.email("Enter a valid email address"),
|
||||
phone: z.string().trim().optional().or(z.literal("")),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
|
||||
export const installBusinessSchema = z.object({
|
||||
name: z.string().trim().min(1, "Business name is required"),
|
||||
shortName: z.string().trim().max(40).optional().or(z.literal("")),
|
||||
description: z.string().trim().optional().or(z.literal("")),
|
||||
email: z.email("Enter a valid email address").optional().or(z.literal("")),
|
||||
phone: z.string().trim().optional().or(z.literal("")),
|
||||
website: z.string().trim().optional().or(z.literal("")),
|
||||
});
|
||||
|
||||
export const installLocationSchema = z.object({
|
||||
addressLine1: z.string().trim().min(1, "Address line 1 is required"),
|
||||
addressLine2: z.string().trim().optional().or(z.literal("")),
|
||||
city: z.string().trim().min(1, "Town or city is required"),
|
||||
region: z.string().trim().optional().or(z.literal("")),
|
||||
postalCode: z.string().trim().min(1, "Postcode is required"),
|
||||
country: z.string().trim().min(1, "Country is required"),
|
||||
timezone: z.string().trim().min(1, "Timezone is required"),
|
||||
currency: z.string().trim().length(3, "Currency should be a 3-letter code, e.g. GBP"),
|
||||
locale: z.string().trim().min(1, "Locale is required"),
|
||||
});
|
||||
|
||||
const weekdaySchema = z.enum(["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"]);
|
||||
|
||||
export const installHoursDaySchema = z.object({
|
||||
dayOfWeek: weekdaySchema,
|
||||
isClosed: z.boolean(),
|
||||
opensAtMinutes: z.number().int().min(0).max(1439).optional(),
|
||||
closesAtMinutes: z.number().int().min(0).max(1439).optional(),
|
||||
});
|
||||
|
||||
export const installBookingRulesSchema = z.object({
|
||||
allowGuestBooking: z.boolean(),
|
||||
minNoticeMinutes: z.number().int().min(0),
|
||||
maxAdvanceDays: z.number().int().min(1),
|
||||
cancellationWindowHours: z.number().int().min(0),
|
||||
rescheduleWindowHours: z.number().int().min(0),
|
||||
slotIntervalMinutes: z.number().int().min(5).max(120),
|
||||
bufferBeforeMinutes: z.number().int().min(0),
|
||||
bufferAfterMinutes: z.number().int().min(0),
|
||||
maxAppointmentsPerDay: z.number().int().min(1).nullable(),
|
||||
maxAppointmentsPerStaffPerDay: z.number().int().min(1).nullable(),
|
||||
customerCanChooseStaff: z.boolean(),
|
||||
requireApproval: z.boolean(),
|
||||
});
|
||||
|
||||
export const installBrandingSchema = z.object({
|
||||
logoUrl: z.string().trim().optional().or(z.literal("")),
|
||||
coverImageUrl: z.string().trim().optional().or(z.literal("")),
|
||||
themeColor: z.string().trim().optional().or(z.literal("")),
|
||||
backgroundColor: z.string().trim().optional().or(z.literal("")),
|
||||
});
|
||||
|
||||
const decimalPrice = z.string().regex(/^\d+(\.\d{1,2})?$/, "Enter a price like 22.50");
|
||||
|
||||
export const installStarterServiceSchema = z.object({
|
||||
categoryName: z.string().trim().min(1),
|
||||
name: z.string().trim().min(1),
|
||||
description: z.string().trim().optional().or(z.literal("")),
|
||||
price: decimalPrice,
|
||||
durationMinutes: z.number().int().min(5).max(480),
|
||||
active: z.boolean(),
|
||||
allowOnlineBooking: z.boolean(),
|
||||
});
|
||||
|
||||
export const installSchema = z.object({
|
||||
installationToken: z.string().min(1, "Installation token is required"),
|
||||
administrator: installAdministratorSchema,
|
||||
business: installBusinessSchema,
|
||||
location: installLocationSchema,
|
||||
hours: z.array(installHoursDaySchema).length(7),
|
||||
bookingRules: installBookingRulesSchema,
|
||||
branding: installBrandingSchema,
|
||||
starterServices: z.array(installStarterServiceSchema).max(20),
|
||||
});
|
||||
|
||||
export type InstallAdministratorInput = z.infer<typeof installAdministratorSchema>;
|
||||
export type InstallBusinessInput = z.infer<typeof installBusinessSchema>;
|
||||
export type InstallLocationInput = z.infer<typeof installLocationSchema>;
|
||||
export type InstallHoursDayInput = z.infer<typeof installHoursDaySchema>;
|
||||
export type InstallBookingRulesInput = z.infer<typeof installBookingRulesSchema>;
|
||||
export type InstallBrandingInput = z.infer<typeof installBrandingSchema>;
|
||||
export type InstallStarterServiceInput = z.infer<typeof installStarterServiceSchema>;
|
||||
export type InstallInput = z.infer<typeof installSchema>;
|
||||
@@ -1,41 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, Check, Globe, Rocket } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
const steps = ["Creating database", "Generating configuration", "Creating Dockge stack", "Starting application", "Configuring Caddy", "Checking application"];
|
||||
|
||||
export function ManagerAddBusiness() {
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [slugTouched, setSlugTouched] = useState(false);
|
||||
const [domain, setDomain] = useState("");
|
||||
const [image, setImage] = useState("nextchair:latest");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deployed, setDeployed] = useState<string | null>(null);
|
||||
|
||||
function changeName(value: string) {
|
||||
setName(value);
|
||||
if (!slugTouched) setSlug(value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 22));
|
||||
}
|
||||
|
||||
async function deploy(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setPending(true); setError(null); setProgress(1);
|
||||
const timer = window.setInterval(() => setProgress((value) => Math.min(value + 1, steps.length)), 900);
|
||||
const response = await fetch("/api/manager/businesses", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, slug, domain, image }) });
|
||||
window.clearInterval(timer);
|
||||
if (!response.ok) { setError((await response.json()).error ?? "Deployment failed."); setPending(false); return; }
|
||||
setProgress(steps.length); setPending(false); setDeployed(slug);
|
||||
}
|
||||
|
||||
if (deployed) return <main className="mx-auto max-w-2xl px-5 py-12 sm:px-8"><Card className="p-8 text-center sm:p-12"><div className="mx-auto mb-6 flex h-14 w-14 items-center justify-center rounded-full bg-accent/10 text-accent-foreground"><Check /></div><h1 className="text-3xl font-bold">Business deployed successfully</h1><p className="mt-3 text-sm text-muted">The application stack is ready. Complete the business setup before inviting customers.</p><div className="mt-8 flex flex-col gap-3 sm:flex-row sm:justify-center"><Button asChild><a href={`https://${domain}`} target="_blank" rel="noreferrer">Open Business</a></Button><Button asChild variant="secondary"><a href={`https://${domain}/install`} target="_blank" rel="noreferrer">Continue Setup</a></Button></div></Card></main>;
|
||||
|
||||
return <main className="mx-auto max-w-3xl px-5 py-8 sm:px-8"><Link href="/manager" className="mb-10 inline-flex items-center gap-2 text-xs font-bold tracking-widest text-muted uppercase"><ArrowLeft className="h-4 w-4" /> Businesses</Link><div className="mb-10"><p className="mb-3 text-[10px] font-bold tracking-[0.3em] text-accent-foreground uppercase">New deployment</p><h1 className="text-4xl font-bold tracking-tight">Add Business</h1><p className="mt-2 text-sm text-muted">Create an isolated NextChair application with its own data and media storage.</p></div><Card className="p-6 sm:p-8"><form onSubmit={deploy} className="space-y-5"><Input label="Business name" required value={name} onChange={(event) => changeName(event.target.value)} placeholder="Blake Heritage" /><Input label="Slug" required value={slug} onChange={(event) => { setSlugTouched(true); setSlug(event.target.value.toLowerCase()); }} hint="Lowercase letters, numbers, and hyphens. Maximum 22 characters." placeholder="blake-heritage" /><Input label="Domain" required value={domain} onChange={(event) => setDomain(event.target.value.toLowerCase())} icon={<Globe className="h-4 w-4" />} placeholder="blakeheritage.com" /><Input label="Application version" required value={image} onChange={(event) => setImage(event.target.value)} hint="Use nextchair:latest locally or a pinned registry image in production." placeholder="nextchair:2026-09-04" />{error && <p className="text-sm font-medium text-danger" role="alert">{error}</p>}{pending && <div className="rounded-xl bg-stone p-4"><p className="mb-3 text-xs font-bold tracking-widest uppercase">Deployment progress</p><div className="space-y-2">{steps.map((step, index) => <p key={step} className={`flex items-center gap-2 text-sm ${index < progress ? "text-accent-foreground" : "text-muted"}`}>{index < progress ? <Check className="h-4 w-4" /> : <span className="h-4 w-4 rounded-full border border-line" />}{step}{index === progress - 1 && <span className="ml-auto text-xs">Working</span>}</p>)}</div></div>}<Button type="submit" className="w-full" isLoading={pending}><Rocket className="h-4 w-4" /> Deploy Business</Button></form></Card></main>;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, ExternalLink, Play, RotateCw, Square, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { BusinessStatus } from "@/services/management/types";
|
||||
|
||||
function formatBytes(value: number | null) { return value === null ? "Not available" : value < 1024 * 1024 ? `${Math.round(value / 1024)} KB` : `${(value / 1024 / 1024).toFixed(1)} MB`; }
|
||||
|
||||
export function ManagerBusinessDetail({ business: initialBusiness, dockgeUrl }: { business: BusinessStatus; dockgeUrl: string }) {
|
||||
const [business, setBusiness] = useState(initialBusiness);
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
const [logs, setLogs] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function action(actionName: string) {
|
||||
if (actionName === "remove" && !window.confirm("Remove the application deployment? The database and media volume will be kept.")) return;
|
||||
setPending(actionName); setError(null);
|
||||
const response = await fetch(`/api/manager/businesses/${business.slug}/actions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: actionName }) });
|
||||
const result = await response.json();
|
||||
setPending(null);
|
||||
if (!response.ok) { setError(result.error ?? "Action failed."); return; }
|
||||
if (actionName === "remove") { window.location.assign("/manager"); return; }
|
||||
setBusiness(result);
|
||||
}
|
||||
|
||||
async function showLogs() {
|
||||
setPending("logs"); const response = await fetch(`/api/manager/businesses/${business.slug}/logs`); setLogs(await response.text()); setPending(null);
|
||||
}
|
||||
|
||||
return <main className="mx-auto max-w-5xl px-5 py-8 sm:px-8"><Link href="/manager" className="mb-10 inline-flex items-center gap-2 text-xs font-bold tracking-widest text-muted uppercase"><ArrowLeft className="h-4 w-4" /> Businesses</Link><header className="mb-8 flex flex-col justify-between gap-5 sm:flex-row sm:items-end"><div><p className="mb-3 text-[10px] font-bold tracking-[0.3em] text-accent-foreground uppercase">Business deployment</p><h1 className="text-4xl font-bold tracking-tight">{business.name}</h1><p className="mt-2 text-sm text-muted">{business.domain} <span className="px-1">·</span> {business.slug}</p></div><div className="flex flex-wrap gap-2"><Button asChild size="sm"><a href={`https://${business.domain}`} target="_blank" rel="noreferrer">Open Business <ExternalLink className="h-3.5 w-3.5" /></a></Button><Button asChild size="sm" variant="secondary"><a href={`https://${business.domain}/owner`} target="_blank" rel="noreferrer">Open Admin <ExternalLink className="h-3.5 w-3.5" /></a></Button></div></header><div className="grid gap-4 md:grid-cols-2"><Card className="p-6"><h2 className="mb-5 text-lg font-bold">Runtime</h2><dl className="grid grid-cols-2 gap-5 text-sm"><div><dt className="text-[10px] font-bold tracking-widest text-muted uppercase">Application version</dt><dd className="mt-1 break-all font-medium">{business.image}</dd></div><div><dt className="text-[10px] font-bold tracking-widest text-muted uppercase">Container</dt><dd className="mt-1 font-medium">{business.container}</dd></div><div><dt className="text-[10px] font-bold tracking-widest text-muted uppercase">Health</dt><dd className="mt-1 font-medium">{business.health}</dd></div><div><dt className="text-[10px] font-bold tracking-widest text-muted uppercase">Database</dt><dd className="mt-1 font-medium">{business.database}</dd></div><div><dt className="text-[10px] font-bold tracking-widest text-muted uppercase">Media storage</dt><dd className="mt-1 font-medium">{formatBytes(business.storageBytes)}</dd></div><div><dt className="text-[10px] font-bold tracking-widest text-muted uppercase">Last updated</dt><dd className="mt-1 font-medium">{new Date(business.updatedAt).toLocaleString()}</dd></div></dl></Card><Card className="p-6"><h2 className="mb-5 text-lg font-bold">Manage deployment</h2><div className="flex flex-wrap gap-2"><Button size="sm" onClick={() => action("start")} isLoading={pending === "start"}><Play className="h-3.5 w-3.5" /> Start</Button><Button size="sm" variant="secondary" onClick={() => action("stop")} isLoading={pending === "stop"}><Square className="h-3.5 w-3.5" /> Stop</Button><Button size="sm" variant="secondary" onClick={() => action("restart")} isLoading={pending === "restart"}><RotateCw className="h-3.5 w-3.5" /> Restart</Button><Button size="sm" variant="secondary" onClick={() => action("update")} isLoading={pending === "update"}>Update</Button><Button size="sm" variant="ghost" onClick={showLogs} isLoading={pending === "logs"}>View Logs</Button>{dockgeUrl && <Button asChild size="sm" variant="ghost"><a href={`${dockgeUrl.replace(/\/$/, "")}/compose/${business.slug}`} target="_blank" rel="noreferrer">Open in Dockge</a></Button>}</div><p className="mt-6 text-xs leading-relaxed text-muted">Update is explicit and uses the image pinned to this business. Removing the application does not delete its database or media volume.</p><Button size="sm" variant="destructive" className="mt-5" onClick={() => action("remove")} isLoading={pending === "remove"}><Trash2 className="h-3.5 w-3.5" /> Remove application</Button></Card></div>{error && <p className="mt-5 text-sm font-medium text-danger" role="alert">{error}</p>}{logs !== null && <Card className="mt-4 overflow-hidden"><div className="border-b border-line px-6 py-4"><h2 className="font-bold">Recent logs</h2></div><pre className="max-h-[32rem] overflow-auto bg-ink p-6 text-xs leading-relaxed whitespace-pre-wrap text-white">{logs}</pre></Card>}</main>;
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink, LogOut, Plus, RefreshCw, Server, Store, Activity } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { BusinessStatus } from "@/services/management/types";
|
||||
|
||||
function formatBytes(value: number | null) {
|
||||
if (value === null) return "Not available";
|
||||
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`;
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function stateTone(value: string) {
|
||||
return value === "running" || value === "healthy" || value === "online" ? "text-accent-foreground" : value === "unknown" ? "text-muted" : "text-danger";
|
||||
}
|
||||
|
||||
export function ManagerDashboard({ initialBusinesses }: { initialBusinesses: BusinessStatus[] }) {
|
||||
const [businesses, setBusinesses] = useState(initialBusinesses);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function refresh() {
|
||||
setLoading(true);
|
||||
const response = await fetch("/api/manager/businesses", { cache: "no-store" });
|
||||
if (response.ok) setBusinesses(await response.json());
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
await fetch("/api/manager/auth", { method: "DELETE" });
|
||||
window.location.assign("/manager/sign-in");
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-5 py-8 sm:px-8 lg:px-10">
|
||||
<header className="mb-12 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="mb-3 text-[10px] font-bold tracking-[0.3em] text-accent-foreground uppercase">NextChair Manager</p>
|
||||
<h1 className="text-4xl font-bold tracking-tight">Businesses</h1>
|
||||
<p className="mt-2 text-sm text-muted">Product-level deployment control. Detailed Docker operations remain in Dockge.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={refresh} aria-label="Refresh businesses"><RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} /></Button>
|
||||
<Button variant="ghost" size="icon" onClick={signOut} aria-label="Sign out"><LogOut className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<Card className="p-5"><Store className="mb-6 h-5 w-5 text-accent-foreground" /><p className="text-3xl font-bold">{businesses.length}</p><p className="mt-1 text-xs text-muted">Deployed businesses</p></Card>
|
||||
<Card className="p-5"><Activity className="mb-6 h-5 w-5 text-accent-foreground" /><p className="text-3xl font-bold">{businesses.filter((business) => business.container === "running").length}</p><p className="mt-1 text-xs text-muted">Running applications</p></Card>
|
||||
<Card className="p-5"><Server className="mb-6 h-5 w-5 text-accent-foreground" /><p className="text-3xl font-bold">{businesses.filter((business) => business.database === "online").length}</p><p className="mt-1 text-xs text-muted">Database connections</p></Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 flex items-center justify-between"><h2 className="text-xl font-bold">All deployments</h2><Button asChild size="sm"><Link href="/manager/add"><Plus className="h-4 w-4" /> Add Business</Link></Button></div>
|
||||
{businesses.length === 0 ? <Card className="p-12 text-center"><p className="font-bold">No businesses deployed yet.</p><p className="mt-2 text-sm text-muted">Create the first business to start its isolated application stack.</p></Card> : <div className="grid gap-4 lg:grid-cols-2">{businesses.map((business) => <Card key={business.slug} className="p-6"><div className="flex items-start justify-between gap-4"><div><p className="text-xl font-bold">{business.name}</p><p className="mt-1 text-sm text-muted">{business.domain} <span className="px-1">·</span> {business.slug}</p></div><span className={`rounded-full bg-stone px-3 py-1 text-[10px] font-bold tracking-widest uppercase ${stateTone(business.container)}`}>{business.container}</span></div><div className="mt-6 grid grid-cols-2 gap-y-4 text-sm"><div><p className="text-[10px] font-bold tracking-widest text-muted uppercase">Version</p><p className="mt-1 font-medium">{business.image}</p></div><div><p className="text-[10px] font-bold tracking-widest text-muted uppercase">Health</p><p className={`mt-1 font-medium ${stateTone(business.health)}`}>{business.health}</p></div><div><p className="text-[10px] font-bold tracking-widest text-muted uppercase">Database</p><p className={`mt-1 font-medium ${stateTone(business.database)}`}>{business.database}</p></div><div><p className="text-[10px] font-bold tracking-widest text-muted uppercase">Media</p><p className="mt-1 font-medium">{formatBytes(business.storageBytes)}</p></div></div><div className="mt-6 flex flex-wrap gap-2 border-t border-line pt-5"><Button asChild size="sm" variant="secondary"><Link href={`/manager/businesses/${business.slug}`}>Manage</Link></Button><Button asChild size="sm" variant="ghost"><a href={`https://${business.domain}`} target="_blank" rel="noreferrer">Open business <ExternalLink className="h-3.5 w-3.5" /></a></Button></div></Card>)}</div>}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Lock, Mail } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export function ManagerSignInForm() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setPending(true);
|
||||
setError(null);
|
||||
const response = await fetch("/api/manager/auth", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }) });
|
||||
setPending(false);
|
||||
if (!response.ok) {
|
||||
setError("Incorrect manager credentials.");
|
||||
return;
|
||||
}
|
||||
router.push("/manager");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<Input label="Administrator email" type="email" required value={email} onChange={(event) => setEmail(event.target.value)} icon={<Mail className="h-4 w-4" />} />
|
||||
<Input label="Password" type="password" required value={password} onChange={(event) => setPassword(event.target.value)} icon={<Lock className="h-4 w-4" />} />
|
||||
{error && <p className="text-sm font-medium text-danger" role="alert">{error}</p>}
|
||||
<Button type="submit" className="w-full" isLoading={pending}>Sign in to Manager</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import type { ActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { createMediaAsset, deleteMediaAsset, listMediaAssets } from "@/services/media/media.service";
|
||||
import type { MediaAssetSummary } from "@/services/media/media.service";
|
||||
import { imageSize } from "image-size";
|
||||
import { isAllowedMediaMimeType, MEDIA_MAX_UPLOAD_BYTES, storeMediaFile } from "@/lib/media/media-storage";
|
||||
|
||||
/**
|
||||
* File uploads travel through a Server Action, not a separate REST route
|
||||
* — Next.js Server Actions accept FormData (including File values)
|
||||
* natively, so this stays consistent with every other owner mutation in
|
||||
* the app instead of introducing a one-off API route just for this.
|
||||
*
|
||||
* Uses getActingBusinessContext (not getActingStaff/a raw StaffProfile
|
||||
* lookup) so a Super Administrator — who by design has no StaffProfile,
|
||||
* see docs/phase-9-summary.md — can manage the media library too.
|
||||
*
|
||||
* Deliberately NOT gated on MANAGE_MEDIA_LIBRARY here: any signed-in
|
||||
* staff member needs to add/browse images for fields they're otherwise
|
||||
* allowed to edit (e.g. a barber uploading their own profile photo).
|
||||
* What a URL actually gets SAVED to is still authorized by that specific
|
||||
* field's own Server Action (updateProfileAction, updateBusinessProfileAction,
|
||||
* etc.) exactly as before — this only gates "can add to the library at all".
|
||||
*/
|
||||
async function requireStaffOrSuperAdmin(): Promise<ActingBusinessContext> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error("You must be signed in.");
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) throw new Error("You must be signed in as staff.");
|
||||
return context;
|
||||
}
|
||||
|
||||
/** Deleting an image can affect fields other staff/other pages reference — restricted to MANAGE_MEDIA_LIBRARY (Owner/Super Admin), unlike upload/browse above. */
|
||||
async function requireMediaLibraryManager(): Promise<ActingBusinessContext> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error("You must be signed in.");
|
||||
if (!(await hasPermission(session.user.id, Permission.MANAGE_MEDIA_LIBRARY))) {
|
||||
throw new Error("You don't have permission to delete media.");
|
||||
}
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) throw new Error("No business context.");
|
||||
return context;
|
||||
}
|
||||
|
||||
export async function listMediaAssetsAction(params: {
|
||||
category?: string;
|
||||
q?: string;
|
||||
}): Promise<{ success: true; assets: MediaAssetSummary[] } | { success: false; error: string }> {
|
||||
try {
|
||||
const context = await requireStaffOrSuperAdmin();
|
||||
const assets = await listMediaAssets(prisma, { businessId: context.businessId, ...params });
|
||||
return { success: true, assets };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : "Couldn't load the media library." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadMediaAssetAction(
|
||||
formData: FormData,
|
||||
): Promise<{ success: true; asset: MediaAssetSummary } | { success: false; error: string }> {
|
||||
try {
|
||||
const context = await requireStaffOrSuperAdmin();
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) return { success: false, error: "No file provided." };
|
||||
if (!isAllowedMediaMimeType(file.type)) {
|
||||
return { success: false, error: "Unsupported image type — use PNG, JPEG, WEBP, or GIF." };
|
||||
}
|
||||
if (file.size > MEDIA_MAX_UPLOAD_BYTES) {
|
||||
return { success: false, error: `File is too large — max ${Math.floor(MEDIA_MAX_UPLOAD_BYTES / 1_000_000)}MB.` };
|
||||
}
|
||||
|
||||
const categoryRaw = formData.get("category");
|
||||
const category = typeof categoryRaw === "string" && categoryRaw.trim() ? categoryRaw.trim() : "other";
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
let width: number | null = null;
|
||||
let height: number | null = null;
|
||||
try {
|
||||
const dimensions = imageSize(buffer);
|
||||
width = dimensions.width ?? null;
|
||||
height = dimensions.height ?? null;
|
||||
} catch {
|
||||
// Best-effort — an unrecognised/corrupt header shouldn't block the upload.
|
||||
}
|
||||
|
||||
const stored = await storeMediaFile({ buffer, mimeType: file.type, category });
|
||||
const asset = await createMediaAsset(prisma, {
|
||||
businessId: context.businessId,
|
||||
url: stored.url,
|
||||
originalFilename: file.name || null,
|
||||
mimeType: file.type,
|
||||
sizeBytes: file.size,
|
||||
width,
|
||||
height,
|
||||
category,
|
||||
uploadedByUserId: context.userId,
|
||||
});
|
||||
|
||||
return { success: true, asset };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : "Upload failed." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMediaAssetAction(id: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const context = await requireMediaLibraryManager();
|
||||
const result = await deleteMediaAsset(prisma, { id, businessId: context.businessId });
|
||||
if (!result.success) return { success: false, error: "That media item couldn't be found." };
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : "Delete failed." };
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { Check, Copy, ImageOff, Link as LinkIcon, Search, Trash2, Upload } from "lucide-react";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import { deleteMediaAssetAction, listMediaAssetsAction, uploadMediaAssetAction } from "@/features/media/actions/media";
|
||||
import type { MediaAssetSummary } from "@/services/media/media.service";
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export interface MediaManagerModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (url: string) => void;
|
||||
/** Tags new uploads for this field — e.g. "branding", "loyalty", "staff", "service", "portfolio", "welcome". The Library tab is one shared pool across every field by default; this only narrows it down if "This Field Only" is picked. */
|
||||
category: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one Media Manager used everywhere an image field exists — see
|
||||
* docs/phase-9-summary.md. Upload / Library / External URL, all in one
|
||||
* dialog, backed by src/features/media/actions/media.ts (Server Actions,
|
||||
* not a REST route — see that file's header for why).
|
||||
*
|
||||
* The Library is a single, business-wide pool: an image uploaded from the
|
||||
* Gallery manager shows up when picking a logo, and vice versa — "category"
|
||||
* only tags where an upload came from and lets the "This Field Only" filter
|
||||
* narrow the view, it never hides an image from other fields by default.
|
||||
*/
|
||||
export function MediaManagerModal({ open, onClose, onSelect, category }: MediaManagerModalProps) {
|
||||
const [tab, setTab] = useState("library");
|
||||
const [assets, setAssets] = useState<MediaAssetSummary[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
// Defaults to "all" — the library is one shared pool across every image
|
||||
// field (logo, cover, gallery, staff/service photos, ...), so an image
|
||||
// uploaded anywhere should be visible everywhere by default. "category"
|
||||
// is an opt-in narrowing filter, not the default view.
|
||||
const [scope, setScope] = useState<"all" | "category">("all");
|
||||
const [selectedAsset, setSelectedAsset] = useState<MediaAssetSummary | null>(null);
|
||||
const [externalUrl, setExternalUrl] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
startTransition(async () => {
|
||||
const result = await listMediaAssetsAction({ category: scope === "category" ? category : undefined, q: query || undefined });
|
||||
if (result.success) {
|
||||
setAssets(result.assets);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(result.error);
|
||||
}
|
||||
});
|
||||
}, [open, scope, query, category, startTransition]);
|
||||
|
||||
function handleUpload(file: File) {
|
||||
setError(null);
|
||||
const formData = new FormData();
|
||||
formData.set("file", file);
|
||||
formData.set("category", category);
|
||||
startTransition(async () => {
|
||||
const result = await uploadMediaAssetAction(formData);
|
||||
if (!result.success) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
onSelect(result.asset.url);
|
||||
onClose();
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete(asset: MediaAssetSummary) {
|
||||
startTransition(async () => {
|
||||
const result = await deleteMediaAssetAction(asset.id);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Couldn't delete that image.");
|
||||
return;
|
||||
}
|
||||
setAssets((prev) => prev.filter((a) => a.id !== asset.id));
|
||||
setSelectedAsset((prev) => (prev?.id === asset.id ? null : prev));
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Media Manager" className="max-w-2xl">
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="library">Library</TabsTrigger>
|
||||
<TabsTrigger value="upload">Upload</TabsTrigger>
|
||||
<TabsTrigger value="url">External URL</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-danger mt-4 text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<TabsContent value="library">
|
||||
<div className="mb-4 space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="text-muted pointer-events-none absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2" aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search by filename"
|
||||
className="border-line focus:ring-ink w-full rounded-xl border bg-white py-2.5 pr-4 pl-11 text-sm outline-none focus:ring-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{(
|
||||
[
|
||||
{ value: "all", label: "All Images" },
|
||||
{ value: "category", label: "This Field Only" },
|
||||
] as const
|
||||
).map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setScope(option.value)}
|
||||
aria-pressed={scope === option.value}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
scope === option.value ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPending && assets.length === 0 && !error ? (
|
||||
<p className="text-muted py-10 text-center text-sm">Loading…</p>
|
||||
) : assets.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-10 text-center">
|
||||
<ImageOff className="text-muted mb-3 h-8 w-8" aria-hidden="true" />
|
||||
<p className="text-muted text-sm">No images yet — upload one to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid max-h-96 grid-cols-3 gap-3 overflow-y-auto sm:grid-cols-4">
|
||||
{assets.map((asset) => (
|
||||
<button
|
||||
key={asset.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedAsset(asset)}
|
||||
aria-pressed={selectedAsset?.id === asset.id}
|
||||
className={cn(
|
||||
"border-line relative aspect-square overflow-hidden rounded-xl border",
|
||||
selectedAsset?.id === asset.id && "ring-ink ring-2",
|
||||
)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URLs, same pattern used app-wide */}
|
||||
<img src={asset.url} alt={asset.originalFilename ?? ""} className="h-full w-full object-cover" />
|
||||
{selectedAsset?.id === asset.id && (
|
||||
<span className="bg-ink absolute top-1.5 right-1.5 flex h-5 w-5 items-center justify-center rounded-full text-white">
|
||||
<Check className="h-3 w-3" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedAsset && (
|
||||
<div className="bg-stone mt-4 rounded-xl p-4 text-xs">
|
||||
<p className="mb-2 truncate font-bold">{selectedAsset.originalFilename ?? "Untitled"}</p>
|
||||
<dl className="text-muted grid grid-cols-2 gap-y-1">
|
||||
<div className="flex gap-1">
|
||||
<dt className="font-bold">Size:</dt>
|
||||
<dd>{formatBytes(selectedAsset.sizeBytes)}</dd>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<dt className="font-bold">Dimensions:</dt>
|
||||
<dd>{selectedAsset.width && selectedAsset.height ? `${selectedAsset.width}×${selectedAsset.height}` : "Unknown"}</dd>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<dt className="font-bold">Uploaded:</dt>
|
||||
<dd>{new Date(selectedAsset.createdAt).toLocaleDateString("en-GB")}</dd>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<dt className="font-bold">Type:</dt>
|
||||
<dd>{selectedAsset.mimeType}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onSelect(selectedAsset.url);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Use this image
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => navigator.clipboard.writeText(new URL(selectedAsset.url, window.location.origin).toString())}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" aria-hidden="true" /> Copy URL
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="destructive" isLoading={isPending} onClick={() => handleDelete(selectedAsset)}>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" /> Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="upload">
|
||||
<label className="border-line bg-stone flex cursor-pointer flex-col items-center gap-3 rounded-xl border-2 border-dashed px-6 py-12 text-center">
|
||||
<Upload className="text-muted h-8 w-8" aria-hidden="true" />
|
||||
<span className="text-sm font-bold">Choose an image to upload</span>
|
||||
<span className="text-muted text-xs">PNG, JPEG, WEBP, or GIF — max 5MB</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleUpload(file);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{isPending && <p className="text-muted mt-3 text-center text-sm">Uploading…</p>}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="url">
|
||||
<Input label="Image URL" placeholder="https://…" value={externalUrl} onChange={(e) => setExternalUrl(e.target.value)} />
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-4 w-full"
|
||||
disabled={!externalUrl.trim()}
|
||||
onClick={() => {
|
||||
onSelect(externalUrl.trim());
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<LinkIcon className="h-4 w-4" aria-hidden="true" /> Use this URL
|
||||
</Button>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ImageOff, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MediaManagerModal } from "@/features/media/components/media-manager-modal";
|
||||
|
||||
export interface MediaPickerProps {
|
||||
label?: string;
|
||||
value: string | null | undefined;
|
||||
onChange: (url: string | null) => void;
|
||||
/** Tags new uploads with where they came from — e.g. "branding", "loyalty", "staff", "service", "portfolio", "welcome". The library itself is shared across every field; this doesn't hide other fields' images by default (see MediaManagerModal). */
|
||||
category: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The standard image-field component — preview, Change (opens the Media
|
||||
* Manager), Remove. Replaces every plain-text "... URL" Input across the
|
||||
* app (business logo/cover, loyalty card background, staff photo,
|
||||
* service photo, welcome backgrounds, PWA icon/favicon). See
|
||||
* docs/phase-9-summary.md.
|
||||
*/
|
||||
export function MediaPicker({ label, value, onChange, category, hint }: MediaPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && <p className="text-muted mb-2 ml-1 text-[10px] font-bold tracking-widest uppercase">{label}</p>}
|
||||
<div className="border-line flex items-center gap-4 rounded-xl border bg-white p-4">
|
||||
<div className="bg-stone flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg">
|
||||
{value ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URLs, same pattern used app-wide
|
||||
<img src={value} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<ImageOff className="text-muted h-5 w-5" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-wrap gap-2">
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => setOpen(true)}>
|
||||
{value ? "Change" : "Choose image"}
|
||||
</Button>
|
||||
{value && (
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => onChange(null)}>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" /> Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hint && <p className="text-muted mt-1.5 ml-1 text-xs">{hint}</p>}
|
||||
|
||||
<MediaManagerModal open={open} onClose={() => setOpen(false)} onSelect={(url) => onChange(url)} category={category} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { markAllNotificationsRead, markNotificationRead } from "@/services/notifications/notification.service";
|
||||
|
||||
export interface MarkReadActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared across all three notification-centre pages (customer/staff/owner)
|
||||
* — ownership is enforced by recipientUserId matching the session, exactly
|
||||
* the same rule regardless of which role is asking (see
|
||||
* NotificationService.markNotificationRead's own scoped WHERE clause), so
|
||||
* one action file covers all of them rather than three near-identical
|
||||
* copies.
|
||||
*/
|
||||
export async function markNotificationReadAction(notificationId: string): Promise<MarkReadActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { success: false, error: "You must be signed in." };
|
||||
|
||||
await markNotificationRead(prisma, { notificationId, userId: session.user.id });
|
||||
revalidatePath("/notifications");
|
||||
revalidatePath("/staff/notifications");
|
||||
revalidatePath("/owner/notifications");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function markAllNotificationsReadAction(): Promise<MarkReadActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { success: false, error: "You must be signed in." };
|
||||
|
||||
await markAllNotificationsRead(prisma, session.user.id);
|
||||
revalidatePath("/notifications");
|
||||
revalidatePath("/staff/notifications");
|
||||
revalidatePath("/owner/notifications");
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { NotificationType } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { setNotificationPreference } from "@/services/notifications/notification.service";
|
||||
import type { PreferenceCellState } from "@/features/staff/components/notification-preferences-form";
|
||||
|
||||
export interface PreferencesActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer-side counterpart to features/staff/actions/profile.ts's
|
||||
* saveNotificationPreferencesAction — same shape, but scoped to the
|
||||
* session's own customer account and the one preference-controlled
|
||||
* customer-facing type (BOOKING_REMINDER; see notifications/types.ts).
|
||||
*/
|
||||
export async function saveCustomerNotificationPreferencesAction(
|
||||
cells: PreferenceCellState[],
|
||||
): Promise<PreferencesActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { success: false, error: "You must be signed in." };
|
||||
|
||||
for (const cell of cells) {
|
||||
await setNotificationPreference(prisma, {
|
||||
userId: session.user.id,
|
||||
type: NotificationType.BOOKING_REMINDER,
|
||||
channel: cell.channel,
|
||||
isEnabled: cell.isEnabled,
|
||||
});
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
||||
import { subscribeToPush, unsubscribeFromPush } from "@/services/notifications/push-subscription.service";
|
||||
|
||||
export interface PushActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered users only (staff, owner, signed-in customer) — a guest has
|
||||
* no session to attribute the subscription to. A guest subscribing right
|
||||
* after a guest booking is a real, documented gap for a future pass (see
|
||||
* docs/phase-7-summary.md, "Known limitations"), not an oversight.
|
||||
*/
|
||||
export async function subscribeToPushAction(input: {
|
||||
endpoint: string;
|
||||
p256dhKey: string;
|
||||
authKey: string;
|
||||
deviceLabel?: string;
|
||||
}): Promise<PushActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { success: false, error: "You must be signed in." };
|
||||
|
||||
const [business, customerProfile] = await Promise.all([
|
||||
getPrimaryBusiness(),
|
||||
prisma.customerProfile.findUnique({ where: { userId: session.user.id } }),
|
||||
]);
|
||||
|
||||
await subscribeToPush(prisma, {
|
||||
businessId: business.id,
|
||||
userId: session.user.id,
|
||||
customerId: customerProfile?.id,
|
||||
endpoint: input.endpoint,
|
||||
p256dhKey: input.p256dhKey,
|
||||
authKey: input.authKey,
|
||||
deviceLabel: input.deviceLabel,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function unsubscribeFromPushAction(endpoint: string): Promise<PushActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { success: false, error: "You must be signed in." };
|
||||
|
||||
await unsubscribeFromPush(prisma, { endpoint, ownerUserId: session.user.id });
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Bell } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
/** Pages that render their own inline bell (see InlineNotificationBell) as part of a proper header — the shell's own floating bell steps aside there instead of sitting on top of it. */
|
||||
const PAGES_WITH_INLINE_BELL = [
|
||||
"/staff",
|
||||
"/owner/calendar",
|
||||
"/staff/current",
|
||||
"/staff/clients",
|
||||
"/staff/settings",
|
||||
"/appointments",
|
||||
"/loyalty",
|
||||
"/settings",
|
||||
"/owner",
|
||||
];
|
||||
|
||||
function badge(unreadCount: number) {
|
||||
if (unreadCount === 0) return null;
|
||||
return (
|
||||
<span className="bg-danger absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-[10px] font-bold text-white">
|
||||
{unreadCount > 9 ? "9+" : unreadCount}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed top-right on every screen inside the shell that renders it (see
|
||||
* StaffShell and (customer)/layout.tsx) — unread count is fetched
|
||||
* server-side by the layout and passed in, not polled client-side, so it
|
||||
* only ever reflects "as of this page load/navigation". Real-time badge
|
||||
* updates without a reload would need a persistent connection this phase
|
||||
* doesn't build (see docs/phase-7-summary.md, "Known limitations").
|
||||
*
|
||||
* Steps aside (renders nothing) on pages that build their own proper
|
||||
* header — a fixed top-right bell has nowhere sensible to sit once a page
|
||||
* already has its own top-right control (e.g. Calendar's "New" button) —
|
||||
* see InlineNotificationBell below, used in exactly those pages' headers.
|
||||
*/
|
||||
export function NotificationBell({ href, unreadCount }: { href: string; unreadCount: number }) {
|
||||
const pathname = usePathname();
|
||||
if (PAGES_WITH_INLINE_BELL.includes(pathname)) return null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : "Notifications"}
|
||||
className="fixed top-4 right-4 z-40 flex h-10 w-10 items-center justify-center rounded-full border border-line bg-white shadow-sm"
|
||||
>
|
||||
<Bell className="h-4 w-4" aria-hidden="true" />
|
||||
{badge(unreadCount)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** Same look as the fixed bell above, but a normal in-flow element — for a page's own TopNav `leftAction`, not the shell's floating corner. */
|
||||
export function InlineNotificationBell({ href, unreadCount }: { href: string; unreadCount: number }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : "Notifications"}
|
||||
className="relative flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-line"
|
||||
>
|
||||
<Bell className="h-4 w-4" aria-hidden="true" />
|
||||
{badge(unreadCount)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Bell, BellOff } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/feedback/empty-state";
|
||||
import { markAllNotificationsReadAction, markNotificationReadAction } from "../actions/mark-read";
|
||||
|
||||
export interface NotificationListItem {
|
||||
id: string;
|
||||
subject: string | null;
|
||||
bodyText: string;
|
||||
deepLinkPath: string | null;
|
||||
readAt: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
function formatTimestamp(date: Date): string {
|
||||
return date.toLocaleString("en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared by /notifications, /staff/notifications, /owner/notifications —
|
||||
* ownership scoping already happened server-side (each page only ever
|
||||
* passes that session's own notifications, via
|
||||
* NotificationService.listInAppNotifications), so this component just
|
||||
* renders whatever list it's given and reports read/unread actions back.
|
||||
*/
|
||||
export function NotificationList({ notifications }: { notifications: NotificationListItem[] }) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const hasUnread = notifications.some((n) => !n.readAt);
|
||||
|
||||
const handleOpen = (notification: NotificationListItem) => {
|
||||
if (!notification.readAt) {
|
||||
startTransition(async () => {
|
||||
await markNotificationReadAction(notification.id);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllRead = () => {
|
||||
startTransition(async () => {
|
||||
await markAllNotificationsReadAction();
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
if (notifications.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={BellOff}
|
||||
title="No notifications yet"
|
||||
description="You'll see booking updates and reminders here."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{hasUnread && (
|
||||
<div className="flex justify-end">
|
||||
<Button variant="secondary" size="sm" isLoading={isPending} onClick={handleMarkAllRead}>
|
||||
Mark All Read
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<ul className="space-y-2">
|
||||
{notifications.map((notification) => {
|
||||
const unread = !notification.readAt;
|
||||
const content = (
|
||||
<div
|
||||
className={`rounded-md border p-4 transition-colors ${
|
||||
unread ? "border-accent/30 bg-accent/5" : "border-line bg-white"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Bell className={`mt-0.5 h-4 w-4 shrink-0 ${unread ? "text-accent" : "text-muted"}`} aria-hidden="true" />
|
||||
<div>
|
||||
{notification.subject && <p className="text-sm font-bold">{notification.subject}</p>}
|
||||
<p className="text-muted mt-0.5 text-sm">{notification.bodyText}</p>
|
||||
<p className="text-muted mt-1 text-xs">{formatTimestamp(notification.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{unread && <span role="status" className="bg-accent mt-1 h-2 w-2 shrink-0 rounded-full" aria-label="Unread" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<li key={notification.id}>
|
||||
{notification.deepLinkPath ? (
|
||||
<Link href={notification.deepLinkPath} onClick={() => handleOpen(notification)} className="block">
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<button type="button" onClick={() => handleOpen(notification)} className="block w-full text-left">
|
||||
{content}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { subscribeToPushAction, unsubscribeFromPushAction } from "../actions/push-subscriptions";
|
||||
import { urlBase64ToUint8Array } from "../lib/vapid";
|
||||
|
||||
type Status = "checking" | "unsupported" | "subscribed" | "not-subscribed" | "denied";
|
||||
|
||||
/**
|
||||
* The one control that ever asks for push permission — deliberately not
|
||||
* rendered anywhere that loads automatically on first visit (see the
|
||||
* Phase 7 brief's "never request permission on first load"). Placed in
|
||||
* Settings, a screen someone only reaches by deliberately navigating
|
||||
* there, with the benefit explained in the paragraph above the button
|
||||
* before anything is requested.
|
||||
*/
|
||||
function isPushSupported(): boolean {
|
||||
return typeof window !== "undefined" && "serviceWorker" in navigator && "PushManager" in window;
|
||||
}
|
||||
|
||||
export function PushSubscribeToggle() {
|
||||
// Always starts as "checking" (renders nothing) on both server and
|
||||
// client — `isPushSupported`/`Notification.permission` are
|
||||
// client-only reads, so resolving them in a useState initializer would
|
||||
// make the client's hydration render disagree with the server's and
|
||||
// trigger a hydration error. The real status is only ever resolved
|
||||
// client-side, post-mount, in the effect below.
|
||||
const [status, setStatus] = useState<Status>("checking");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "checking") return;
|
||||
let cancelled = false;
|
||||
|
||||
Promise.resolve()
|
||||
.then(async () => {
|
||||
if (!isPushSupported()) return "unsupported" as const;
|
||||
if (Notification.permission === "denied") return "denied" as const;
|
||||
const reg = await navigator.serviceWorker.getRegistration();
|
||||
const sub = await reg?.pushManager.getSubscription();
|
||||
return sub ? ("subscribed" as const) : ("not-subscribed" as const);
|
||||
})
|
||||
.then((resolved) => {
|
||||
if (!cancelled) setStatus(resolved);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setStatus("not-subscribed");
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [status]);
|
||||
|
||||
const subscribe = () => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
|
||||
if (!vapidPublicKey) {
|
||||
setError("Push notifications aren't configured for this business yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.register("/sw.js");
|
||||
await navigator.serviceWorker.ready;
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== "granted") {
|
||||
setStatus("denied");
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey) as BufferSource,
|
||||
});
|
||||
const json = subscription.toJSON();
|
||||
if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) {
|
||||
setError("Couldn't complete the push subscription. Please try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await subscribeToPushAction({
|
||||
endpoint: json.endpoint,
|
||||
p256dhKey: json.keys.p256dh,
|
||||
authKey: json.keys.auth,
|
||||
deviceLabel: navigator.userAgent.slice(0, 100),
|
||||
});
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Couldn't save your subscription.");
|
||||
return;
|
||||
}
|
||||
setStatus("subscribed");
|
||||
} catch {
|
||||
setError("Couldn't enable push notifications. Please try again.");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const unsubscribe = () => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const registration = await navigator.serviceWorker.getRegistration();
|
||||
const subscription = await registration?.pushManager.getSubscription();
|
||||
if (subscription) {
|
||||
await unsubscribeFromPushAction(subscription.endpoint);
|
||||
await subscription.unsubscribe();
|
||||
}
|
||||
setStatus("not-subscribed");
|
||||
});
|
||||
};
|
||||
|
||||
if (status === "checking") return null;
|
||||
|
||||
if (status === "unsupported") {
|
||||
return <p className="text-muted text-sm">Push notifications aren't supported in this browser.</p>;
|
||||
}
|
||||
|
||||
if (status === "denied") {
|
||||
return (
|
||||
<p className="text-muted text-sm">
|
||||
Push notifications are blocked for this site in your browser settings. Allow notifications for this site to
|
||||
turn them back on.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{status === "not-subscribed" && (
|
||||
<p className="text-muted text-sm">
|
||||
Get a push notification on this device for reminders and important updates, in addition to email.
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant={status === "subscribed" ? "secondary" : "primary"}
|
||||
size="sm"
|
||||
isLoading={isPending}
|
||||
onClick={status === "subscribed" ? unsubscribe : subscribe}
|
||||
>
|
||||
{status === "subscribed" ? "Disable Push Notifications" : "Enable Push Notifications"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
/** Web Push wants the VAPID public key as a Uint8Array, not the base64url string the browser subscribe() call ultimately needs to sign with — standard boilerplate conversion, no library needed for it. */
|
||||
export function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const rawData = atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
for (let i = 0; i < rawData.length; i++) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { hasPermission, isSuperAdmin } from "@/lib/auth/permissions";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "../authorization";
|
||||
import { deactivateStaff, updateStaffAsOwner } from "@/services/staff/staff.service";
|
||||
import { deleteCategory } from "@/services/catalogue/service-catalogue.service";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import {
|
||||
cleanupTestBusiness,
|
||||
createTestBusiness,
|
||||
createTestStaff,
|
||||
prisma,
|
||||
} from "@/services/scheduling/__tests__/fixtures";
|
||||
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||
|
||||
/**
|
||||
* Real-DB coverage of "a normal barber must not be able to perform owner
|
||||
* actions" and "owner actions never leak across businesses" — the two
|
||||
* defense-in-depth rules the Phase 6 brief calls out repeatedly. This
|
||||
* re-derives permissions from the database exactly as the owner Server
|
||||
* Actions do (assertOwnerPermission -> hasPermission -> a fresh
|
||||
* StaffProfile/Role/RolePermission query), rather than trusting a cached
|
||||
* session claim.
|
||||
*/
|
||||
describe("Owner authorization and business isolation (integration)", () => {
|
||||
let business: TestBusiness;
|
||||
|
||||
beforeEach(async () => {
|
||||
business = await createTestBusiness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestBusiness(business);
|
||||
});
|
||||
|
||||
describe("hasPermission / assertOwnerPermission", () => {
|
||||
it("grants MANAGE_STAFF to a staff member whose role holds it", async () => {
|
||||
const ownerRole = await prisma.role.create({
|
||||
data: {
|
||||
businessId: business.businessId,
|
||||
name: "Owner",
|
||||
permissions: { create: [{ permission: "MANAGE_STAFF" }] },
|
||||
},
|
||||
});
|
||||
const { userId } = await createTestStaff(business);
|
||||
await prisma.staffProfile.update({ where: { userId }, data: { roleId: ownerRole.id } });
|
||||
|
||||
expect(await hasPermission(userId, "MANAGE_STAFF")).toBe(true);
|
||||
await expect(assertOwnerPermission(userId, "MANAGE_STAFF")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("denies MANAGE_STAFF to an ordinary barber whose role doesn't hold it", async () => {
|
||||
const { userId } = await createTestStaff(business); // createTestStaff's auto-role has no permissions
|
||||
|
||||
expect(await hasPermission(userId, "MANAGE_STAFF")).toBe(false);
|
||||
await expect(assertOwnerPermission(userId, "MANAGE_STAFF")).rejects.toBeInstanceOf(OwnerPermissionDeniedError);
|
||||
});
|
||||
|
||||
it("reflects a permission change immediately — no caching across the role edit", async () => {
|
||||
const barberRole = await prisma.role.create({ data: { businessId: business.businessId, name: "Barber" } });
|
||||
const { userId } = await createTestStaff(business);
|
||||
await prisma.staffProfile.update({ where: { userId }, data: { roleId: barberRole.id } });
|
||||
expect(await hasPermission(userId, "MANAGE_SERVICES")).toBe(false);
|
||||
|
||||
await prisma.rolePermission.create({ data: { roleId: barberRole.id, permission: "MANAGE_SERVICES" } });
|
||||
expect(await hasPermission(userId, "MANAGE_SERVICES")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("business isolation", () => {
|
||||
it("updateStaffAsOwner can't reach a staff member from a different business", async () => {
|
||||
const otherBusiness = await createTestBusiness();
|
||||
try {
|
||||
const { staffId: otherStaffId } = await createTestStaff(otherBusiness);
|
||||
const result = await updateStaffAsOwner(prisma, {
|
||||
staffId: otherStaffId,
|
||||
businessId: business.businessId,
|
||||
input: { bio: "hijacked" },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) throw new Error("unreachable");
|
||||
expect(result.validation.issues[0]?.code).toBe("STAFF_NOT_FOUND");
|
||||
|
||||
const unaffected = await prisma.staffProfile.findUniqueOrThrow({ where: { id: otherStaffId } });
|
||||
expect(unaffected.bio).not.toBe("hijacked");
|
||||
} finally {
|
||||
await cleanupTestBusiness(otherBusiness);
|
||||
}
|
||||
});
|
||||
|
||||
it("deactivateStaff can't reach a staff member from a different business", async () => {
|
||||
const otherBusiness = await createTestBusiness();
|
||||
try {
|
||||
const { staffId: otherStaffId } = await createTestStaff(otherBusiness);
|
||||
const result = await deactivateStaff(prisma, { staffId: otherStaffId, businessId: business.businessId });
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
const unaffected = await prisma.staffProfile.findUniqueOrThrow({ where: { id: otherStaffId } });
|
||||
expect(unaffected.status).toBe("ACTIVE");
|
||||
} finally {
|
||||
await cleanupTestBusiness(otherBusiness);
|
||||
}
|
||||
});
|
||||
|
||||
it("deleteCategory can't reach a category from a different business", async () => {
|
||||
const otherBusiness = await createTestBusiness();
|
||||
try {
|
||||
// Move the other business's fixture category to be empty so only
|
||||
// business-scoping (not the has-services guard) is under test.
|
||||
await prisma.service.deleteMany({ where: { businessId: otherBusiness.businessId } });
|
||||
const result = await deleteCategory(prisma, {
|
||||
categoryId: otherBusiness.categoryId,
|
||||
businessId: business.businessId,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) throw new Error("unreachable");
|
||||
expect(result.validation.issues[0]?.code).toBe("CATEGORY_NOT_FOUND");
|
||||
|
||||
const stillExists = await prisma.serviceCategory.findUnique({ where: { id: otherBusiness.categoryId } });
|
||||
expect(stillExists).not.toBeNull();
|
||||
} finally {
|
||||
await cleanupTestBusiness(otherBusiness);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Super Administrator (Phase 9)", () => {
|
||||
/** Deliberately no StaffProfile at all — see scripts/ensure-super-admin.ts and docs/phase-9-summary.md. */
|
||||
async function createTestSuperAdmin() {
|
||||
const user = await prisma.user.create({
|
||||
data: { email: `super-admin-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@test.local`, isSuperAdmin: true },
|
||||
});
|
||||
return user.id;
|
||||
}
|
||||
|
||||
it("isSuperAdmin reflects the User.isSuperAdmin flag", async () => {
|
||||
const { userId: ordinaryUserId } = await createTestStaff(business);
|
||||
const superAdminUserId = await createTestSuperAdmin();
|
||||
try {
|
||||
expect(await isSuperAdmin(ordinaryUserId)).toBe(false);
|
||||
expect(await isSuperAdmin(superAdminUserId)).toBe(true);
|
||||
} finally {
|
||||
await prisma.user.delete({ where: { id: superAdminUserId } });
|
||||
}
|
||||
});
|
||||
|
||||
it("hasPermission grants every permission to a Super Administrator with no StaffProfile at all", async () => {
|
||||
const superAdminUserId = await createTestSuperAdmin();
|
||||
try {
|
||||
for (const permission of Object.values(Permission)) {
|
||||
expect(await hasPermission(superAdminUserId, permission)).toBe(true);
|
||||
}
|
||||
expect(await prisma.staffProfile.findUnique({ where: { userId: superAdminUserId } })).toBeNull();
|
||||
} finally {
|
||||
await prisma.user.delete({ where: { id: superAdminUserId } });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not grant permissions to an ordinary user who merely lacks a StaffProfile", async () => {
|
||||
const user = await prisma.user.create({ data: { email: `plain-${Date.now()}@test.local` } });
|
||||
try {
|
||||
expect(await hasPermission(user.id, Permission.MANAGE_STAFF)).toBe(false);
|
||||
} finally {
|
||||
await prisma.user.delete({ where: { id: user.id } });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
import { createBreak, createTimeOff, deleteBreak, deleteTimeOff } from "@/services/staff/staff.service";
|
||||
import {
|
||||
cleanupTestBusiness,
|
||||
createTestBusiness,
|
||||
createTestStaff,
|
||||
prisma,
|
||||
} from "@/services/scheduling/__tests__/fixtures";
|
||||
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||
|
||||
/**
|
||||
* Owner-on-behalf-of-staff breaks and time off (Phase 7, closing the Phase
|
||||
* 6-documented gap — see docs/phase-7-summary.md, "Owner-managed
|
||||
* availability"). The owner Server Actions in
|
||||
* src/features/owner/actions/breaks.ts and time-off.ts are thin wrappers:
|
||||
* `assertOwnerPermission` (already covered by
|
||||
* src/features/owner/__tests__/authorization.integration.test.ts) plus a
|
||||
* business-scoped staffId lookup, then these exact StaffService calls with
|
||||
* an explicit target staffId instead of the caller's own. Importing the
|
||||
* "use server" action files directly here isn't possible — they import
|
||||
* `@/auth`, which pulls in next-auth's NextAuth() factory and fails
|
||||
* outside Next's own bundler/runtime (`Cannot find module
|
||||
* "next/server"`), so what these tests verify is the StaffService plumbing
|
||||
* an owner action delegates to, exactly as every other StaffService
|
||||
* integration test in this suite does.
|
||||
*/
|
||||
describe("Owner-managed breaks and time off (integration)", () => {
|
||||
let business: TestBusiness;
|
||||
|
||||
beforeEach(async () => {
|
||||
business = await createTestBusiness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestBusiness(business);
|
||||
});
|
||||
|
||||
it("creates and deletes a break against an explicit target staffId, not the caller's own", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
|
||||
const created = await createBreak(prisma, {
|
||||
staffId,
|
||||
businessTimezone: business.timezone,
|
||||
input: { label: "Owner-added break", startMinutes: 12 * 60, endMinutes: 12 * 60 + 30, days: [Weekday.MONDAY] },
|
||||
});
|
||||
expect(created.success).toBe(true);
|
||||
if (!created.success) return;
|
||||
|
||||
const stored = await prisma.break.findUnique({ where: { id: created.break.id } });
|
||||
expect(stored?.staffId).toBe(staffId);
|
||||
expect(stored?.label).toBe("Owner-added break");
|
||||
|
||||
const removed = await deleteBreak(prisma, { breakId: created.break.id, staffId });
|
||||
expect(removed.success).toBe(true);
|
||||
const gone = await prisma.break.findUnique({ where: { id: created.break.id } });
|
||||
expect(gone).toBeNull();
|
||||
});
|
||||
|
||||
it("creates and deletes whole-day time off against an explicit target staffId", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
|
||||
const created = await createTimeOff(prisma, {
|
||||
staffId,
|
||||
businessTimezone: business.timezone,
|
||||
input: { startDate: "2027-02-01", endDate: "2027-02-03", reason: "Owner-added leave" },
|
||||
});
|
||||
expect(created.success).toBe(true);
|
||||
if (!created.success) return;
|
||||
|
||||
const stored = await prisma.timeOff.findUnique({ where: { id: created.timeOff.id } });
|
||||
expect(stored?.staffId).toBe(staffId);
|
||||
expect(stored?.reason).toBe("Owner-added leave");
|
||||
|
||||
const removed = await deleteTimeOff(prisma, { timeOffId: created.timeOff.id, staffId });
|
||||
expect(removed.success).toBe(true);
|
||||
const gone = await prisma.timeOff.findUnique({ where: { id: created.timeOff.id } });
|
||||
expect(gone).toBeNull();
|
||||
});
|
||||
|
||||
it("a break created for staff A is never visible when querying by staff B's id (no accidental cross-staff writes)", async () => {
|
||||
const { staffId: staffA } = await createTestStaff(business);
|
||||
const { staffId: staffB } = await createTestStaff(business);
|
||||
|
||||
await createBreak(prisma, {
|
||||
staffId: staffA,
|
||||
businessTimezone: business.timezone,
|
||||
input: { label: "A's break", startMinutes: 9 * 60, endMinutes: 9 * 60 + 15, days: [Weekday.TUESDAY] },
|
||||
});
|
||||
|
||||
const staffBBreaks = await prisma.break.findMany({ where: { staffId: staffB } });
|
||||
expect(staffBBreaks).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { AppointmentActionSet } from "@/features/staff/components/appointment-status-actions";
|
||||
import {
|
||||
ownerCancelAppointmentAction,
|
||||
ownerCheckInAppointmentAction,
|
||||
ownerCompleteAppointmentAction,
|
||||
ownerConfirmAppointmentAction,
|
||||
ownerMarkNoShowAction,
|
||||
ownerStartAppointmentAction,
|
||||
} from "./appointments";
|
||||
|
||||
/**
|
||||
* A plain object assembling the owner Server Actions into the shape
|
||||
* AppointmentStatusActions expects — kept in its own (non-"use server")
|
||||
* module because a "use server" file may only export async functions, not
|
||||
* an object literal, even one whose values are all Server Actions.
|
||||
*/
|
||||
export const ownerAppointmentActions: AppointmentActionSet = {
|
||||
confirm: ownerConfirmAppointmentAction,
|
||||
checkIn: ownerCheckInAppointmentAction,
|
||||
start: ownerStartAppointmentAction,
|
||||
complete: ownerCompleteAppointmentAction,
|
||||
markNoShow: ownerMarkNoShowAction,
|
||||
cancel: ownerCancelAppointmentAction,
|
||||
};
|
||||
@@ -1,191 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization";
|
||||
import { cancelBooking, rescheduleBooking } from "@/services/scheduling/booking.service";
|
||||
import {
|
||||
checkIn,
|
||||
complete,
|
||||
confirm,
|
||||
getAppointmentById,
|
||||
markNoShow,
|
||||
start,
|
||||
} from "@/services/scheduling/appointment.service";
|
||||
import { awardStampForCompletedAppointment } from "@/services/loyalty/loyalty.service";
|
||||
import { notifyAppointmentNoShow, notifyBookingApproved, notifyBookingBarberChanged, notifyBookingCompleted } from "@/services/scheduling/booking-notifications";
|
||||
import { syncAppointmentSafely } from "@/services/calendar/calendar-sync.service";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import type { StaffAppointmentActionResult } from "@/features/staff/actions/appointment-actions";
|
||||
|
||||
/**
|
||||
* Owner equivalents of src/features/staff/actions/appointment-actions.ts —
|
||||
* same AppointmentService/BookingService/LoyaltyService calls, but scoped
|
||||
* to "any appointment in the owner's own business" (assertAppointmentInBusiness)
|
||||
* behind a MANAGE_ALL_APPOINTMENTS permission check, instead of "my own
|
||||
* appointments only". Barber permissions are untouched; this is a
|
||||
* parallel, explicit path, not a weakening of assertOwnAppointment — see
|
||||
* docs/phase-6-summary.md, "Permission and business-isolation rules".
|
||||
*/
|
||||
|
||||
async function loadOwnerAppointment(appointmentId: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return { owner: null, appointment: null, error: "You must be signed in." } as const;
|
||||
}
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_ALL_APPOINTMENTS);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) {
|
||||
return { owner: null, appointment: null, error: error.message } as const;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!owner) return { owner: null, appointment: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
const appointment = await getAppointmentById(prisma, appointmentId);
|
||||
if (!appointment) return { owner, appointment: null, error: "Appointment not found." } as const;
|
||||
|
||||
try {
|
||||
assertAppointmentInBusiness(appointment, { id: owner.id, businessId: owner.businessId });
|
||||
} catch (error) {
|
||||
if (error instanceof StaffPermissionDeniedError) {
|
||||
return { owner, appointment: null, error: error.message } as const;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { owner, appointment, error: null } as const;
|
||||
}
|
||||
|
||||
function friendlyTransitionMessage(code?: string): string {
|
||||
if (code === "INVALID_STATUS_TRANSITION") {
|
||||
return "This appointment can't move to that status right now — someone may have already updated it.";
|
||||
}
|
||||
if (code === "APPOINTMENT_NOT_FOUND") return "We couldn't find that appointment.";
|
||||
return "Something went wrong. Please try again.";
|
||||
}
|
||||
|
||||
export async function ownerConfirmAppointmentAction(appointmentId: string): Promise<StaffAppointmentActionResult> {
|
||||
const { appointment, error } = await loadOwnerAppointment(appointmentId);
|
||||
if (!appointment) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await confirm(prisma, appointmentId);
|
||||
if (!result.success) {
|
||||
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
await notifyBookingApproved(prisma, result.appointment);
|
||||
await syncAppointmentSafely(prisma, appointmentId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function ownerCheckInAppointmentAction(appointmentId: string): Promise<StaffAppointmentActionResult> {
|
||||
const { appointment, error } = await loadOwnerAppointment(appointmentId);
|
||||
if (!appointment) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await checkIn(prisma, appointmentId);
|
||||
return result.success
|
||||
? { success: true }
|
||||
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
|
||||
export async function ownerStartAppointmentAction(appointmentId: string): Promise<StaffAppointmentActionResult> {
|
||||
const { appointment, error } = await loadOwnerAppointment(appointmentId);
|
||||
if (!appointment) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await start(prisma, appointmentId);
|
||||
return result.success
|
||||
? { success: true }
|
||||
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
|
||||
export async function ownerCompleteAppointmentAction(appointmentId: string): Promise<StaffAppointmentActionResult> {
|
||||
const { owner, appointment, error } = await loadOwnerAppointment(appointmentId);
|
||||
if (!appointment || !owner) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await complete(prisma, appointmentId);
|
||||
if (!result.success) {
|
||||
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
await notifyBookingCompleted(prisma, result.appointment);
|
||||
|
||||
const loyaltyResult = await awardStampForCompletedAppointment(prisma, { appointmentId, staffId: appointment.staffId });
|
||||
return { success: true, loyaltyStampAwarded: loyaltyResult.success && !loyaltyResult.alreadyAwarded };
|
||||
}
|
||||
|
||||
export async function ownerMarkNoShowAction(appointmentId: string): Promise<StaffAppointmentActionResult> {
|
||||
const { appointment, error } = await loadOwnerAppointment(appointmentId);
|
||||
if (!appointment) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await markNoShow(prisma, appointmentId);
|
||||
if (!result.success) {
|
||||
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
await notifyAppointmentNoShow(prisma, result.appointment);
|
||||
await syncAppointmentSafely(prisma, appointmentId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function ownerCancelAppointmentAction(params: {
|
||||
appointmentId: string;
|
||||
reason?: string;
|
||||
}): Promise<StaffAppointmentActionResult> {
|
||||
const { owner, appointment, error } = await loadOwnerAppointment(params.appointmentId);
|
||||
if (!appointment || !owner) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await cancelBooking(prisma, {
|
||||
appointmentId: params.appointmentId,
|
||||
cancelledByUserId: owner.userId,
|
||||
reason: params.reason,
|
||||
});
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't cancel this appointment." };
|
||||
}
|
||||
await syncAppointmentSafely(prisma, params.appointmentId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export interface OwnerRescheduleResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Reassignment is just a reschedule with a different staffId and the same slot — see docs/phase-6-summary.md, "Appointment reassignment approach". Also covers a genuine date/time reschedule when startAt changes. */
|
||||
export async function ownerRescheduleAppointmentAction(params: {
|
||||
appointmentId: string;
|
||||
staffId?: string;
|
||||
startAt: string;
|
||||
}): Promise<OwnerRescheduleResult> {
|
||||
const { owner, appointment, error } = await loadOwnerAppointment(params.appointmentId);
|
||||
if (!appointment || !owner) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const previousStaffId = appointment.staffId;
|
||||
|
||||
const result = await rescheduleBooking(prisma, {
|
||||
appointmentId: params.appointmentId,
|
||||
staffId: params.staffId,
|
||||
startAt: new Date(params.startAt),
|
||||
});
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this appointment." };
|
||||
}
|
||||
|
||||
if (params.staffId && params.staffId !== previousStaffId) {
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "APPOINTMENT_REASSIGNED",
|
||||
entityType: "Appointment",
|
||||
entityId: params.appointmentId,
|
||||
metadata: { fromStaffId: previousStaffId, toStaffId: params.staffId },
|
||||
});
|
||||
await notifyBookingBarberChanged(prisma, result.appointment);
|
||||
}
|
||||
|
||||
await syncAppointmentSafely(prisma, params.appointmentId);
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import { updateBookingRule } from "@/services/scheduling/booking-rule.service";
|
||||
import type { BookingRuleInput } from "@/services/scheduling/booking-rule.service";
|
||||
|
||||
export interface BookingRuleActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function updateBookingRuleAction(input: BookingRuleInput): Promise<BookingRuleActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { success: false, error: "You must be signed in." };
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_BOOKING_RULES);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) return { success: false, error: error.message };
|
||||
throw error;
|
||||
}
|
||||
|
||||
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!owner) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await updateBookingRule(prisma, { businessId: owner.businessId, input });
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't save booking rules." };
|
||||
}
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "BOOKING_RULES_UPDATED",
|
||||
entityType: "BookingRule",
|
||||
entityId: owner.businessId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { ownerCreateBreakAction, ownerDeleteBreakAction, ownerUpdateBreakAction } from "./breaks";
|
||||
import type { BreakActionSet } from "@/features/staff/components/break-form-sheet";
|
||||
|
||||
/**
|
||||
* Plain module (no "use server") assembling the owner break Server Actions
|
||||
* into the `BreakActionSet` shape BreaksList/BreakFormSheet expect, bound
|
||||
* to one target staff member.
|
||||
*
|
||||
* Uses `.bind(null, staffId)`, not an arrow-function wrapper — a Client
|
||||
* Component can only receive an actual Server Action reference as a prop;
|
||||
* React's Flight serializer specifically recognizes `.bind()` on a Server
|
||||
* Action (Next's own documented pattern for passing extra arguments) and
|
||||
* reconstructs the call server-side, but a plain closure like `(input) =>
|
||||
* ownerCreateBreakAction(staffId, input)` is just an ordinary function with
|
||||
* no such registration and throws "Functions cannot be passed directly to
|
||||
* Client Components" at render time — found empirically in Phase 7 (see
|
||||
* docs/phase-7-summary.md) when this file's first version used arrow
|
||||
* wrappers instead.
|
||||
*/
|
||||
export function createOwnerBreakActionSet(staffId: string): BreakActionSet {
|
||||
return {
|
||||
create: ownerCreateBreakAction.bind(null, staffId),
|
||||
update: ownerUpdateBreakAction.bind(null, staffId),
|
||||
remove: ownerDeleteBreakAction.bind(null, staffId),
|
||||
};
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import { createBreak, deleteBreak, updateBreak } from "@/services/staff/staff.service";
|
||||
import type { BreakInput } from "@/services/staff/staff.service";
|
||||
import { notifyStaffScheduleChanged } from "@/services/staff/staff-notifications";
|
||||
|
||||
export interface BreakConflictItem {
|
||||
id: string;
|
||||
customerName: string;
|
||||
serviceNames: string;
|
||||
startAt: string;
|
||||
}
|
||||
|
||||
export interface BreakActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: BreakConflictItem[];
|
||||
}
|
||||
|
||||
function mapConflicts(conflicts: { id: string; customer: { firstName: string; lastName: string }; services: { service: { name: string } }[]; startAt: Date }[]): BreakConflictItem[] {
|
||||
return conflicts.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async function requireStaffManagerFor(staffId: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { owner: null, error: "You must be signed in." } as const;
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) return { owner: null, error: error.message } as const;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const owner = await prisma.staffProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
include: { business: { select: { name: true, timezone: true } }, user: { select: { name: true } } },
|
||||
});
|
||||
if (!owner) return { owner: null, target: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
const target = await prisma.staffProfile.findFirst({ where: { id: staffId, businessId: owner.businessId } });
|
||||
if (!target) return { owner: null, target: null, error: "Staff member not found." } as const;
|
||||
|
||||
return { owner, target, error: null } as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner-on-behalf-of-staff break management — same StaffService calls the
|
||||
* barber's own editor uses, targeting an explicit `staffId` argument
|
||||
* instead of the caller's own. Assembled into BreaksList/BreakFormSheet's
|
||||
* `actions` prop shape by the plain (non-"use server") factory in
|
||||
* break-action-set.ts — a "use server" file may only export async
|
||||
* functions, never a plain object or a function that returns one
|
||||
* synchronously, so the per-staffId binding has to live outside this file
|
||||
* (see docs/phase-6-summary.md for where this constraint was first found).
|
||||
*/
|
||||
export async function ownerCreateBreakAction(
|
||||
staffId: string,
|
||||
input: BreakInput,
|
||||
acknowledgeConflicts?: boolean,
|
||||
): Promise<BreakActionResult> {
|
||||
const { owner, target, error } = await requireStaffManagerFor(staffId);
|
||||
if (!owner || !target) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await createBreak(prisma, {
|
||||
staffId,
|
||||
businessTimezone: owner.business.timezone,
|
||||
input,
|
||||
acknowledgeConflicts,
|
||||
});
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't create this break.", conflicts: mapConflicts(result.conflicts) };
|
||||
}
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_BREAK_CHANGED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
});
|
||||
await notifyStaffScheduleChanged(prisma, {
|
||||
businessId: owner.businessId,
|
||||
staffUserId: target.userId,
|
||||
changedByName: owner.user.name ?? "The business owner",
|
||||
whatChanged: "your breaks",
|
||||
viewSchedulePath: "/staff/settings/breaks",
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function ownerUpdateBreakAction(
|
||||
staffId: string,
|
||||
breakId: string,
|
||||
input: BreakInput,
|
||||
acknowledgeConflicts?: boolean,
|
||||
): Promise<BreakActionResult> {
|
||||
const { owner, target, error } = await requireStaffManagerFor(staffId);
|
||||
if (!owner || !target) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await updateBreak(prisma, {
|
||||
breakId,
|
||||
staffId,
|
||||
businessTimezone: owner.business.timezone,
|
||||
input,
|
||||
acknowledgeConflicts,
|
||||
});
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this break.", conflicts: mapConflicts(result.conflicts) };
|
||||
}
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_BREAK_CHANGED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
});
|
||||
await notifyStaffScheduleChanged(prisma, {
|
||||
businessId: owner.businessId,
|
||||
staffUserId: target.userId,
|
||||
changedByName: owner.user.name ?? "The business owner",
|
||||
whatChanged: "your breaks",
|
||||
viewSchedulePath: "/staff/settings/breaks",
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function ownerDeleteBreakAction(staffId: string, breakId: string): Promise<BreakActionResult> {
|
||||
const { owner, target, error } = await requireStaffManagerFor(staffId);
|
||||
if (!owner || !target) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await deleteBreak(prisma, { breakId, staffId });
|
||||
if (!result.success) return { success: false, error: "Couldn't delete this break." };
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_BREAK_CHANGED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
});
|
||||
await notifyStaffScheduleChanged(prisma, {
|
||||
businessId: owner.businessId,
|
||||
staffUserId: target.userId,
|
||||
changedByName: owner.user.name ?? "The business owner",
|
||||
whatChanged: "your breaks",
|
||||
viewSchedulePath: "/staff/settings/breaks",
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import JSZip from "jszip";
|
||||
import { imageSize } from "image-size";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { isSuperAdmin } from "@/lib/auth/permissions";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { parseSplashScreens } from "@/lib/business/branding";
|
||||
import type { SplashScreenEntry } from "@/lib/business/branding";
|
||||
import { MEDIA_MAX_UPLOAD_BYTES, storeMediaFile } from "@/lib/media/media-storage";
|
||||
import { createMediaAsset } from "@/services/media/media.service";
|
||||
import {
|
||||
setOpeningHours,
|
||||
updateAppIconSettings,
|
||||
updateBusinessProfile,
|
||||
updateBusinessSettings,
|
||||
updateNavigationSettings,
|
||||
} from "@/services/business/business.service";
|
||||
import type {
|
||||
AppIconSettingsInput,
|
||||
BusinessLocationInput,
|
||||
BusinessProfileInput,
|
||||
BusinessSettingsInput,
|
||||
NavigationSettingsInput,
|
||||
OpeningHoursDayInput,
|
||||
} from "@/services/business/business.service";
|
||||
|
||||
export interface BusinessActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses getActingBusinessContext (not a raw StaffProfile lookup) so a
|
||||
* Super Administrator — who has no StaffProfile by design, see
|
||||
* docs/phase-9-summary.md — can manage Business Branding too.
|
||||
*/
|
||||
async function requireBusinessManager() {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { staff: null, error: "You must be signed in." } as const;
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) return { staff: null, error: error.message } as const;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) return { staff: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
return { staff: { businessId: context.businessId, userId: context.userId }, error: null } as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stricter than requireBusinessManager above — the App Icon & Home
|
||||
* Screen settings are Super Administrator only (not Owner), unlike every
|
||||
* other business-profile action in this file. Re-checked here rather
|
||||
* than trusting the page-level redirect, same "enforce again at the
|
||||
* action layer" rule the rest of the app follows.
|
||||
*/
|
||||
async function requireSuperAdminBusinessManager() {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { staff: null, error: "You must be signed in." } as const;
|
||||
|
||||
if (!(await isSuperAdmin(session.user.id))) {
|
||||
return { staff: null, error: "Only a Super Administrator can change these settings." } as const;
|
||||
}
|
||||
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) return { staff: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
return { staff: { businessId: context.businessId, userId: context.userId }, error: null } as const;
|
||||
}
|
||||
|
||||
export async function updateBusinessProfileAction(params: {
|
||||
profile: BusinessProfileInput;
|
||||
location: BusinessLocationInput;
|
||||
}): Promise<BusinessActionResult> {
|
||||
const { staff, error } = await requireBusinessManager();
|
||||
if (!staff) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await updateBusinessProfile(prisma, { businessId: staff.businessId, ...params });
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't save business details." };
|
||||
}
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: staff.userId,
|
||||
action: "BUSINESS_PROFILE_UPDATED",
|
||||
entityType: "Business",
|
||||
entityId: staff.businessId,
|
||||
metadata: { name: params.profile.name },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateBusinessSettingsAction(settings: BusinessSettingsInput): Promise<BusinessActionResult> {
|
||||
const { staff, error } = await requireBusinessManager();
|
||||
if (!staff) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
await updateBusinessSettings(prisma, { businessId: staff.businessId, settings });
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: staff.userId,
|
||||
action: "BUSINESS_SETTINGS_UPDATED",
|
||||
entityType: "BusinessSettings",
|
||||
entityId: staff.businessId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateAppIconSettingsAction(settings: AppIconSettingsInput): Promise<BusinessActionResult> {
|
||||
const { staff, error } = await requireSuperAdminBusinessManager();
|
||||
if (!staff) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
await updateAppIconSettings(prisma, { businessId: staff.businessId, settings });
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: staff.userId,
|
||||
action: "BUSINESS_APP_ICON_SETTINGS_UPDATED",
|
||||
entityType: "Business",
|
||||
entityId: staff.businessId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Icon+label vs icon-only bottom navigation — Super Administrator only, same rationale as updateAppIconSettingsAction (a change here is visible to every signed-in user immediately, not scoped to the actor). */
|
||||
export async function updateNavigationSettingsAction(
|
||||
settings: NavigationSettingsInput,
|
||||
): Promise<BusinessActionResult> {
|
||||
const { staff, error } = await requireSuperAdminBusinessManager();
|
||||
if (!staff) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
await updateNavigationSettings(prisma, { businessId: staff.businessId, settings });
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: staff.userId,
|
||||
action: "BUSINESS_NAVIGATION_SETTINGS_UPDATED",
|
||||
entityType: "Business",
|
||||
entityId: staff.businessId,
|
||||
metadata: { bottomNavShowLabels: settings.bottomNavShowLabels },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
function isPngBuffer(buffer: Buffer): boolean {
|
||||
return buffer.length >= PNG_SIGNATURE.length && buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts `<link rel="apple-touch-startup-image" media="..." href="...">`
|
||||
* tags from a generator's readme (e.g. progressier.com's PWA icon/splash
|
||||
* bundle) — attribute-order-agnostic within each tag, since the only
|
||||
* contract that matters is "this is an apple-touch-startup-image link
|
||||
* with a media query and an href", not the exact source formatting.
|
||||
*/
|
||||
function parseAppleTouchStartupImageTags(readmeText: string): { media: string; hrefBasename: string }[] {
|
||||
const tags = readmeText.match(/<link\b[^>]*rel="apple-touch-startup-image"[^>]*>/gi) ?? [];
|
||||
const results: { media: string; hrefBasename: string }[] = [];
|
||||
for (const tag of tags) {
|
||||
const media = tag.match(/media="([^"]*)"/i)?.[1];
|
||||
const href = tag.match(/href="([^"]*)"/i)?.[1];
|
||||
if (!media || !href) continue;
|
||||
results.push({ media, hrefBasename: href.split("/").pop() ?? href });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Validates, stores, and records one extracted ZIP image — null on anything that fails validation (caller turns that into a warning, not a hard failure). */
|
||||
async function uploadZipImage(
|
||||
entry: JSZip.JSZipObject,
|
||||
staff: { businessId: string; userId: string },
|
||||
): Promise<string | null> {
|
||||
const buffer = Buffer.from(await entry.async("nodebuffer"));
|
||||
if (!isPngBuffer(buffer) || buffer.length > MEDIA_MAX_UPLOAD_BYTES) return null;
|
||||
|
||||
let width: number | null = null;
|
||||
let height: number | null = null;
|
||||
try {
|
||||
const dimensions = imageSize(buffer);
|
||||
width = dimensions.width ?? null;
|
||||
height = dimensions.height ?? null;
|
||||
} catch {
|
||||
// Best-effort — an unrecognised/corrupt header shouldn't block the upload.
|
||||
}
|
||||
|
||||
const stored = await storeMediaFile({ buffer, mimeType: "image/png", category: "branding" });
|
||||
await createMediaAsset(prisma, {
|
||||
businessId: staff.businessId,
|
||||
url: stored.url,
|
||||
originalFilename: entry.name.split("/").pop() ?? null,
|
||||
mimeType: "image/png",
|
||||
sizeBytes: buffer.length,
|
||||
width,
|
||||
height,
|
||||
category: "branding",
|
||||
uploadedByUserId: staff.userId,
|
||||
});
|
||||
return stored.url;
|
||||
}
|
||||
|
||||
export interface ImportAppIconZipResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
warnings?: string[];
|
||||
applied?: {
|
||||
appIconUrl: string | null;
|
||||
icon192Url: string | null;
|
||||
icon512Url: string | null;
|
||||
maskableIcon192Url: string | null;
|
||||
maskableIcon512Url: string | null;
|
||||
splashScreens: SplashScreenEntry[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts a ZIP from a PWA icon/splash-screen generator (e.g.
|
||||
* progressier.com) — extracts `icon.png` (applied to the Apple touch icon
|
||||
* AND all four Android manifest icon fields, since the generator only
|
||||
* produces one square source image) and every splash screen referenced
|
||||
* by `readme.txt`'s `apple-touch-startup-image` tags (see
|
||||
* parseAppleTouchStartupImageTags), each uploaded through the same media
|
||||
* pipeline as a manual MediaPicker upload. Partial-success by design: a
|
||||
* missing readme, a missing icon, or a handful of unreadable entries
|
||||
* degrade to warnings rather than aborting the whole import — only "found
|
||||
* nothing at all usable" is a hard failure.
|
||||
*/
|
||||
export async function importAppIconZipAction(formData: FormData): Promise<ImportAppIconZipResult> {
|
||||
const { staff, error } = await requireSuperAdminBusinessManager();
|
||||
if (!staff) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) return { success: false, error: "No file provided." };
|
||||
if (!file.name.toLowerCase().endsWith(".zip")) {
|
||||
return { success: false, error: "Please upload a .zip file." };
|
||||
}
|
||||
|
||||
let zip: JSZip;
|
||||
try {
|
||||
zip = await JSZip.loadAsync(Buffer.from(await file.arrayBuffer()));
|
||||
} catch {
|
||||
return { success: false, error: "Couldn't read that file as a ZIP archive." };
|
||||
}
|
||||
|
||||
const entries = Object.values(zip.files).filter((entry) => !entry.dir);
|
||||
const entryByBasename = new Map(entries.map((entry) => [entry.name.split("/").pop() ?? entry.name, entry]));
|
||||
|
||||
const warnings: string[] = [];
|
||||
|
||||
const iconEntry = entries.find((entry) => /^icon\.png$/i.test(entry.name.split("/").pop() ?? ""));
|
||||
let iconUrl: string | null = null;
|
||||
if (iconEntry) {
|
||||
iconUrl = await uploadZipImage(iconEntry, staff);
|
||||
if (!iconUrl) warnings.push("Couldn't import icon.png — it may be too large or not a valid PNG.");
|
||||
} else {
|
||||
warnings.push("No icon.png found in the ZIP — icon fields were left unchanged.");
|
||||
}
|
||||
|
||||
const readmeEntry = entries.find((entry) => (entry.name.split("/").pop() ?? "").toLowerCase() === "readme.txt");
|
||||
const splashScreens: SplashScreenEntry[] = [];
|
||||
if (readmeEntry) {
|
||||
const tags = parseAppleTouchStartupImageTags(await readmeEntry.async("text"));
|
||||
if (tags.length === 0) {
|
||||
warnings.push("readme.txt was found but contained no apple-touch-startup-image tags.");
|
||||
}
|
||||
for (const { media, hrefBasename } of tags) {
|
||||
const imageEntry = entryByBasename.get(hrefBasename);
|
||||
if (!imageEntry) {
|
||||
warnings.push(`Referenced splash image "${hrefBasename}" wasn't found in the ZIP — skipped.`);
|
||||
continue;
|
||||
}
|
||||
const url = await uploadZipImage(imageEntry, staff);
|
||||
if (url) {
|
||||
splashScreens.push({ media, url });
|
||||
} else {
|
||||
warnings.push(`Couldn't import "${hrefBasename}" — skipped.`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warnings.push("No readme.txt found in the ZIP — splash screens were left unchanged.");
|
||||
}
|
||||
|
||||
if (!iconUrl && splashScreens.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Nothing usable was found in that ZIP — expected an icon.png and/or a readme.txt with apple-touch-startup-image tags.",
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
const settings: AppIconSettingsInput = {};
|
||||
if (iconUrl) {
|
||||
settings.appIconUrl = iconUrl;
|
||||
settings.icon192Url = iconUrl;
|
||||
settings.icon512Url = iconUrl;
|
||||
settings.maskableIcon192Url = iconUrl;
|
||||
settings.maskableIcon512Url = iconUrl;
|
||||
}
|
||||
if (splashScreens.length > 0) settings.splashScreens = splashScreens;
|
||||
|
||||
await updateAppIconSettings(prisma, { businessId: staff.businessId, settings });
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: staff.userId,
|
||||
action: "BUSINESS_APP_ICONS_IMPORTED_FROM_ZIP",
|
||||
entityType: "Business",
|
||||
entityId: staff.businessId,
|
||||
metadata: { iconImported: !!iconUrl, splashScreenCount: splashScreens.length, warningCount: warnings.length },
|
||||
});
|
||||
|
||||
const current = await prisma.business.findUnique({
|
||||
where: { id: staff.businessId },
|
||||
select: {
|
||||
appIconUrl: true,
|
||||
icon192Url: true,
|
||||
icon512Url: true,
|
||||
maskableIcon192Url: true,
|
||||
maskableIcon512Url: true,
|
||||
splashScreens: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
applied: {
|
||||
appIconUrl: current?.appIconUrl ?? null,
|
||||
icon192Url: current?.icon192Url ?? null,
|
||||
icon512Url: current?.icon512Url ?? null,
|
||||
maskableIcon192Url: current?.maskableIcon192Url ?? null,
|
||||
maskableIcon512Url: current?.maskableIcon512Url ?? null,
|
||||
splashScreens: parseSplashScreens(current?.splashScreens),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpeningHoursActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: { id: string; customerName: string; serviceNames: string; startAt: string; staffName: string }[];
|
||||
}
|
||||
|
||||
export async function updateOpeningHoursAction(
|
||||
days: OpeningHoursDayInput[],
|
||||
acknowledgeConflicts?: boolean,
|
||||
): Promise<OpeningHoursActionResult> {
|
||||
const { staff, error } = await requireBusinessManager();
|
||||
if (!staff) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const business = await prisma.business.findUnique({ where: { id: staff.businessId }, select: { timezone: true } });
|
||||
if (!business) return { success: false, error: "Business not found." };
|
||||
|
||||
const result = await setOpeningHours(prisma, {
|
||||
businessId: staff.businessId,
|
||||
businessTimezone: business.timezone,
|
||||
days,
|
||||
acknowledgeConflicts,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Couldn't save opening hours.",
|
||||
conflicts: result.conflicts.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
staffName: c.staff.user.name ?? "Staff",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: staff.userId,
|
||||
action: "BUSINESS_HOURS_UPDATED",
|
||||
entityType: "BusinessHours",
|
||||
entityId: staff.businessId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { createTimeOff } from "@/services/staff/staff.service";
|
||||
|
||||
export interface OwnerBlockTimeConflict {
|
||||
id: string;
|
||||
customerName: string;
|
||||
serviceNames: string;
|
||||
startAt: string;
|
||||
}
|
||||
|
||||
export interface OwnerBlockTimeResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: OwnerBlockTimeConflict[];
|
||||
}
|
||||
|
||||
/** Owner variant of Phase 5's blockTimeAction — same underlying StaffService.createTimeOff, just targeting any staff member in the business rather than only the caller's own schedule. See docs/phase-5-summary.md for why Block Time is a partial-day TimeOff row, not a separate model. */
|
||||
export async function ownerBlockTimeAction(input: {
|
||||
staffId: string;
|
||||
date: string;
|
||||
startMinutes: number;
|
||||
endMinutes: number;
|
||||
reason: string;
|
||||
acknowledgeConflicts?: boolean;
|
||||
}): Promise<OwnerBlockTimeResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { success: false, error: "You must be signed in." };
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) return { success: false, error: error.message };
|
||||
throw error;
|
||||
}
|
||||
|
||||
const owner = await prisma.staffProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
include: { business: { select: { timezone: true } } },
|
||||
});
|
||||
if (!owner) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const target = await prisma.staffProfile.findFirst({
|
||||
where: { id: input.staffId, businessId: owner.businessId },
|
||||
});
|
||||
if (!target) return { success: false, error: "Staff member not found." };
|
||||
|
||||
const result = await createTimeOff(prisma, {
|
||||
staffId: input.staffId,
|
||||
businessTimezone: owner.business.timezone,
|
||||
input: {
|
||||
startDate: input.date,
|
||||
endDate: input.date,
|
||||
reason: input.reason.trim() || "Blocked",
|
||||
startMinutes: input.startMinutes,
|
||||
endMinutes: input.endMinutes,
|
||||
},
|
||||
acknowledgeConflicts: input.acknowledgeConflicts,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Something went wrong. Please try again.",
|
||||
conflicts: result.conflicts?.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import {
|
||||
createCategory,
|
||||
createService,
|
||||
deleteCategory,
|
||||
reorderCategories,
|
||||
reorderServices,
|
||||
setCategoryActive,
|
||||
setServiceActive,
|
||||
updateCategory,
|
||||
updateService,
|
||||
} from "@/services/catalogue/service-catalogue.service";
|
||||
import type { CategoryInput, ServiceInput } from "@/services/catalogue/service-catalogue.service";
|
||||
|
||||
export interface CatalogueActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function requireServiceManager() {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { staff: null, error: "You must be signed in." } as const;
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_SERVICES);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) return { staff: null, error: error.message } as const;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!staff) return { staff: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
return { staff, error: null } as const;
|
||||
}
|
||||
|
||||
export async function createCategoryAction(input: CategoryInput): Promise<CatalogueActionResult> {
|
||||
const { staff: owner, error } = await requireServiceManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await createCategory(prisma, { businessId: owner.businessId, input });
|
||||
if (!result.success) return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't create category." };
|
||||
|
||||
await writeAuditLog(prisma, { actorUserId: owner.userId, action: "CATEGORY_CREATED", entityType: "ServiceCategory", entityId: result.category.id });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateCategoryAction(categoryId: string, input: CategoryInput): Promise<CatalogueActionResult> {
|
||||
const { staff: owner, error } = await requireServiceManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await updateCategory(prisma, { categoryId, businessId: owner.businessId, input });
|
||||
if (!result.success) return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update category." };
|
||||
|
||||
await writeAuditLog(prisma, { actorUserId: owner.userId, action: "CATEGORY_UPDATED", entityType: "ServiceCategory", entityId: categoryId });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function setCategoryActiveAction(categoryId: string, isActive: boolean): Promise<CatalogueActionResult> {
|
||||
const { staff: owner, error } = await requireServiceManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await setCategoryActive(prisma, { categoryId, businessId: owner.businessId, isActive });
|
||||
if (!result.success) return { success: false, error: "Category not found." };
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: isActive ? "CATEGORY_ACTIVATED" : "CATEGORY_DEACTIVATED",
|
||||
entityType: "ServiceCategory",
|
||||
entityId: categoryId,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteCategoryAction(categoryId: string): Promise<CatalogueActionResult> {
|
||||
const { staff: owner, error } = await requireServiceManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await deleteCategory(prisma, { categoryId, businessId: owner.businessId });
|
||||
if (!result.success) return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't delete category." };
|
||||
|
||||
await writeAuditLog(prisma, { actorUserId: owner.userId, action: "CATEGORY_DELETED", entityType: "ServiceCategory", entityId: categoryId });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function reorderCategoriesAction(orderedCategoryIds: string[]): Promise<CatalogueActionResult> {
|
||||
const { staff: owner, error } = await requireServiceManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
await reorderCategories(prisma, { businessId: owner.businessId, orderedCategoryIds });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function createServiceAction(input: ServiceInput): Promise<CatalogueActionResult> {
|
||||
const { staff: owner, error } = await requireServiceManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await createService(prisma, { businessId: owner.businessId, input });
|
||||
if (!result.success) return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't create service." };
|
||||
|
||||
await writeAuditLog(prisma, { actorUserId: owner.userId, action: "SERVICE_CREATED", entityType: "Service", entityId: result.service.id, metadata: { name: input.name } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateServiceAction(serviceId: string, input: ServiceInput): Promise<CatalogueActionResult> {
|
||||
const { staff: owner, error } = await requireServiceManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await updateService(prisma, { serviceId, businessId: owner.businessId, input });
|
||||
if (!result.success) return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update service." };
|
||||
|
||||
await writeAuditLog(prisma, { actorUserId: owner.userId, action: "SERVICE_UPDATED", entityType: "Service", entityId: serviceId });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function setServiceActiveAction(serviceId: string, isActive: boolean): Promise<CatalogueActionResult> {
|
||||
const { staff: owner, error } = await requireServiceManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await setServiceActive(prisma, { serviceId, businessId: owner.businessId, isActive });
|
||||
if (!result.success) return { success: false, error: "Service not found." };
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: isActive ? "SERVICE_ACTIVATED" : "SERVICE_DEACTIVATED",
|
||||
entityType: "Service",
|
||||
entityId: serviceId,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function reorderServicesAction(categoryId: string, orderedServiceIds: string[]): Promise<CatalogueActionResult> {
|
||||
const { staff: owner, error } = await requireServiceManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
await reorderServices(prisma, { businessId: owner.businessId, categoryId, orderedServiceIds });
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import { setEligibleServices, setEligibleStaff, updateProgrammeConfig } from "@/services/loyalty/loyalty.service";
|
||||
import type { ProgrammeConfigInput } from "@/services/loyalty/loyalty.service";
|
||||
|
||||
export interface LoyaltyConfigActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function requireLoyaltyManager() {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { staff: null, error: "You must be signed in." } as const;
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_LOYALTY_PROGRAMME);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) return { staff: null, error: error.message } as const;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!staff) return { staff: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
return { staff, error: null } as const;
|
||||
}
|
||||
|
||||
export async function updateLoyaltyConfigAction(params: {
|
||||
config: ProgrammeConfigInput;
|
||||
eligibleServiceIds: string[];
|
||||
eligibleStaffIds: string[];
|
||||
}): Promise<LoyaltyConfigActionResult> {
|
||||
const { staff: owner, error } = await requireLoyaltyManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await updateProgrammeConfig(prisma, { businessId: owner.businessId, input: params.config });
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't save loyalty settings." };
|
||||
}
|
||||
|
||||
const programme = await prisma.loyaltyProgramme.findUniqueOrThrow({ where: { businessId: owner.businessId } });
|
||||
await Promise.all([
|
||||
setEligibleServices(prisma, { loyaltyProgrammeId: programme.id, serviceIds: params.eligibleServiceIds }),
|
||||
setEligibleStaff(prisma, { loyaltyProgrammeId: programme.id, staffIds: params.eligibleStaffIds }),
|
||||
]);
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "LOYALTY_CONFIG_UPDATED",
|
||||
entityType: "LoyaltyProgramme",
|
||||
entityId: programme.id,
|
||||
metadata: { isEnabled: params.config.isEnabled, rewardType: params.config.rewardType },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { redeemReward } from "@/services/loyalty/loyalty.service";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
|
||||
export interface RedeemRewardResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
discountAmount?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redemption is an operational action a permitted barber does day to day
|
||||
* (not "configure the loyalty programme"), so it's gated on
|
||||
* MANAGE_LOYALTY_STAMPS — the same permission Phase 5 already grants to
|
||||
* the Barber role for adding/correcting stamps — rather than
|
||||
* MANAGE_LOYALTY_PROGRAMME, which is owner-only configuration. Both Owner
|
||||
* and Barber roles hold MANAGE_LOYALTY_STAMPS in the seed. See
|
||||
* docs/phase-6-summary.md, "Loyalty configuration and redemption approach".
|
||||
*/
|
||||
export async function redeemRewardAction(params: {
|
||||
rewardId: string;
|
||||
appointmentId: string;
|
||||
}): Promise<RedeemRewardResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { success: false, error: "You must be signed in." };
|
||||
|
||||
const allowed = await hasPermission(session.user.id, Permission.MANAGE_LOYALTY_STAMPS);
|
||||
if (!allowed) return { success: false, error: "You don't have permission to do that." };
|
||||
|
||||
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await redeemReward(prisma, {
|
||||
rewardId: params.rewardId,
|
||||
appointmentId: params.appointmentId,
|
||||
staffId: staff.id,
|
||||
businessId: staff.businessId,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't redeem this reward." };
|
||||
}
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: "LOYALTY_REWARD_REDEEMED",
|
||||
entityType: "LoyaltyReward",
|
||||
entityId: params.rewardId,
|
||||
metadata: { appointmentId: params.appointmentId, discountAmount: result.discountAmount },
|
||||
});
|
||||
|
||||
return { success: true, discountAmount: result.discountAmount };
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import { updateBusinessProfile } from "@/services/business/business.service";
|
||||
import { syncGoogleReviewsForBusiness } from "@/services/reviews/review-sync.service";
|
||||
|
||||
export interface ReviewsActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function requireReviewsManager() {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { businessId: null, userId: null, error: "You must be signed in." } as const;
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_REVIEWS);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) return { businessId: null, userId: null, error: error.message } as const;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) return { businessId: null, userId: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
return { businessId: context.businessId, userId: context.userId, error: null } as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reuses updateBusinessProfile (the same function Business Details uses)
|
||||
* rather than a parallel update path — googlePlaceId lives on Business.
|
||||
* Deliberately no format regex on googlePlaceId beyond trimming — Google's
|
||||
* own Place ID formats vary (place, postal code, plus-code IDs all look
|
||||
* different) and this app has no need to validate more than "the owner
|
||||
* typed something."
|
||||
*
|
||||
* Writes an audit log only when the Place ID actually changed; the next
|
||||
* sync (scheduled or manual, via syncReviewsNowAction) naturally picks up
|
||||
* whatever rating/count the new Place ID has.
|
||||
*/
|
||||
export async function updateReviewsSettingsAction(input: { googlePlaceId: string | null }): Promise<ReviewsActionResult> {
|
||||
const { businessId, userId, error } = await requireReviewsManager();
|
||||
if (!businessId) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const business = await prisma.business.findUnique({ where: { id: businessId } });
|
||||
if (!business) return { success: false, error: "Business not found." };
|
||||
const location = await prisma.businessLocation.findFirst({ where: { businessId, isPrimary: true } });
|
||||
if (!location) return { success: false, error: "Business location not found." };
|
||||
|
||||
const nextPlaceId = input.googlePlaceId?.trim() || null;
|
||||
const placeIdChanged = nextPlaceId !== business.googlePlaceId;
|
||||
|
||||
const profileResult = await updateBusinessProfile(prisma, {
|
||||
businessId,
|
||||
profile: {
|
||||
name: business.name,
|
||||
timezone: business.timezone,
|
||||
currency: business.currency,
|
||||
locale: business.locale,
|
||||
googlePlaceId: nextPlaceId,
|
||||
},
|
||||
location: {
|
||||
addressLine1: location.addressLine1,
|
||||
city: location.city,
|
||||
postalCode: location.postalCode,
|
||||
country: location.country,
|
||||
},
|
||||
});
|
||||
if (!profileResult.success) {
|
||||
return { success: false, error: profileResult.validation.issues[0]?.message ?? "Couldn't save the Google Place ID." };
|
||||
}
|
||||
|
||||
if (placeIdChanged) {
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: userId,
|
||||
action: "GOOGLE_PLACE_ID_CHANGED",
|
||||
entityType: "Business",
|
||||
entityId: businessId,
|
||||
metadata: { previousPlaceId: business.googlePlaceId, nextPlaceId },
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Manual "sync now" — the scheduled job (/api/cron/sync-google-reviews) covers the normal 24h cadence; this lets an owner see the rating/count immediately after entering a Place ID for the first time. */
|
||||
export async function syncReviewsNowAction(): Promise<ReviewsActionResult> {
|
||||
const { businessId, error } = await requireReviewsManager();
|
||||
if (!businessId) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const business = await prisma.business.findUnique({ where: { id: businessId }, select: { googlePlaceId: true } });
|
||||
if (!business?.googlePlaceId) {
|
||||
return { success: false, error: "Set a Google Place ID first." };
|
||||
}
|
||||
|
||||
const result = await syncGoogleReviewsForBusiness(prisma, { businessId, placeId: business.googlePlaceId });
|
||||
if (result.error) return { success: false, error: result.error };
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import {
|
||||
createStaffProfile,
|
||||
deactivateStaff,
|
||||
reactivateStaff,
|
||||
setStaffServiceEligibility,
|
||||
setWorkingHours,
|
||||
updateStaffAsOwner,
|
||||
} from "@/services/staff/staff.service";
|
||||
import type { CreateStaffInput, UpdateStaffAsOwnerInput, WorkingHoursDayInput } from "@/services/staff/staff.service";
|
||||
import { createInvitation, regenerateInvitation } from "@/services/staff/staff-invitation.service";
|
||||
import { notifyStaffScheduleChanged } from "@/services/staff/staff-notifications";
|
||||
|
||||
async function requireStaffManager() {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { staff: null, error: "You must be signed in." } as const;
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) return { staff: null, error: error.message } as const;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const staff = await prisma.staffProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
include: { business: { select: { timezone: true } }, user: { select: { name: true } } },
|
||||
});
|
||||
if (!staff) return { staff: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
return { staff, error: null } as const;
|
||||
}
|
||||
|
||||
export interface AddStaffResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
staffId?: string;
|
||||
inviteToken?: string;
|
||||
}
|
||||
|
||||
export async function addStaffAction(input: CreateStaffInput): Promise<AddStaffResult> {
|
||||
const { staff: owner, error } = await requireStaffManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await createStaffProfile(prisma, { businessId: owner.businessId, input });
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't add this staff member." };
|
||||
}
|
||||
|
||||
const invitation = await createInvitation(prisma, {
|
||||
businessId: owner.businessId,
|
||||
staffId: result.staff.id,
|
||||
email: input.email.toLowerCase(),
|
||||
invitedByUserId: owner.userId,
|
||||
});
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_ADDED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: result.staff.id,
|
||||
metadata: { name: input.name, email: input.email, roleId: input.roleId },
|
||||
});
|
||||
|
||||
return { success: true, staffId: result.staff.id, inviteToken: invitation.token };
|
||||
}
|
||||
|
||||
export interface StaffActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function updateStaffAction(staffId: string, input: UpdateStaffAsOwnerInput): Promise<StaffActionResult> {
|
||||
const { staff: owner, error } = await requireStaffManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await updateStaffAsOwner(prisma, { staffId, businessId: owner.businessId, input });
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this staff member." };
|
||||
}
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: input.roleId ? "STAFF_ROLE_CHANGED" : "STAFF_UPDATED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
metadata: { ...(input.roleId ? { roleId: input.roleId } : {}) },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export interface DeactivateStaffResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: { id: string; customerName: string; serviceNames: string; startAt: string }[];
|
||||
}
|
||||
|
||||
export async function deactivateStaffAction(staffId: string, acknowledgeConflicts?: boolean): Promise<DeactivateStaffResult> {
|
||||
const { staff: owner, error } = await requireStaffManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await deactivateStaff(prisma, { staffId, businessId: owner.businessId, acknowledgeConflicts });
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Couldn't deactivate this staff member.",
|
||||
conflicts: result.conflicts.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_DEACTIVATED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function reactivateStaffAction(staffId: string): Promise<StaffActionResult> {
|
||||
const { staff: owner, error } = await requireStaffManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await reactivateStaff(prisma, { staffId, businessId: owner.businessId });
|
||||
if (!result.success) return { success: false, error: "Couldn't reactivate this staff member." };
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_REACTIVATED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export interface SetEligibilityResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: { id: string; customerName: string; serviceNames: string; startAt: string }[];
|
||||
}
|
||||
|
||||
export async function setStaffServiceEligibilityAction(
|
||||
staffId: string,
|
||||
serviceIds: string[],
|
||||
acknowledgeConflicts?: boolean,
|
||||
): Promise<SetEligibilityResult> {
|
||||
const { staff: owner, error } = await requireStaffManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await setStaffServiceEligibility(prisma, { staffId, businessId: owner.businessId, serviceIds, acknowledgeConflicts });
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Couldn't update this staff member's services.",
|
||||
conflicts: result.conflicts.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_SERVICES_CHANGED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
metadata: { serviceIds },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export interface WorkingHoursConflictItem {
|
||||
id: string;
|
||||
customerName: string;
|
||||
serviceNames: string;
|
||||
startAt: string;
|
||||
}
|
||||
|
||||
export interface WorkingHoursActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: WorkingHoursConflictItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner-on-behalf-of-staff working hours — same StaffService.setWorkingHours
|
||||
* call the barber's own Phase 5 editor uses, just targeting a chosen
|
||||
* staffId instead of the caller. Now conflict-reviewed like every other
|
||||
* owner mutation that can strand an appointment (see
|
||||
* docs/phase-7-summary.md, "Working-hours conflict review") — this closes
|
||||
* the gap Phase 6 documented and deliberately left open.
|
||||
*/
|
||||
export async function updateStaffWorkingHoursAction(
|
||||
staffId: string,
|
||||
days: WorkingHoursDayInput[],
|
||||
acknowledgeConflicts = false,
|
||||
): Promise<WorkingHoursActionResult> {
|
||||
const { staff: owner, error } = await requireStaffManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const target = await prisma.staffProfile.findFirst({ where: { id: staffId, businessId: owner.businessId } });
|
||||
if (!target) return { success: false, error: "Staff member not found." };
|
||||
|
||||
const result = await setWorkingHours(prisma, {
|
||||
staffId,
|
||||
businessTimezone: owner.business.timezone,
|
||||
days,
|
||||
acknowledgeConflicts,
|
||||
});
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Couldn't save working hours.",
|
||||
conflicts: result.conflicts.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_WORKING_HOURS_CHANGED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
});
|
||||
await notifyStaffScheduleChanged(prisma, {
|
||||
businessId: owner.businessId,
|
||||
staffUserId: target.userId,
|
||||
changedByName: owner.user.name ?? "The business owner",
|
||||
whatChanged: "your working hours",
|
||||
viewSchedulePath: "/staff/settings/working-hours",
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export interface RegenerateInvitationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
inviteToken?: string;
|
||||
}
|
||||
|
||||
export async function regenerateInvitationAction(staffId: string): Promise<RegenerateInvitationResult> {
|
||||
const { staff: owner, error } = await requireStaffManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const invitation = await regenerateInvitation(prisma, { staffId, businessId: owner.businessId });
|
||||
if (!invitation) return { success: false, error: "No invitation found for this staff member." };
|
||||
|
||||
return { success: true, inviteToken: invitation.token };
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { ownerCreateTimeOffAction, ownerDeleteTimeOffAction, ownerUpdateTimeOffAction } from "./time-off";
|
||||
import type { TimeOffActionSet } from "@/features/staff/components/time-off-form-sheet";
|
||||
|
||||
/**
|
||||
* Plain module (no "use server") assembling the owner time-off Server
|
||||
* Actions into the `TimeOffActionSet` shape TimeOffList/TimeOffFormSheet
|
||||
* expect, bound to one target staff member — see break-action-set.ts for
|
||||
* why `.bind(null, staffId)` is required here instead of an arrow-function
|
||||
* wrapper.
|
||||
*/
|
||||
export function createOwnerTimeOffActionSet(staffId: string): TimeOffActionSet {
|
||||
return {
|
||||
create: ownerCreateTimeOffAction.bind(null, staffId),
|
||||
update: ownerUpdateTimeOffAction.bind(null, staffId),
|
||||
remove: ownerDeleteTimeOffAction.bind(null, staffId),
|
||||
};
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import { createTimeOff, deleteTimeOff, updateTimeOff } from "@/services/staff/staff.service";
|
||||
import type { TimeOffInput } from "@/services/staff/staff.service";
|
||||
import { notifyStaffScheduleChanged } from "@/services/staff/staff-notifications";
|
||||
|
||||
export interface TimeOffConflictItem {
|
||||
id: string;
|
||||
customerName: string;
|
||||
serviceNames: string;
|
||||
startAt: string;
|
||||
}
|
||||
|
||||
export interface TimeOffActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: TimeOffConflictItem[];
|
||||
}
|
||||
|
||||
function mapConflicts(conflicts: { id: string; customer: { firstName: string; lastName: string }; services: { service: { name: string } }[]; startAt: Date }[]): TimeOffConflictItem[] {
|
||||
return conflicts.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async function requireStaffManagerFor(staffId: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { owner: null, target: null, error: "You must be signed in." } as const;
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) return { owner: null, target: null, error: error.message } as const;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const owner = await prisma.staffProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
include: { business: { select: { name: true, timezone: true } }, user: { select: { name: true } } },
|
||||
});
|
||||
if (!owner) return { owner: null, target: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
const target = await prisma.staffProfile.findFirst({ where: { id: staffId, businessId: owner.businessId } });
|
||||
if (!target) return { owner: null, target: null, error: "Staff member not found." } as const;
|
||||
|
||||
return { owner, target, error: null } as const;
|
||||
}
|
||||
|
||||
/** Owner-on-behalf-of-staff time off — mirrors breaks.ts exactly, including why the per-staffId binding lives in the separate time-off-action-set.ts factory rather than here. */
|
||||
export async function ownerCreateTimeOffAction(
|
||||
staffId: string,
|
||||
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
|
||||
acknowledgeConflicts?: boolean,
|
||||
): Promise<TimeOffActionResult> {
|
||||
const { owner, target, error } = await requireStaffManagerFor(staffId);
|
||||
if (!owner || !target) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await createTimeOff(prisma, {
|
||||
staffId,
|
||||
businessTimezone: owner.business.timezone,
|
||||
input,
|
||||
acknowledgeConflicts,
|
||||
});
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't add this time off.", conflicts: mapConflicts(result.conflicts) };
|
||||
}
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_TIME_OFF_CHANGED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
});
|
||||
await notifyStaffScheduleChanged(prisma, {
|
||||
businessId: owner.businessId,
|
||||
staffUserId: target.userId,
|
||||
changedByName: owner.user.name ?? "The business owner",
|
||||
whatChanged: "your time off",
|
||||
viewSchedulePath: "/staff/settings/time-off",
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function ownerUpdateTimeOffAction(
|
||||
staffId: string,
|
||||
timeOffId: string,
|
||||
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
|
||||
acknowledgeConflicts?: boolean,
|
||||
): Promise<TimeOffActionResult> {
|
||||
const { owner, target, error } = await requireStaffManagerFor(staffId);
|
||||
if (!owner || !target) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await updateTimeOff(prisma, {
|
||||
timeOffId,
|
||||
staffId,
|
||||
businessTimezone: owner.business.timezone,
|
||||
input,
|
||||
acknowledgeConflicts,
|
||||
});
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this time off.", conflicts: mapConflicts(result.conflicts) };
|
||||
}
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_TIME_OFF_CHANGED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
});
|
||||
await notifyStaffScheduleChanged(prisma, {
|
||||
businessId: owner.businessId,
|
||||
staffUserId: target.userId,
|
||||
changedByName: owner.user.name ?? "The business owner",
|
||||
whatChanged: "your time off",
|
||||
viewSchedulePath: "/staff/settings/time-off",
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function ownerDeleteTimeOffAction(staffId: string, timeOffId: string): Promise<TimeOffActionResult> {
|
||||
const { owner, target, error } = await requireStaffManagerFor(staffId);
|
||||
if (!owner || !target) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await deleteTimeOff(prisma, { timeOffId, staffId });
|
||||
if (!result.success) return { success: false, error: "Couldn't remove this time off." };
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_TIME_OFF_CHANGED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
});
|
||||
await notifyStaffScheduleChanged(prisma, {
|
||||
businessId: owner.businessId,
|
||||
staffUserId: target.userId,
|
||||
changedByName: owner.user.name ?? "The business owner",
|
||||
whatChanged: "your time off",
|
||||
viewSchedulePath: "/staff/settings/time-off",
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { Permission } from "@/generated/prisma/enums";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
|
||||
/**
|
||||
* Per-action owner permission checks — the mirror of
|
||||
* src/features/staff/authorization.ts and src/features/booking/authorization.ts.
|
||||
* The `(owner)` route group layout already gates on MANAGE_STAFF as a
|
||||
* coarse "is this person any kind of owner" shell check, but that is not
|
||||
* enough on its own (see docs/phase-6-summary.md, "Permission and
|
||||
* business-isolation rules") — every owner Server Action re-derives the
|
||||
* session and checks the *specific* permission that action needs, since a
|
||||
* future non-Owner role could plausibly hold some but not all of these.
|
||||
*/
|
||||
|
||||
export class OwnerPermissionDeniedError extends Error {
|
||||
constructor(message = "You don't have permission to do that.") {
|
||||
super(message);
|
||||
this.name = "OwnerPermissionDeniedError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertOwnerPermission(userId: string, permission: Permission): Promise<void> {
|
||||
const allowed = await hasPermission(userId, permission);
|
||||
if (!allowed) throw new OwnerPermissionDeniedError();
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { addStaffAction } from "../actions/staff";
|
||||
|
||||
export interface AddStaffFormProps {
|
||||
roles: { id: string; name: string }[];
|
||||
services: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function AddStaffForm({ roles, services }: AddStaffFormProps) {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [image, setImage] = useState("");
|
||||
const [bio, setBio] = useState("");
|
||||
const [roleId, setRoleId] = useState(roles[0]?.id ?? "");
|
||||
const [serviceIds, setServiceIds] = useState<string[]>([]);
|
||||
const [isBookableOnline, setIsBookableOnline] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const toggleService = (id: string) => {
|
||||
setServiceIds((prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]));
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await addStaffAction({ name, email, phone: phone || undefined, image: image || undefined, bio: bio || undefined, roleId, serviceIds, isBookableOnline });
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Couldn't add this staff member.");
|
||||
return;
|
||||
}
|
||||
if (result.inviteToken) {
|
||||
setInviteLink(`${window.location.origin}/invite/accept?token=${result.inviteToken}`);
|
||||
} else {
|
||||
router.push(`/owner/team/${result.staffId}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const copyLink = async () => {
|
||||
if (!inviteLink) return;
|
||||
await navigator.clipboard.writeText(inviteLink);
|
||||
setCopied(true);
|
||||
};
|
||||
|
||||
if (inviteLink) {
|
||||
return (
|
||||
<div className="space-y-4 rounded-md border-line border bg-white p-6">
|
||||
<p className="text-sm font-bold">{name} has been added.</p>
|
||||
<p className="text-muted text-sm">
|
||||
Email delivery isn't wired up yet — copy this invite link and send it to them yourself. It lets them
|
||||
set their own password. Expires in 7 days.
|
||||
</p>
|
||||
<div className="border-line bg-stone break-all rounded-lg border p-3 text-xs">{inviteLink}</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={copyLink}>
|
||||
{copied ? "Copied" : "Copy Link"}
|
||||
</Button>
|
||||
<Button className="flex-1" onClick={() => router.push("/owner/team")}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<Input label="Name" required value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<Input label="Email" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<Input label="Phone (optional)" type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||
<Input label="Photo URL (optional)" value={image} onChange={(e) => setImage(e.target.value)} />
|
||||
<Textarea label="Bio (optional)" value={bio} onChange={(e) => setBio(e.target.value)} />
|
||||
<Select label="Role" value={roleId} onChange={(e) => setRoleId(e.target.value)}>
|
||||
{roles.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Switch label="Visible for online booking" checked={isBookableOnline} onCheckedChange={setIsBookableOnline} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border-line border bg-white p-6">
|
||||
<p className="mb-3 text-xs font-bold tracking-widest uppercase">Services Offered</p>
|
||||
<div className="space-y-3">
|
||||
{services.map((s) => (
|
||||
<Checkbox key={s.id} label={s.name} checked={serviceIds.includes(s.id)} onChange={() => toggleService(s.id)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" isLoading={isPending} onClick={submit}>
|
||||
Add Staff Member
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FileArchive } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ColorPicker } from "@/components/ui/color-picker";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { MediaPicker } from "@/features/media/components/media-picker";
|
||||
import type { SplashScreenEntry } from "@/lib/business/branding";
|
||||
import {
|
||||
importAppIconZipAction,
|
||||
updateAppIconSettingsAction,
|
||||
updateNavigationSettingsAction,
|
||||
} from "../actions/business";
|
||||
|
||||
export interface AppIconSettingsInitial {
|
||||
shortName: string;
|
||||
appIconUrl: string | null;
|
||||
themeColor: string | null;
|
||||
backgroundColor: string | null;
|
||||
icon192Url: string | null;
|
||||
icon512Url: string | null;
|
||||
maskableIcon192Url: string | null;
|
||||
maskableIcon512Url: string | null;
|
||||
splashScreens: SplashScreenEntry[];
|
||||
bottomNavShowLabels: boolean;
|
||||
}
|
||||
|
||||
export function AppIconSettingsForm({ initial }: { initial: AppIconSettingsInitial }) {
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState(initial);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [importWarnings, setImportWarnings] = useState<string[]>([]);
|
||||
const [importSuccessMessage, setImportSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
const set = <K extends keyof AppIconSettingsInitial>(key: K, value: AppIconSettingsInitial[K]) => {
|
||||
setSaved(false);
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleZipSelected = async (fileList: FileList | null) => {
|
||||
const file = fileList?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setImportError(null);
|
||||
setImportWarnings([]);
|
||||
setImportSuccessMessage(null);
|
||||
setIsImporting(true);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.set("file", file);
|
||||
const result = await importAppIconZipAction(formData);
|
||||
|
||||
setIsImporting(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
|
||||
if (!result.success || !result.applied) {
|
||||
setImportError(result.error ?? "Couldn't import that ZIP.");
|
||||
setImportWarnings(result.warnings ?? []);
|
||||
return;
|
||||
}
|
||||
|
||||
setForm((prev) => ({ ...prev, ...result.applied }));
|
||||
setImportWarnings(result.warnings ?? []);
|
||||
setImportSuccessMessage(
|
||||
`Imported${result.applied.appIconUrl ? " icon" : ""}${
|
||||
result.applied.appIconUrl && result.applied.splashScreens.length > 0 ? " and" : ""
|
||||
}${
|
||||
result.applied.splashScreens.length > 0
|
||||
? ` ${result.applied.splashScreens.length} splash screen${result.applied.splashScreens.length === 1 ? "" : "s"}`
|
||||
: ""
|
||||
}. Review below, then Save.`,
|
||||
);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const [iconResult, navigationResult] = await Promise.all([
|
||||
updateAppIconSettingsAction({
|
||||
shortName: form.shortName || null,
|
||||
appIconUrl: form.appIconUrl,
|
||||
themeColor: form.themeColor,
|
||||
backgroundColor: form.backgroundColor,
|
||||
icon192Url: form.icon192Url,
|
||||
icon512Url: form.icon512Url,
|
||||
maskableIcon192Url: form.maskableIcon192Url,
|
||||
maskableIcon512Url: form.maskableIcon512Url,
|
||||
splashScreens: form.splashScreens,
|
||||
}),
|
||||
updateNavigationSettingsAction({ bottomNavShowLabels: form.bottomNavShowLabels }),
|
||||
]);
|
||||
if (!iconResult.success) {
|
||||
setError(iconResult.error ?? "Couldn't save these settings.");
|
||||
return;
|
||||
}
|
||||
if (!navigationResult.success) {
|
||||
setError(navigationResult.error ?? "Couldn't save the navigation setting.");
|
||||
return;
|
||||
}
|
||||
setSaved(true);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Import from a Generator</h3>
|
||||
<div className="rounded-md border-line space-y-3 border bg-white p-6">
|
||||
<p className="text-muted text-xs">
|
||||
Generate a bundle at {" "}
|
||||
<span className="font-bold">progressier.com/pwa-icons-and-ios-splash-screen-generator</span> and upload
|
||||
the ZIP here — the icon and every splash screen it contains are imported automatically, no manual
|
||||
resizing needed.
|
||||
</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip,application/zip,application/x-zip-compressed"
|
||||
className="hidden"
|
||||
onChange={(e) => void handleZipSelected(e.target.files)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
isLoading={isImporting}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<FileArchive className="h-4 w-4" aria-hidden="true" />
|
||||
{isImporting ? "Importing…" : "Upload ZIP"}
|
||||
</Button>
|
||||
{importError && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{importError}
|
||||
</p>
|
||||
)}
|
||||
{importSuccessMessage && !importError && (
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
{importSuccessMessage}
|
||||
</p>
|
||||
)}
|
||||
{importWarnings.length > 0 && (
|
||||
<ul className="text-muted list-inside list-disc space-y-1 text-xs">
|
||||
{importWarnings.map((w, i) => (
|
||||
<li key={i}>{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Preview</h3>
|
||||
<div
|
||||
className="rounded-md border-line flex items-center gap-4 border p-6"
|
||||
style={{ backgroundColor: form.backgroundColor || undefined }}
|
||||
>
|
||||
<div className="h-16 w-16 shrink-0 overflow-hidden rounded-2xl border border-line bg-white shadow-sm">
|
||||
{form.icon512Url || form.appIconUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern as MediaPicker
|
||||
<img src={form.icon512Url || form.appIconUrl || ""} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-center text-lg font-bold text-white"
|
||||
style={{ backgroundColor: form.themeColor || "#111b15" }}
|
||||
>
|
||||
{(form.shortName || "A").charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold">{form.shortName || "App Name"}</p>
|
||||
<p className="text-muted text-xs">How the icon looks on a home screen</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Name & Colours</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<Input
|
||||
label="App Name"
|
||||
value={form.shortName}
|
||||
maxLength={30}
|
||||
onChange={(e) => set("shortName", e.target.value)}
|
||||
hint="Shown under the icon once someone adds this to their home screen. Keep it short."
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<ColorPicker label="Theme Colour" value={form.themeColor} onChange={(hex) => set("themeColor", hex)} />
|
||||
<ColorPicker
|
||||
label="Background Colour"
|
||||
value={form.backgroundColor}
|
||||
onChange={(hex) => set("backgroundColor", hex)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted text-xs">
|
||||
Theme and background colour set the status bar and Android splash screen shown while the app opens
|
||||
after installing.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Navigation</h3>
|
||||
<div className="rounded-md border-line border bg-white p-6">
|
||||
<Switch
|
||||
label="Show labels on the bottom bar"
|
||||
description="Turn off to show icons only, with no text underneath — applies to every customer and staff/owner bottom bar."
|
||||
checked={form.bottomNavShowLabels}
|
||||
onCheckedChange={(checked) => set("bottomNavShowLabels", checked)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">iOS Icon</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<MediaPicker
|
||||
label="Apple Touch Icon"
|
||||
value={form.appIconUrl}
|
||||
onChange={(url) => set("appIconUrl", url)}
|
||||
category="branding"
|
||||
hint="A square image works best — used when someone adds this to their home screen on iPhone/iPad."
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Android Icons</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<p className="text-muted text-xs">
|
||||
Android/Chrome installs read these directly — each falls back to a neutral icon on its own if left
|
||||
unset.
|
||||
</p>
|
||||
<MediaPicker
|
||||
label="Icon — 192×192"
|
||||
value={form.icon192Url}
|
||||
onChange={(url) => set("icon192Url", url)}
|
||||
category="branding"
|
||||
hint="Square, 192×192px."
|
||||
/>
|
||||
<MediaPicker
|
||||
label="Icon — 512×512"
|
||||
value={form.icon512Url}
|
||||
onChange={(url) => set("icon512Url", url)}
|
||||
category="branding"
|
||||
hint="Square, 512×512px."
|
||||
/>
|
||||
<MediaPicker
|
||||
label="Maskable Icon — 192×192"
|
||||
value={form.maskableIcon192Url}
|
||||
onChange={(url) => set("maskableIcon192Url", url)}
|
||||
category="branding"
|
||||
hint="Square, 192×192px. Keep the subject within the center ~80% — Android may crop this to a circle or rounded square."
|
||||
/>
|
||||
<MediaPicker
|
||||
label="Maskable Icon — 512×512"
|
||||
value={form.maskableIcon512Url}
|
||||
onChange={(url) => set("maskableIcon512Url", url)}
|
||||
category="branding"
|
||||
hint="Square, 512×512px. Same center-safe-zone rule as above."
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">iOS Splash Screens</h3>
|
||||
<div className="rounded-md border-line space-y-3 border bg-white p-6">
|
||||
{form.splashScreens.length === 0 ? (
|
||||
<p className="text-muted text-sm">No splash screens imported yet — upload a ZIP above.</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-bold">
|
||||
{form.splashScreens.length} splash screen{form.splashScreens.length === 1 ? "" : "s"} imported
|
||||
</p>
|
||||
<div className="grid grid-cols-6 gap-2 sm:grid-cols-8">
|
||||
{form.splashScreens.map((s, i) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern as MediaPicker
|
||||
<img
|
||||
key={i}
|
||||
src={s.url}
|
||||
alt=""
|
||||
className="border-line aspect-[9/19] w-full rounded border object-cover"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => set("splashScreens", [])}
|
||||
>
|
||||
Remove all splash screens
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{saved && !error && (
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
Saved — changes are live immediately.
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { updateBookingRuleAction } from "../actions/booking-rules";
|
||||
import type { BookingRuleInput } from "@/services/scheduling/booking-rule.service";
|
||||
|
||||
export function BookingRulesForm({ initial }: { initial: BookingRuleInput }) {
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState(initial);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const set = <K extends keyof BookingRuleInput>(key: K, value: BookingRuleInput[K]) => {
|
||||
setSaved(false);
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await updateBookingRuleAction(form);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Couldn't save booking rules.");
|
||||
return;
|
||||
}
|
||||
setSaved(true);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 lg:grid lg:grid-cols-2 lg:items-start lg:gap-6 lg:space-y-0">
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Booking Window</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<Input
|
||||
label="Minimum notice (minutes)"
|
||||
hint="How soon before a slot a booking can be made."
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.minNoticeMinutes}
|
||||
onChange={(e) => set("minNoticeMinutes", Number(e.target.value))}
|
||||
/>
|
||||
<Input
|
||||
label="Maximum advance booking (days)"
|
||||
hint="How far ahead customers can book."
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.maxAdvanceDays}
|
||||
onChange={(e) => set("maxAdvanceDays", Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Cancellation & Rescheduling</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<Input
|
||||
label="Free cancellation window (hours)"
|
||||
hint="Cancelling later than this may count as a late cancellation."
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.cancellationWindowHours}
|
||||
onChange={(e) => set("cancellationWindowHours", Number(e.target.value))}
|
||||
/>
|
||||
<Input
|
||||
label="Free reschedule window (hours)"
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.rescheduleWindowHours}
|
||||
onChange={(e) => set("rescheduleWindowHours", Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Scheduling</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<Select
|
||||
label="Appointment slot interval"
|
||||
value={String(form.slotIntervalMinutes)}
|
||||
onChange={(e) => set("slotIntervalMinutes", Number(e.target.value))}
|
||||
>
|
||||
{[15, 30, 45, 60].map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m} min
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Buffer before (min)"
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.bufferBeforeMinutes}
|
||||
onChange={(e) => set("bufferBeforeMinutes", Number(e.target.value))}
|
||||
/>
|
||||
<Input
|
||||
label="Buffer after (min)"
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.bufferAfterMinutes}
|
||||
onChange={(e) => set("bufferAfterMinutes", Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Max appointments per day (blank = no limit)"
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.maxAppointmentsPerDay ?? ""}
|
||||
onChange={(e) => set("maxAppointmentsPerDay", e.target.value ? Number(e.target.value) : null)}
|
||||
/>
|
||||
<Input
|
||||
label="Max appointments per staff member per day (blank = no limit)"
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.maxAppointmentsPerStaffPerDay ?? ""}
|
||||
onChange={(e) => set("maxAppointmentsPerStaffPerDay", e.target.value ? Number(e.target.value) : null)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Customer Options</h3>
|
||||
<div className="rounded-md border-line space-y-5 border bg-white p-6">
|
||||
<Switch
|
||||
label="Customers can choose a barber"
|
||||
description='If off, "Anyone" is assigned automatically.'
|
||||
checked={form.customerCanChooseStaff}
|
||||
onCheckedChange={(v) => set("customerCanChooseStaff", v)}
|
||||
/>
|
||||
<Switch
|
||||
label="Allow customers to book as guests"
|
||||
description="When enabled, customers can book without creating an account. When disabled, customers must sign in or create an account before confirming their booking."
|
||||
checked={form.allowGuestBooking}
|
||||
onCheckedChange={(v) => set("allowGuestBooking", v)}
|
||||
/>
|
||||
<Switch
|
||||
label="Require approval for new bookings"
|
||||
description="Bookings wait for staff confirmation before they're CONFIRMED."
|
||||
checked={form.requireApproval}
|
||||
onCheckedChange={(v) => set("requireApproval", v)}
|
||||
/>
|
||||
<Switch label="Require deposit" description="Coming soon — not enforced yet." checked={false} disabled />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="space-y-4 lg:col-span-2">
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{saved && !error && (
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
Saved — the customer booking flow uses these rules immediately. Existing appointments are unaffected.
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MediaPicker } from "@/features/media/components/media-picker";
|
||||
import { updateBusinessProfileAction, updateBusinessSettingsAction } from "../actions/business";
|
||||
|
||||
const TIMEZONES = ["Europe/London", "Europe/Paris", "Europe/Dublin", "America/New_York", "America/Los_Angeles"];
|
||||
const CURRENCIES = ["GBP", "USD", "EUR"];
|
||||
const LOCALES = ["en-GB", "en-US"];
|
||||
|
||||
export interface BusinessDetailsInitial {
|
||||
name: string;
|
||||
description: string;
|
||||
logoUrl: string | null;
|
||||
coverImageUrl: string | null;
|
||||
welcomeBackgroundDesktopUrl: string | null;
|
||||
welcomeBackgroundMobileUrl: string | null;
|
||||
faviconUrl: string | null;
|
||||
phone: string;
|
||||
email: string;
|
||||
website: string;
|
||||
instagramUrl: string;
|
||||
facebookUrl: string;
|
||||
timezone: string;
|
||||
currency: string;
|
||||
locale: string;
|
||||
addressLine1: string;
|
||||
addressLine2: string;
|
||||
city: string;
|
||||
region: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
cancellationPolicy: string;
|
||||
lateArrivalPolicy: string;
|
||||
bookingTerms: string;
|
||||
privacyPolicyUrl: string;
|
||||
}
|
||||
|
||||
export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsInitial }) {
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState(initial);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const set = <K extends keyof BusinessDetailsInitial>(key: K, value: BusinessDetailsInitial[K]) => {
|
||||
setSaved(false);
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const profileResult = await updateBusinessProfileAction({
|
||||
profile: {
|
||||
name: form.name,
|
||||
description: form.description || undefined,
|
||||
logoUrl: form.logoUrl,
|
||||
coverImageUrl: form.coverImageUrl,
|
||||
welcomeBackgroundDesktopUrl: form.welcomeBackgroundDesktopUrl,
|
||||
welcomeBackgroundMobileUrl: form.welcomeBackgroundMobileUrl,
|
||||
faviconUrl: form.faviconUrl,
|
||||
phone: form.phone || undefined,
|
||||
email: form.email || undefined,
|
||||
website: form.website || undefined,
|
||||
instagramUrl: form.instagramUrl || undefined,
|
||||
facebookUrl: form.facebookUrl || undefined,
|
||||
timezone: form.timezone,
|
||||
currency: form.currency,
|
||||
locale: form.locale,
|
||||
},
|
||||
location: {
|
||||
addressLine1: form.addressLine1,
|
||||
addressLine2: form.addressLine2 || undefined,
|
||||
city: form.city,
|
||||
region: form.region || undefined,
|
||||
postalCode: form.postalCode,
|
||||
country: form.country,
|
||||
},
|
||||
});
|
||||
if (!profileResult.success) {
|
||||
setError(profileResult.error ?? "Couldn't save business details.");
|
||||
return;
|
||||
}
|
||||
|
||||
const settingsResult = await updateBusinessSettingsAction({
|
||||
cancellationPolicy: form.cancellationPolicy || undefined,
|
||||
lateArrivalPolicy: form.lateArrivalPolicy || undefined,
|
||||
bookingTerms: form.bookingTerms || undefined,
|
||||
privacyPolicyUrl: form.privacyPolicyUrl || undefined,
|
||||
});
|
||||
if (!settingsResult.success) {
|
||||
setError(settingsResult.error ?? "Couldn't save business policies.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaved(true);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 lg:grid lg:grid-cols-2 lg:items-start lg:gap-6 lg:space-y-0">
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Profile</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<Input label="Business Name" required value={form.name} onChange={(e) => set("name", e.target.value)} />
|
||||
<MediaPicker label="Logo" value={form.logoUrl} onChange={(url) => set("logoUrl", url)} category="branding" />
|
||||
<Textarea label="Description" value={form.description} onChange={(e) => set("description", e.target.value)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Branding</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<MediaPicker
|
||||
label="Cover Image"
|
||||
value={form.coverImageUrl}
|
||||
onChange={(url) => set("coverImageUrl", url)}
|
||||
category="branding"
|
||||
hint="Shown at the top of the customer Home page."
|
||||
/>
|
||||
<MediaPicker
|
||||
label="Welcome Background — Desktop"
|
||||
value={form.welcomeBackgroundDesktopUrl}
|
||||
onChange={(url) => set("welcomeBackgroundDesktopUrl", url)}
|
||||
category="welcome"
|
||||
/>
|
||||
<MediaPicker
|
||||
label="Welcome Background — Mobile"
|
||||
value={form.welcomeBackgroundMobileUrl}
|
||||
onChange={(url) => set("welcomeBackgroundMobileUrl", url)}
|
||||
category="welcome"
|
||||
hint="Shown on the sign-in/welcome screen, before someone signs in."
|
||||
/>
|
||||
<MediaPicker
|
||||
label="Favicon"
|
||||
value={form.faviconUrl}
|
||||
onChange={(url) => set("faviconUrl", url)}
|
||||
category="branding"
|
||||
hint="Browser tab icon. Falls back to the app default when unset."
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Contact & Address</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<Input label="Street Address" required value={form.addressLine1} onChange={(e) => set("addressLine1", e.target.value)} />
|
||||
<Input label="Address Line 2 (optional)" value={form.addressLine2} onChange={(e) => set("addressLine2", e.target.value)} />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input label="City" required value={form.city} onChange={(e) => set("city", e.target.value)} />
|
||||
<Input label="Postcode" required value={form.postalCode} onChange={(e) => set("postalCode", e.target.value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input label="Region (optional)" value={form.region} onChange={(e) => set("region", e.target.value)} />
|
||||
<Input label="Country" value={form.country} onChange={(e) => set("country", e.target.value)} />
|
||||
</div>
|
||||
<Input label="Phone" type="tel" value={form.phone} onChange={(e) => set("phone", e.target.value)} />
|
||||
<Input label="Email" type="email" value={form.email} onChange={(e) => set("email", e.target.value)} />
|
||||
<Input label="Website" type="url" placeholder="https://" value={form.website} onChange={(e) => set("website", e.target.value)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Social Links</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<Input label="Instagram" placeholder="@yourshop" value={form.instagramUrl} onChange={(e) => set("instagramUrl", e.target.value)} />
|
||||
<Input label="Facebook" placeholder="facebook.com/yourshop" value={form.facebookUrl} onChange={(e) => set("facebookUrl", e.target.value)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Regional Settings</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<Select label="Time Zone" value={form.timezone} onChange={(e) => set("timezone", e.target.value)}>
|
||||
{TIMEZONES.map((tz) => (
|
||||
<option key={tz} value={tz}>
|
||||
{tz}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Select label="Currency" value={form.currency} onChange={(e) => set("currency", e.target.value)}>
|
||||
{CURRENCIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select label="Locale" value={form.locale} onChange={(e) => set("locale", e.target.value)}>
|
||||
{LOCALES.map((l) => (
|
||||
<option key={l} value={l}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Policies</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<Textarea
|
||||
label="Cancellation Policy"
|
||||
value={form.cancellationPolicy}
|
||||
onChange={(e) => set("cancellationPolicy", e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
label="Late Arrival Policy"
|
||||
value={form.lateArrivalPolicy}
|
||||
onChange={(e) => set("lateArrivalPolicy", e.target.value)}
|
||||
/>
|
||||
<Textarea label="Booking Terms" value={form.bookingTerms} onChange={(e) => set("bookingTerms", e.target.value)} />
|
||||
<Input
|
||||
label="Privacy Policy URL"
|
||||
type="url"
|
||||
placeholder="https://"
|
||||
value={form.privacyPolicyUrl}
|
||||
onChange={(e) => set("privacyPolicyUrl", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="space-y-4 lg:col-span-2">
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{saved && !error && (
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
Saved — changes are live for customers immediately.
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronUp, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
createCategoryAction,
|
||||
deleteCategoryAction,
|
||||
reorderCategoriesAction,
|
||||
reorderServicesAction,
|
||||
setCategoryActiveAction,
|
||||
setServiceActiveAction,
|
||||
updateCategoryAction,
|
||||
} from "../actions/catalogue";
|
||||
import { ServiceFormSheet } from "./service-form-sheet";
|
||||
import type { EditableService } from "./service-form-sheet";
|
||||
|
||||
export interface CatalogueService {
|
||||
id: string;
|
||||
categoryId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
imageUrl: string | null;
|
||||
durationMinutes: number;
|
||||
price: string;
|
||||
bufferBeforeMinutes: number;
|
||||
bufferAfterMinutes: number;
|
||||
isActive: boolean;
|
||||
allowOnlineBooking: boolean;
|
||||
}
|
||||
|
||||
export interface CatalogueCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
services: CatalogueService[];
|
||||
}
|
||||
|
||||
function formatPrice(price: string): string {
|
||||
return `£${Number(price).toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function CatalogueManager({ categories }: { categories: CatalogueCategory[] }) {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [newCategoryName, setNewCategoryName] = useState("");
|
||||
const [editingCategoryId, setEditingCategoryId] = useState<string | null>(null);
|
||||
const [editingCategoryName, setEditingCategoryName] = useState("");
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [sheetCategoryId, setSheetCategoryId] = useState<string>(categories[0]?.id ?? "");
|
||||
const [editingService, setEditingService] = useState<EditableService | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const run = (fn: () => Promise<{ success: boolean; error?: string }>) => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await fn();
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Something went wrong.");
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const addCategory = () => {
|
||||
if (!newCategoryName.trim()) return;
|
||||
run(async () => {
|
||||
const result = await createCategoryAction({ name: newCategoryName.trim() });
|
||||
if (result.success) setNewCategoryName("");
|
||||
return result;
|
||||
});
|
||||
};
|
||||
|
||||
const saveCategoryName = (id: string) => {
|
||||
if (!editingCategoryName.trim()) return;
|
||||
run(async () => {
|
||||
const result = await updateCategoryAction(id, { name: editingCategoryName.trim() });
|
||||
if (result.success) setEditingCategoryId(null);
|
||||
return result;
|
||||
});
|
||||
};
|
||||
|
||||
const moveCategory = (index: number, direction: -1 | 1) => {
|
||||
const targetIndex = index + direction;
|
||||
if (targetIndex < 0 || targetIndex >= categories.length) return;
|
||||
const reordered = [...categories];
|
||||
const [moved] = reordered.splice(index, 1);
|
||||
if (!moved) return;
|
||||
reordered.splice(targetIndex, 0, moved);
|
||||
run(() => reorderCategoriesAction(reordered.map((c) => c.id)));
|
||||
};
|
||||
|
||||
const moveService = (category: CatalogueCategory, index: number, direction: -1 | 1) => {
|
||||
const targetIndex = index + direction;
|
||||
if (targetIndex < 0 || targetIndex >= category.services.length) return;
|
||||
const reordered = [...category.services];
|
||||
const [moved] = reordered.splice(index, 1);
|
||||
if (!moved) return;
|
||||
reordered.splice(targetIndex, 0, moved);
|
||||
run(() => reorderServicesAction(category.id, reordered.map((s) => s.id)));
|
||||
};
|
||||
|
||||
const openNewService = (categoryId: string) => {
|
||||
setEditingService(null);
|
||||
setSheetCategoryId(categoryId);
|
||||
setSheetOpen(true);
|
||||
};
|
||||
const openEditService = (service: CatalogueService) => {
|
||||
setEditingService({
|
||||
id: service.id,
|
||||
categoryId: service.categoryId,
|
||||
name: service.name,
|
||||
description: service.description,
|
||||
imageUrl: service.imageUrl,
|
||||
durationMinutes: service.durationMinutes,
|
||||
price: service.price,
|
||||
bufferBeforeMinutes: service.bufferBeforeMinutes,
|
||||
bufferAfterMinutes: service.bufferAfterMinutes,
|
||||
allowOnlineBooking: service.allowOnlineBooking,
|
||||
});
|
||||
setSheetCategoryId(service.categoryId);
|
||||
setSheetOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{categories.map((category, categoryIndex) => (
|
||||
<section key={category.id}>
|
||||
<div className="mb-3 flex items-center justify-between px-1">
|
||||
{editingCategoryId === category.id ? (
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<Input value={editingCategoryName} onChange={(e) => setEditingCategoryName(e.target.value)} />
|
||||
<Button size="sm" isLoading={isPending} onClick={() => saveCategoryName(category.id)}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className={category.isActive ? "text-sm font-bold" : "text-muted text-sm font-bold line-through"}>
|
||||
{category.name}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingCategoryId(category.id);
|
||||
setEditingCategoryName(category.name);
|
||||
}}
|
||||
className="text-muted"
|
||||
aria-label={`Rename ${category.name}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveCategory(categoryIndex, -1)}
|
||||
disabled={categoryIndex === 0}
|
||||
aria-label={`Move ${category.name} up`}
|
||||
className="text-muted disabled:opacity-30"
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveCategory(categoryIndex, 1)}
|
||||
disabled={categoryIndex === categories.length - 1}
|
||||
aria-label={`Move ${category.name} down`}
|
||||
className="text-muted disabled:opacity-30"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<Switch
|
||||
checked={category.isActive}
|
||||
onCheckedChange={(checked) => run(() => setCategoryActiveAction(category.id, checked))}
|
||||
aria-label={`${category.name} active`}
|
||||
/>
|
||||
{category.services.length === 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => run(() => deleteCategoryAction(category.id))}
|
||||
className="text-danger"
|
||||
aria-label={`Delete ${category.name}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border-line divide-line divide-y border bg-white">
|
||||
{category.services.map((service, serviceIndex) => (
|
||||
<div key={service.id} className="flex items-center gap-3 px-5 py-4">
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveService(category, serviceIndex, -1)}
|
||||
disabled={serviceIndex === 0}
|
||||
aria-label={`Move ${service.name} up`}
|
||||
className="text-muted disabled:opacity-30"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveService(category, serviceIndex, 1)}
|
||||
disabled={serviceIndex === category.services.length - 1}
|
||||
aria-label={`Move ${service.name} down`}
|
||||
className="text-muted disabled:opacity-30"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onClick={() => openEditService(service)} className="flex min-w-0 flex-1 items-center gap-3 text-left">
|
||||
{service.imageUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide
|
||||
<img src={service.imageUrl} alt="" className="bg-stone h-10 w-10 shrink-0 rounded-lg object-cover" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<p className={service.isActive ? "text-sm font-bold" : "text-muted text-sm font-bold line-through"}>
|
||||
{service.name}
|
||||
</p>
|
||||
<p className="text-muted mt-0.5 text-xs">
|
||||
{service.durationMinutes} min · {formatPrice(service.price)}
|
||||
{!service.allowOnlineBooking && " · Not bookable online"}
|
||||
</p>
|
||||
</span>
|
||||
</button>
|
||||
<Switch
|
||||
checked={service.isActive}
|
||||
onCheckedChange={(checked) => run(() => setServiceActiveAction(service.id, checked))}
|
||||
aria-label={`${service.name} active`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openNewService(category.id)}
|
||||
className="text-accent flex w-full items-center gap-3 px-5 py-4 text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" /> Add Service
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input placeholder="New category name" value={newCategoryName} onChange={(e) => setNewCategoryName(e.target.value)} />
|
||||
<Button isLoading={isPending} onClick={addCategory}>
|
||||
Add Category
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ServiceFormSheet
|
||||
key={editingService?.id ?? "new"}
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
categories={categories.map((c) => ({ id: c.id, name: c.name }))}
|
||||
defaultCategoryId={sheetCategoryId}
|
||||
existing={editingService}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ChevronDown, ChevronUp, Plus, Trash2 } from "lucide-react";
|
||||
import { GalleryPlacement } from "@/generated/prisma/enums";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { MediaManagerModal } from "@/features/media/components/media-manager-modal";
|
||||
import {
|
||||
addGalleryImageAction,
|
||||
deleteGalleryImageAction,
|
||||
reorderGalleryImagesAction,
|
||||
updateGalleryImageAction,
|
||||
} from "@/features/gallery/actions/gallery";
|
||||
import type { GalleryImageSummary } from "@/services/gallery/gallery.service";
|
||||
|
||||
export interface GalleryManagerProps {
|
||||
placement: GalleryPlacement;
|
||||
title: string;
|
||||
description: string;
|
||||
/** Portfolio images carry a category tag for the customer-facing filter pills; the hero gallery has no use for one. */
|
||||
showCategory: boolean;
|
||||
initialImages: GalleryImageSummary[];
|
||||
}
|
||||
|
||||
export function GalleryManager({ placement, title, description, showCategory, initialImages }: GalleryManagerProps) {
|
||||
const router = useRouter();
|
||||
const [images, setImages] = useState(initialImages);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const run = (fn: () => Promise<{ success: boolean; error?: string }>) => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await fn();
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Something went wrong.");
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelect = (url: string) => {
|
||||
run(async () => {
|
||||
const result = await addGalleryImageAction({ placement, imageUrl: url });
|
||||
if (result.success) setImages((prev) => [...prev, result.image]);
|
||||
return result;
|
||||
});
|
||||
};
|
||||
|
||||
const move = (index: number, direction: -1 | 1) => {
|
||||
const targetIndex = index + direction;
|
||||
if (targetIndex < 0 || targetIndex >= images.length) return;
|
||||
const reordered = [...images];
|
||||
const [moved] = reordered.splice(index, 1);
|
||||
if (!moved) return;
|
||||
reordered.splice(targetIndex, 0, moved);
|
||||
setImages(reordered);
|
||||
run(() => reorderGalleryImagesAction(placement, reordered.map((i) => i.id)));
|
||||
};
|
||||
|
||||
const remove = (id: string) => {
|
||||
setImages((prev) => prev.filter((i) => i.id !== id));
|
||||
run(() => deleteGalleryImageAction(id));
|
||||
};
|
||||
|
||||
const setCategory = (id: string, category: string) => {
|
||||
setImages((prev) => prev.map((i) => (i.id === id ? { ...i, category } : i)));
|
||||
run(() => updateGalleryImageAction(id, { category }));
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">{title}</h3>
|
||||
<p className="text-muted mt-1 ml-1 text-xs">{description}</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{images.map((image, index) => (
|
||||
<div key={image.id} className="border-line overflow-hidden rounded-xl border bg-white">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide */}
|
||||
<img src={image.imageUrl} alt="" className="bg-stone h-28 w-full object-cover" />
|
||||
<div className="space-y-2 p-2">
|
||||
{showCategory && (
|
||||
<Input
|
||||
value={image.category ?? ""}
|
||||
onChange={(e) => setCategory(image.id, e.target.value)}
|
||||
placeholder="Category (optional)"
|
||||
className="px-3 py-2 text-xs"
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(index, -1)}
|
||||
disabled={index === 0}
|
||||
aria-label="Move earlier"
|
||||
className="text-muted disabled:opacity-30"
|
||||
>
|
||||
<ChevronUp className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(index, 1)}
|
||||
disabled={index === images.length - 1}
|
||||
aria-label="Move later"
|
||||
className="text-muted disabled:opacity-30"
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onClick={() => remove(image.id)} aria-label="Delete image" className="text-danger">
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
disabled={isPending}
|
||||
className="border-line text-muted flex h-full min-h-[9rem] flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed"
|
||||
>
|
||||
<Plus className="h-5 w-5" aria-hidden="true" />
|
||||
<span className="text-xs font-bold tracking-widest uppercase">Add Image</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<MediaManagerModal
|
||||
open={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onSelect={handleSelect}
|
||||
category={placement === GalleryPlacement.HERO ? "gallery" : "portfolio"}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,388 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ColorPicker } from "@/components/ui/color-picker";
|
||||
import { IconPicker } from "@/components/ui/icon-picker";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LoyaltyCard } from "@/components/ui/loyalty-card";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { MediaPicker } from "@/features/media/components/media-picker";
|
||||
import { LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode } from "@/generated/prisma/enums";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import { MAX_STAMPS_REQUIRED, resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
|
||||
import { updateLoyaltyConfigAction } from "../actions/loyalty-config";
|
||||
import type { ProgrammeConfigInput } from "@/services/loyalty/loyalty.service";
|
||||
|
||||
const REWARD_TYPES: { value: ProgrammeConfigInput["rewardType"]; label: string }[] = [
|
||||
{ value: "PERCENTAGE", label: "Percentage Discount" },
|
||||
{ value: "FIXED_AMOUNT", label: "Fixed-Value Discount" },
|
||||
{ value: "FREE_SERVICE", label: "Free Service" },
|
||||
];
|
||||
|
||||
const FONT_OPTIONS: { value: LoyaltyCardFont; label: string }[] = [
|
||||
{ value: LoyaltyCardFont.SANS, label: "Sans Serif" },
|
||||
{ value: LoyaltyCardFont.SERIF, label: "Serif" },
|
||||
{ value: LoyaltyCardFont.MONO, label: "Monospace" },
|
||||
];
|
||||
|
||||
const TITLE_SIZE_OPTIONS: { value: LoyaltyCardTextSize; label: string }[] = [
|
||||
{ value: LoyaltyCardTextSize.SM, label: "Small" },
|
||||
{ value: LoyaltyCardTextSize.MD, label: "Medium" },
|
||||
{ value: LoyaltyCardTextSize.LG, label: "Large" },
|
||||
];
|
||||
|
||||
export interface LoyaltyConfigFormProps {
|
||||
businessName: string;
|
||||
initialConfig: ProgrammeConfigInput;
|
||||
initialEligibleServiceIds: string[];
|
||||
initialEligibleStaffIds: string[];
|
||||
services: { id: string; name: string }[];
|
||||
staff: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function LoyaltyConfigForm({
|
||||
businessName,
|
||||
initialConfig,
|
||||
initialEligibleServiceIds,
|
||||
initialEligibleStaffIds,
|
||||
services,
|
||||
staff,
|
||||
}: LoyaltyConfigFormProps) {
|
||||
const router = useRouter();
|
||||
const [config, setConfig] = useState(initialConfig);
|
||||
const [eligibleServiceIds, setEligibleServiceIds] = useState(initialEligibleServiceIds);
|
||||
const [eligibleStaffIds, setEligibleStaffIds] = useState(initialEligibleStaffIds);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const set = <K extends keyof ProgrammeConfigInput>(key: K, value: ProgrammeConfigInput[K]) => {
|
||||
setSaved(false);
|
||||
setConfig((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const toggleInList = (list: string[], setList: (v: string[]) => void, id: string) => {
|
||||
setSaved(false);
|
||||
setList(list.includes(id) ? list.filter((x) => x !== id) : [...list, id]);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await updateLoyaltyConfigAction({ config, eligibleServiceIds, eligibleStaffIds });
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Couldn't save loyalty settings.");
|
||||
return;
|
||||
}
|
||||
setSaved(true);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 lg:grid lg:grid-cols-2 lg:items-start lg:gap-6 lg:space-y-0">
|
||||
<section className="rounded-md border-line border bg-white p-6 lg:col-span-2">
|
||||
<Switch
|
||||
label="Loyalty programme enabled"
|
||||
description="Turn off to stop earning/redeeming everywhere, without losing the configuration below."
|
||||
checked={config.isEnabled}
|
||||
onCheckedChange={(v) => set("isEnabled", v)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Reward Rules</h3>
|
||||
<div className="rounded-md border-line space-y-5 border bg-white p-6">
|
||||
<Input
|
||||
label={`Stamps required for reward (1–${MAX_STAMPS_REQUIRED})`}
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_STAMPS_REQUIRED}
|
||||
value={config.stampsRequired}
|
||||
onChange={(e) => {
|
||||
const next = Number(e.target.value);
|
||||
set("stampsRequired", Number.isFinite(next) ? Math.min(Math.max(next, 1), MAX_STAMPS_REQUIRED) : 1);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 ml-1 text-[10px] font-bold tracking-widest uppercase text-muted">Reward Timing</p>
|
||||
<div className="space-y-2">
|
||||
{(
|
||||
[
|
||||
{
|
||||
value: RewardPositionMode.AFTER_ALL_STAMPS,
|
||||
title: "Reward after all stamps",
|
||||
description: `Collect all ${config.stampsRequired} stamps, then the reward is given as a separate perk — every position on the card is a normal stamp.`,
|
||||
},
|
||||
{
|
||||
value: RewardPositionMode.FINAL_SLOT_IS_REWARD,
|
||||
title: "Final position is the reward",
|
||||
description: `Collect ${resolveRewardThreshold({ stampsRequired: config.stampsRequired, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD })} stamps — the last position on the card is the reward itself, marked visually, not a stamp to fill.`,
|
||||
},
|
||||
] as const
|
||||
).map((option) => {
|
||||
const selected = config.rewardPosition === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => set("rewardPosition", option.value)}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
"w-full rounded-xl border p-4 text-left transition-colors",
|
||||
selected ? "border-accent-loyalty bg-accent-loyalty/10" : "border-line bg-white",
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-bold">{option.title}</p>
|
||||
<p className="text-muted mt-1 text-xs">{option.description}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Select label="Reward Type" value={config.rewardType} onChange={(e) => set("rewardType", e.target.value as ProgrammeConfigInput["rewardType"])}>
|
||||
{REWARD_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{config.rewardType === "FREE_SERVICE" ? (
|
||||
<Select
|
||||
label="Free Service"
|
||||
value={config.freeServiceId ?? ""}
|
||||
onChange={(e) => set("freeServiceId", e.target.value)}
|
||||
>
|
||||
<option value="">Choose a service…</option>
|
||||
{services.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
label={config.rewardType === "PERCENTAGE" ? "Percentage off (1–100)" : "Fixed amount off"}
|
||||
value={config.rewardValue ?? ""}
|
||||
onChange={(e) => set("rewardValue", e.target.value)}
|
||||
placeholder={config.rewardType === "PERCENTAGE" ? "50" : "10.00"}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Input label="Reward Name" value={config.rewardLabel ?? ""} onChange={(e) => set("rewardLabel", e.target.value)} placeholder="e.g. 50% Off Any Service" />
|
||||
<Input
|
||||
label="Maximum reward value (optional)"
|
||||
value={config.maxRewardValue ?? ""}
|
||||
onChange={(e) => set("maxRewardValue", e.target.value)}
|
||||
hint="Caps a percentage discount so it never exceeds this amount."
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 lg:col-span-2">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Eligibility</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6 lg:grid lg:grid-cols-2 lg:gap-6 lg:space-y-0">
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-bold">Eligible services (none checked = all services earn stamps)</p>
|
||||
<div className="space-y-2">
|
||||
{services.map((s) => (
|
||||
<Checkbox
|
||||
key={s.id}
|
||||
label={s.name}
|
||||
checked={eligibleServiceIds.includes(s.id)}
|
||||
onChange={() => toggleInList(eligibleServiceIds, setEligibleServiceIds, s.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-line border-t pt-4 lg:border-t-0 lg:border-l lg:pt-0 lg:pl-6">
|
||||
<p className="mb-2 text-xs font-bold">Eligible staff (none checked = all staff earn stamps)</p>
|
||||
<div className="space-y-2">
|
||||
{staff.map((s) => (
|
||||
<Checkbox
|
||||
key={s.id}
|
||||
label={s.name}
|
||||
checked={eligibleStaffIds.includes(s.id)}
|
||||
onChange={() => toggleInList(eligibleStaffIds, setEligibleStaffIds, s.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Rules & Expiry</h3>
|
||||
<div className="rounded-md border-line space-y-5 border bg-white p-6">
|
||||
<Switch
|
||||
label="Apply reward automatically"
|
||||
description="Redemption is always staff-initiated this phase — this setting is saved but has no automatic effect yet."
|
||||
checked={config.autoApplyReward}
|
||||
onCheckedChange={(v) => set("autoApplyReward", v)}
|
||||
/>
|
||||
<Switch
|
||||
label="Require staff approval to redeem"
|
||||
description="Redemption already requires a permitted staff member — this is informational only this phase."
|
||||
checked={config.requiresStaffApproval}
|
||||
onCheckedChange={(v) => set("requiresStaffApproval", v)}
|
||||
/>
|
||||
<Switch label="Earn stamps on cancelled appointments" checked={config.earnOnCancelled} onCheckedChange={(v) => set("earnOnCancelled", v)} />
|
||||
<Switch label="Earn stamps on no-shows" checked={config.earnOnNoShow} onCheckedChange={(v) => set("earnOnNoShow", v)} />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Stamps expire after (days, optional)"
|
||||
type="number"
|
||||
min={1}
|
||||
value={config.stampsExpireAfterDays ?? ""}
|
||||
onChange={(e) => set("stampsExpireAfterDays", e.target.value ? Number(e.target.value) : undefined)}
|
||||
/>
|
||||
<Input
|
||||
label="Reward expires after (days, optional)"
|
||||
type="number"
|
||||
min={1}
|
||||
value={config.rewardExpiresAfterDays ?? ""}
|
||||
onChange={(e) => set("rewardExpiresAfterDays", e.target.value ? Number(e.target.value) : undefined)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 lg:col-span-2">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Card Appearance</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6 lg:grid lg:grid-cols-[minmax(0,320px)_1fr] lg:gap-8 lg:space-y-0">
|
||||
<div>
|
||||
<p className="text-muted mb-2 ml-1 text-[10px] font-bold tracking-widest uppercase">Live Preview</p>
|
||||
<LoyaltyCard
|
||||
businessName={businessName}
|
||||
memberNumber="A1B2C3"
|
||||
stampsRequired={Math.max(config.stampsRequired, 1)}
|
||||
stampsEarned={Math.max(
|
||||
resolveRewardThreshold({ stampsRequired: Math.max(config.stampsRequired, 1), rewardPosition: config.rewardPosition }) - 1,
|
||||
0,
|
||||
)}
|
||||
rewardLabel={config.rewardLabel || "Reward"}
|
||||
termsLine={config.termsLine}
|
||||
cardBackgroundColor={config.cardBackgroundColor}
|
||||
cardBackgroundImageUrl={config.cardBackgroundImageUrl}
|
||||
stampIcon={config.stampIcon}
|
||||
rewardPosition={config.rewardPosition}
|
||||
cardTitleText={config.cardTitleText}
|
||||
cardFont={config.cardFont}
|
||||
cardTitleTextSize={config.cardTitleTextSize}
|
||||
cardTitleTextColor={config.cardTitleTextColor}
|
||||
cardBorderColor={config.cardBorderColor}
|
||||
cardMemberNumberColor={config.cardMemberNumberColor}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 lg:mt-0">
|
||||
<Tabs defaultValue="colours">
|
||||
<TabsList>
|
||||
<TabsTrigger value="colours">Colours</TabsTrigger>
|
||||
<TabsTrigger value="stamp">Stamp</TabsTrigger>
|
||||
<TabsTrigger value="text">Text</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="colours" className="space-y-4">
|
||||
<ColorPicker
|
||||
label="Background colour"
|
||||
value={config.cardBackgroundColor}
|
||||
onChange={(hex) => set("cardBackgroundColor", hex)}
|
||||
/>
|
||||
<MediaPicker
|
||||
label="Background image (optional)"
|
||||
value={config.cardBackgroundImageUrl}
|
||||
onChange={(url) => set("cardBackgroundImageUrl", url ?? undefined)}
|
||||
category="loyalty"
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Border colour"
|
||||
value={config.cardBorderColor}
|
||||
onChange={(hex) => set("cardBorderColor", hex)}
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Title text colour"
|
||||
value={config.cardTitleTextColor}
|
||||
onChange={(hex) => set("cardTitleTextColor", hex)}
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Member number colour"
|
||||
value={config.cardMemberNumberColor}
|
||||
onChange={(hex) => set("cardMemberNumberColor", hex)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="stamp" className="space-y-4">
|
||||
<IconPicker label="Stamp icon" value={config.stampIcon} onChange={(iconName) => set("stampIcon", iconName)} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="text" className="space-y-4">
|
||||
<Input
|
||||
label="Card title"
|
||||
value={config.cardTitleText ?? ""}
|
||||
onChange={(e) => set("cardTitleText", e.target.value)}
|
||||
placeholder="Loyalty Card"
|
||||
hint={`Shown top-left on the card, in place of the default "Loyalty Card". Leave blank to keep the default.`}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Select
|
||||
label="Font"
|
||||
value={config.cardFont}
|
||||
onChange={(e) => set("cardFont", e.target.value as ProgrammeConfigInput["cardFont"])}
|
||||
>
|
||||
{FONT_OPTIONS.map((f) => (
|
||||
<option key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
label="Title size"
|
||||
value={config.cardTitleTextSize}
|
||||
onChange={(e) => set("cardTitleTextSize", e.target.value as ProgrammeConfigInput["cardTitleTextSize"])}
|
||||
>
|
||||
{TITLE_SIZE_OPTIONS.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-muted -mt-2 text-[10px]">Font applies to the whole card, not just the title.</p>
|
||||
<Input
|
||||
label="Terms line"
|
||||
value={config.termsLine ?? ""}
|
||||
onChange={(e) => set("termsLine", e.target.value)}
|
||||
placeholder="Valid for 12 months. One reward per visit."
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="space-y-4 lg:col-span-2">
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{saved && !error && (
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
import { updateOpeningHoursAction } from "../actions/business";
|
||||
import type { OpeningHoursActionResult } from "../actions/business";
|
||||
|
||||
const DAY_ORDER: Weekday[] = [
|
||||
Weekday.MONDAY,
|
||||
Weekday.TUESDAY,
|
||||
Weekday.WEDNESDAY,
|
||||
Weekday.THURSDAY,
|
||||
Weekday.FRIDAY,
|
||||
Weekday.SATURDAY,
|
||||
Weekday.SUNDAY,
|
||||
];
|
||||
const DAY_LABEL: Record<Weekday, string> = {
|
||||
MONDAY: "Monday",
|
||||
TUESDAY: "Tuesday",
|
||||
WEDNESDAY: "Wednesday",
|
||||
THURSDAY: "Thursday",
|
||||
FRIDAY: "Friday",
|
||||
SATURDAY: "Saturday",
|
||||
SUNDAY: "Sunday",
|
||||
};
|
||||
|
||||
function toTimeString(minutes: number | null): string {
|
||||
if (minutes === null) return "09:00";
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||
}
|
||||
function toMinutes(time: string): number {
|
||||
const [h, m] = time.split(":").map(Number);
|
||||
return (h ?? 0) * 60 + (m ?? 0);
|
||||
}
|
||||
|
||||
interface DayState {
|
||||
dayOfWeek: Weekday;
|
||||
isClosed: boolean;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}
|
||||
|
||||
export interface InitialOpeningHoursRow {
|
||||
dayOfWeek: Weekday;
|
||||
isClosed: boolean;
|
||||
opensAtMinutes: number | null;
|
||||
closesAtMinutes: number | null;
|
||||
}
|
||||
|
||||
export function OpeningHoursForm({ initialRows }: { initialRows: InitialOpeningHoursRow[] }) {
|
||||
const router = useRouter();
|
||||
const [days, setDays] = useState<DayState[]>(() =>
|
||||
DAY_ORDER.map((dayOfWeek) => {
|
||||
const existing = initialRows.find((r) => r.dayOfWeek === dayOfWeek);
|
||||
return {
|
||||
dayOfWeek,
|
||||
isClosed: existing?.isClosed ?? true,
|
||||
startTime: toTimeString(existing?.opensAtMinutes ?? null),
|
||||
endTime: toTimeString(existing?.closesAtMinutes ?? 19 * 60),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [conflicts, setConflicts] = useState<NonNullable<OpeningHoursActionResult["conflicts"]>>([]);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const updateDay = (dayOfWeek: Weekday, patch: Partial<DayState>) => {
|
||||
setSaved(false);
|
||||
setConflicts([]);
|
||||
setDays((prev) => prev.map((d) => (d.dayOfWeek === dayOfWeek ? { ...d, ...patch } : d)));
|
||||
};
|
||||
|
||||
const copyMondayToAll = () => {
|
||||
const monday = days.find((d) => d.dayOfWeek === Weekday.MONDAY);
|
||||
if (!monday) return;
|
||||
setSaved(false);
|
||||
setConflicts([]);
|
||||
setDays((prev) =>
|
||||
prev.map((d) => (d.dayOfWeek === Weekday.MONDAY ? d : { ...d, isClosed: monday.isClosed, startTime: monday.startTime, endTime: monday.endTime })),
|
||||
);
|
||||
};
|
||||
|
||||
const submit = (acknowledgeConflicts: boolean) => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await updateOpeningHoursAction(
|
||||
days.map((d) => ({
|
||||
dayOfWeek: d.dayOfWeek,
|
||||
isClosed: d.isClosed,
|
||||
...(d.isClosed ? {} : { opensAtMinutes: toMinutes(d.startTime), closesAtMinutes: toMinutes(d.endTime) }),
|
||||
})),
|
||||
acknowledgeConflicts,
|
||||
);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Couldn't save opening hours.");
|
||||
setConflicts(result.conflicts ?? []);
|
||||
return;
|
||||
}
|
||||
setSaved(true);
|
||||
setConflicts([]);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
if (conflicts.length > 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
These hours would leave {conflicts.length} upcoming appointment{conflicts.length > 1 ? "s" : ""} outside
|
||||
the business's open hours. They won't be cancelled automatically — review them, then confirm if
|
||||
you still want to save.
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{conflicts.map((c) => (
|
||||
<li key={c.id} className="border-line rounded-lg border bg-white p-3 text-xs">
|
||||
<Link href={`/owner/appointments/${c.id}`} className="font-bold underline">
|
||||
{c.customerName}
|
||||
</Link>
|
||||
<p className="text-muted">
|
||||
{c.serviceNames} · with {c.staffName}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={() => setConflicts([])}>
|
||||
Go Back
|
||||
</Button>
|
||||
<Button variant="destructive" className="flex-1" isLoading={isPending} onClick={() => submit(true)}>
|
||||
Save Anyway
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button type="button" onClick={copyMondayToAll} className="text-accent text-xs font-bold tracking-widest uppercase underline">
|
||||
Copy Monday to all days
|
||||
</button>
|
||||
|
||||
<div className="divide-line rounded-md border-line divide-y border bg-white">
|
||||
{days.map((day) => (
|
||||
<div key={day.dayOfWeek} className="flex items-center justify-between gap-4 px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={!day.isClosed}
|
||||
onCheckedChange={(checked) => updateDay(day.dayOfWeek, { isClosed: !checked })}
|
||||
aria-label={`${DAY_LABEL[day.dayOfWeek]} open`}
|
||||
/>
|
||||
<p className="text-sm font-bold">{DAY_LABEL[day.dayOfWeek]}</p>
|
||||
</div>
|
||||
{day.isClosed ? (
|
||||
<span className="text-muted text-xs">Closed</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="time"
|
||||
value={day.startTime}
|
||||
onChange={(e) => updateDay(day.dayOfWeek, { startTime: e.target.value })}
|
||||
className="border-line rounded-lg border px-2 py-1.5 text-xs"
|
||||
aria-label={`${DAY_LABEL[day.dayOfWeek]} opening time`}
|
||||
/>
|
||||
<span className="text-muted text-xs">to</span>
|
||||
<input
|
||||
type="time"
|
||||
value={day.endTime}
|
||||
onChange={(e) => updateDay(day.dayOfWeek, { endTime: e.target.value })}
|
||||
className="border-line rounded-lg border px-2 py-1.5 text-xs"
|
||||
aria-label={`${DAY_LABEL[day.dayOfWeek]} closing time`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{saved && !error && (
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
Saved. The booking engine reflects these hours immediately.
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" isLoading={isPending} onClick={() => submit(false)}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CalendarOff } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BottomSheet } from "@/components/ui/bottom-sheet";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { OwnerBlockTimeConflict } from "../actions/calendar";
|
||||
import { ownerBlockTimeAction } from "../actions/calendar";
|
||||
|
||||
function toMinutes(hhmm: string): number | null {
|
||||
const match = /^(\d{2}):(\d{2})$/.exec(hhmm);
|
||||
if (!match) return null;
|
||||
return Number(match[1]) * 60 + Number(match[2]);
|
||||
}
|
||||
|
||||
/** Owner variant of the staff BlockTimeButton (src/features/staff/components/block-time-button.tsx) — same UI, targets a chosen staff member instead of only the signed-in user. */
|
||||
export function OwnerBlockTimeButton({ staffId, staffName, date }: { staffId: string; staffName: string; date: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [startTime, setStartTime] = useState("12:00");
|
||||
const [endTime, setEndTime] = useState("13:00");
|
||||
const [reason, setReason] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [conflicts, setConflicts] = useState<OwnerBlockTimeConflict[] | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
setError(null);
|
||||
setConflicts(null);
|
||||
setReason("");
|
||||
};
|
||||
|
||||
const submit = (acknowledgeConflicts: boolean) => {
|
||||
const startMinutes = toMinutes(startTime);
|
||||
const endMinutes = toMinutes(endTime);
|
||||
if (startMinutes === null || endMinutes === null || startMinutes >= endMinutes) {
|
||||
setError("Start time must be before end time.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await ownerBlockTimeAction({ staffId, date, startMinutes, endMinutes, reason, acknowledgeConflicts });
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Something went wrong.");
|
||||
setConflicts(result.conflicts ?? null);
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
setConflicts(null);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="secondary" size="sm" aria-label="Block Time" onClick={() => setOpen(true)}>
|
||||
<CalendarOff className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="hidden sm:inline">Block Time</span>
|
||||
</Button>
|
||||
<BottomSheet open={open} onClose={handleClose} title={`Block Time — ${staffName}`}>
|
||||
<div className="space-y-4 pb-2">
|
||||
<p className="text-muted text-sm">
|
||||
Reserve part of{" "}
|
||||
{new Date(`${date}T00:00:00`).toLocaleDateString("en-GB", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
})}{" "}
|
||||
on {staffName}'s schedule for something other than appointments.
|
||||
</p>
|
||||
|
||||
{conflicts && conflicts.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
This overlaps {conflicts.length} existing appointment
|
||||
{conflicts.length > 1 ? "s" : ""}:
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{conflicts.map((c) => (
|
||||
<li key={c.id} className="border-line rounded-lg border bg-white p-3 text-xs">
|
||||
<Link href={`/owner/appointments/${c.id}`} className="font-bold underline">
|
||||
{c.customerName}
|
||||
</Link>
|
||||
<p className="text-muted">{c.serviceNames}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" className="flex-1" isLoading={isPending} onClick={() => submit(true)}>
|
||||
Block Anyway
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input label="Start" type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} />
|
||||
<Input label="End" type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} />
|
||||
</div>
|
||||
<Input
|
||||
label="Reason (optional)"
|
||||
placeholder="e.g. Team meeting"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" isLoading={isPending} onClick={() => submit(false)}>
|
||||
Block This Time
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</BottomSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Gift } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { redeemRewardAction } from "../actions/loyalty";
|
||||
|
||||
/**
|
||||
* Available to any staff member with MANAGE_LOYALTY_STAMPS (Owner and
|
||||
* Barber both hold it in the seed — see redeemRewardAction) — surfaced on
|
||||
* both the owner and staff appointment details pages, since redemption is
|
||||
* an operational action, not owner-only configuration. See
|
||||
* docs/phase-6-summary.md, "Loyalty configuration and redemption approach".
|
||||
*/
|
||||
export function RedeemRewardButton({
|
||||
appointmentId,
|
||||
rewardId,
|
||||
rewardLabel,
|
||||
subtotal,
|
||||
}: {
|
||||
appointmentId: string;
|
||||
rewardId: string;
|
||||
rewardLabel: string;
|
||||
subtotal: number;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<{ discountAmount: string } | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const close = () => {
|
||||
const shouldRefresh = !!result;
|
||||
setOpen(false);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
if (shouldRefresh) router.refresh();
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const res = await redeemRewardAction({ rewardId, appointmentId });
|
||||
if (!res.success) {
|
||||
setError(res.error ?? "Couldn't redeem this reward.");
|
||||
return;
|
||||
}
|
||||
setResult({ discountAmount: res.discountAmount ?? "0.00" });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
|
||||
<Gift className="text-accent-loyalty h-4 w-4" aria-hidden="true" />
|
||||
Redeem Reward
|
||||
</Button>
|
||||
<Modal open={open} onClose={close} title={result ? "Reward Redeemed" : "Redeem Reward"}>
|
||||
{result ? (
|
||||
<div className="space-y-4">
|
||||
<p role="status" className="text-muted text-sm">
|
||||
{rewardLabel} has been applied to this appointment — £{result.discountAmount} off.
|
||||
</p>
|
||||
<Button className="w-full" onClick={close}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted text-sm">
|
||||
Apply <span className="text-ink font-bold">{rewardLabel}</span> to this appointment
|
||||
(subtotal £{subtotal.toFixed(2)})? This can't be undone.
|
||||
</p>
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="flex-1" isLoading={isPending} onClick={submit}>
|
||||
Apply Reward
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { syncReviewsNowAction, updateReviewsSettingsAction } from "../actions/reviews";
|
||||
|
||||
export interface ReviewsManagerProps {
|
||||
initialGooglePlaceId: string;
|
||||
googleRating: number | null;
|
||||
googleRatingCount: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Just the Google Place ID + aggregate rating/count sync — no individual
|
||||
* review import/management. Google's Place Details API only ever returns
|
||||
* up to 5 "most relevant" reviews with no pagination to get the rest (see
|
||||
* google-places.provider.ts's header), so a review list here could never
|
||||
* be a real, representative picture — the aggregate rating/count alone is
|
||||
* accurate and is all this app surfaces.
|
||||
*/
|
||||
export function ReviewsManager({ initialGooglePlaceId, googleRating, googleRatingCount }: ReviewsManagerProps) {
|
||||
const router = useRouter();
|
||||
const [googlePlaceId, setGooglePlaceId] = useState(initialGooglePlaceId);
|
||||
const [settingsError, setSettingsError] = useState<string | null>(null);
|
||||
const [settingsSaved, setSettingsSaved] = useState(false);
|
||||
const [syncMessage, setSyncMessage] = useState<string | null>(null);
|
||||
const [isSettingsPending, startSettingsTransition] = useTransition();
|
||||
const [isSyncPending, startSyncTransition] = useTransition();
|
||||
|
||||
const saveSettings = () => {
|
||||
setSettingsError(null);
|
||||
setSettingsSaved(false);
|
||||
startSettingsTransition(async () => {
|
||||
const result = await updateReviewsSettingsAction({ googlePlaceId: googlePlaceId.trim() || null });
|
||||
if (!result.success) {
|
||||
setSettingsError(result.error ?? "Couldn't save review settings.");
|
||||
return;
|
||||
}
|
||||
setSettingsSaved(true);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const syncNow = () => {
|
||||
setSyncMessage(null);
|
||||
startSyncTransition(async () => {
|
||||
const result = await syncReviewsNowAction();
|
||||
setSyncMessage(result.success ? "Synced — refreshing…" : (result.error ?? "Sync failed."));
|
||||
if (result.success) router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<h3 className="text-sm font-bold">Google Reviews Sync</h3>
|
||||
{googleRating !== null && (
|
||||
<p className="text-muted text-sm">
|
||||
Google's aggregate: <span className="text-ink font-bold">{googleRating.toFixed(1)}</span> from{" "}
|
||||
{googleRatingCount ?? 0} ratings.
|
||||
</p>
|
||||
)}
|
||||
<Input
|
||||
label="Google Place ID"
|
||||
value={googlePlaceId}
|
||||
onChange={(e) => setGooglePlaceId(e.target.value)}
|
||||
placeholder="ChIJ…"
|
||||
hint="Used to pull this business's rating and review count from Google. Find it by searching your business on Google Maps, opening Share → Embed a map, and copying the id from the embed code — or search “Google Place ID Finder” for Google's own lookup tool. Leave blank to disable syncing."
|
||||
/>
|
||||
{settingsError && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{settingsError}
|
||||
</p>
|
||||
)}
|
||||
{settingsSaved && !settingsError && (
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button isLoading={isSettingsPending} onClick={saveSettings}>
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="secondary" isLoading={isSyncPending} onClick={syncNow}>
|
||||
<RefreshCw className="h-4 w-4" aria-hidden="true" /> Sync Now
|
||||
</Button>
|
||||
</div>
|
||||
{syncMessage && <p className="text-muted text-xs">{syncMessage}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BottomSheet } from "@/components/ui/bottom-sheet";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MediaPicker } from "@/features/media/components/media-picker";
|
||||
import { createServiceAction, updateServiceAction } from "../actions/catalogue";
|
||||
|
||||
export interface EditableService {
|
||||
id: string;
|
||||
categoryId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
imageUrl: string | null;
|
||||
durationMinutes: number;
|
||||
price: string;
|
||||
bufferBeforeMinutes: number;
|
||||
bufferAfterMinutes: number;
|
||||
allowOnlineBooking: boolean;
|
||||
}
|
||||
|
||||
export function ServiceFormSheet({
|
||||
open,
|
||||
onClose,
|
||||
categories,
|
||||
defaultCategoryId,
|
||||
existing,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
categories: { id: string; name: string }[];
|
||||
defaultCategoryId: string;
|
||||
existing?: EditableService | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [categoryId, setCategoryId] = useState(existing?.categoryId ?? defaultCategoryId);
|
||||
const [name, setName] = useState(existing?.name ?? "");
|
||||
const [description, setDescription] = useState(existing?.description ?? "");
|
||||
const [imageUrl, setImageUrl] = useState(existing?.imageUrl ?? "");
|
||||
const [duration, setDuration] = useState(String(existing?.durationMinutes ?? 30));
|
||||
const [price, setPrice] = useState(existing?.price ?? "");
|
||||
const [bufferBefore, setBufferBefore] = useState(String(existing?.bufferBeforeMinutes ?? 0));
|
||||
const [bufferAfter, setBufferAfter] = useState(String(existing?.bufferAfterMinutes ?? 0));
|
||||
const [allowOnlineBooking, setAllowOnlineBooking] = useState(existing?.allowOnlineBooking ?? true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const submit = () => {
|
||||
setError(null);
|
||||
const durationMinutes = Number(duration);
|
||||
const bufferBeforeMinutes = Number(bufferBefore);
|
||||
const bufferAfterMinutes = Number(bufferAfter);
|
||||
if (!Number.isInteger(durationMinutes) || durationMinutes <= 0) {
|
||||
setError("Duration must be a positive number of minutes.");
|
||||
return;
|
||||
}
|
||||
startTransition(async () => {
|
||||
const input = {
|
||||
categoryId,
|
||||
name,
|
||||
description: description || undefined,
|
||||
imageUrl: imageUrl || undefined,
|
||||
durationMinutes,
|
||||
price,
|
||||
bufferBeforeMinutes,
|
||||
bufferAfterMinutes,
|
||||
allowOnlineBooking,
|
||||
};
|
||||
const result = existing ? await updateServiceAction(existing.id, input) : await createServiceAction(input);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Something went wrong.");
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<BottomSheet open={open} onClose={onClose} title={existing ? "Edit Service" : "New Service"}>
|
||||
<div className="space-y-4 pb-2">
|
||||
<Select label="Category" value={categoryId} onChange={(e) => setCategoryId(e.target.value)}>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Input label="Name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<Textarea label="Description (optional)" value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
<MediaPicker label="Photo (optional)" value={imageUrl} onChange={(url) => setImageUrl(url ?? "")} category="service" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input label="Duration (min)" type="number" min={1} value={duration} onChange={(e) => setDuration(e.target.value)} />
|
||||
<Input label="Price" placeholder="22.50" value={price} onChange={(e) => setPrice(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input label="Buffer Before (min)" type="number" min={0} value={bufferBefore} onChange={(e) => setBufferBefore(e.target.value)} />
|
||||
<Input label="Buffer After (min)" type="number" min={0} value={bufferAfter} onChange={(e) => setBufferAfter(e.target.value)} />
|
||||
</div>
|
||||
<Switch label="Available for online booking" checked={allowOnlineBooking} onCheckedChange={setAllowOnlineBooking} />
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" isLoading={isPending} onClick={submit}>
|
||||
{existing ? "Save Changes" : "Add Service"}
|
||||
</Button>
|
||||
</div>
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import {
|
||||
deactivateStaffAction,
|
||||
reactivateStaffAction,
|
||||
regenerateInvitationAction,
|
||||
setStaffServiceEligibilityAction,
|
||||
updateStaffAction,
|
||||
} from "../actions/staff";
|
||||
|
||||
export interface StaffDetailPanelProps {
|
||||
staffId: string;
|
||||
currentRoleId: string;
|
||||
roles: { id: string; name: string }[];
|
||||
allServices: { id: string; name: string }[];
|
||||
initialServiceIds: string[];
|
||||
status: "ACTIVE" | "ON_BREAK" | "INACTIVE";
|
||||
hasPendingInvitation: boolean;
|
||||
}
|
||||
|
||||
type ConflictItem = { id: string; customerName: string; serviceNames: string; startAt: string };
|
||||
|
||||
export function StaffDetailPanel({
|
||||
staffId,
|
||||
currentRoleId,
|
||||
roles,
|
||||
allServices,
|
||||
initialServiceIds,
|
||||
status,
|
||||
hasPendingInvitation,
|
||||
}: StaffDetailPanelProps) {
|
||||
const router = useRouter();
|
||||
const [roleId, setRoleId] = useState(currentRoleId);
|
||||
const [serviceIds, setServiceIds] = useState(initialServiceIds);
|
||||
const [roleError, setRoleError] = useState<string | null>(null);
|
||||
const [roleSaved, setRoleSaved] = useState(false);
|
||||
const [servicesError, setServicesError] = useState<string | null>(null);
|
||||
const [servicesSaved, setServicesSaved] = useState(false);
|
||||
const [serviceConflicts, setServiceConflicts] = useState<ConflictItem[]>([]);
|
||||
const [deactivateConflicts, setDeactivateConflicts] = useState<ConflictItem[] | null>(null);
|
||||
const [deactivateError, setDeactivateError] = useState<string | null>(null);
|
||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isRolePending, startRoleTransition] = useTransition();
|
||||
const [isServicesPending, startServicesTransition] = useTransition();
|
||||
const [isDeactivatePending, startDeactivateTransition] = useTransition();
|
||||
const [isInvitePending, startInviteTransition] = useTransition();
|
||||
|
||||
const saveRole = () => {
|
||||
setRoleError(null);
|
||||
setRoleSaved(false);
|
||||
startRoleTransition(async () => {
|
||||
const result = await updateStaffAction(staffId, { roleId });
|
||||
if (!result.success) {
|
||||
setRoleError(result.error ?? "Couldn't change role.");
|
||||
return;
|
||||
}
|
||||
setRoleSaved(true);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const toggleService = (id: string) => {
|
||||
setServicesSaved(false);
|
||||
setServiceIds((prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]));
|
||||
};
|
||||
|
||||
const saveServices = (acknowledgeConflicts: boolean) => {
|
||||
setServicesError(null);
|
||||
startServicesTransition(async () => {
|
||||
const result = await setStaffServiceEligibilityAction(staffId, serviceIds, acknowledgeConflicts);
|
||||
if (!result.success) {
|
||||
setServicesError(result.error ?? "Couldn't update services.");
|
||||
setServiceConflicts(result.conflicts ?? []);
|
||||
return;
|
||||
}
|
||||
setServicesSaved(true);
|
||||
setServiceConflicts([]);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const deactivate = (acknowledgeConflicts: boolean) => {
|
||||
setDeactivateError(null);
|
||||
startDeactivateTransition(async () => {
|
||||
const result = await deactivateStaffAction(staffId, acknowledgeConflicts);
|
||||
if (!result.success) {
|
||||
setDeactivateError(result.error ?? "Couldn't deactivate this staff member.");
|
||||
setDeactivateConflicts(result.conflicts ?? []);
|
||||
return;
|
||||
}
|
||||
setDeactivateConflicts(null);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const reactivate = () => {
|
||||
startDeactivateTransition(async () => {
|
||||
await reactivateStaffAction(staffId);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const regenerateLink = () => {
|
||||
startInviteTransition(async () => {
|
||||
const result = await regenerateInvitationAction(staffId);
|
||||
if (result.success && result.inviteToken) {
|
||||
setInviteLink(`${window.location.origin}/invite/accept?token=${result.inviteToken}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const copyLink = async () => {
|
||||
if (!inviteLink) return;
|
||||
await navigator.clipboard.writeText(inviteLink);
|
||||
setCopied(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{hasPendingInvitation && (
|
||||
<section className="rounded-md border-warning/30 bg-warning/10 border p-5">
|
||||
<p className="text-warning-foreground text-sm font-bold">Invitation pending</p>
|
||||
<p className="text-muted mt-1 text-xs">
|
||||
This person hasn't set a password yet. Email delivery isn't wired up — send them the link
|
||||
yourself.
|
||||
</p>
|
||||
{inviteLink ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="border-line bg-white break-all rounded-lg border p-3 text-xs">{inviteLink}</div>
|
||||
<Button size="sm" variant="secondary" onClick={copyLink}>
|
||||
{copied ? "Copied" : "Copy Link"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button size="sm" variant="secondary" className="mt-3" isLoading={isInvitePending} onClick={regenerateLink}>
|
||||
Get Invite Link
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Role & Permissions</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<Select label="Role" value={roleId} onChange={(e) => setRoleId(e.target.value)}>
|
||||
{roles.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{roleError && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{roleError}
|
||||
</p>
|
||||
)}
|
||||
{roleSaved && !roleError && (
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
<Button size="sm" isLoading={isRolePending} onClick={saveRole} disabled={roleId === currentRoleId}>
|
||||
Save Role
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Services Provided</h3>
|
||||
<div className="rounded-md border-line space-y-3 border bg-white p-6">
|
||||
{allServices.map((s) => (
|
||||
<Checkbox key={s.id} label={s.name} checked={serviceIds.includes(s.id)} onChange={() => toggleService(s.id)} />
|
||||
))}
|
||||
|
||||
{serviceConflicts.length > 0 && (
|
||||
<div className="border-line space-y-2 border-t pt-3">
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
This staff member has upcoming appointments for a service you're removing:
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{serviceConflicts.map((c) => (
|
||||
<li key={c.id} className="border-line rounded-lg border bg-white p-3 text-xs">
|
||||
<Link href={`/owner/appointments/${c.id}`} className="font-bold underline">
|
||||
{c.customerName}
|
||||
</Link>
|
||||
<p className="text-muted">{c.serviceNames}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button size="sm" variant="destructive" isLoading={isServicesPending} onClick={() => saveServices(true)}>
|
||||
Save Anyway
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{servicesError && serviceConflicts.length === 0 && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{servicesError}
|
||||
</p>
|
||||
)}
|
||||
{servicesSaved && !servicesError && (
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
{serviceConflicts.length === 0 && (
|
||||
<Button size="sm" isLoading={isServicesPending} onClick={() => saveServices(false)}>
|
||||
Save Services
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Availability</h3>
|
||||
<div className="rounded-md border-line divide-line divide-y overflow-hidden border bg-white">
|
||||
<Link href={`/owner/team/${staffId}/hours`} className="hover:bg-stone flex items-center justify-between px-6 py-4">
|
||||
<span className="text-sm font-bold">Working Hours</span>
|
||||
<span className="text-muted text-xs">Edit</span>
|
||||
</Link>
|
||||
<Link href={`/owner/team/${staffId}/breaks`} className="hover:bg-stone flex items-center justify-between px-6 py-4">
|
||||
<span className="text-sm font-bold">Breaks</span>
|
||||
<span className="text-muted text-xs">Edit</span>
|
||||
</Link>
|
||||
<Link href={`/owner/team/${staffId}/time-off`} className="hover:bg-stone flex items-center justify-between px-6 py-4">
|
||||
<span className="text-sm font-bold">Time Off</span>
|
||||
<span className="text-muted text-xs">Edit</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="pb-2">
|
||||
{status === "INACTIVE" ? (
|
||||
<Button variant="secondary" className="w-full" isLoading={isDeactivatePending} onClick={reactivate}>
|
||||
Reactivate Staff Member
|
||||
</Button>
|
||||
) : deactivateConflicts && deactivateConflicts.length > 0 ? (
|
||||
<div className="border-danger/30 bg-danger/5 space-y-3 rounded-md border p-5">
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
This staff member still has {deactivateConflicts.length} upcoming appointment
|
||||
{deactivateConflicts.length > 1 ? "s" : ""}. Deactivating won't cancel them — they'll need to
|
||||
be reassigned or handled separately.
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{deactivateConflicts.map((c) => (
|
||||
<li key={c.id} className="rounded-lg border border-white bg-white p-3 text-xs">
|
||||
<Link href={`/owner/appointments/${c.id}`} className="font-bold underline">
|
||||
{c.customerName}
|
||||
</Link>
|
||||
<p className="text-muted">{c.serviceNames}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={() => setDeactivateConflicts(null)}>
|
||||
Go Back
|
||||
</Button>
|
||||
<Button variant="destructive" className="flex-1" isLoading={isDeactivatePending} onClick={() => deactivate(true)}>
|
||||
Deactivate Anyway
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{deactivateError && (
|
||||
<p role="alert" className="text-danger mb-2 text-sm font-medium">
|
||||
{deactivateError}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
isLoading={isDeactivatePending}
|
||||
onClick={() => deactivate(false)}
|
||||
>
|
||||
Deactivate Staff Member
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Users } from "lucide-react";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
export interface StaffFilterOption {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string | null;
|
||||
/** Working-hours range for the day (e.g. "10:00 AM – 7:00 PM"), or null if off entirely. */
|
||||
hoursLabel: string | null;
|
||||
}
|
||||
|
||||
const CARD_CLASS = "flex shrink-0 items-center gap-2 rounded-2xl border bg-white px-3 py-2 text-left";
|
||||
|
||||
/** Avatar + name + hours cards for filtering the all-staff calendar by barber — an "All Staff" card plus one per active staff member, matching the day grid's own column-header style so the filter row and the grid read as one system. */
|
||||
export function StaffFilterPills({
|
||||
options,
|
||||
selectedId,
|
||||
hrefFor,
|
||||
}: {
|
||||
options: StaffFilterOption[];
|
||||
/** undefined = "All Staff" selected. */
|
||||
selectedId?: string;
|
||||
hrefFor: (staffId: string | null) => string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-2 overflow-x-auto px-6 py-1">
|
||||
<Link
|
||||
href={hrefFor(null)}
|
||||
aria-current={!selectedId ? "true" : undefined}
|
||||
className={cn(CARD_CLASS, !selectedId ? "border-ink ring-ink ring-1" : "border-line")}
|
||||
>
|
||||
<span className="bg-stone text-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-line">
|
||||
<Users className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<p className="text-sm font-bold">All Staff</p>
|
||||
<p className="text-muted text-[10px]">Everyone</p>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{options.map((option) => (
|
||||
<Link
|
||||
key={option.id}
|
||||
href={hrefFor(option.id)}
|
||||
aria-current={selectedId === option.id ? "true" : undefined}
|
||||
className={cn(CARD_CLASS, selectedId === option.id ? "border-ink ring-ink ring-1" : "border-line")}
|
||||
>
|
||||
{option.avatarUrl ? (
|
||||
<Image
|
||||
src={option.avatarUrl}
|
||||
alt=""
|
||||
width={36}
|
||||
height={36}
|
||||
className="h-9 w-9 shrink-0 rounded-full border border-line object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-stone text-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-line text-sm font-bold">
|
||||
{option.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
<p className="truncate text-sm font-bold">{option.name}</p>
|
||||
<p className="text-muted truncate text-[10px]">{option.hoursLabel ?? "Off today"}</p>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useInstallPrompt } from "../hooks/use-install-prompt";
|
||||
|
||||
/**
|
||||
* Chrome/Edge/Android fire `beforeinstallprompt` when the manifest +
|
||||
* service worker criteria are met — captured and triggered from this
|
||||
* button instead of an unsolicited popup (see use-install-prompt.ts,
|
||||
* shared with WelcomeInstallCard so there's exactly one PWA install
|
||||
* implementation, not two). iOS Safari never fires that event at all, so
|
||||
* it gets manual "Add to Home Screen" instructions instead. `businessName`
|
||||
* comes from the caller — every Settings page already fetches branding
|
||||
* server-side, so this client component doesn't need to.
|
||||
*/
|
||||
export function InstallAppButton({ businessName }: { businessName: string }) {
|
||||
const { installed, ios, canPromptNatively, promptInstall } = useInstallPrompt();
|
||||
const [showIosInstructions, setShowIosInstructions] = useState(false);
|
||||
|
||||
if (installed) {
|
||||
return <p className="text-muted text-sm">{businessName} is installed on this device.</p>;
|
||||
}
|
||||
|
||||
if (canPromptNatively) {
|
||||
return (
|
||||
<Button size="sm" onClick={promptInstall}>
|
||||
<Download className="h-4 w-4" aria-hidden="true" /> Install App
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (ios) {
|
||||
return showIosInstructions ? (
|
||||
<p className="text-muted text-sm">
|
||||
Tap the Share icon in Safari, then choose “Add to Home Screen”.
|
||||
</p>
|
||||
) : (
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowIosInstructions(true)}>
|
||||
<Download className="h-4 w-4" aria-hidden="true" /> Install App
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return <p className="text-muted text-sm">Installing isn't available in this browser yet.</p>;
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Registers the service worker once, app-wide, and surfaces the
|
||||
* update-available handshake public/sw.js sets up: a new SW version
|
||||
* installs and sits `waiting` rather than taking over immediately, so an
|
||||
* open tab isn't silently switched to a new app version mid-session. This
|
||||
* component detects that waiting worker and lets the user opt into the
|
||||
* refresh instead.
|
||||
*/
|
||||
export function ServiceWorkerManager() {
|
||||
const [waitingWorker, setWaitingWorker] = useState<ServiceWorker | null>(null);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
|
||||
navigator.serviceWorker
|
||||
.register("/sw.js")
|
||||
.then((registration) => {
|
||||
// An update was already downloaded (e.g. by another tab) before this page loaded.
|
||||
if (registration.waiting && navigator.serviceWorker.controller) {
|
||||
setWaitingWorker(registration.waiting);
|
||||
}
|
||||
|
||||
registration.addEventListener("updatefound", () => {
|
||||
const installing = registration.installing;
|
||||
if (!installing) return;
|
||||
installing.addEventListener("statechange", () => {
|
||||
// Only a real update — an installing worker with no existing
|
||||
// controller is just this tab's first-ever SW install, not an update.
|
||||
if (installing.state === "installed" && navigator.serviceWorker.controller) {
|
||||
setWaitingWorker(installing);
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort — offline/install/update support degrades gracefully without it.
|
||||
});
|
||||
|
||||
let reloaded = false;
|
||||
const onControllerChange = () => {
|
||||
if (reloaded) return;
|
||||
reloaded = true;
|
||||
window.location.reload();
|
||||
};
|
||||
navigator.serviceWorker.addEventListener("controllerchange", onControllerChange);
|
||||
return () => navigator.serviceWorker.removeEventListener("controllerchange", onControllerChange);
|
||||
}, []);
|
||||
|
||||
if (!waitingWorker || dismissed) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="rounded-md bg-ink fixed inset-x-6 top-4 z-50 mx-auto flex max-w-sm items-center justify-between gap-3 px-5 py-3 text-sm text-white shadow-xl"
|
||||
>
|
||||
<span>A new version is available.</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="font-bold whitespace-nowrap underline"
|
||||
onClick={() => waitingWorker.postMessage({ type: "SKIP_WAITING" })}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss"
|
||||
className="text-white/60"
|
||||
onClick={() => setDismissed(true)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useInstallPrompt } from "../hooks/use-install-prompt";
|
||||
|
||||
const DISMISSED_UNTIL_KEY = "nextchair:install-card-dismissed-until";
|
||||
/** Re-prompt interval after "Not Now" — see the brief's "reasonable re-prompt interval such as 14 or 30 days." 30 days chosen: this card is one of several places to install (Settings always has it too), so a longer, less-naggy interval is appropriate here specifically. */
|
||||
const DISMISS_DAYS = 30;
|
||||
|
||||
function isDismissed(): boolean {
|
||||
if (typeof window === "undefined") return true;
|
||||
const raw = window.localStorage.getItem(DISMISSED_UNTIL_KEY);
|
||||
if (!raw) return false;
|
||||
const until = Number(raw);
|
||||
return Number.isFinite(until) && Date.now() < until;
|
||||
}
|
||||
|
||||
/**
|
||||
* The prominent, inline Welcome-page counterpart to the Settings-page
|
||||
* `InstallAppButton` — same underlying detection (see
|
||||
* use-install-prompt.ts), never a second installer. Renders nothing when
|
||||
* already installed, on a non-iOS/non-installable browser, or recently
|
||||
* dismissed — it never blocks Sign In / Create Account / Continue as
|
||||
* Guest / Browse Services above it, and never opens as an unsolicited
|
||||
* modal (see the brief's explicit "do not automatically open a modal").
|
||||
*/
|
||||
export function WelcomeInstallCard({ businessName }: { businessName: string }) {
|
||||
const { installed, ios, canPromptNatively, promptInstall } = useInstallPrompt();
|
||||
const [dismissed, setDismissed] = useState(true);
|
||||
const [showIosSteps, setShowIosSteps] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void Promise.resolve().then(() => setDismissed(isDismissed()));
|
||||
}, []);
|
||||
|
||||
const dismiss = () => {
|
||||
window.localStorage.setItem(DISMISSED_UNTIL_KEY, String(Date.now() + DISMISS_DAYS * 24 * 60 * 60 * 1000));
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
if (installed || dismissed || (!canPromptNatively && !ios)) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-md mb-6 border border-white/10 bg-white/5 p-5 text-white">
|
||||
<p className="text-sm font-bold">Add {businessName} to your Home Screen</p>
|
||||
<p className="mt-1 text-sm text-white/70">Book appointments, manage your visits and receive reminders more easily.</p>
|
||||
|
||||
{ios && showIosSteps ? (
|
||||
<div className="mt-4 space-y-2 text-sm text-white/80">
|
||||
<p className="font-bold text-white">Install {businessName}</p>
|
||||
<ol className="list-inside list-decimal space-y-1">
|
||||
<li>Tap the Share button in Safari.</li>
|
||||
<li>Scroll down and choose “Add to Home Screen”.</li>
|
||||
<li>Tap Add.</li>
|
||||
</ol>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="bg-white"
|
||||
onClick={ios ? () => setShowIosSteps(true) : promptInstall}
|
||||
>
|
||||
Add to Home Screen
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="text-white/70 hover:bg-white/10 hover:text-white" onClick={dismiss}>
|
||||
Not Now
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface BeforeInstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||
}
|
||||
|
||||
function isStandalone(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return (
|
||||
window.matchMedia("(display-mode: standalone)").matches ||
|
||||
// iOS Safari's own (non-standard, but the only signal it exposes).
|
||||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
|
||||
);
|
||||
}
|
||||
|
||||
function isIos(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
return /iphone|ipad|ipod/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared `beforeinstallprompt` capture + standalone/iOS detection — the
|
||||
* one PWA install implementation both `InstallAppButton` (Settings, every
|
||||
* role) and `WelcomeInstallCard` (the unauthenticated Welcome page) use.
|
||||
* See the brief's explicit "do not create a competing second installer".
|
||||
* All detection lives in `useEffect`, never a `useState` lazy
|
||||
* initializer — reading `window`/`navigator` during the initializer would
|
||||
* run during SSR too and mismatch the client's first render (the same
|
||||
* hydration bug this app's push-subscribe-toggle.tsx fixed in Phase 7).
|
||||
*/
|
||||
export function useInstallPrompt() {
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [installed, setInstalled] = useState(false);
|
||||
const [ios, setIos] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Async chain, not a direct setState call in the effect body — same
|
||||
// pattern push-subscribe-toggle.tsx uses (see that file's own
|
||||
// comment) to satisfy react-hooks/set-state-in-effect while still
|
||||
// reading browser-only APIs that can't run during SSR.
|
||||
void Promise.resolve().then(() => {
|
||||
setInstalled(isStandalone());
|
||||
setIos(isIos());
|
||||
});
|
||||
|
||||
const onBeforeInstallPrompt = (event: Event) => {
|
||||
event.preventDefault();
|
||||
setDeferredPrompt(event as BeforeInstallPromptEvent);
|
||||
};
|
||||
const onInstalled = () => {
|
||||
setInstalled(true);
|
||||
setDeferredPrompt(null);
|
||||
};
|
||||
window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt);
|
||||
window.addEventListener("appinstalled", onInstalled);
|
||||
return () => {
|
||||
window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt);
|
||||
window.removeEventListener("appinstalled", onInstalled);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const promptInstall = async (): Promise<void> => {
|
||||
if (!deferredPrompt) return;
|
||||
await deferredPrompt.prompt();
|
||||
await deferredPrompt.userChoice;
|
||||
setDeferredPrompt(null);
|
||||
};
|
||||
|
||||
return { installed, ios, canPromptNatively: !!deferredPrompt, promptInstall };
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
StaffPermissionDeniedError,
|
||||
assertAppointmentInBusiness,
|
||||
assertOwnAppointment,
|
||||
assertOwnNote,
|
||||
} from "../authorization";
|
||||
|
||||
const staff = { id: "staff_1", businessId: "biz_1" };
|
||||
const otherStaff = { id: "staff_2", businessId: "biz_1" };
|
||||
|
||||
describe("assertAppointmentInBusiness", () => {
|
||||
it("allows an appointment in the staff member's own business", () => {
|
||||
expect(() =>
|
||||
assertAppointmentInBusiness({ businessId: "biz_1", staffId: "staff_9" }, staff),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("denies an appointment from a different business, even if the staffId happened to match", () => {
|
||||
expect(() =>
|
||||
assertAppointmentInBusiness({ businessId: "biz_2", staffId: "staff_1" }, staff),
|
||||
).toThrow(StaffPermissionDeniedError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertOwnAppointment", () => {
|
||||
it("allows the assigned staff member to manage their own appointment", () => {
|
||||
expect(() =>
|
||||
assertOwnAppointment({ businessId: "biz_1", staffId: "staff_1" }, staff),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("denies another staff member in the same business from managing it", () => {
|
||||
expect(() =>
|
||||
assertOwnAppointment({ businessId: "biz_1", staffId: "staff_1" }, otherStaff),
|
||||
).toThrow(StaffPermissionDeniedError);
|
||||
});
|
||||
|
||||
it("denies a cross-business appointment even for the matching staffId", () => {
|
||||
expect(() =>
|
||||
assertOwnAppointment({ businessId: "biz_2", staffId: "staff_1" }, staff),
|
||||
).toThrow(StaffPermissionDeniedError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertOwnNote", () => {
|
||||
it("allows the author to act on their own note", () => {
|
||||
expect(() => assertOwnNote({ authorStaffId: "staff_1" }, staff)).not.toThrow();
|
||||
});
|
||||
|
||||
it("denies a staff member editing another staff member's note", () => {
|
||||
expect(() => assertOwnNote({ authorStaffId: "staff_1" }, otherStaff)).toThrow(
|
||||
StaffPermissionDeniedError,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,182 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { cancelBooking, rescheduleBooking } from "@/services/scheduling/booking.service";
|
||||
import {
|
||||
checkIn,
|
||||
complete,
|
||||
confirm,
|
||||
getAppointmentById,
|
||||
markNoShow,
|
||||
start,
|
||||
} from "@/services/scheduling/appointment.service";
|
||||
import { awardStampForCompletedAppointment } from "@/services/loyalty/loyalty.service";
|
||||
import { notifyAppointmentNoShow, notifyBookingApproved, notifyBookingCompleted } from "@/services/scheduling/booking-notifications";
|
||||
import { syncAppointmentSafely } from "@/services/calendar/calendar-sync.service";
|
||||
import { assertOwnAppointment, StaffPermissionDeniedError } from "../authorization";
|
||||
|
||||
/**
|
||||
* Every status transition here does exactly three things: load the
|
||||
* appointment, assert it's this staff member's own (in their own
|
||||
* business), and delegate to AppointmentService/BookingService for the
|
||||
* actual transition — the transition rules themselves (which statuses can
|
||||
* move to which) live entirely in AppointmentService's own state machine,
|
||||
* never re-implemented here. Completing an appointment additionally calls
|
||||
* LoyaltyService, since "complete this visit" and "award a stamp for it"
|
||||
* are two different domain responsibilities this layer is allowed to
|
||||
* orchestrate together — see docs/phase-5-summary.md.
|
||||
*/
|
||||
|
||||
export interface StaffAppointmentActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
loyaltyStampAwarded?: boolean;
|
||||
}
|
||||
|
||||
async function loadOwnAppointment(appointmentId: string) {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff)
|
||||
return { staff: null, appointment: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
const appointment = await getAppointmentById(prisma, appointmentId);
|
||||
if (!appointment) return { staff, appointment: null, error: "Appointment not found." } as const;
|
||||
|
||||
try {
|
||||
assertOwnAppointment(appointment, staff);
|
||||
} catch (error) {
|
||||
if (error instanceof StaffPermissionDeniedError)
|
||||
return { staff, appointment: null, error: error.message } as const;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { staff, appointment, error: null } as const;
|
||||
}
|
||||
|
||||
export async function confirmAppointmentAction(
|
||||
appointmentId: string,
|
||||
): Promise<StaffAppointmentActionResult> {
|
||||
const { appointment, error } = await loadOwnAppointment(appointmentId);
|
||||
if (!appointment) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await confirm(prisma, appointmentId);
|
||||
if (!result.success) {
|
||||
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
await notifyBookingApproved(prisma, result.appointment);
|
||||
// Approval is the first point a PENDING appointment becomes syncable —
|
||||
// see calendar-sync.service.ts's own policy comment.
|
||||
await syncAppointmentSafely(prisma, appointmentId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function checkInAppointmentAction(
|
||||
appointmentId: string,
|
||||
): Promise<StaffAppointmentActionResult> {
|
||||
const { appointment, error } = await loadOwnAppointment(appointmentId);
|
||||
if (!appointment) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await checkIn(prisma, appointmentId);
|
||||
return result.success
|
||||
? { success: true }
|
||||
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
|
||||
export async function startAppointmentAction(
|
||||
appointmentId: string,
|
||||
): Promise<StaffAppointmentActionResult> {
|
||||
const { appointment, error } = await loadOwnAppointment(appointmentId);
|
||||
if (!appointment) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await start(prisma, appointmentId);
|
||||
return result.success
|
||||
? { success: true }
|
||||
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
|
||||
export async function completeAppointmentAction(
|
||||
appointmentId: string,
|
||||
): Promise<StaffAppointmentActionResult> {
|
||||
const { staff, appointment, error } = await loadOwnAppointment(appointmentId);
|
||||
if (!appointment || !staff) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await complete(prisma, appointmentId);
|
||||
if (!result.success) {
|
||||
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
await notifyBookingCompleted(prisma, result.appointment);
|
||||
|
||||
const loyaltyResult = await awardStampForCompletedAppointment(prisma, {
|
||||
appointmentId,
|
||||
staffId: staff.id,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
loyaltyStampAwarded: loyaltyResult.success && !loyaltyResult.alreadyAwarded,
|
||||
};
|
||||
}
|
||||
|
||||
export async function markNoShowAction(
|
||||
appointmentId: string,
|
||||
): Promise<StaffAppointmentActionResult> {
|
||||
const { appointment, error } = await loadOwnAppointment(appointmentId);
|
||||
if (!appointment) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await markNoShow(prisma, appointmentId);
|
||||
if (!result.success) {
|
||||
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
await notifyAppointmentNoShow(prisma, result.appointment);
|
||||
await syncAppointmentSafely(prisma, appointmentId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function cancelStaffAppointmentAction(params: {
|
||||
appointmentId: string;
|
||||
reason?: string;
|
||||
}): Promise<StaffAppointmentActionResult> {
|
||||
const { staff, appointment, error } = await loadOwnAppointment(params.appointmentId);
|
||||
if (!appointment || !staff) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await cancelBooking(prisma, {
|
||||
appointmentId: params.appointmentId,
|
||||
cancelledByUserId: staff.user.id,
|
||||
reason: params.reason,
|
||||
});
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't cancel this appointment." };
|
||||
}
|
||||
await syncAppointmentSafely(prisma, params.appointmentId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function rescheduleStaffAppointmentAction(params: {
|
||||
appointmentId: string;
|
||||
staffId?: string;
|
||||
startAt: string;
|
||||
}): Promise<StaffAppointmentActionResult> {
|
||||
const { appointment, error } = await loadOwnAppointment(params.appointmentId);
|
||||
if (!appointment) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await rescheduleBooking(prisma, {
|
||||
appointmentId: params.appointmentId,
|
||||
staffId: params.staffId,
|
||||
startAt: new Date(params.startAt),
|
||||
});
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't reschedule this appointment." };
|
||||
}
|
||||
// Covers both a plain reschedule and a barber reassignment (staffId
|
||||
// change) — syncAppointmentSafely itself detects and cancels a stale
|
||||
// event under the previous staff member's connection when the staffId
|
||||
// changed (see that function's own comment).
|
||||
await syncAppointmentSafely(prisma, params.appointmentId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
function friendlyTransitionMessage(code?: string): string {
|
||||
if (code === "INVALID_STATUS_TRANSITION")
|
||||
return "This appointment can't move to that status right now — someone may have already updated it.";
|
||||
if (code === "APPOINTMENT_NOT_FOUND") return "We couldn't find that appointment.";
|
||||
return "Something went wrong. Please try again.";
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { createTimeOff } from "@/services/staff/staff.service";
|
||||
|
||||
export interface BlockTimeConflict {
|
||||
id: string;
|
||||
customerName: string;
|
||||
serviceNames: string;
|
||||
startAt: string;
|
||||
}
|
||||
|
||||
export interface BlockTimeActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: BlockTimeConflict[];
|
||||
}
|
||||
|
||||
/** One-off "Block Time" from the calendar — represented as a partial-day TimeOff row, see schema.prisma and docs/phase-5-summary.md. */
|
||||
export async function blockTimeAction(input: {
|
||||
date: string;
|
||||
startMinutes: number;
|
||||
endMinutes: number;
|
||||
reason: string;
|
||||
acknowledgeConflicts?: boolean;
|
||||
}): Promise<BlockTimeActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff to do that." };
|
||||
|
||||
const result = await createTimeOff(prisma, {
|
||||
staffId: staff.id,
|
||||
businessTimezone: staff.business.timezone,
|
||||
input: {
|
||||
startDate: input.date,
|
||||
endDate: input.date,
|
||||
reason: input.reason.trim() || "Blocked",
|
||||
startMinutes: input.startMinutes,
|
||||
endMinutes: input.endMinutes,
|
||||
},
|
||||
acknowledgeConflicts: input.acknowledgeConflicts,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Something went wrong. Please try again.",
|
||||
conflicts: result.conflicts?.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { createBreak, deleteBreak, updateBreak } from "@/services/staff/staff.service";
|
||||
import type { BreakInput } from "@/services/staff/staff.service";
|
||||
|
||||
export interface BreakConflictItem {
|
||||
id: string;
|
||||
customerName: string;
|
||||
serviceNames: string;
|
||||
startAt: string;
|
||||
}
|
||||
|
||||
export interface BreakActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: BreakConflictItem[];
|
||||
}
|
||||
|
||||
function mapConflicts(conflicts: { id: string; customer: { firstName: string; lastName: string }; services: { service: { name: string } }[]; startAt: Date }[]): BreakConflictItem[] {
|
||||
return conflicts.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createBreakAction(input: BreakInput, acknowledgeConflicts?: boolean): Promise<BreakActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await createBreak(prisma, { staffId: staff.id, businessTimezone: staff.business.timezone, input, acknowledgeConflicts });
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't create this break.", conflicts: mapConflicts(result.conflicts) };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateBreakAction(breakId: string, input: BreakInput, acknowledgeConflicts?: boolean): Promise<BreakActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await updateBreak(prisma, { breakId, staffId: staff.id, businessTimezone: staff.business.timezone, input, acknowledgeConflicts });
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this break.", conflicts: mapConflicts(result.conflicts) };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteBreakAction(breakId: string): Promise<BreakActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await deleteBreak(prisma, { breakId, staffId: staff.id });
|
||||
return result.success ? { success: true } : { success: false, error: "Couldn't delete this break." };
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { assertOwnNote, StaffPermissionDeniedError } from "../authorization";
|
||||
|
||||
/**
|
||||
* Note permission model (documented per the Phase 5 brief's "choose a
|
||||
* sensible permission model and document it"):
|
||||
* - Any staff member in the business may ADD a note — notes are shared
|
||||
* context for whoever's working with this customer next, not private.
|
||||
* - Only the AUTHOR may edit or delete their own note — prevents one
|
||||
* staff member silently rewriting or erasing another's observation.
|
||||
* - Edits and deletes are recorded to the existing AuditLog table (see
|
||||
* schema.prisma — built in Phase 2, unused until now) rather than a new
|
||||
* edit-history table; CustomerNote.updatedAt alone tells a reader "this
|
||||
* was edited", AuditLog keeps the discarded content in case it matters
|
||||
* later. Notes are never silently overwritten with no trace.
|
||||
*/
|
||||
|
||||
export interface CustomerNoteActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function addCustomerNoteAction(params: {
|
||||
customerId: string;
|
||||
body: string;
|
||||
}): Promise<CustomerNoteActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
if (!params.body.trim()) return { success: false, error: "Note can't be empty." };
|
||||
|
||||
await prisma.customerNote.create({
|
||||
data: { customerId: params.customerId, authorStaffId: staff.id, body: params.body.trim() },
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateCustomerNoteAction(params: {
|
||||
noteId: string;
|
||||
body: string;
|
||||
}): Promise<CustomerNoteActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
if (!params.body.trim()) return { success: false, error: "Note can't be empty." };
|
||||
|
||||
const existing = await prisma.customerNote.findUnique({ where: { id: params.noteId } });
|
||||
if (!existing) return { success: false, error: "Note not found." };
|
||||
|
||||
try {
|
||||
assertOwnNote(existing, staff);
|
||||
} catch (error) {
|
||||
if (error instanceof StaffPermissionDeniedError)
|
||||
return { success: false, error: error.message };
|
||||
throw error;
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.customerNote.update({
|
||||
where: { id: params.noteId },
|
||||
data: { body: params.body.trim() },
|
||||
}),
|
||||
prisma.auditLog.create({
|
||||
data: {
|
||||
actorUserId: staff.user.id,
|
||||
action: "UPDATE",
|
||||
entityType: "CustomerNote",
|
||||
entityId: params.noteId,
|
||||
metadata: { previousBody: existing.body },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteCustomerNoteAction(params: {
|
||||
noteId: string;
|
||||
}): Promise<CustomerNoteActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const existing = await prisma.customerNote.findUnique({ where: { id: params.noteId } });
|
||||
if (!existing) return { success: false, error: "Note not found." };
|
||||
|
||||
try {
|
||||
assertOwnNote(existing, staff);
|
||||
} catch (error) {
|
||||
if (error instanceof StaffPermissionDeniedError)
|
||||
return { success: false, error: error.message };
|
||||
throw error;
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.auditLog.create({
|
||||
data: {
|
||||
actorUserId: staff.user.id,
|
||||
action: "DELETE",
|
||||
entityType: "CustomerNote",
|
||||
entityId: params.noteId,
|
||||
metadata: { body: existing.body, customerId: existing.customerId },
|
||||
},
|
||||
}),
|
||||
prisma.customerNote.delete({ where: { id: params.noteId } }),
|
||||
]);
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { addStamp, correctStamp } from "@/services/loyalty/loyalty.service";
|
||||
|
||||
export interface LoyaltyActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
rewardCreated?: boolean;
|
||||
}
|
||||
|
||||
export async function addLoyaltyStampAction(params: {
|
||||
customerId: string;
|
||||
amount: number;
|
||||
appointmentId?: string;
|
||||
reason?: string;
|
||||
}): Promise<LoyaltyActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await addStamp(prisma, {
|
||||
customerId: params.customerId,
|
||||
businessId: staff.businessId,
|
||||
amount: params.amount,
|
||||
staffId: staff.id,
|
||||
appointmentId: params.appointmentId,
|
||||
reason: params.reason,
|
||||
});
|
||||
|
||||
return result.success
|
||||
? { success: true, rewardCreated: result.rewardCreated }
|
||||
: { success: false, error: result.validation.issues[0]?.message ?? "Couldn't add a stamp." };
|
||||
}
|
||||
|
||||
export async function correctLoyaltyStampAction(params: {
|
||||
loyaltyAccountId: string;
|
||||
amount: number;
|
||||
reason: string;
|
||||
}): Promise<LoyaltyActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await correctStamp(prisma, {
|
||||
loyaltyAccountId: params.loyaltyAccountId,
|
||||
amount: params.amount,
|
||||
staffId: staff.id,
|
||||
reason: params.reason,
|
||||
});
|
||||
|
||||
return result.success
|
||||
? { success: true }
|
||||
: {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Couldn't correct that stamp.",
|
||||
};
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { NotificationType } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { setNotificationPreference } from "@/services/notifications/notification.service";
|
||||
import type { PreferenceCellState } from "@/features/staff/components/notification-preferences-form";
|
||||
import { updateStaffProfile } from "@/services/staff/staff.service";
|
||||
|
||||
export interface ProfileActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function updateProfileAction(input: {
|
||||
name: string;
|
||||
phone: string;
|
||||
image: string;
|
||||
bio: string;
|
||||
isBookableOnline: boolean;
|
||||
}): Promise<ProfileActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
if (!input.name.trim()) return { success: false, error: "Name can't be empty." };
|
||||
|
||||
const result = await updateStaffProfile(prisma, {
|
||||
staffId: staff.id,
|
||||
name: input.name.trim(),
|
||||
phone: input.phone.trim(),
|
||||
image: input.image.trim(),
|
||||
bio: input.bio.trim(),
|
||||
isBookableOnline: input.isBookableOnline,
|
||||
});
|
||||
|
||||
return result.success ? { success: true } : { success: false, error: "Couldn't save your profile." };
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyed purely by userId, not a StaffProfile — a Super Administrator (who
|
||||
* has no StaffProfile, see isSuperAdmin) still has their own notification
|
||||
* preferences, so this checks session auth directly rather than requiring
|
||||
* getActingStaff() the way the staff-specific actions above do.
|
||||
*/
|
||||
export async function saveNotificationPreferencesAction(
|
||||
cells: PreferenceCellState[],
|
||||
): Promise<ProfileActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { success: false, error: "You must be signed in." };
|
||||
|
||||
for (const cell of cells) {
|
||||
await setNotificationPreference(prisma, {
|
||||
userId: session.user.id,
|
||||
type: NotificationType.STAFF_APPOINTMENT_REMINDER,
|
||||
channel: cell.channel,
|
||||
isEnabled: cell.isEnabled,
|
||||
});
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import {
|
||||
clearStaffDraft,
|
||||
readStaffDraft,
|
||||
writeLastBookingReference,
|
||||
writeStaffDraft,
|
||||
} from "@/lib/booking-draft/cookie";
|
||||
import { guestDetailsSchema } from "@/lib/booking-draft/schema";
|
||||
import { getFirstFriendlyMessage } from "@/features/booking/error-messages";
|
||||
import { resolveCustomerForBooking } from "@/services/customer/customer.service";
|
||||
import { createBooking } from "@/services/scheduling/booking.service";
|
||||
import { validateBookingSlot } from "@/services/scheduling/availability.service";
|
||||
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
||||
import { assertOwnAppointment, StaffPermissionDeniedError } from "../authorization";
|
||||
|
||||
/**
|
||||
* The staff "book for customer" flow — collects input across a few steps
|
||||
* (same signed-draft-cookie pattern as the customer flow, see
|
||||
* src/lib/booking-draft), then hands off entirely to BookingService at
|
||||
* confirm time. No availability or validation logic lives here; every
|
||||
* check either already happened in AvailabilityService (via
|
||||
* validateBookingSlot, for a better error message earlier) or happens
|
||||
* again, authoritatively, inside BookingService.createBooking itself.
|
||||
*/
|
||||
|
||||
export interface StaffBookingStepResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string[] | undefined>;
|
||||
}
|
||||
|
||||
export async function selectExistingCustomerAction(customerId: string): Promise<void> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) redirect("/home");
|
||||
|
||||
const draft = await readStaffDraft(staff.businessId);
|
||||
await writeStaffDraft({ ...draft, businessId: staff.businessId, customerId, guest: undefined });
|
||||
redirect("/staff/book/services");
|
||||
}
|
||||
|
||||
export async function selectNewGuestCustomerAction(
|
||||
input: unknown,
|
||||
): Promise<StaffBookingStepResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const parsed = guestDetailsSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Please check the highlighted fields.",
|
||||
fieldErrors: parsed.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
|
||||
const draft = await readStaffDraft(staff.businessId);
|
||||
await writeStaffDraft({
|
||||
...draft,
|
||||
businessId: staff.businessId,
|
||||
guest: parsed.data,
|
||||
customerId: undefined,
|
||||
});
|
||||
redirect("/staff/book/services");
|
||||
}
|
||||
|
||||
export async function saveStaffServicesStepAction(input: {
|
||||
serviceIds: string[];
|
||||
}): Promise<StaffBookingStepResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
if (input.serviceIds.length === 0)
|
||||
return { success: false, error: "Please select at least one service." };
|
||||
|
||||
const draft = await readStaffDraft(staff.businessId);
|
||||
if (!draft.customerId && !draft.guest) redirect("/staff/book");
|
||||
|
||||
const previousServiceIds = [...draft.serviceIds].sort().join(",");
|
||||
const nextServiceIds = [...input.serviceIds].sort().join(",");
|
||||
const servicesChanged = previousServiceIds !== nextServiceIds;
|
||||
|
||||
await writeStaffDraft({
|
||||
...draft,
|
||||
businessId: staff.businessId,
|
||||
serviceIds: input.serviceIds,
|
||||
...(servicesChanged ? { staffId: undefined, date: undefined, startAt: undefined } : {}),
|
||||
});
|
||||
redirect("/staff/book/barber-time");
|
||||
}
|
||||
|
||||
export async function saveStaffBarberTimeStepAction(input: {
|
||||
staffId?: string;
|
||||
date: string;
|
||||
startAt: string;
|
||||
}): Promise<StaffBookingStepResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const draft = await readStaffDraft(staff.businessId);
|
||||
if (draft.serviceIds.length === 0)
|
||||
return { success: false, error: "Please select a service first." };
|
||||
|
||||
// Only pre-validate a specific staff pick — "any available barber" is
|
||||
// resolved by BookingService itself at confirm time, same reasoning as
|
||||
// the customer flow's equivalent step (see draft.ts).
|
||||
if (input.staffId) {
|
||||
const { validation } = await validateBookingSlot(prisma, {
|
||||
businessId: staff.businessId,
|
||||
serviceIds: draft.serviceIds,
|
||||
staffId: input.staffId,
|
||||
startAt: new Date(input.startAt),
|
||||
});
|
||||
if (!validation.valid) return { success: false, error: getFirstFriendlyMessage(validation) };
|
||||
}
|
||||
|
||||
await writeStaffDraft({
|
||||
...draft,
|
||||
staffId: input.staffId,
|
||||
date: input.date,
|
||||
startAt: input.startAt,
|
||||
});
|
||||
redirect("/staff/book/review");
|
||||
}
|
||||
|
||||
export async function saveStaffNotesAction(notes: string): Promise<StaffBookingStepResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
const draft = await readStaffDraft(staff.businessId);
|
||||
await writeStaffDraft({ ...draft, notes: notes || undefined });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function confirmStaffBookingAction(notes?: string): Promise<StaffBookingStepResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const draft = await readStaffDraft(staff.businessId);
|
||||
if (draft.serviceIds.length === 0 || !draft.startAt) {
|
||||
return { success: false, error: "Booking details are incomplete — please start again." };
|
||||
}
|
||||
if (!draft.customerId && !draft.guest) {
|
||||
return { success: false, error: "Please select or add a customer first." };
|
||||
}
|
||||
|
||||
let customerId: string;
|
||||
try {
|
||||
if (draft.customerId) {
|
||||
customerId = draft.customerId;
|
||||
} else {
|
||||
const resolved = await resolveCustomerForBooking(prisma, {
|
||||
businessId: staff.businessId,
|
||||
guest: draft.guest,
|
||||
});
|
||||
customerId = resolved.customerId;
|
||||
}
|
||||
} catch {
|
||||
return { success: false, error: "We couldn't resolve the customer. Please try again." };
|
||||
}
|
||||
|
||||
const result = await createBooking(prisma, {
|
||||
businessId: staff.businessId,
|
||||
customerId,
|
||||
serviceIds: draft.serviceIds,
|
||||
staffId: draft.staffId,
|
||||
startAt: new Date(draft.startAt),
|
||||
notes: notes || draft.notes,
|
||||
createdByUserId: staff.user.id,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: getFirstFriendlyMessage(result.validation) };
|
||||
}
|
||||
|
||||
await writeLastBookingReference({ appointmentId: result.appointment.id });
|
||||
await clearStaffDraft();
|
||||
redirect(`/staff/appointments/${result.appointment.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Book Follow-Up" — preselects customer, services, and barber from a
|
||||
* past appointment, then sends the barber straight to the services step
|
||||
* (skipping customer selection, since it's already known). Prices and
|
||||
* availability are recalculated fresh at every later step; nothing here
|
||||
* copies the old appointment's snapshot values.
|
||||
*/
|
||||
export async function startStaffFollowUpAction(appointmentId: string): Promise<void> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) redirect("/home");
|
||||
|
||||
const appointment = await getAppointmentById(prisma, appointmentId);
|
||||
if (!appointment) redirect("/staff/current");
|
||||
|
||||
try {
|
||||
assertOwnAppointment(appointment, staff);
|
||||
} catch (error) {
|
||||
if (error instanceof StaffPermissionDeniedError) redirect("/staff/current");
|
||||
throw error;
|
||||
}
|
||||
|
||||
const activeServiceIds = appointment.services
|
||||
.filter((line) => line.service.isActive)
|
||||
.map((line) => line.serviceId);
|
||||
const staffId =
|
||||
appointment.staff.status === "ACTIVE" && appointment.staff.isBookableOnline
|
||||
? appointment.staffId
|
||||
: undefined;
|
||||
|
||||
await clearStaffDraft();
|
||||
await writeStaffDraft({
|
||||
businessId: appointment.businessId,
|
||||
serviceIds: activeServiceIds,
|
||||
staffId,
|
||||
customerId: appointment.customerId,
|
||||
followUpFromAppointmentId: appointment.id,
|
||||
});
|
||||
|
||||
redirect("/staff/book/services");
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { createTimeOff, deleteTimeOff, updateTimeOff } from "@/services/staff/staff.service";
|
||||
import type { TimeOffInput } from "@/services/staff/staff.service";
|
||||
|
||||
export interface TimeOffConflictItem {
|
||||
id: string;
|
||||
customerName: string;
|
||||
serviceNames: string;
|
||||
startAt: string;
|
||||
}
|
||||
|
||||
export interface TimeOffActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: TimeOffConflictItem[];
|
||||
}
|
||||
|
||||
function mapConflicts(conflicts: { id: string; customer: { firstName: string; lastName: string }; services: { service: { name: string } }[]; startAt: Date }[]): TimeOffConflictItem[] {
|
||||
return conflicts.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Whole-day time off only — the settings editor doesn't handle partial-day blocks; that's the calendar's "Block Time" action (see block-time.ts). */
|
||||
export async function createTimeOffAction(
|
||||
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
|
||||
acknowledgeConflicts?: boolean,
|
||||
): Promise<TimeOffActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await createTimeOff(prisma, {
|
||||
staffId: staff.id,
|
||||
businessTimezone: staff.business.timezone,
|
||||
input,
|
||||
acknowledgeConflicts,
|
||||
});
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't add this time off.", conflicts: mapConflicts(result.conflicts) };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateTimeOffAction(
|
||||
timeOffId: string,
|
||||
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
|
||||
acknowledgeConflicts?: boolean,
|
||||
): Promise<TimeOffActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await updateTimeOff(prisma, {
|
||||
timeOffId,
|
||||
staffId: staff.id,
|
||||
businessTimezone: staff.business.timezone,
|
||||
input,
|
||||
acknowledgeConflicts,
|
||||
});
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this time off.", conflicts: mapConflicts(result.conflicts) };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteTimeOffAction(timeOffId: string): Promise<TimeOffActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await deleteTimeOff(prisma, { timeOffId, staffId: staff.id });
|
||||
return result.success ? { success: true } : { success: false, error: "Couldn't remove this time off." };
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { setWorkingHours } from "@/services/staff/staff.service";
|
||||
import type { WorkingHoursDayInput } from "@/services/staff/staff.service";
|
||||
|
||||
export interface WorkingHoursConflictItem {
|
||||
id: string;
|
||||
customerName: string;
|
||||
serviceNames: string;
|
||||
startAt: string;
|
||||
}
|
||||
|
||||
export interface WorkingHoursActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: WorkingHoursConflictItem[];
|
||||
}
|
||||
|
||||
export async function saveWorkingHoursAction(
|
||||
days: WorkingHoursDayInput[],
|
||||
acknowledgeConflicts = false,
|
||||
): Promise<WorkingHoursActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await setWorkingHours(prisma, {
|
||||
staffId: staff.id,
|
||||
businessTimezone: staff.business.timezone,
|
||||
days,
|
||||
acknowledgeConflicts,
|
||||
});
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Couldn't save working hours.",
|
||||
conflicts: result.conflicts.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
Loaded 100 of 690 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user