PHP Rebuild
CI / ci (push) Canceled after 0s

Initial PHP rebuild of the app
This commit is contained in:
Stefan Lazic committed 2026-09-05 08:21:33 +01:00
1 parent 8a2b211069
commit 2ef3654841
690 files changed
+33944 -12046

No files matched your search

@@ -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,
};
-191
View File
@@ -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),
};
}
-155
View File
@@ -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 };
}
-394
View File
@@ -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 };
}
-79
View File
@@ -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 };
}
-144
View File
@@ -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 };
}
-58
View File
@@ -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 };
}
-104
View File
@@ -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 };
}
-271
View File
@@ -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),
};
}
-146
View File
@@ -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 };
}
-25
View File
@@ -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&apos;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 &amp; 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 &amp; 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 &amp; 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 &amp; 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&apos;s open hours. They won&apos;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}&apos;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&apos;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&apos;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&apos;t set a password yet. Email delivery isn&apos;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 &amp; 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&apos;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&apos;t cancel them — they&apos;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>
);
}