Initial PHP rebuild of the app
This commit is contained in:
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,
|
||||
};
|
||||
@@ -1,191 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization";
|
||||
import { cancelBooking, rescheduleBooking } from "@/services/scheduling/booking.service";
|
||||
import {
|
||||
checkIn,
|
||||
complete,
|
||||
confirm,
|
||||
getAppointmentById,
|
||||
markNoShow,
|
||||
start,
|
||||
} from "@/services/scheduling/appointment.service";
|
||||
import { awardStampForCompletedAppointment } from "@/services/loyalty/loyalty.service";
|
||||
import { notifyAppointmentNoShow, notifyBookingApproved, notifyBookingBarberChanged, notifyBookingCompleted } from "@/services/scheduling/booking-notifications";
|
||||
import { syncAppointmentSafely } from "@/services/calendar/calendar-sync.service";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import type { StaffAppointmentActionResult } from "@/features/staff/actions/appointment-actions";
|
||||
|
||||
/**
|
||||
* Owner equivalents of src/features/staff/actions/appointment-actions.ts —
|
||||
* same AppointmentService/BookingService/LoyaltyService calls, but scoped
|
||||
* to "any appointment in the owner's own business" (assertAppointmentInBusiness)
|
||||
* behind a MANAGE_ALL_APPOINTMENTS permission check, instead of "my own
|
||||
* appointments only". Barber permissions are untouched; this is a
|
||||
* parallel, explicit path, not a weakening of assertOwnAppointment — see
|
||||
* docs/phase-6-summary.md, "Permission and business-isolation rules".
|
||||
*/
|
||||
|
||||
async function loadOwnerAppointment(appointmentId: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return { owner: null, appointment: null, error: "You must be signed in." } as const;
|
||||
}
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_ALL_APPOINTMENTS);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) {
|
||||
return { owner: null, appointment: null, error: error.message } as const;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!owner) return { owner: null, appointment: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
const appointment = await getAppointmentById(prisma, appointmentId);
|
||||
if (!appointment) return { owner, appointment: null, error: "Appointment not found." } as const;
|
||||
|
||||
try {
|
||||
assertAppointmentInBusiness(appointment, { id: owner.id, businessId: owner.businessId });
|
||||
} catch (error) {
|
||||
if (error instanceof StaffPermissionDeniedError) {
|
||||
return { owner, appointment: null, error: error.message } as const;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { owner, appointment, error: null } as const;
|
||||
}
|
||||
|
||||
function friendlyTransitionMessage(code?: string): string {
|
||||
if (code === "INVALID_STATUS_TRANSITION") {
|
||||
return "This appointment can't move to that status right now — someone may have already updated it.";
|
||||
}
|
||||
if (code === "APPOINTMENT_NOT_FOUND") return "We couldn't find that appointment.";
|
||||
return "Something went wrong. Please try again.";
|
||||
}
|
||||
|
||||
export async function ownerConfirmAppointmentAction(appointmentId: string): Promise<StaffAppointmentActionResult> {
|
||||
const { appointment, error } = await loadOwnerAppointment(appointmentId);
|
||||
if (!appointment) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await confirm(prisma, appointmentId);
|
||||
if (!result.success) {
|
||||
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
await notifyBookingApproved(prisma, result.appointment);
|
||||
await syncAppointmentSafely(prisma, appointmentId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function ownerCheckInAppointmentAction(appointmentId: string): Promise<StaffAppointmentActionResult> {
|
||||
const { appointment, error } = await loadOwnerAppointment(appointmentId);
|
||||
if (!appointment) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await checkIn(prisma, appointmentId);
|
||||
return result.success
|
||||
? { success: true }
|
||||
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
|
||||
export async function ownerStartAppointmentAction(appointmentId: string): Promise<StaffAppointmentActionResult> {
|
||||
const { appointment, error } = await loadOwnerAppointment(appointmentId);
|
||||
if (!appointment) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await start(prisma, appointmentId);
|
||||
return result.success
|
||||
? { success: true }
|
||||
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
|
||||
export async function ownerCompleteAppointmentAction(appointmentId: string): Promise<StaffAppointmentActionResult> {
|
||||
const { owner, appointment, error } = await loadOwnerAppointment(appointmentId);
|
||||
if (!appointment || !owner) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await complete(prisma, appointmentId);
|
||||
if (!result.success) {
|
||||
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
await notifyBookingCompleted(prisma, result.appointment);
|
||||
|
||||
const loyaltyResult = await awardStampForCompletedAppointment(prisma, { appointmentId, staffId: appointment.staffId });
|
||||
return { success: true, loyaltyStampAwarded: loyaltyResult.success && !loyaltyResult.alreadyAwarded };
|
||||
}
|
||||
|
||||
export async function ownerMarkNoShowAction(appointmentId: string): Promise<StaffAppointmentActionResult> {
|
||||
const { appointment, error } = await loadOwnerAppointment(appointmentId);
|
||||
if (!appointment) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await markNoShow(prisma, appointmentId);
|
||||
if (!result.success) {
|
||||
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
await notifyAppointmentNoShow(prisma, result.appointment);
|
||||
await syncAppointmentSafely(prisma, appointmentId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function ownerCancelAppointmentAction(params: {
|
||||
appointmentId: string;
|
||||
reason?: string;
|
||||
}): Promise<StaffAppointmentActionResult> {
|
||||
const { owner, appointment, error } = await loadOwnerAppointment(params.appointmentId);
|
||||
if (!appointment || !owner) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await cancelBooking(prisma, {
|
||||
appointmentId: params.appointmentId,
|
||||
cancelledByUserId: owner.userId,
|
||||
reason: params.reason,
|
||||
});
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't cancel this appointment." };
|
||||
}
|
||||
await syncAppointmentSafely(prisma, params.appointmentId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export interface OwnerRescheduleResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Reassignment is just a reschedule with a different staffId and the same slot — see docs/phase-6-summary.md, "Appointment reassignment approach". Also covers a genuine date/time reschedule when startAt changes. */
|
||||
export async function ownerRescheduleAppointmentAction(params: {
|
||||
appointmentId: string;
|
||||
staffId?: string;
|
||||
startAt: string;
|
||||
}): Promise<OwnerRescheduleResult> {
|
||||
const { owner, appointment, error } = await loadOwnerAppointment(params.appointmentId);
|
||||
if (!appointment || !owner) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const previousStaffId = appointment.staffId;
|
||||
|
||||
const result = await rescheduleBooking(prisma, {
|
||||
appointmentId: params.appointmentId,
|
||||
staffId: params.staffId,
|
||||
startAt: new Date(params.startAt),
|
||||
});
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this appointment." };
|
||||
}
|
||||
|
||||
if (params.staffId && params.staffId !== previousStaffId) {
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "APPOINTMENT_REASSIGNED",
|
||||
entityType: "Appointment",
|
||||
entityId: params.appointmentId,
|
||||
metadata: { fromStaffId: previousStaffId, toStaffId: params.staffId },
|
||||
});
|
||||
await notifyBookingBarberChanged(prisma, result.appointment);
|
||||
}
|
||||
|
||||
await syncAppointmentSafely(prisma, params.appointmentId);
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import { updateBookingRule } from "@/services/scheduling/booking-rule.service";
|
||||
import type { BookingRuleInput } from "@/services/scheduling/booking-rule.service";
|
||||
|
||||
export interface BookingRuleActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function updateBookingRuleAction(input: BookingRuleInput): Promise<BookingRuleActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { success: false, error: "You must be signed in." };
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_BOOKING_RULES);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) return { success: false, error: error.message };
|
||||
throw error;
|
||||
}
|
||||
|
||||
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!owner) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await updateBookingRule(prisma, { businessId: owner.businessId, input });
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't save booking rules." };
|
||||
}
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "BOOKING_RULES_UPDATED",
|
||||
entityType: "BookingRule",
|
||||
entityId: owner.businessId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { ownerCreateBreakAction, ownerDeleteBreakAction, ownerUpdateBreakAction } from "./breaks";
|
||||
import type { BreakActionSet } from "@/features/staff/components/break-form-sheet";
|
||||
|
||||
/**
|
||||
* Plain module (no "use server") assembling the owner break Server Actions
|
||||
* into the `BreakActionSet` shape BreaksList/BreakFormSheet expect, bound
|
||||
* to one target staff member.
|
||||
*
|
||||
* Uses `.bind(null, staffId)`, not an arrow-function wrapper — a Client
|
||||
* Component can only receive an actual Server Action reference as a prop;
|
||||
* React's Flight serializer specifically recognizes `.bind()` on a Server
|
||||
* Action (Next's own documented pattern for passing extra arguments) and
|
||||
* reconstructs the call server-side, but a plain closure like `(input) =>
|
||||
* ownerCreateBreakAction(staffId, input)` is just an ordinary function with
|
||||
* no such registration and throws "Functions cannot be passed directly to
|
||||
* Client Components" at render time — found empirically in Phase 7 (see
|
||||
* docs/phase-7-summary.md) when this file's first version used arrow
|
||||
* wrappers instead.
|
||||
*/
|
||||
export function createOwnerBreakActionSet(staffId: string): BreakActionSet {
|
||||
return {
|
||||
create: ownerCreateBreakAction.bind(null, staffId),
|
||||
update: ownerUpdateBreakAction.bind(null, staffId),
|
||||
remove: ownerDeleteBreakAction.bind(null, staffId),
|
||||
};
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import { createBreak, deleteBreak, updateBreak } from "@/services/staff/staff.service";
|
||||
import type { BreakInput } from "@/services/staff/staff.service";
|
||||
import { notifyStaffScheduleChanged } from "@/services/staff/staff-notifications";
|
||||
|
||||
export interface BreakConflictItem {
|
||||
id: string;
|
||||
customerName: string;
|
||||
serviceNames: string;
|
||||
startAt: string;
|
||||
}
|
||||
|
||||
export interface BreakActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: BreakConflictItem[];
|
||||
}
|
||||
|
||||
function mapConflicts(conflicts: { id: string; customer: { firstName: string; lastName: string }; services: { service: { name: string } }[]; startAt: Date }[]): BreakConflictItem[] {
|
||||
return conflicts.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async function requireStaffManagerFor(staffId: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { owner: null, error: "You must be signed in." } as const;
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) return { owner: null, error: error.message } as const;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const owner = await prisma.staffProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
include: { business: { select: { name: true, timezone: true } }, user: { select: { name: true } } },
|
||||
});
|
||||
if (!owner) return { owner: null, target: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
const target = await prisma.staffProfile.findFirst({ where: { id: staffId, businessId: owner.businessId } });
|
||||
if (!target) return { owner: null, target: null, error: "Staff member not found." } as const;
|
||||
|
||||
return { owner, target, error: null } as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner-on-behalf-of-staff break management — same StaffService calls the
|
||||
* barber's own editor uses, targeting an explicit `staffId` argument
|
||||
* instead of the caller's own. Assembled into BreaksList/BreakFormSheet's
|
||||
* `actions` prop shape by the plain (non-"use server") factory in
|
||||
* break-action-set.ts — a "use server" file may only export async
|
||||
* functions, never a plain object or a function that returns one
|
||||
* synchronously, so the per-staffId binding has to live outside this file
|
||||
* (see docs/phase-6-summary.md for where this constraint was first found).
|
||||
*/
|
||||
export async function ownerCreateBreakAction(
|
||||
staffId: string,
|
||||
input: BreakInput,
|
||||
acknowledgeConflicts?: boolean,
|
||||
): Promise<BreakActionResult> {
|
||||
const { owner, target, error } = await requireStaffManagerFor(staffId);
|
||||
if (!owner || !target) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await createBreak(prisma, {
|
||||
staffId,
|
||||
businessTimezone: owner.business.timezone,
|
||||
input,
|
||||
acknowledgeConflicts,
|
||||
});
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't create this break.", conflicts: mapConflicts(result.conflicts) };
|
||||
}
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_BREAK_CHANGED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
});
|
||||
await notifyStaffScheduleChanged(prisma, {
|
||||
businessId: owner.businessId,
|
||||
staffUserId: target.userId,
|
||||
changedByName: owner.user.name ?? "The business owner",
|
||||
whatChanged: "your breaks",
|
||||
viewSchedulePath: "/staff/settings/breaks",
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function ownerUpdateBreakAction(
|
||||
staffId: string,
|
||||
breakId: string,
|
||||
input: BreakInput,
|
||||
acknowledgeConflicts?: boolean,
|
||||
): Promise<BreakActionResult> {
|
||||
const { owner, target, error } = await requireStaffManagerFor(staffId);
|
||||
if (!owner || !target) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await updateBreak(prisma, {
|
||||
breakId,
|
||||
staffId,
|
||||
businessTimezone: owner.business.timezone,
|
||||
input,
|
||||
acknowledgeConflicts,
|
||||
});
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this break.", conflicts: mapConflicts(result.conflicts) };
|
||||
}
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_BREAK_CHANGED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
});
|
||||
await notifyStaffScheduleChanged(prisma, {
|
||||
businessId: owner.businessId,
|
||||
staffUserId: target.userId,
|
||||
changedByName: owner.user.name ?? "The business owner",
|
||||
whatChanged: "your breaks",
|
||||
viewSchedulePath: "/staff/settings/breaks",
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function ownerDeleteBreakAction(staffId: string, breakId: string): Promise<BreakActionResult> {
|
||||
const { owner, target, error } = await requireStaffManagerFor(staffId);
|
||||
if (!owner || !target) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await deleteBreak(prisma, { breakId, staffId });
|
||||
if (!result.success) return { success: false, error: "Couldn't delete this break." };
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_BREAK_CHANGED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
});
|
||||
await notifyStaffScheduleChanged(prisma, {
|
||||
businessId: owner.businessId,
|
||||
staffUserId: target.userId,
|
||||
changedByName: owner.user.name ?? "The business owner",
|
||||
whatChanged: "your breaks",
|
||||
viewSchedulePath: "/staff/settings/breaks",
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import JSZip from "jszip";
|
||||
import { imageSize } from "image-size";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { isSuperAdmin } from "@/lib/auth/permissions";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { parseSplashScreens } from "@/lib/business/branding";
|
||||
import type { SplashScreenEntry } from "@/lib/business/branding";
|
||||
import { MEDIA_MAX_UPLOAD_BYTES, storeMediaFile } from "@/lib/media/media-storage";
|
||||
import { createMediaAsset } from "@/services/media/media.service";
|
||||
import {
|
||||
setOpeningHours,
|
||||
updateAppIconSettings,
|
||||
updateBusinessProfile,
|
||||
updateBusinessSettings,
|
||||
updateNavigationSettings,
|
||||
} from "@/services/business/business.service";
|
||||
import type {
|
||||
AppIconSettingsInput,
|
||||
BusinessLocationInput,
|
||||
BusinessProfileInput,
|
||||
BusinessSettingsInput,
|
||||
NavigationSettingsInput,
|
||||
OpeningHoursDayInput,
|
||||
} from "@/services/business/business.service";
|
||||
|
||||
export interface BusinessActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses getActingBusinessContext (not a raw StaffProfile lookup) so a
|
||||
* Super Administrator — who has no StaffProfile by design, see
|
||||
* docs/phase-9-summary.md — can manage Business Branding too.
|
||||
*/
|
||||
async function requireBusinessManager() {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { staff: null, error: "You must be signed in." } as const;
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) return { staff: null, error: error.message } as const;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) return { staff: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
return { staff: { businessId: context.businessId, userId: context.userId }, error: null } as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stricter than requireBusinessManager above — the App Icon & Home
|
||||
* Screen settings are Super Administrator only (not Owner), unlike every
|
||||
* other business-profile action in this file. Re-checked here rather
|
||||
* than trusting the page-level redirect, same "enforce again at the
|
||||
* action layer" rule the rest of the app follows.
|
||||
*/
|
||||
async function requireSuperAdminBusinessManager() {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { staff: null, error: "You must be signed in." } as const;
|
||||
|
||||
if (!(await isSuperAdmin(session.user.id))) {
|
||||
return { staff: null, error: "Only a Super Administrator can change these settings." } as const;
|
||||
}
|
||||
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) return { staff: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
return { staff: { businessId: context.businessId, userId: context.userId }, error: null } as const;
|
||||
}
|
||||
|
||||
export async function updateBusinessProfileAction(params: {
|
||||
profile: BusinessProfileInput;
|
||||
location: BusinessLocationInput;
|
||||
}): Promise<BusinessActionResult> {
|
||||
const { staff, error } = await requireBusinessManager();
|
||||
if (!staff) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await updateBusinessProfile(prisma, { businessId: staff.businessId, ...params });
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't save business details." };
|
||||
}
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: staff.userId,
|
||||
action: "BUSINESS_PROFILE_UPDATED",
|
||||
entityType: "Business",
|
||||
entityId: staff.businessId,
|
||||
metadata: { name: params.profile.name },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateBusinessSettingsAction(settings: BusinessSettingsInput): Promise<BusinessActionResult> {
|
||||
const { staff, error } = await requireBusinessManager();
|
||||
if (!staff) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
await updateBusinessSettings(prisma, { businessId: staff.businessId, settings });
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: staff.userId,
|
||||
action: "BUSINESS_SETTINGS_UPDATED",
|
||||
entityType: "BusinessSettings",
|
||||
entityId: staff.businessId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateAppIconSettingsAction(settings: AppIconSettingsInput): Promise<BusinessActionResult> {
|
||||
const { staff, error } = await requireSuperAdminBusinessManager();
|
||||
if (!staff) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
await updateAppIconSettings(prisma, { businessId: staff.businessId, settings });
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: staff.userId,
|
||||
action: "BUSINESS_APP_ICON_SETTINGS_UPDATED",
|
||||
entityType: "Business",
|
||||
entityId: staff.businessId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Icon+label vs icon-only bottom navigation — Super Administrator only, same rationale as updateAppIconSettingsAction (a change here is visible to every signed-in user immediately, not scoped to the actor). */
|
||||
export async function updateNavigationSettingsAction(
|
||||
settings: NavigationSettingsInput,
|
||||
): Promise<BusinessActionResult> {
|
||||
const { staff, error } = await requireSuperAdminBusinessManager();
|
||||
if (!staff) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
await updateNavigationSettings(prisma, { businessId: staff.businessId, settings });
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: staff.userId,
|
||||
action: "BUSINESS_NAVIGATION_SETTINGS_UPDATED",
|
||||
entityType: "Business",
|
||||
entityId: staff.businessId,
|
||||
metadata: { bottomNavShowLabels: settings.bottomNavShowLabels },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
function isPngBuffer(buffer: Buffer): boolean {
|
||||
return buffer.length >= PNG_SIGNATURE.length && buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts `<link rel="apple-touch-startup-image" media="..." href="...">`
|
||||
* tags from a generator's readme (e.g. progressier.com's PWA icon/splash
|
||||
* bundle) — attribute-order-agnostic within each tag, since the only
|
||||
* contract that matters is "this is an apple-touch-startup-image link
|
||||
* with a media query and an href", not the exact source formatting.
|
||||
*/
|
||||
function parseAppleTouchStartupImageTags(readmeText: string): { media: string; hrefBasename: string }[] {
|
||||
const tags = readmeText.match(/<link\b[^>]*rel="apple-touch-startup-image"[^>]*>/gi) ?? [];
|
||||
const results: { media: string; hrefBasename: string }[] = [];
|
||||
for (const tag of tags) {
|
||||
const media = tag.match(/media="([^"]*)"/i)?.[1];
|
||||
const href = tag.match(/href="([^"]*)"/i)?.[1];
|
||||
if (!media || !href) continue;
|
||||
results.push({ media, hrefBasename: href.split("/").pop() ?? href });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Validates, stores, and records one extracted ZIP image — null on anything that fails validation (caller turns that into a warning, not a hard failure). */
|
||||
async function uploadZipImage(
|
||||
entry: JSZip.JSZipObject,
|
||||
staff: { businessId: string; userId: string },
|
||||
): Promise<string | null> {
|
||||
const buffer = Buffer.from(await entry.async("nodebuffer"));
|
||||
if (!isPngBuffer(buffer) || buffer.length > MEDIA_MAX_UPLOAD_BYTES) return null;
|
||||
|
||||
let width: number | null = null;
|
||||
let height: number | null = null;
|
||||
try {
|
||||
const dimensions = imageSize(buffer);
|
||||
width = dimensions.width ?? null;
|
||||
height = dimensions.height ?? null;
|
||||
} catch {
|
||||
// Best-effort — an unrecognised/corrupt header shouldn't block the upload.
|
||||
}
|
||||
|
||||
const stored = await storeMediaFile({ buffer, mimeType: "image/png", category: "branding" });
|
||||
await createMediaAsset(prisma, {
|
||||
businessId: staff.businessId,
|
||||
url: stored.url,
|
||||
originalFilename: entry.name.split("/").pop() ?? null,
|
||||
mimeType: "image/png",
|
||||
sizeBytes: buffer.length,
|
||||
width,
|
||||
height,
|
||||
category: "branding",
|
||||
uploadedByUserId: staff.userId,
|
||||
});
|
||||
return stored.url;
|
||||
}
|
||||
|
||||
export interface ImportAppIconZipResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
warnings?: string[];
|
||||
applied?: {
|
||||
appIconUrl: string | null;
|
||||
icon192Url: string | null;
|
||||
icon512Url: string | null;
|
||||
maskableIcon192Url: string | null;
|
||||
maskableIcon512Url: string | null;
|
||||
splashScreens: SplashScreenEntry[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts a ZIP from a PWA icon/splash-screen generator (e.g.
|
||||
* progressier.com) — extracts `icon.png` (applied to the Apple touch icon
|
||||
* AND all four Android manifest icon fields, since the generator only
|
||||
* produces one square source image) and every splash screen referenced
|
||||
* by `readme.txt`'s `apple-touch-startup-image` tags (see
|
||||
* parseAppleTouchStartupImageTags), each uploaded through the same media
|
||||
* pipeline as a manual MediaPicker upload. Partial-success by design: a
|
||||
* missing readme, a missing icon, or a handful of unreadable entries
|
||||
* degrade to warnings rather than aborting the whole import — only "found
|
||||
* nothing at all usable" is a hard failure.
|
||||
*/
|
||||
export async function importAppIconZipAction(formData: FormData): Promise<ImportAppIconZipResult> {
|
||||
const { staff, error } = await requireSuperAdminBusinessManager();
|
||||
if (!staff) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) return { success: false, error: "No file provided." };
|
||||
if (!file.name.toLowerCase().endsWith(".zip")) {
|
||||
return { success: false, error: "Please upload a .zip file." };
|
||||
}
|
||||
|
||||
let zip: JSZip;
|
||||
try {
|
||||
zip = await JSZip.loadAsync(Buffer.from(await file.arrayBuffer()));
|
||||
} catch {
|
||||
return { success: false, error: "Couldn't read that file as a ZIP archive." };
|
||||
}
|
||||
|
||||
const entries = Object.values(zip.files).filter((entry) => !entry.dir);
|
||||
const entryByBasename = new Map(entries.map((entry) => [entry.name.split("/").pop() ?? entry.name, entry]));
|
||||
|
||||
const warnings: string[] = [];
|
||||
|
||||
const iconEntry = entries.find((entry) => /^icon\.png$/i.test(entry.name.split("/").pop() ?? ""));
|
||||
let iconUrl: string | null = null;
|
||||
if (iconEntry) {
|
||||
iconUrl = await uploadZipImage(iconEntry, staff);
|
||||
if (!iconUrl) warnings.push("Couldn't import icon.png — it may be too large or not a valid PNG.");
|
||||
} else {
|
||||
warnings.push("No icon.png found in the ZIP — icon fields were left unchanged.");
|
||||
}
|
||||
|
||||
const readmeEntry = entries.find((entry) => (entry.name.split("/").pop() ?? "").toLowerCase() === "readme.txt");
|
||||
const splashScreens: SplashScreenEntry[] = [];
|
||||
if (readmeEntry) {
|
||||
const tags = parseAppleTouchStartupImageTags(await readmeEntry.async("text"));
|
||||
if (tags.length === 0) {
|
||||
warnings.push("readme.txt was found but contained no apple-touch-startup-image tags.");
|
||||
}
|
||||
for (const { media, hrefBasename } of tags) {
|
||||
const imageEntry = entryByBasename.get(hrefBasename);
|
||||
if (!imageEntry) {
|
||||
warnings.push(`Referenced splash image "${hrefBasename}" wasn't found in the ZIP — skipped.`);
|
||||
continue;
|
||||
}
|
||||
const url = await uploadZipImage(imageEntry, staff);
|
||||
if (url) {
|
||||
splashScreens.push({ media, url });
|
||||
} else {
|
||||
warnings.push(`Couldn't import "${hrefBasename}" — skipped.`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warnings.push("No readme.txt found in the ZIP — splash screens were left unchanged.");
|
||||
}
|
||||
|
||||
if (!iconUrl && splashScreens.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Nothing usable was found in that ZIP — expected an icon.png and/or a readme.txt with apple-touch-startup-image tags.",
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
const settings: AppIconSettingsInput = {};
|
||||
if (iconUrl) {
|
||||
settings.appIconUrl = iconUrl;
|
||||
settings.icon192Url = iconUrl;
|
||||
settings.icon512Url = iconUrl;
|
||||
settings.maskableIcon192Url = iconUrl;
|
||||
settings.maskableIcon512Url = iconUrl;
|
||||
}
|
||||
if (splashScreens.length > 0) settings.splashScreens = splashScreens;
|
||||
|
||||
await updateAppIconSettings(prisma, { businessId: staff.businessId, settings });
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: staff.userId,
|
||||
action: "BUSINESS_APP_ICONS_IMPORTED_FROM_ZIP",
|
||||
entityType: "Business",
|
||||
entityId: staff.businessId,
|
||||
metadata: { iconImported: !!iconUrl, splashScreenCount: splashScreens.length, warningCount: warnings.length },
|
||||
});
|
||||
|
||||
const current = await prisma.business.findUnique({
|
||||
where: { id: staff.businessId },
|
||||
select: {
|
||||
appIconUrl: true,
|
||||
icon192Url: true,
|
||||
icon512Url: true,
|
||||
maskableIcon192Url: true,
|
||||
maskableIcon512Url: true,
|
||||
splashScreens: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
applied: {
|
||||
appIconUrl: current?.appIconUrl ?? null,
|
||||
icon192Url: current?.icon192Url ?? null,
|
||||
icon512Url: current?.icon512Url ?? null,
|
||||
maskableIcon192Url: current?.maskableIcon192Url ?? null,
|
||||
maskableIcon512Url: current?.maskableIcon512Url ?? null,
|
||||
splashScreens: parseSplashScreens(current?.splashScreens),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpeningHoursActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: { id: string; customerName: string; serviceNames: string; startAt: string; staffName: string }[];
|
||||
}
|
||||
|
||||
export async function updateOpeningHoursAction(
|
||||
days: OpeningHoursDayInput[],
|
||||
acknowledgeConflicts?: boolean,
|
||||
): Promise<OpeningHoursActionResult> {
|
||||
const { staff, error } = await requireBusinessManager();
|
||||
if (!staff) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const business = await prisma.business.findUnique({ where: { id: staff.businessId }, select: { timezone: true } });
|
||||
if (!business) return { success: false, error: "Business not found." };
|
||||
|
||||
const result = await setOpeningHours(prisma, {
|
||||
businessId: staff.businessId,
|
||||
businessTimezone: business.timezone,
|
||||
days,
|
||||
acknowledgeConflicts,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Couldn't save opening hours.",
|
||||
conflicts: result.conflicts.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
staffName: c.staff.user.name ?? "Staff",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: staff.userId,
|
||||
action: "BUSINESS_HOURS_UPDATED",
|
||||
entityType: "BusinessHours",
|
||||
entityId: staff.businessId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { createTimeOff } from "@/services/staff/staff.service";
|
||||
|
||||
export interface OwnerBlockTimeConflict {
|
||||
id: string;
|
||||
customerName: string;
|
||||
serviceNames: string;
|
||||
startAt: string;
|
||||
}
|
||||
|
||||
export interface OwnerBlockTimeResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: OwnerBlockTimeConflict[];
|
||||
}
|
||||
|
||||
/** Owner variant of Phase 5's blockTimeAction — same underlying StaffService.createTimeOff, just targeting any staff member in the business rather than only the caller's own schedule. See docs/phase-5-summary.md for why Block Time is a partial-day TimeOff row, not a separate model. */
|
||||
export async function ownerBlockTimeAction(input: {
|
||||
staffId: string;
|
||||
date: string;
|
||||
startMinutes: number;
|
||||
endMinutes: number;
|
||||
reason: string;
|
||||
acknowledgeConflicts?: boolean;
|
||||
}): Promise<OwnerBlockTimeResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { success: false, error: "You must be signed in." };
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) return { success: false, error: error.message };
|
||||
throw error;
|
||||
}
|
||||
|
||||
const owner = await prisma.staffProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
include: { business: { select: { timezone: true } } },
|
||||
});
|
||||
if (!owner) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const target = await prisma.staffProfile.findFirst({
|
||||
where: { id: input.staffId, businessId: owner.businessId },
|
||||
});
|
||||
if (!target) return { success: false, error: "Staff member not found." };
|
||||
|
||||
const result = await createTimeOff(prisma, {
|
||||
staffId: input.staffId,
|
||||
businessTimezone: owner.business.timezone,
|
||||
input: {
|
||||
startDate: input.date,
|
||||
endDate: input.date,
|
||||
reason: input.reason.trim() || "Blocked",
|
||||
startMinutes: input.startMinutes,
|
||||
endMinutes: input.endMinutes,
|
||||
},
|
||||
acknowledgeConflicts: input.acknowledgeConflicts,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Something went wrong. Please try again.",
|
||||
conflicts: result.conflicts?.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import {
|
||||
createCategory,
|
||||
createService,
|
||||
deleteCategory,
|
||||
reorderCategories,
|
||||
reorderServices,
|
||||
setCategoryActive,
|
||||
setServiceActive,
|
||||
updateCategory,
|
||||
updateService,
|
||||
} from "@/services/catalogue/service-catalogue.service";
|
||||
import type { CategoryInput, ServiceInput } from "@/services/catalogue/service-catalogue.service";
|
||||
|
||||
export interface CatalogueActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function requireServiceManager() {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { staff: null, error: "You must be signed in." } as const;
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_SERVICES);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) return { staff: null, error: error.message } as const;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!staff) return { staff: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
return { staff, error: null } as const;
|
||||
}
|
||||
|
||||
export async function createCategoryAction(input: CategoryInput): Promise<CatalogueActionResult> {
|
||||
const { staff: owner, error } = await requireServiceManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await createCategory(prisma, { businessId: owner.businessId, input });
|
||||
if (!result.success) return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't create category." };
|
||||
|
||||
await writeAuditLog(prisma, { actorUserId: owner.userId, action: "CATEGORY_CREATED", entityType: "ServiceCategory", entityId: result.category.id });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateCategoryAction(categoryId: string, input: CategoryInput): Promise<CatalogueActionResult> {
|
||||
const { staff: owner, error } = await requireServiceManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await updateCategory(prisma, { categoryId, businessId: owner.businessId, input });
|
||||
if (!result.success) return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update category." };
|
||||
|
||||
await writeAuditLog(prisma, { actorUserId: owner.userId, action: "CATEGORY_UPDATED", entityType: "ServiceCategory", entityId: categoryId });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function setCategoryActiveAction(categoryId: string, isActive: boolean): Promise<CatalogueActionResult> {
|
||||
const { staff: owner, error } = await requireServiceManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await setCategoryActive(prisma, { categoryId, businessId: owner.businessId, isActive });
|
||||
if (!result.success) return { success: false, error: "Category not found." };
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: isActive ? "CATEGORY_ACTIVATED" : "CATEGORY_DEACTIVATED",
|
||||
entityType: "ServiceCategory",
|
||||
entityId: categoryId,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteCategoryAction(categoryId: string): Promise<CatalogueActionResult> {
|
||||
const { staff: owner, error } = await requireServiceManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await deleteCategory(prisma, { categoryId, businessId: owner.businessId });
|
||||
if (!result.success) return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't delete category." };
|
||||
|
||||
await writeAuditLog(prisma, { actorUserId: owner.userId, action: "CATEGORY_DELETED", entityType: "ServiceCategory", entityId: categoryId });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function reorderCategoriesAction(orderedCategoryIds: string[]): Promise<CatalogueActionResult> {
|
||||
const { staff: owner, error } = await requireServiceManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
await reorderCategories(prisma, { businessId: owner.businessId, orderedCategoryIds });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function createServiceAction(input: ServiceInput): Promise<CatalogueActionResult> {
|
||||
const { staff: owner, error } = await requireServiceManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await createService(prisma, { businessId: owner.businessId, input });
|
||||
if (!result.success) return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't create service." };
|
||||
|
||||
await writeAuditLog(prisma, { actorUserId: owner.userId, action: "SERVICE_CREATED", entityType: "Service", entityId: result.service.id, metadata: { name: input.name } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateServiceAction(serviceId: string, input: ServiceInput): Promise<CatalogueActionResult> {
|
||||
const { staff: owner, error } = await requireServiceManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await updateService(prisma, { serviceId, businessId: owner.businessId, input });
|
||||
if (!result.success) return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update service." };
|
||||
|
||||
await writeAuditLog(prisma, { actorUserId: owner.userId, action: "SERVICE_UPDATED", entityType: "Service", entityId: serviceId });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function setServiceActiveAction(serviceId: string, isActive: boolean): Promise<CatalogueActionResult> {
|
||||
const { staff: owner, error } = await requireServiceManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await setServiceActive(prisma, { serviceId, businessId: owner.businessId, isActive });
|
||||
if (!result.success) return { success: false, error: "Service not found." };
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: isActive ? "SERVICE_ACTIVATED" : "SERVICE_DEACTIVATED",
|
||||
entityType: "Service",
|
||||
entityId: serviceId,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function reorderServicesAction(categoryId: string, orderedServiceIds: string[]): Promise<CatalogueActionResult> {
|
||||
const { staff: owner, error } = await requireServiceManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
await reorderServices(prisma, { businessId: owner.businessId, categoryId, orderedServiceIds });
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import { setEligibleServices, setEligibleStaff, updateProgrammeConfig } from "@/services/loyalty/loyalty.service";
|
||||
import type { ProgrammeConfigInput } from "@/services/loyalty/loyalty.service";
|
||||
|
||||
export interface LoyaltyConfigActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function requireLoyaltyManager() {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { staff: null, error: "You must be signed in." } as const;
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_LOYALTY_PROGRAMME);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) return { staff: null, error: error.message } as const;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!staff) return { staff: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
return { staff, error: null } as const;
|
||||
}
|
||||
|
||||
export async function updateLoyaltyConfigAction(params: {
|
||||
config: ProgrammeConfigInput;
|
||||
eligibleServiceIds: string[];
|
||||
eligibleStaffIds: string[];
|
||||
}): Promise<LoyaltyConfigActionResult> {
|
||||
const { staff: owner, error } = await requireLoyaltyManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await updateProgrammeConfig(prisma, { businessId: owner.businessId, input: params.config });
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't save loyalty settings." };
|
||||
}
|
||||
|
||||
const programme = await prisma.loyaltyProgramme.findUniqueOrThrow({ where: { businessId: owner.businessId } });
|
||||
await Promise.all([
|
||||
setEligibleServices(prisma, { loyaltyProgrammeId: programme.id, serviceIds: params.eligibleServiceIds }),
|
||||
setEligibleStaff(prisma, { loyaltyProgrammeId: programme.id, staffIds: params.eligibleStaffIds }),
|
||||
]);
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "LOYALTY_CONFIG_UPDATED",
|
||||
entityType: "LoyaltyProgramme",
|
||||
entityId: programme.id,
|
||||
metadata: { isEnabled: params.config.isEnabled, rewardType: params.config.rewardType },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { redeemReward } from "@/services/loyalty/loyalty.service";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
|
||||
export interface RedeemRewardResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
discountAmount?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redemption is an operational action a permitted barber does day to day
|
||||
* (not "configure the loyalty programme"), so it's gated on
|
||||
* MANAGE_LOYALTY_STAMPS — the same permission Phase 5 already grants to
|
||||
* the Barber role for adding/correcting stamps — rather than
|
||||
* MANAGE_LOYALTY_PROGRAMME, which is owner-only configuration. Both Owner
|
||||
* and Barber roles hold MANAGE_LOYALTY_STAMPS in the seed. See
|
||||
* docs/phase-6-summary.md, "Loyalty configuration and redemption approach".
|
||||
*/
|
||||
export async function redeemRewardAction(params: {
|
||||
rewardId: string;
|
||||
appointmentId: string;
|
||||
}): Promise<RedeemRewardResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { success: false, error: "You must be signed in." };
|
||||
|
||||
const allowed = await hasPermission(session.user.id, Permission.MANAGE_LOYALTY_STAMPS);
|
||||
if (!allowed) return { success: false, error: "You don't have permission to do that." };
|
||||
|
||||
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await redeemReward(prisma, {
|
||||
rewardId: params.rewardId,
|
||||
appointmentId: params.appointmentId,
|
||||
staffId: staff.id,
|
||||
businessId: staff.businessId,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't redeem this reward." };
|
||||
}
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: "LOYALTY_REWARD_REDEEMED",
|
||||
entityType: "LoyaltyReward",
|
||||
entityId: params.rewardId,
|
||||
metadata: { appointmentId: params.appointmentId, discountAmount: result.discountAmount },
|
||||
});
|
||||
|
||||
return { success: true, discountAmount: result.discountAmount };
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import { updateBusinessProfile } from "@/services/business/business.service";
|
||||
import { syncGoogleReviewsForBusiness } from "@/services/reviews/review-sync.service";
|
||||
|
||||
export interface ReviewsActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function requireReviewsManager() {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { businessId: null, userId: null, error: "You must be signed in." } as const;
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_REVIEWS);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) return { businessId: null, userId: null, error: error.message } as const;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) return { businessId: null, userId: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
return { businessId: context.businessId, userId: context.userId, error: null } as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reuses updateBusinessProfile (the same function Business Details uses)
|
||||
* rather than a parallel update path — googlePlaceId lives on Business.
|
||||
* Deliberately no format regex on googlePlaceId beyond trimming — Google's
|
||||
* own Place ID formats vary (place, postal code, plus-code IDs all look
|
||||
* different) and this app has no need to validate more than "the owner
|
||||
* typed something."
|
||||
*
|
||||
* Writes an audit log only when the Place ID actually changed; the next
|
||||
* sync (scheduled or manual, via syncReviewsNowAction) naturally picks up
|
||||
* whatever rating/count the new Place ID has.
|
||||
*/
|
||||
export async function updateReviewsSettingsAction(input: { googlePlaceId: string | null }): Promise<ReviewsActionResult> {
|
||||
const { businessId, userId, error } = await requireReviewsManager();
|
||||
if (!businessId) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const business = await prisma.business.findUnique({ where: { id: businessId } });
|
||||
if (!business) return { success: false, error: "Business not found." };
|
||||
const location = await prisma.businessLocation.findFirst({ where: { businessId, isPrimary: true } });
|
||||
if (!location) return { success: false, error: "Business location not found." };
|
||||
|
||||
const nextPlaceId = input.googlePlaceId?.trim() || null;
|
||||
const placeIdChanged = nextPlaceId !== business.googlePlaceId;
|
||||
|
||||
const profileResult = await updateBusinessProfile(prisma, {
|
||||
businessId,
|
||||
profile: {
|
||||
name: business.name,
|
||||
timezone: business.timezone,
|
||||
currency: business.currency,
|
||||
locale: business.locale,
|
||||
googlePlaceId: nextPlaceId,
|
||||
},
|
||||
location: {
|
||||
addressLine1: location.addressLine1,
|
||||
city: location.city,
|
||||
postalCode: location.postalCode,
|
||||
country: location.country,
|
||||
},
|
||||
});
|
||||
if (!profileResult.success) {
|
||||
return { success: false, error: profileResult.validation.issues[0]?.message ?? "Couldn't save the Google Place ID." };
|
||||
}
|
||||
|
||||
if (placeIdChanged) {
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: userId,
|
||||
action: "GOOGLE_PLACE_ID_CHANGED",
|
||||
entityType: "Business",
|
||||
entityId: businessId,
|
||||
metadata: { previousPlaceId: business.googlePlaceId, nextPlaceId },
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Manual "sync now" — the scheduled job (/api/cron/sync-google-reviews) covers the normal 24h cadence; this lets an owner see the rating/count immediately after entering a Place ID for the first time. */
|
||||
export async function syncReviewsNowAction(): Promise<ReviewsActionResult> {
|
||||
const { businessId, error } = await requireReviewsManager();
|
||||
if (!businessId) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const business = await prisma.business.findUnique({ where: { id: businessId }, select: { googlePlaceId: true } });
|
||||
if (!business?.googlePlaceId) {
|
||||
return { success: false, error: "Set a Google Place ID first." };
|
||||
}
|
||||
|
||||
const result = await syncGoogleReviewsForBusiness(prisma, { businessId, placeId: business.googlePlaceId });
|
||||
if (result.error) return { success: false, error: result.error };
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import {
|
||||
createStaffProfile,
|
||||
deactivateStaff,
|
||||
reactivateStaff,
|
||||
setStaffServiceEligibility,
|
||||
setWorkingHours,
|
||||
updateStaffAsOwner,
|
||||
} from "@/services/staff/staff.service";
|
||||
import type { CreateStaffInput, UpdateStaffAsOwnerInput, WorkingHoursDayInput } from "@/services/staff/staff.service";
|
||||
import { createInvitation, regenerateInvitation } from "@/services/staff/staff-invitation.service";
|
||||
import { notifyStaffScheduleChanged } from "@/services/staff/staff-notifications";
|
||||
|
||||
async function requireStaffManager() {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { staff: null, error: "You must be signed in." } as const;
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) return { staff: null, error: error.message } as const;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const staff = await prisma.staffProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
include: { business: { select: { timezone: true } }, user: { select: { name: true } } },
|
||||
});
|
||||
if (!staff) return { staff: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
return { staff, error: null } as const;
|
||||
}
|
||||
|
||||
export interface AddStaffResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
staffId?: string;
|
||||
inviteToken?: string;
|
||||
}
|
||||
|
||||
export async function addStaffAction(input: CreateStaffInput): Promise<AddStaffResult> {
|
||||
const { staff: owner, error } = await requireStaffManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await createStaffProfile(prisma, { businessId: owner.businessId, input });
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't add this staff member." };
|
||||
}
|
||||
|
||||
const invitation = await createInvitation(prisma, {
|
||||
businessId: owner.businessId,
|
||||
staffId: result.staff.id,
|
||||
email: input.email.toLowerCase(),
|
||||
invitedByUserId: owner.userId,
|
||||
});
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_ADDED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: result.staff.id,
|
||||
metadata: { name: input.name, email: input.email, roleId: input.roleId },
|
||||
});
|
||||
|
||||
return { success: true, staffId: result.staff.id, inviteToken: invitation.token };
|
||||
}
|
||||
|
||||
export interface StaffActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function updateStaffAction(staffId: string, input: UpdateStaffAsOwnerInput): Promise<StaffActionResult> {
|
||||
const { staff: owner, error } = await requireStaffManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await updateStaffAsOwner(prisma, { staffId, businessId: owner.businessId, input });
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this staff member." };
|
||||
}
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: input.roleId ? "STAFF_ROLE_CHANGED" : "STAFF_UPDATED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
metadata: { ...(input.roleId ? { roleId: input.roleId } : {}) },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export interface DeactivateStaffResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: { id: string; customerName: string; serviceNames: string; startAt: string }[];
|
||||
}
|
||||
|
||||
export async function deactivateStaffAction(staffId: string, acknowledgeConflicts?: boolean): Promise<DeactivateStaffResult> {
|
||||
const { staff: owner, error } = await requireStaffManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await deactivateStaff(prisma, { staffId, businessId: owner.businessId, acknowledgeConflicts });
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Couldn't deactivate this staff member.",
|
||||
conflicts: result.conflicts.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_DEACTIVATED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function reactivateStaffAction(staffId: string): Promise<StaffActionResult> {
|
||||
const { staff: owner, error } = await requireStaffManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await reactivateStaff(prisma, { staffId, businessId: owner.businessId });
|
||||
if (!result.success) return { success: false, error: "Couldn't reactivate this staff member." };
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_REACTIVATED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export interface SetEligibilityResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: { id: string; customerName: string; serviceNames: string; startAt: string }[];
|
||||
}
|
||||
|
||||
export async function setStaffServiceEligibilityAction(
|
||||
staffId: string,
|
||||
serviceIds: string[],
|
||||
acknowledgeConflicts?: boolean,
|
||||
): Promise<SetEligibilityResult> {
|
||||
const { staff: owner, error } = await requireStaffManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await setStaffServiceEligibility(prisma, { staffId, businessId: owner.businessId, serviceIds, acknowledgeConflicts });
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Couldn't update this staff member's services.",
|
||||
conflicts: result.conflicts.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_SERVICES_CHANGED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
metadata: { serviceIds },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export interface WorkingHoursConflictItem {
|
||||
id: string;
|
||||
customerName: string;
|
||||
serviceNames: string;
|
||||
startAt: string;
|
||||
}
|
||||
|
||||
export interface WorkingHoursActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: WorkingHoursConflictItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner-on-behalf-of-staff working hours — same StaffService.setWorkingHours
|
||||
* call the barber's own Phase 5 editor uses, just targeting a chosen
|
||||
* staffId instead of the caller. Now conflict-reviewed like every other
|
||||
* owner mutation that can strand an appointment (see
|
||||
* docs/phase-7-summary.md, "Working-hours conflict review") — this closes
|
||||
* the gap Phase 6 documented and deliberately left open.
|
||||
*/
|
||||
export async function updateStaffWorkingHoursAction(
|
||||
staffId: string,
|
||||
days: WorkingHoursDayInput[],
|
||||
acknowledgeConflicts = false,
|
||||
): Promise<WorkingHoursActionResult> {
|
||||
const { staff: owner, error } = await requireStaffManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const target = await prisma.staffProfile.findFirst({ where: { id: staffId, businessId: owner.businessId } });
|
||||
if (!target) return { success: false, error: "Staff member not found." };
|
||||
|
||||
const result = await setWorkingHours(prisma, {
|
||||
staffId,
|
||||
businessTimezone: owner.business.timezone,
|
||||
days,
|
||||
acknowledgeConflicts,
|
||||
});
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Couldn't save working hours.",
|
||||
conflicts: result.conflicts.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_WORKING_HOURS_CHANGED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
});
|
||||
await notifyStaffScheduleChanged(prisma, {
|
||||
businessId: owner.businessId,
|
||||
staffUserId: target.userId,
|
||||
changedByName: owner.user.name ?? "The business owner",
|
||||
whatChanged: "your working hours",
|
||||
viewSchedulePath: "/staff/settings/working-hours",
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export interface RegenerateInvitationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
inviteToken?: string;
|
||||
}
|
||||
|
||||
export async function regenerateInvitationAction(staffId: string): Promise<RegenerateInvitationResult> {
|
||||
const { staff: owner, error } = await requireStaffManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const invitation = await regenerateInvitation(prisma, { staffId, businessId: owner.businessId });
|
||||
if (!invitation) return { success: false, error: "No invitation found for this staff member." };
|
||||
|
||||
return { success: true, inviteToken: invitation.token };
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { ownerCreateTimeOffAction, ownerDeleteTimeOffAction, ownerUpdateTimeOffAction } from "./time-off";
|
||||
import type { TimeOffActionSet } from "@/features/staff/components/time-off-form-sheet";
|
||||
|
||||
/**
|
||||
* Plain module (no "use server") assembling the owner time-off Server
|
||||
* Actions into the `TimeOffActionSet` shape TimeOffList/TimeOffFormSheet
|
||||
* expect, bound to one target staff member — see break-action-set.ts for
|
||||
* why `.bind(null, staffId)` is required here instead of an arrow-function
|
||||
* wrapper.
|
||||
*/
|
||||
export function createOwnerTimeOffActionSet(staffId: string): TimeOffActionSet {
|
||||
return {
|
||||
create: ownerCreateTimeOffAction.bind(null, staffId),
|
||||
update: ownerUpdateTimeOffAction.bind(null, staffId),
|
||||
remove: ownerDeleteTimeOffAction.bind(null, staffId),
|
||||
};
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import { createTimeOff, deleteTimeOff, updateTimeOff } from "@/services/staff/staff.service";
|
||||
import type { TimeOffInput } from "@/services/staff/staff.service";
|
||||
import { notifyStaffScheduleChanged } from "@/services/staff/staff-notifications";
|
||||
|
||||
export interface TimeOffConflictItem {
|
||||
id: string;
|
||||
customerName: string;
|
||||
serviceNames: string;
|
||||
startAt: string;
|
||||
}
|
||||
|
||||
export interface TimeOffActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: TimeOffConflictItem[];
|
||||
}
|
||||
|
||||
function mapConflicts(conflicts: { id: string; customer: { firstName: string; lastName: string }; services: { service: { name: string } }[]; startAt: Date }[]): TimeOffConflictItem[] {
|
||||
return conflicts.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async function requireStaffManagerFor(staffId: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { owner: null, target: null, error: "You must be signed in." } as const;
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) return { owner: null, target: null, error: error.message } as const;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const owner = await prisma.staffProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
include: { business: { select: { name: true, timezone: true } }, user: { select: { name: true } } },
|
||||
});
|
||||
if (!owner) return { owner: null, target: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
const target = await prisma.staffProfile.findFirst({ where: { id: staffId, businessId: owner.businessId } });
|
||||
if (!target) return { owner: null, target: null, error: "Staff member not found." } as const;
|
||||
|
||||
return { owner, target, error: null } as const;
|
||||
}
|
||||
|
||||
/** Owner-on-behalf-of-staff time off — mirrors breaks.ts exactly, including why the per-staffId binding lives in the separate time-off-action-set.ts factory rather than here. */
|
||||
export async function ownerCreateTimeOffAction(
|
||||
staffId: string,
|
||||
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
|
||||
acknowledgeConflicts?: boolean,
|
||||
): Promise<TimeOffActionResult> {
|
||||
const { owner, target, error } = await requireStaffManagerFor(staffId);
|
||||
if (!owner || !target) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await createTimeOff(prisma, {
|
||||
staffId,
|
||||
businessTimezone: owner.business.timezone,
|
||||
input,
|
||||
acknowledgeConflicts,
|
||||
});
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't add this time off.", conflicts: mapConflicts(result.conflicts) };
|
||||
}
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_TIME_OFF_CHANGED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
});
|
||||
await notifyStaffScheduleChanged(prisma, {
|
||||
businessId: owner.businessId,
|
||||
staffUserId: target.userId,
|
||||
changedByName: owner.user.name ?? "The business owner",
|
||||
whatChanged: "your time off",
|
||||
viewSchedulePath: "/staff/settings/time-off",
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function ownerUpdateTimeOffAction(
|
||||
staffId: string,
|
||||
timeOffId: string,
|
||||
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
|
||||
acknowledgeConflicts?: boolean,
|
||||
): Promise<TimeOffActionResult> {
|
||||
const { owner, target, error } = await requireStaffManagerFor(staffId);
|
||||
if (!owner || !target) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await updateTimeOff(prisma, {
|
||||
timeOffId,
|
||||
staffId,
|
||||
businessTimezone: owner.business.timezone,
|
||||
input,
|
||||
acknowledgeConflicts,
|
||||
});
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this time off.", conflicts: mapConflicts(result.conflicts) };
|
||||
}
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_TIME_OFF_CHANGED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
});
|
||||
await notifyStaffScheduleChanged(prisma, {
|
||||
businessId: owner.businessId,
|
||||
staffUserId: target.userId,
|
||||
changedByName: owner.user.name ?? "The business owner",
|
||||
whatChanged: "your time off",
|
||||
viewSchedulePath: "/staff/settings/time-off",
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function ownerDeleteTimeOffAction(staffId: string, timeOffId: string): Promise<TimeOffActionResult> {
|
||||
const { owner, target, error } = await requireStaffManagerFor(staffId);
|
||||
if (!owner || !target) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await deleteTimeOff(prisma, { timeOffId, staffId });
|
||||
if (!result.success) return { success: false, error: "Couldn't remove this time off." };
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_TIME_OFF_CHANGED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
});
|
||||
await notifyStaffScheduleChanged(prisma, {
|
||||
businessId: owner.businessId,
|
||||
staffUserId: target.userId,
|
||||
changedByName: owner.user.name ?? "The business owner",
|
||||
whatChanged: "your time off",
|
||||
viewSchedulePath: "/staff/settings/time-off",
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { Permission } from "@/generated/prisma/enums";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
|
||||
/**
|
||||
* Per-action owner permission checks — the mirror of
|
||||
* src/features/staff/authorization.ts and src/features/booking/authorization.ts.
|
||||
* The `(owner)` route group layout already gates on MANAGE_STAFF as a
|
||||
* coarse "is this person any kind of owner" shell check, but that is not
|
||||
* enough on its own (see docs/phase-6-summary.md, "Permission and
|
||||
* business-isolation rules") — every owner Server Action re-derives the
|
||||
* session and checks the *specific* permission that action needs, since a
|
||||
* future non-Owner role could plausibly hold some but not all of these.
|
||||
*/
|
||||
|
||||
export class OwnerPermissionDeniedError extends Error {
|
||||
constructor(message = "You don't have permission to do that.") {
|
||||
super(message);
|
||||
this.name = "OwnerPermissionDeniedError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertOwnerPermission(userId: string, permission: Permission): Promise<void> {
|
||||
const allowed = await hasPermission(userId, permission);
|
||||
if (!allowed) throw new OwnerPermissionDeniedError();
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { addStaffAction } from "../actions/staff";
|
||||
|
||||
export interface AddStaffFormProps {
|
||||
roles: { id: string; name: string }[];
|
||||
services: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function AddStaffForm({ roles, services }: AddStaffFormProps) {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [image, setImage] = useState("");
|
||||
const [bio, setBio] = useState("");
|
||||
const [roleId, setRoleId] = useState(roles[0]?.id ?? "");
|
||||
const [serviceIds, setServiceIds] = useState<string[]>([]);
|
||||
const [isBookableOnline, setIsBookableOnline] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const toggleService = (id: string) => {
|
||||
setServiceIds((prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]));
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await addStaffAction({ name, email, phone: phone || undefined, image: image || undefined, bio: bio || undefined, roleId, serviceIds, isBookableOnline });
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Couldn't add this staff member.");
|
||||
return;
|
||||
}
|
||||
if (result.inviteToken) {
|
||||
setInviteLink(`${window.location.origin}/invite/accept?token=${result.inviteToken}`);
|
||||
} else {
|
||||
router.push(`/owner/team/${result.staffId}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const copyLink = async () => {
|
||||
if (!inviteLink) return;
|
||||
await navigator.clipboard.writeText(inviteLink);
|
||||
setCopied(true);
|
||||
};
|
||||
|
||||
if (inviteLink) {
|
||||
return (
|
||||
<div className="space-y-4 rounded-md border-line border bg-white p-6">
|
||||
<p className="text-sm font-bold">{name} has been added.</p>
|
||||
<p className="text-muted text-sm">
|
||||
Email delivery isn't wired up yet — copy this invite link and send it to them yourself. It lets them
|
||||
set their own password. Expires in 7 days.
|
||||
</p>
|
||||
<div className="border-line bg-stone break-all rounded-lg border p-3 text-xs">{inviteLink}</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={copyLink}>
|
||||
{copied ? "Copied" : "Copy Link"}
|
||||
</Button>
|
||||
<Button className="flex-1" onClick={() => router.push("/owner/team")}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<Input label="Name" required value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<Input label="Email" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<Input label="Phone (optional)" type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||
<Input label="Photo URL (optional)" value={image} onChange={(e) => setImage(e.target.value)} />
|
||||
<Textarea label="Bio (optional)" value={bio} onChange={(e) => setBio(e.target.value)} />
|
||||
<Select label="Role" value={roleId} onChange={(e) => setRoleId(e.target.value)}>
|
||||
{roles.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Switch label="Visible for online booking" checked={isBookableOnline} onCheckedChange={setIsBookableOnline} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border-line border bg-white p-6">
|
||||
<p className="mb-3 text-xs font-bold tracking-widest uppercase">Services Offered</p>
|
||||
<div className="space-y-3">
|
||||
{services.map((s) => (
|
||||
<Checkbox key={s.id} label={s.name} checked={serviceIds.includes(s.id)} onChange={() => toggleService(s.id)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" isLoading={isPending} onClick={submit}>
|
||||
Add Staff Member
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FileArchive } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ColorPicker } from "@/components/ui/color-picker";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { MediaPicker } from "@/features/media/components/media-picker";
|
||||
import type { SplashScreenEntry } from "@/lib/business/branding";
|
||||
import {
|
||||
importAppIconZipAction,
|
||||
updateAppIconSettingsAction,
|
||||
updateNavigationSettingsAction,
|
||||
} from "../actions/business";
|
||||
|
||||
export interface AppIconSettingsInitial {
|
||||
shortName: string;
|
||||
appIconUrl: string | null;
|
||||
themeColor: string | null;
|
||||
backgroundColor: string | null;
|
||||
icon192Url: string | null;
|
||||
icon512Url: string | null;
|
||||
maskableIcon192Url: string | null;
|
||||
maskableIcon512Url: string | null;
|
||||
splashScreens: SplashScreenEntry[];
|
||||
bottomNavShowLabels: boolean;
|
||||
}
|
||||
|
||||
export function AppIconSettingsForm({ initial }: { initial: AppIconSettingsInitial }) {
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState(initial);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [importWarnings, setImportWarnings] = useState<string[]>([]);
|
||||
const [importSuccessMessage, setImportSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
const set = <K extends keyof AppIconSettingsInitial>(key: K, value: AppIconSettingsInitial[K]) => {
|
||||
setSaved(false);
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleZipSelected = async (fileList: FileList | null) => {
|
||||
const file = fileList?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setImportError(null);
|
||||
setImportWarnings([]);
|
||||
setImportSuccessMessage(null);
|
||||
setIsImporting(true);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.set("file", file);
|
||||
const result = await importAppIconZipAction(formData);
|
||||
|
||||
setIsImporting(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
|
||||
if (!result.success || !result.applied) {
|
||||
setImportError(result.error ?? "Couldn't import that ZIP.");
|
||||
setImportWarnings(result.warnings ?? []);
|
||||
return;
|
||||
}
|
||||
|
||||
setForm((prev) => ({ ...prev, ...result.applied }));
|
||||
setImportWarnings(result.warnings ?? []);
|
||||
setImportSuccessMessage(
|
||||
`Imported${result.applied.appIconUrl ? " icon" : ""}${
|
||||
result.applied.appIconUrl && result.applied.splashScreens.length > 0 ? " and" : ""
|
||||
}${
|
||||
result.applied.splashScreens.length > 0
|
||||
? ` ${result.applied.splashScreens.length} splash screen${result.applied.splashScreens.length === 1 ? "" : "s"}`
|
||||
: ""
|
||||
}. Review below, then Save.`,
|
||||
);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const [iconResult, navigationResult] = await Promise.all([
|
||||
updateAppIconSettingsAction({
|
||||
shortName: form.shortName || null,
|
||||
appIconUrl: form.appIconUrl,
|
||||
themeColor: form.themeColor,
|
||||
backgroundColor: form.backgroundColor,
|
||||
icon192Url: form.icon192Url,
|
||||
icon512Url: form.icon512Url,
|
||||
maskableIcon192Url: form.maskableIcon192Url,
|
||||
maskableIcon512Url: form.maskableIcon512Url,
|
||||
splashScreens: form.splashScreens,
|
||||
}),
|
||||
updateNavigationSettingsAction({ bottomNavShowLabels: form.bottomNavShowLabels }),
|
||||
]);
|
||||
if (!iconResult.success) {
|
||||
setError(iconResult.error ?? "Couldn't save these settings.");
|
||||
return;
|
||||
}
|
||||
if (!navigationResult.success) {
|
||||
setError(navigationResult.error ?? "Couldn't save the navigation setting.");
|
||||
return;
|
||||
}
|
||||
setSaved(true);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Import from a Generator</h3>
|
||||
<div className="rounded-md border-line space-y-3 border bg-white p-6">
|
||||
<p className="text-muted text-xs">
|
||||
Generate a bundle at {" "}
|
||||
<span className="font-bold">progressier.com/pwa-icons-and-ios-splash-screen-generator</span> and upload
|
||||
the ZIP here — the icon and every splash screen it contains are imported automatically, no manual
|
||||
resizing needed.
|
||||
</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip,application/zip,application/x-zip-compressed"
|
||||
className="hidden"
|
||||
onChange={(e) => void handleZipSelected(e.target.files)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
isLoading={isImporting}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<FileArchive className="h-4 w-4" aria-hidden="true" />
|
||||
{isImporting ? "Importing…" : "Upload ZIP"}
|
||||
</Button>
|
||||
{importError && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{importError}
|
||||
</p>
|
||||
)}
|
||||
{importSuccessMessage && !importError && (
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
{importSuccessMessage}
|
||||
</p>
|
||||
)}
|
||||
{importWarnings.length > 0 && (
|
||||
<ul className="text-muted list-inside list-disc space-y-1 text-xs">
|
||||
{importWarnings.map((w, i) => (
|
||||
<li key={i}>{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Preview</h3>
|
||||
<div
|
||||
className="rounded-md border-line flex items-center gap-4 border p-6"
|
||||
style={{ backgroundColor: form.backgroundColor || undefined }}
|
||||
>
|
||||
<div className="h-16 w-16 shrink-0 overflow-hidden rounded-2xl border border-line bg-white shadow-sm">
|
||||
{form.icon512Url || form.appIconUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern as MediaPicker
|
||||
<img src={form.icon512Url || form.appIconUrl || ""} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-center text-lg font-bold text-white"
|
||||
style={{ backgroundColor: form.themeColor || "#111b15" }}
|
||||
>
|
||||
{(form.shortName || "A").charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold">{form.shortName || "App Name"}</p>
|
||||
<p className="text-muted text-xs">How the icon looks on a home screen</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Name & Colours</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<Input
|
||||
label="App Name"
|
||||
value={form.shortName}
|
||||
maxLength={30}
|
||||
onChange={(e) => set("shortName", e.target.value)}
|
||||
hint="Shown under the icon once someone adds this to their home screen. Keep it short."
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<ColorPicker label="Theme Colour" value={form.themeColor} onChange={(hex) => set("themeColor", hex)} />
|
||||
<ColorPicker
|
||||
label="Background Colour"
|
||||
value={form.backgroundColor}
|
||||
onChange={(hex) => set("backgroundColor", hex)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted text-xs">
|
||||
Theme and background colour set the status bar and Android splash screen shown while the app opens
|
||||
after installing.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Navigation</h3>
|
||||
<div className="rounded-md border-line border bg-white p-6">
|
||||
<Switch
|
||||
label="Show labels on the bottom bar"
|
||||
description="Turn off to show icons only, with no text underneath — applies to every customer and staff/owner bottom bar."
|
||||
checked={form.bottomNavShowLabels}
|
||||
onCheckedChange={(checked) => set("bottomNavShowLabels", checked)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">iOS Icon</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<MediaPicker
|
||||
label="Apple Touch Icon"
|
||||
value={form.appIconUrl}
|
||||
onChange={(url) => set("appIconUrl", url)}
|
||||
category="branding"
|
||||
hint="A square image works best — used when someone adds this to their home screen on iPhone/iPad."
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Android Icons</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<p className="text-muted text-xs">
|
||||
Android/Chrome installs read these directly — each falls back to a neutral icon on its own if left
|
||||
unset.
|
||||
</p>
|
||||
<MediaPicker
|
||||
label="Icon — 192×192"
|
||||
value={form.icon192Url}
|
||||
onChange={(url) => set("icon192Url", url)}
|
||||
category="branding"
|
||||
hint="Square, 192×192px."
|
||||
/>
|
||||
<MediaPicker
|
||||
label="Icon — 512×512"
|
||||
value={form.icon512Url}
|
||||
onChange={(url) => set("icon512Url", url)}
|
||||
category="branding"
|
||||
hint="Square, 512×512px."
|
||||
/>
|
||||
<MediaPicker
|
||||
label="Maskable Icon — 192×192"
|
||||
value={form.maskableIcon192Url}
|
||||
onChange={(url) => set("maskableIcon192Url", url)}
|
||||
category="branding"
|
||||
hint="Square, 192×192px. Keep the subject within the center ~80% — Android may crop this to a circle or rounded square."
|
||||
/>
|
||||
<MediaPicker
|
||||
label="Maskable Icon — 512×512"
|
||||
value={form.maskableIcon512Url}
|
||||
onChange={(url) => set("maskableIcon512Url", url)}
|
||||
category="branding"
|
||||
hint="Square, 512×512px. Same center-safe-zone rule as above."
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">iOS Splash Screens</h3>
|
||||
<div className="rounded-md border-line space-y-3 border bg-white p-6">
|
||||
{form.splashScreens.length === 0 ? (
|
||||
<p className="text-muted text-sm">No splash screens imported yet — upload a ZIP above.</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-bold">
|
||||
{form.splashScreens.length} splash screen{form.splashScreens.length === 1 ? "" : "s"} imported
|
||||
</p>
|
||||
<div className="grid grid-cols-6 gap-2 sm:grid-cols-8">
|
||||
{form.splashScreens.map((s, i) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern as MediaPicker
|
||||
<img
|
||||
key={i}
|
||||
src={s.url}
|
||||
alt=""
|
||||
className="border-line aspect-[9/19] w-full rounded border object-cover"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => set("splashScreens", [])}
|
||||
>
|
||||
Remove all splash screens
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{saved && !error && (
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
Saved — changes are live immediately.
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { updateBookingRuleAction } from "../actions/booking-rules";
|
||||
import type { BookingRuleInput } from "@/services/scheduling/booking-rule.service";
|
||||
|
||||
export function BookingRulesForm({ initial }: { initial: BookingRuleInput }) {
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState(initial);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const set = <K extends keyof BookingRuleInput>(key: K, value: BookingRuleInput[K]) => {
|
||||
setSaved(false);
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await updateBookingRuleAction(form);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Couldn't save booking rules.");
|
||||
return;
|
||||
}
|
||||
setSaved(true);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 lg:grid lg:grid-cols-2 lg:items-start lg:gap-6 lg:space-y-0">
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Booking Window</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<Input
|
||||
label="Minimum notice (minutes)"
|
||||
hint="How soon before a slot a booking can be made."
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.minNoticeMinutes}
|
||||
onChange={(e) => set("minNoticeMinutes", Number(e.target.value))}
|
||||
/>
|
||||
<Input
|
||||
label="Maximum advance booking (days)"
|
||||
hint="How far ahead customers can book."
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.maxAdvanceDays}
|
||||
onChange={(e) => set("maxAdvanceDays", Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Cancellation & Rescheduling</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<Input
|
||||
label="Free cancellation window (hours)"
|
||||
hint="Cancelling later than this may count as a late cancellation."
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.cancellationWindowHours}
|
||||
onChange={(e) => set("cancellationWindowHours", Number(e.target.value))}
|
||||
/>
|
||||
<Input
|
||||
label="Free reschedule window (hours)"
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.rescheduleWindowHours}
|
||||
onChange={(e) => set("rescheduleWindowHours", Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Scheduling</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<Select
|
||||
label="Appointment slot interval"
|
||||
value={String(form.slotIntervalMinutes)}
|
||||
onChange={(e) => set("slotIntervalMinutes", Number(e.target.value))}
|
||||
>
|
||||
{[15, 30, 45, 60].map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m} min
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Buffer before (min)"
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.bufferBeforeMinutes}
|
||||
onChange={(e) => set("bufferBeforeMinutes", Number(e.target.value))}
|
||||
/>
|
||||
<Input
|
||||
label="Buffer after (min)"
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.bufferAfterMinutes}
|
||||
onChange={(e) => set("bufferAfterMinutes", Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Max appointments per day (blank = no limit)"
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.maxAppointmentsPerDay ?? ""}
|
||||
onChange={(e) => set("maxAppointmentsPerDay", e.target.value ? Number(e.target.value) : null)}
|
||||
/>
|
||||
<Input
|
||||
label="Max appointments per staff member per day (blank = no limit)"
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.maxAppointmentsPerStaffPerDay ?? ""}
|
||||
onChange={(e) => set("maxAppointmentsPerStaffPerDay", e.target.value ? Number(e.target.value) : null)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Customer Options</h3>
|
||||
<div className="rounded-md border-line space-y-5 border bg-white p-6">
|
||||
<Switch
|
||||
label="Customers can choose a barber"
|
||||
description='If off, "Anyone" is assigned automatically.'
|
||||
checked={form.customerCanChooseStaff}
|
||||
onCheckedChange={(v) => set("customerCanChooseStaff", v)}
|
||||
/>
|
||||
<Switch
|
||||
label="Allow customers to book as guests"
|
||||
description="When enabled, customers can book without creating an account. When disabled, customers must sign in or create an account before confirming their booking."
|
||||
checked={form.allowGuestBooking}
|
||||
onCheckedChange={(v) => set("allowGuestBooking", v)}
|
||||
/>
|
||||
<Switch
|
||||
label="Require approval for new bookings"
|
||||
description="Bookings wait for staff confirmation before they're CONFIRMED."
|
||||
checked={form.requireApproval}
|
||||
onCheckedChange={(v) => set("requireApproval", v)}
|
||||
/>
|
||||
<Switch label="Require deposit" description="Coming soon — not enforced yet." checked={false} disabled />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="space-y-4 lg:col-span-2">
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{saved && !error && (
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
Saved — the customer booking flow uses these rules immediately. Existing appointments are unaffected.
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MediaPicker } from "@/features/media/components/media-picker";
|
||||
import { updateBusinessProfileAction, updateBusinessSettingsAction } from "../actions/business";
|
||||
|
||||
const TIMEZONES = ["Europe/London", "Europe/Paris", "Europe/Dublin", "America/New_York", "America/Los_Angeles"];
|
||||
const CURRENCIES = ["GBP", "USD", "EUR"];
|
||||
const LOCALES = ["en-GB", "en-US"];
|
||||
|
||||
export interface BusinessDetailsInitial {
|
||||
name: string;
|
||||
description: string;
|
||||
logoUrl: string | null;
|
||||
coverImageUrl: string | null;
|
||||
welcomeBackgroundDesktopUrl: string | null;
|
||||
welcomeBackgroundMobileUrl: string | null;
|
||||
faviconUrl: string | null;
|
||||
phone: string;
|
||||
email: string;
|
||||
website: string;
|
||||
instagramUrl: string;
|
||||
facebookUrl: string;
|
||||
timezone: string;
|
||||
currency: string;
|
||||
locale: string;
|
||||
addressLine1: string;
|
||||
addressLine2: string;
|
||||
city: string;
|
||||
region: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
cancellationPolicy: string;
|
||||
lateArrivalPolicy: string;
|
||||
bookingTerms: string;
|
||||
privacyPolicyUrl: string;
|
||||
}
|
||||
|
||||
export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsInitial }) {
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState(initial);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const set = <K extends keyof BusinessDetailsInitial>(key: K, value: BusinessDetailsInitial[K]) => {
|
||||
setSaved(false);
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const profileResult = await updateBusinessProfileAction({
|
||||
profile: {
|
||||
name: form.name,
|
||||
description: form.description || undefined,
|
||||
logoUrl: form.logoUrl,
|
||||
coverImageUrl: form.coverImageUrl,
|
||||
welcomeBackgroundDesktopUrl: form.welcomeBackgroundDesktopUrl,
|
||||
welcomeBackgroundMobileUrl: form.welcomeBackgroundMobileUrl,
|
||||
faviconUrl: form.faviconUrl,
|
||||
phone: form.phone || undefined,
|
||||
email: form.email || undefined,
|
||||
website: form.website || undefined,
|
||||
instagramUrl: form.instagramUrl || undefined,
|
||||
facebookUrl: form.facebookUrl || undefined,
|
||||
timezone: form.timezone,
|
||||
currency: form.currency,
|
||||
locale: form.locale,
|
||||
},
|
||||
location: {
|
||||
addressLine1: form.addressLine1,
|
||||
addressLine2: form.addressLine2 || undefined,
|
||||
city: form.city,
|
||||
region: form.region || undefined,
|
||||
postalCode: form.postalCode,
|
||||
country: form.country,
|
||||
},
|
||||
});
|
||||
if (!profileResult.success) {
|
||||
setError(profileResult.error ?? "Couldn't save business details.");
|
||||
return;
|
||||
}
|
||||
|
||||
const settingsResult = await updateBusinessSettingsAction({
|
||||
cancellationPolicy: form.cancellationPolicy || undefined,
|
||||
lateArrivalPolicy: form.lateArrivalPolicy || undefined,
|
||||
bookingTerms: form.bookingTerms || undefined,
|
||||
privacyPolicyUrl: form.privacyPolicyUrl || undefined,
|
||||
});
|
||||
if (!settingsResult.success) {
|
||||
setError(settingsResult.error ?? "Couldn't save business policies.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaved(true);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 lg:grid lg:grid-cols-2 lg:items-start lg:gap-6 lg:space-y-0">
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Profile</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<Input label="Business Name" required value={form.name} onChange={(e) => set("name", e.target.value)} />
|
||||
<MediaPicker label="Logo" value={form.logoUrl} onChange={(url) => set("logoUrl", url)} category="branding" />
|
||||
<Textarea label="Description" value={form.description} onChange={(e) => set("description", e.target.value)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Branding</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<MediaPicker
|
||||
label="Cover Image"
|
||||
value={form.coverImageUrl}
|
||||
onChange={(url) => set("coverImageUrl", url)}
|
||||
category="branding"
|
||||
hint="Shown at the top of the customer Home page."
|
||||
/>
|
||||
<MediaPicker
|
||||
label="Welcome Background — Desktop"
|
||||
value={form.welcomeBackgroundDesktopUrl}
|
||||
onChange={(url) => set("welcomeBackgroundDesktopUrl", url)}
|
||||
category="welcome"
|
||||
/>
|
||||
<MediaPicker
|
||||
label="Welcome Background — Mobile"
|
||||
value={form.welcomeBackgroundMobileUrl}
|
||||
onChange={(url) => set("welcomeBackgroundMobileUrl", url)}
|
||||
category="welcome"
|
||||
hint="Shown on the sign-in/welcome screen, before someone signs in."
|
||||
/>
|
||||
<MediaPicker
|
||||
label="Favicon"
|
||||
value={form.faviconUrl}
|
||||
onChange={(url) => set("faviconUrl", url)}
|
||||
category="branding"
|
||||
hint="Browser tab icon. Falls back to the app default when unset."
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Contact & Address</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<Input label="Street Address" required value={form.addressLine1} onChange={(e) => set("addressLine1", e.target.value)} />
|
||||
<Input label="Address Line 2 (optional)" value={form.addressLine2} onChange={(e) => set("addressLine2", e.target.value)} />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input label="City" required value={form.city} onChange={(e) => set("city", e.target.value)} />
|
||||
<Input label="Postcode" required value={form.postalCode} onChange={(e) => set("postalCode", e.target.value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input label="Region (optional)" value={form.region} onChange={(e) => set("region", e.target.value)} />
|
||||
<Input label="Country" value={form.country} onChange={(e) => set("country", e.target.value)} />
|
||||
</div>
|
||||
<Input label="Phone" type="tel" value={form.phone} onChange={(e) => set("phone", e.target.value)} />
|
||||
<Input label="Email" type="email" value={form.email} onChange={(e) => set("email", e.target.value)} />
|
||||
<Input label="Website" type="url" placeholder="https://" value={form.website} onChange={(e) => set("website", e.target.value)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Social Links</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<Input label="Instagram" placeholder="@yourshop" value={form.instagramUrl} onChange={(e) => set("instagramUrl", e.target.value)} />
|
||||
<Input label="Facebook" placeholder="facebook.com/yourshop" value={form.facebookUrl} onChange={(e) => set("facebookUrl", e.target.value)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Regional Settings</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<Select label="Time Zone" value={form.timezone} onChange={(e) => set("timezone", e.target.value)}>
|
||||
{TIMEZONES.map((tz) => (
|
||||
<option key={tz} value={tz}>
|
||||
{tz}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Select label="Currency" value={form.currency} onChange={(e) => set("currency", e.target.value)}>
|
||||
{CURRENCIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select label="Locale" value={form.locale} onChange={(e) => set("locale", e.target.value)}>
|
||||
{LOCALES.map((l) => (
|
||||
<option key={l} value={l}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Policies</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<Textarea
|
||||
label="Cancellation Policy"
|
||||
value={form.cancellationPolicy}
|
||||
onChange={(e) => set("cancellationPolicy", e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
label="Late Arrival Policy"
|
||||
value={form.lateArrivalPolicy}
|
||||
onChange={(e) => set("lateArrivalPolicy", e.target.value)}
|
||||
/>
|
||||
<Textarea label="Booking Terms" value={form.bookingTerms} onChange={(e) => set("bookingTerms", e.target.value)} />
|
||||
<Input
|
||||
label="Privacy Policy URL"
|
||||
type="url"
|
||||
placeholder="https://"
|
||||
value={form.privacyPolicyUrl}
|
||||
onChange={(e) => set("privacyPolicyUrl", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="space-y-4 lg:col-span-2">
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{saved && !error && (
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
Saved — changes are live for customers immediately.
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronUp, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
createCategoryAction,
|
||||
deleteCategoryAction,
|
||||
reorderCategoriesAction,
|
||||
reorderServicesAction,
|
||||
setCategoryActiveAction,
|
||||
setServiceActiveAction,
|
||||
updateCategoryAction,
|
||||
} from "../actions/catalogue";
|
||||
import { ServiceFormSheet } from "./service-form-sheet";
|
||||
import type { EditableService } from "./service-form-sheet";
|
||||
|
||||
export interface CatalogueService {
|
||||
id: string;
|
||||
categoryId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
imageUrl: string | null;
|
||||
durationMinutes: number;
|
||||
price: string;
|
||||
bufferBeforeMinutes: number;
|
||||
bufferAfterMinutes: number;
|
||||
isActive: boolean;
|
||||
allowOnlineBooking: boolean;
|
||||
}
|
||||
|
||||
export interface CatalogueCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
services: CatalogueService[];
|
||||
}
|
||||
|
||||
function formatPrice(price: string): string {
|
||||
return `£${Number(price).toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function CatalogueManager({ categories }: { categories: CatalogueCategory[] }) {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [newCategoryName, setNewCategoryName] = useState("");
|
||||
const [editingCategoryId, setEditingCategoryId] = useState<string | null>(null);
|
||||
const [editingCategoryName, setEditingCategoryName] = useState("");
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [sheetCategoryId, setSheetCategoryId] = useState<string>(categories[0]?.id ?? "");
|
||||
const [editingService, setEditingService] = useState<EditableService | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const run = (fn: () => Promise<{ success: boolean; error?: string }>) => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await fn();
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Something went wrong.");
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const addCategory = () => {
|
||||
if (!newCategoryName.trim()) return;
|
||||
run(async () => {
|
||||
const result = await createCategoryAction({ name: newCategoryName.trim() });
|
||||
if (result.success) setNewCategoryName("");
|
||||
return result;
|
||||
});
|
||||
};
|
||||
|
||||
const saveCategoryName = (id: string) => {
|
||||
if (!editingCategoryName.trim()) return;
|
||||
run(async () => {
|
||||
const result = await updateCategoryAction(id, { name: editingCategoryName.trim() });
|
||||
if (result.success) setEditingCategoryId(null);
|
||||
return result;
|
||||
});
|
||||
};
|
||||
|
||||
const moveCategory = (index: number, direction: -1 | 1) => {
|
||||
const targetIndex = index + direction;
|
||||
if (targetIndex < 0 || targetIndex >= categories.length) return;
|
||||
const reordered = [...categories];
|
||||
const [moved] = reordered.splice(index, 1);
|
||||
if (!moved) return;
|
||||
reordered.splice(targetIndex, 0, moved);
|
||||
run(() => reorderCategoriesAction(reordered.map((c) => c.id)));
|
||||
};
|
||||
|
||||
const moveService = (category: CatalogueCategory, index: number, direction: -1 | 1) => {
|
||||
const targetIndex = index + direction;
|
||||
if (targetIndex < 0 || targetIndex >= category.services.length) return;
|
||||
const reordered = [...category.services];
|
||||
const [moved] = reordered.splice(index, 1);
|
||||
if (!moved) return;
|
||||
reordered.splice(targetIndex, 0, moved);
|
||||
run(() => reorderServicesAction(category.id, reordered.map((s) => s.id)));
|
||||
};
|
||||
|
||||
const openNewService = (categoryId: string) => {
|
||||
setEditingService(null);
|
||||
setSheetCategoryId(categoryId);
|
||||
setSheetOpen(true);
|
||||
};
|
||||
const openEditService = (service: CatalogueService) => {
|
||||
setEditingService({
|
||||
id: service.id,
|
||||
categoryId: service.categoryId,
|
||||
name: service.name,
|
||||
description: service.description,
|
||||
imageUrl: service.imageUrl,
|
||||
durationMinutes: service.durationMinutes,
|
||||
price: service.price,
|
||||
bufferBeforeMinutes: service.bufferBeforeMinutes,
|
||||
bufferAfterMinutes: service.bufferAfterMinutes,
|
||||
allowOnlineBooking: service.allowOnlineBooking,
|
||||
});
|
||||
setSheetCategoryId(service.categoryId);
|
||||
setSheetOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{categories.map((category, categoryIndex) => (
|
||||
<section key={category.id}>
|
||||
<div className="mb-3 flex items-center justify-between px-1">
|
||||
{editingCategoryId === category.id ? (
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<Input value={editingCategoryName} onChange={(e) => setEditingCategoryName(e.target.value)} />
|
||||
<Button size="sm" isLoading={isPending} onClick={() => saveCategoryName(category.id)}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className={category.isActive ? "text-sm font-bold" : "text-muted text-sm font-bold line-through"}>
|
||||
{category.name}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingCategoryId(category.id);
|
||||
setEditingCategoryName(category.name);
|
||||
}}
|
||||
className="text-muted"
|
||||
aria-label={`Rename ${category.name}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveCategory(categoryIndex, -1)}
|
||||
disabled={categoryIndex === 0}
|
||||
aria-label={`Move ${category.name} up`}
|
||||
className="text-muted disabled:opacity-30"
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveCategory(categoryIndex, 1)}
|
||||
disabled={categoryIndex === categories.length - 1}
|
||||
aria-label={`Move ${category.name} down`}
|
||||
className="text-muted disabled:opacity-30"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<Switch
|
||||
checked={category.isActive}
|
||||
onCheckedChange={(checked) => run(() => setCategoryActiveAction(category.id, checked))}
|
||||
aria-label={`${category.name} active`}
|
||||
/>
|
||||
{category.services.length === 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => run(() => deleteCategoryAction(category.id))}
|
||||
className="text-danger"
|
||||
aria-label={`Delete ${category.name}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border-line divide-line divide-y border bg-white">
|
||||
{category.services.map((service, serviceIndex) => (
|
||||
<div key={service.id} className="flex items-center gap-3 px-5 py-4">
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveService(category, serviceIndex, -1)}
|
||||
disabled={serviceIndex === 0}
|
||||
aria-label={`Move ${service.name} up`}
|
||||
className="text-muted disabled:opacity-30"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveService(category, serviceIndex, 1)}
|
||||
disabled={serviceIndex === category.services.length - 1}
|
||||
aria-label={`Move ${service.name} down`}
|
||||
className="text-muted disabled:opacity-30"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onClick={() => openEditService(service)} className="flex min-w-0 flex-1 items-center gap-3 text-left">
|
||||
{service.imageUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide
|
||||
<img src={service.imageUrl} alt="" className="bg-stone h-10 w-10 shrink-0 rounded-lg object-cover" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<p className={service.isActive ? "text-sm font-bold" : "text-muted text-sm font-bold line-through"}>
|
||||
{service.name}
|
||||
</p>
|
||||
<p className="text-muted mt-0.5 text-xs">
|
||||
{service.durationMinutes} min · {formatPrice(service.price)}
|
||||
{!service.allowOnlineBooking && " · Not bookable online"}
|
||||
</p>
|
||||
</span>
|
||||
</button>
|
||||
<Switch
|
||||
checked={service.isActive}
|
||||
onCheckedChange={(checked) => run(() => setServiceActiveAction(service.id, checked))}
|
||||
aria-label={`${service.name} active`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openNewService(category.id)}
|
||||
className="text-accent flex w-full items-center gap-3 px-5 py-4 text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" /> Add Service
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input placeholder="New category name" value={newCategoryName} onChange={(e) => setNewCategoryName(e.target.value)} />
|
||||
<Button isLoading={isPending} onClick={addCategory}>
|
||||
Add Category
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ServiceFormSheet
|
||||
key={editingService?.id ?? "new"}
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
categories={categories.map((c) => ({ id: c.id, name: c.name }))}
|
||||
defaultCategoryId={sheetCategoryId}
|
||||
existing={editingService}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ChevronDown, ChevronUp, Plus, Trash2 } from "lucide-react";
|
||||
import { GalleryPlacement } from "@/generated/prisma/enums";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { MediaManagerModal } from "@/features/media/components/media-manager-modal";
|
||||
import {
|
||||
addGalleryImageAction,
|
||||
deleteGalleryImageAction,
|
||||
reorderGalleryImagesAction,
|
||||
updateGalleryImageAction,
|
||||
} from "@/features/gallery/actions/gallery";
|
||||
import type { GalleryImageSummary } from "@/services/gallery/gallery.service";
|
||||
|
||||
export interface GalleryManagerProps {
|
||||
placement: GalleryPlacement;
|
||||
title: string;
|
||||
description: string;
|
||||
/** Portfolio images carry a category tag for the customer-facing filter pills; the hero gallery has no use for one. */
|
||||
showCategory: boolean;
|
||||
initialImages: GalleryImageSummary[];
|
||||
}
|
||||
|
||||
export function GalleryManager({ placement, title, description, showCategory, initialImages }: GalleryManagerProps) {
|
||||
const router = useRouter();
|
||||
const [images, setImages] = useState(initialImages);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const run = (fn: () => Promise<{ success: boolean; error?: string }>) => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await fn();
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Something went wrong.");
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelect = (url: string) => {
|
||||
run(async () => {
|
||||
const result = await addGalleryImageAction({ placement, imageUrl: url });
|
||||
if (result.success) setImages((prev) => [...prev, result.image]);
|
||||
return result;
|
||||
});
|
||||
};
|
||||
|
||||
const move = (index: number, direction: -1 | 1) => {
|
||||
const targetIndex = index + direction;
|
||||
if (targetIndex < 0 || targetIndex >= images.length) return;
|
||||
const reordered = [...images];
|
||||
const [moved] = reordered.splice(index, 1);
|
||||
if (!moved) return;
|
||||
reordered.splice(targetIndex, 0, moved);
|
||||
setImages(reordered);
|
||||
run(() => reorderGalleryImagesAction(placement, reordered.map((i) => i.id)));
|
||||
};
|
||||
|
||||
const remove = (id: string) => {
|
||||
setImages((prev) => prev.filter((i) => i.id !== id));
|
||||
run(() => deleteGalleryImageAction(id));
|
||||
};
|
||||
|
||||
const setCategory = (id: string, category: string) => {
|
||||
setImages((prev) => prev.map((i) => (i.id === id ? { ...i, category } : i)));
|
||||
run(() => updateGalleryImageAction(id, { category }));
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">{title}</h3>
|
||||
<p className="text-muted mt-1 ml-1 text-xs">{description}</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{images.map((image, index) => (
|
||||
<div key={image.id} className="border-line overflow-hidden rounded-xl border bg-white">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide */}
|
||||
<img src={image.imageUrl} alt="" className="bg-stone h-28 w-full object-cover" />
|
||||
<div className="space-y-2 p-2">
|
||||
{showCategory && (
|
||||
<Input
|
||||
value={image.category ?? ""}
|
||||
onChange={(e) => setCategory(image.id, e.target.value)}
|
||||
placeholder="Category (optional)"
|
||||
className="px-3 py-2 text-xs"
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(index, -1)}
|
||||
disabled={index === 0}
|
||||
aria-label="Move earlier"
|
||||
className="text-muted disabled:opacity-30"
|
||||
>
|
||||
<ChevronUp className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(index, 1)}
|
||||
disabled={index === images.length - 1}
|
||||
aria-label="Move later"
|
||||
className="text-muted disabled:opacity-30"
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onClick={() => remove(image.id)} aria-label="Delete image" className="text-danger">
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
disabled={isPending}
|
||||
className="border-line text-muted flex h-full min-h-[9rem] flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed"
|
||||
>
|
||||
<Plus className="h-5 w-5" aria-hidden="true" />
|
||||
<span className="text-xs font-bold tracking-widest uppercase">Add Image</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<MediaManagerModal
|
||||
open={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onSelect={handleSelect}
|
||||
category={placement === GalleryPlacement.HERO ? "gallery" : "portfolio"}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,388 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ColorPicker } from "@/components/ui/color-picker";
|
||||
import { IconPicker } from "@/components/ui/icon-picker";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LoyaltyCard } from "@/components/ui/loyalty-card";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { MediaPicker } from "@/features/media/components/media-picker";
|
||||
import { LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode } from "@/generated/prisma/enums";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import { MAX_STAMPS_REQUIRED, resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
|
||||
import { updateLoyaltyConfigAction } from "../actions/loyalty-config";
|
||||
import type { ProgrammeConfigInput } from "@/services/loyalty/loyalty.service";
|
||||
|
||||
const REWARD_TYPES: { value: ProgrammeConfigInput["rewardType"]; label: string }[] = [
|
||||
{ value: "PERCENTAGE", label: "Percentage Discount" },
|
||||
{ value: "FIXED_AMOUNT", label: "Fixed-Value Discount" },
|
||||
{ value: "FREE_SERVICE", label: "Free Service" },
|
||||
];
|
||||
|
||||
const FONT_OPTIONS: { value: LoyaltyCardFont; label: string }[] = [
|
||||
{ value: LoyaltyCardFont.SANS, label: "Sans Serif" },
|
||||
{ value: LoyaltyCardFont.SERIF, label: "Serif" },
|
||||
{ value: LoyaltyCardFont.MONO, label: "Monospace" },
|
||||
];
|
||||
|
||||
const TITLE_SIZE_OPTIONS: { value: LoyaltyCardTextSize; label: string }[] = [
|
||||
{ value: LoyaltyCardTextSize.SM, label: "Small" },
|
||||
{ value: LoyaltyCardTextSize.MD, label: "Medium" },
|
||||
{ value: LoyaltyCardTextSize.LG, label: "Large" },
|
||||
];
|
||||
|
||||
export interface LoyaltyConfigFormProps {
|
||||
businessName: string;
|
||||
initialConfig: ProgrammeConfigInput;
|
||||
initialEligibleServiceIds: string[];
|
||||
initialEligibleStaffIds: string[];
|
||||
services: { id: string; name: string }[];
|
||||
staff: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function LoyaltyConfigForm({
|
||||
businessName,
|
||||
initialConfig,
|
||||
initialEligibleServiceIds,
|
||||
initialEligibleStaffIds,
|
||||
services,
|
||||
staff,
|
||||
}: LoyaltyConfigFormProps) {
|
||||
const router = useRouter();
|
||||
const [config, setConfig] = useState(initialConfig);
|
||||
const [eligibleServiceIds, setEligibleServiceIds] = useState(initialEligibleServiceIds);
|
||||
const [eligibleStaffIds, setEligibleStaffIds] = useState(initialEligibleStaffIds);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const set = <K extends keyof ProgrammeConfigInput>(key: K, value: ProgrammeConfigInput[K]) => {
|
||||
setSaved(false);
|
||||
setConfig((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const toggleInList = (list: string[], setList: (v: string[]) => void, id: string) => {
|
||||
setSaved(false);
|
||||
setList(list.includes(id) ? list.filter((x) => x !== id) : [...list, id]);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await updateLoyaltyConfigAction({ config, eligibleServiceIds, eligibleStaffIds });
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Couldn't save loyalty settings.");
|
||||
return;
|
||||
}
|
||||
setSaved(true);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 lg:grid lg:grid-cols-2 lg:items-start lg:gap-6 lg:space-y-0">
|
||||
<section className="rounded-md border-line border bg-white p-6 lg:col-span-2">
|
||||
<Switch
|
||||
label="Loyalty programme enabled"
|
||||
description="Turn off to stop earning/redeeming everywhere, without losing the configuration below."
|
||||
checked={config.isEnabled}
|
||||
onCheckedChange={(v) => set("isEnabled", v)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Reward Rules</h3>
|
||||
<div className="rounded-md border-line space-y-5 border bg-white p-6">
|
||||
<Input
|
||||
label={`Stamps required for reward (1–${MAX_STAMPS_REQUIRED})`}
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_STAMPS_REQUIRED}
|
||||
value={config.stampsRequired}
|
||||
onChange={(e) => {
|
||||
const next = Number(e.target.value);
|
||||
set("stampsRequired", Number.isFinite(next) ? Math.min(Math.max(next, 1), MAX_STAMPS_REQUIRED) : 1);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 ml-1 text-[10px] font-bold tracking-widest uppercase text-muted">Reward Timing</p>
|
||||
<div className="space-y-2">
|
||||
{(
|
||||
[
|
||||
{
|
||||
value: RewardPositionMode.AFTER_ALL_STAMPS,
|
||||
title: "Reward after all stamps",
|
||||
description: `Collect all ${config.stampsRequired} stamps, then the reward is given as a separate perk — every position on the card is a normal stamp.`,
|
||||
},
|
||||
{
|
||||
value: RewardPositionMode.FINAL_SLOT_IS_REWARD,
|
||||
title: "Final position is the reward",
|
||||
description: `Collect ${resolveRewardThreshold({ stampsRequired: config.stampsRequired, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD })} stamps — the last position on the card is the reward itself, marked visually, not a stamp to fill.`,
|
||||
},
|
||||
] as const
|
||||
).map((option) => {
|
||||
const selected = config.rewardPosition === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => set("rewardPosition", option.value)}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
"w-full rounded-xl border p-4 text-left transition-colors",
|
||||
selected ? "border-accent-loyalty bg-accent-loyalty/10" : "border-line bg-white",
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-bold">{option.title}</p>
|
||||
<p className="text-muted mt-1 text-xs">{option.description}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Select label="Reward Type" value={config.rewardType} onChange={(e) => set("rewardType", e.target.value as ProgrammeConfigInput["rewardType"])}>
|
||||
{REWARD_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{config.rewardType === "FREE_SERVICE" ? (
|
||||
<Select
|
||||
label="Free Service"
|
||||
value={config.freeServiceId ?? ""}
|
||||
onChange={(e) => set("freeServiceId", e.target.value)}
|
||||
>
|
||||
<option value="">Choose a service…</option>
|
||||
{services.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
label={config.rewardType === "PERCENTAGE" ? "Percentage off (1–100)" : "Fixed amount off"}
|
||||
value={config.rewardValue ?? ""}
|
||||
onChange={(e) => set("rewardValue", e.target.value)}
|
||||
placeholder={config.rewardType === "PERCENTAGE" ? "50" : "10.00"}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Input label="Reward Name" value={config.rewardLabel ?? ""} onChange={(e) => set("rewardLabel", e.target.value)} placeholder="e.g. 50% Off Any Service" />
|
||||
<Input
|
||||
label="Maximum reward value (optional)"
|
||||
value={config.maxRewardValue ?? ""}
|
||||
onChange={(e) => set("maxRewardValue", e.target.value)}
|
||||
hint="Caps a percentage discount so it never exceeds this amount."
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 lg:col-span-2">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Eligibility</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6 lg:grid lg:grid-cols-2 lg:gap-6 lg:space-y-0">
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-bold">Eligible services (none checked = all services earn stamps)</p>
|
||||
<div className="space-y-2">
|
||||
{services.map((s) => (
|
||||
<Checkbox
|
||||
key={s.id}
|
||||
label={s.name}
|
||||
checked={eligibleServiceIds.includes(s.id)}
|
||||
onChange={() => toggleInList(eligibleServiceIds, setEligibleServiceIds, s.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-line border-t pt-4 lg:border-t-0 lg:border-l lg:pt-0 lg:pl-6">
|
||||
<p className="mb-2 text-xs font-bold">Eligible staff (none checked = all staff earn stamps)</p>
|
||||
<div className="space-y-2">
|
||||
{staff.map((s) => (
|
||||
<Checkbox
|
||||
key={s.id}
|
||||
label={s.name}
|
||||
checked={eligibleStaffIds.includes(s.id)}
|
||||
onChange={() => toggleInList(eligibleStaffIds, setEligibleStaffIds, s.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Rules & Expiry</h3>
|
||||
<div className="rounded-md border-line space-y-5 border bg-white p-6">
|
||||
<Switch
|
||||
label="Apply reward automatically"
|
||||
description="Redemption is always staff-initiated this phase — this setting is saved but has no automatic effect yet."
|
||||
checked={config.autoApplyReward}
|
||||
onCheckedChange={(v) => set("autoApplyReward", v)}
|
||||
/>
|
||||
<Switch
|
||||
label="Require staff approval to redeem"
|
||||
description="Redemption already requires a permitted staff member — this is informational only this phase."
|
||||
checked={config.requiresStaffApproval}
|
||||
onCheckedChange={(v) => set("requiresStaffApproval", v)}
|
||||
/>
|
||||
<Switch label="Earn stamps on cancelled appointments" checked={config.earnOnCancelled} onCheckedChange={(v) => set("earnOnCancelled", v)} />
|
||||
<Switch label="Earn stamps on no-shows" checked={config.earnOnNoShow} onCheckedChange={(v) => set("earnOnNoShow", v)} />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Stamps expire after (days, optional)"
|
||||
type="number"
|
||||
min={1}
|
||||
value={config.stampsExpireAfterDays ?? ""}
|
||||
onChange={(e) => set("stampsExpireAfterDays", e.target.value ? Number(e.target.value) : undefined)}
|
||||
/>
|
||||
<Input
|
||||
label="Reward expires after (days, optional)"
|
||||
type="number"
|
||||
min={1}
|
||||
value={config.rewardExpiresAfterDays ?? ""}
|
||||
onChange={(e) => set("rewardExpiresAfterDays", e.target.value ? Number(e.target.value) : undefined)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 lg:col-span-2">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Card Appearance</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6 lg:grid lg:grid-cols-[minmax(0,320px)_1fr] lg:gap-8 lg:space-y-0">
|
||||
<div>
|
||||
<p className="text-muted mb-2 ml-1 text-[10px] font-bold tracking-widest uppercase">Live Preview</p>
|
||||
<LoyaltyCard
|
||||
businessName={businessName}
|
||||
memberNumber="A1B2C3"
|
||||
stampsRequired={Math.max(config.stampsRequired, 1)}
|
||||
stampsEarned={Math.max(
|
||||
resolveRewardThreshold({ stampsRequired: Math.max(config.stampsRequired, 1), rewardPosition: config.rewardPosition }) - 1,
|
||||
0,
|
||||
)}
|
||||
rewardLabel={config.rewardLabel || "Reward"}
|
||||
termsLine={config.termsLine}
|
||||
cardBackgroundColor={config.cardBackgroundColor}
|
||||
cardBackgroundImageUrl={config.cardBackgroundImageUrl}
|
||||
stampIcon={config.stampIcon}
|
||||
rewardPosition={config.rewardPosition}
|
||||
cardTitleText={config.cardTitleText}
|
||||
cardFont={config.cardFont}
|
||||
cardTitleTextSize={config.cardTitleTextSize}
|
||||
cardTitleTextColor={config.cardTitleTextColor}
|
||||
cardBorderColor={config.cardBorderColor}
|
||||
cardMemberNumberColor={config.cardMemberNumberColor}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 lg:mt-0">
|
||||
<Tabs defaultValue="colours">
|
||||
<TabsList>
|
||||
<TabsTrigger value="colours">Colours</TabsTrigger>
|
||||
<TabsTrigger value="stamp">Stamp</TabsTrigger>
|
||||
<TabsTrigger value="text">Text</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="colours" className="space-y-4">
|
||||
<ColorPicker
|
||||
label="Background colour"
|
||||
value={config.cardBackgroundColor}
|
||||
onChange={(hex) => set("cardBackgroundColor", hex)}
|
||||
/>
|
||||
<MediaPicker
|
||||
label="Background image (optional)"
|
||||
value={config.cardBackgroundImageUrl}
|
||||
onChange={(url) => set("cardBackgroundImageUrl", url ?? undefined)}
|
||||
category="loyalty"
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Border colour"
|
||||
value={config.cardBorderColor}
|
||||
onChange={(hex) => set("cardBorderColor", hex)}
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Title text colour"
|
||||
value={config.cardTitleTextColor}
|
||||
onChange={(hex) => set("cardTitleTextColor", hex)}
|
||||
/>
|
||||
<ColorPicker
|
||||
label="Member number colour"
|
||||
value={config.cardMemberNumberColor}
|
||||
onChange={(hex) => set("cardMemberNumberColor", hex)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="stamp" className="space-y-4">
|
||||
<IconPicker label="Stamp icon" value={config.stampIcon} onChange={(iconName) => set("stampIcon", iconName)} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="text" className="space-y-4">
|
||||
<Input
|
||||
label="Card title"
|
||||
value={config.cardTitleText ?? ""}
|
||||
onChange={(e) => set("cardTitleText", e.target.value)}
|
||||
placeholder="Loyalty Card"
|
||||
hint={`Shown top-left on the card, in place of the default "Loyalty Card". Leave blank to keep the default.`}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Select
|
||||
label="Font"
|
||||
value={config.cardFont}
|
||||
onChange={(e) => set("cardFont", e.target.value as ProgrammeConfigInput["cardFont"])}
|
||||
>
|
||||
{FONT_OPTIONS.map((f) => (
|
||||
<option key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
label="Title size"
|
||||
value={config.cardTitleTextSize}
|
||||
onChange={(e) => set("cardTitleTextSize", e.target.value as ProgrammeConfigInput["cardTitleTextSize"])}
|
||||
>
|
||||
{TITLE_SIZE_OPTIONS.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-muted -mt-2 text-[10px]">Font applies to the whole card, not just the title.</p>
|
||||
<Input
|
||||
label="Terms line"
|
||||
value={config.termsLine ?? ""}
|
||||
onChange={(e) => set("termsLine", e.target.value)}
|
||||
placeholder="Valid for 12 months. One reward per visit."
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="space-y-4 lg:col-span-2">
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{saved && !error && (
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
import { updateOpeningHoursAction } from "../actions/business";
|
||||
import type { OpeningHoursActionResult } from "../actions/business";
|
||||
|
||||
const DAY_ORDER: Weekday[] = [
|
||||
Weekday.MONDAY,
|
||||
Weekday.TUESDAY,
|
||||
Weekday.WEDNESDAY,
|
||||
Weekday.THURSDAY,
|
||||
Weekday.FRIDAY,
|
||||
Weekday.SATURDAY,
|
||||
Weekday.SUNDAY,
|
||||
];
|
||||
const DAY_LABEL: Record<Weekday, string> = {
|
||||
MONDAY: "Monday",
|
||||
TUESDAY: "Tuesday",
|
||||
WEDNESDAY: "Wednesday",
|
||||
THURSDAY: "Thursday",
|
||||
FRIDAY: "Friday",
|
||||
SATURDAY: "Saturday",
|
||||
SUNDAY: "Sunday",
|
||||
};
|
||||
|
||||
function toTimeString(minutes: number | null): string {
|
||||
if (minutes === null) return "09:00";
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||
}
|
||||
function toMinutes(time: string): number {
|
||||
const [h, m] = time.split(":").map(Number);
|
||||
return (h ?? 0) * 60 + (m ?? 0);
|
||||
}
|
||||
|
||||
interface DayState {
|
||||
dayOfWeek: Weekday;
|
||||
isClosed: boolean;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}
|
||||
|
||||
export interface InitialOpeningHoursRow {
|
||||
dayOfWeek: Weekday;
|
||||
isClosed: boolean;
|
||||
opensAtMinutes: number | null;
|
||||
closesAtMinutes: number | null;
|
||||
}
|
||||
|
||||
export function OpeningHoursForm({ initialRows }: { initialRows: InitialOpeningHoursRow[] }) {
|
||||
const router = useRouter();
|
||||
const [days, setDays] = useState<DayState[]>(() =>
|
||||
DAY_ORDER.map((dayOfWeek) => {
|
||||
const existing = initialRows.find((r) => r.dayOfWeek === dayOfWeek);
|
||||
return {
|
||||
dayOfWeek,
|
||||
isClosed: existing?.isClosed ?? true,
|
||||
startTime: toTimeString(existing?.opensAtMinutes ?? null),
|
||||
endTime: toTimeString(existing?.closesAtMinutes ?? 19 * 60),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [conflicts, setConflicts] = useState<NonNullable<OpeningHoursActionResult["conflicts"]>>([]);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const updateDay = (dayOfWeek: Weekday, patch: Partial<DayState>) => {
|
||||
setSaved(false);
|
||||
setConflicts([]);
|
||||
setDays((prev) => prev.map((d) => (d.dayOfWeek === dayOfWeek ? { ...d, ...patch } : d)));
|
||||
};
|
||||
|
||||
const copyMondayToAll = () => {
|
||||
const monday = days.find((d) => d.dayOfWeek === Weekday.MONDAY);
|
||||
if (!monday) return;
|
||||
setSaved(false);
|
||||
setConflicts([]);
|
||||
setDays((prev) =>
|
||||
prev.map((d) => (d.dayOfWeek === Weekday.MONDAY ? d : { ...d, isClosed: monday.isClosed, startTime: monday.startTime, endTime: monday.endTime })),
|
||||
);
|
||||
};
|
||||
|
||||
const submit = (acknowledgeConflicts: boolean) => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await updateOpeningHoursAction(
|
||||
days.map((d) => ({
|
||||
dayOfWeek: d.dayOfWeek,
|
||||
isClosed: d.isClosed,
|
||||
...(d.isClosed ? {} : { opensAtMinutes: toMinutes(d.startTime), closesAtMinutes: toMinutes(d.endTime) }),
|
||||
})),
|
||||
acknowledgeConflicts,
|
||||
);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Couldn't save opening hours.");
|
||||
setConflicts(result.conflicts ?? []);
|
||||
return;
|
||||
}
|
||||
setSaved(true);
|
||||
setConflicts([]);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
if (conflicts.length > 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
These hours would leave {conflicts.length} upcoming appointment{conflicts.length > 1 ? "s" : ""} outside
|
||||
the business's open hours. They won't be cancelled automatically — review them, then confirm if
|
||||
you still want to save.
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{conflicts.map((c) => (
|
||||
<li key={c.id} className="border-line rounded-lg border bg-white p-3 text-xs">
|
||||
<Link href={`/owner/appointments/${c.id}`} className="font-bold underline">
|
||||
{c.customerName}
|
||||
</Link>
|
||||
<p className="text-muted">
|
||||
{c.serviceNames} · with {c.staffName}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={() => setConflicts([])}>
|
||||
Go Back
|
||||
</Button>
|
||||
<Button variant="destructive" className="flex-1" isLoading={isPending} onClick={() => submit(true)}>
|
||||
Save Anyway
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button type="button" onClick={copyMondayToAll} className="text-accent text-xs font-bold tracking-widest uppercase underline">
|
||||
Copy Monday to all days
|
||||
</button>
|
||||
|
||||
<div className="divide-line rounded-md border-line divide-y border bg-white">
|
||||
{days.map((day) => (
|
||||
<div key={day.dayOfWeek} className="flex items-center justify-between gap-4 px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={!day.isClosed}
|
||||
onCheckedChange={(checked) => updateDay(day.dayOfWeek, { isClosed: !checked })}
|
||||
aria-label={`${DAY_LABEL[day.dayOfWeek]} open`}
|
||||
/>
|
||||
<p className="text-sm font-bold">{DAY_LABEL[day.dayOfWeek]}</p>
|
||||
</div>
|
||||
{day.isClosed ? (
|
||||
<span className="text-muted text-xs">Closed</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="time"
|
||||
value={day.startTime}
|
||||
onChange={(e) => updateDay(day.dayOfWeek, { startTime: e.target.value })}
|
||||
className="border-line rounded-lg border px-2 py-1.5 text-xs"
|
||||
aria-label={`${DAY_LABEL[day.dayOfWeek]} opening time`}
|
||||
/>
|
||||
<span className="text-muted text-xs">to</span>
|
||||
<input
|
||||
type="time"
|
||||
value={day.endTime}
|
||||
onChange={(e) => updateDay(day.dayOfWeek, { endTime: e.target.value })}
|
||||
className="border-line rounded-lg border px-2 py-1.5 text-xs"
|
||||
aria-label={`${DAY_LABEL[day.dayOfWeek]} closing time`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{saved && !error && (
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
Saved. The booking engine reflects these hours immediately.
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" isLoading={isPending} onClick={() => submit(false)}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CalendarOff } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BottomSheet } from "@/components/ui/bottom-sheet";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { OwnerBlockTimeConflict } from "../actions/calendar";
|
||||
import { ownerBlockTimeAction } from "../actions/calendar";
|
||||
|
||||
function toMinutes(hhmm: string): number | null {
|
||||
const match = /^(\d{2}):(\d{2})$/.exec(hhmm);
|
||||
if (!match) return null;
|
||||
return Number(match[1]) * 60 + Number(match[2]);
|
||||
}
|
||||
|
||||
/** Owner variant of the staff BlockTimeButton (src/features/staff/components/block-time-button.tsx) — same UI, targets a chosen staff member instead of only the signed-in user. */
|
||||
export function OwnerBlockTimeButton({ staffId, staffName, date }: { staffId: string; staffName: string; date: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [startTime, setStartTime] = useState("12:00");
|
||||
const [endTime, setEndTime] = useState("13:00");
|
||||
const [reason, setReason] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [conflicts, setConflicts] = useState<OwnerBlockTimeConflict[] | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
setError(null);
|
||||
setConflicts(null);
|
||||
setReason("");
|
||||
};
|
||||
|
||||
const submit = (acknowledgeConflicts: boolean) => {
|
||||
const startMinutes = toMinutes(startTime);
|
||||
const endMinutes = toMinutes(endTime);
|
||||
if (startMinutes === null || endMinutes === null || startMinutes >= endMinutes) {
|
||||
setError("Start time must be before end time.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await ownerBlockTimeAction({ staffId, date, startMinutes, endMinutes, reason, acknowledgeConflicts });
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Something went wrong.");
|
||||
setConflicts(result.conflicts ?? null);
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
setConflicts(null);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="secondary" size="sm" aria-label="Block Time" onClick={() => setOpen(true)}>
|
||||
<CalendarOff className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="hidden sm:inline">Block Time</span>
|
||||
</Button>
|
||||
<BottomSheet open={open} onClose={handleClose} title={`Block Time — ${staffName}`}>
|
||||
<div className="space-y-4 pb-2">
|
||||
<p className="text-muted text-sm">
|
||||
Reserve part of{" "}
|
||||
{new Date(`${date}T00:00:00`).toLocaleDateString("en-GB", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
})}{" "}
|
||||
on {staffName}'s schedule for something other than appointments.
|
||||
</p>
|
||||
|
||||
{conflicts && conflicts.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
This overlaps {conflicts.length} existing appointment
|
||||
{conflicts.length > 1 ? "s" : ""}:
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{conflicts.map((c) => (
|
||||
<li key={c.id} className="border-line rounded-lg border bg-white p-3 text-xs">
|
||||
<Link href={`/owner/appointments/${c.id}`} className="font-bold underline">
|
||||
{c.customerName}
|
||||
</Link>
|
||||
<p className="text-muted">{c.serviceNames}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" className="flex-1" isLoading={isPending} onClick={() => submit(true)}>
|
||||
Block Anyway
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input label="Start" type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} />
|
||||
<Input label="End" type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} />
|
||||
</div>
|
||||
<Input
|
||||
label="Reason (optional)"
|
||||
placeholder="e.g. Team meeting"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" isLoading={isPending} onClick={() => submit(false)}>
|
||||
Block This Time
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</BottomSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Gift } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { redeemRewardAction } from "../actions/loyalty";
|
||||
|
||||
/**
|
||||
* Available to any staff member with MANAGE_LOYALTY_STAMPS (Owner and
|
||||
* Barber both hold it in the seed — see redeemRewardAction) — surfaced on
|
||||
* both the owner and staff appointment details pages, since redemption is
|
||||
* an operational action, not owner-only configuration. See
|
||||
* docs/phase-6-summary.md, "Loyalty configuration and redemption approach".
|
||||
*/
|
||||
export function RedeemRewardButton({
|
||||
appointmentId,
|
||||
rewardId,
|
||||
rewardLabel,
|
||||
subtotal,
|
||||
}: {
|
||||
appointmentId: string;
|
||||
rewardId: string;
|
||||
rewardLabel: string;
|
||||
subtotal: number;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<{ discountAmount: string } | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const close = () => {
|
||||
const shouldRefresh = !!result;
|
||||
setOpen(false);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
if (shouldRefresh) router.refresh();
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const res = await redeemRewardAction({ rewardId, appointmentId });
|
||||
if (!res.success) {
|
||||
setError(res.error ?? "Couldn't redeem this reward.");
|
||||
return;
|
||||
}
|
||||
setResult({ discountAmount: res.discountAmount ?? "0.00" });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
|
||||
<Gift className="text-accent-loyalty h-4 w-4" aria-hidden="true" />
|
||||
Redeem Reward
|
||||
</Button>
|
||||
<Modal open={open} onClose={close} title={result ? "Reward Redeemed" : "Redeem Reward"}>
|
||||
{result ? (
|
||||
<div className="space-y-4">
|
||||
<p role="status" className="text-muted text-sm">
|
||||
{rewardLabel} has been applied to this appointment — £{result.discountAmount} off.
|
||||
</p>
|
||||
<Button className="w-full" onClick={close}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted text-sm">
|
||||
Apply <span className="text-ink font-bold">{rewardLabel}</span> to this appointment
|
||||
(subtotal £{subtotal.toFixed(2)})? This can't be undone.
|
||||
</p>
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="flex-1" isLoading={isPending} onClick={submit}>
|
||||
Apply Reward
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { syncReviewsNowAction, updateReviewsSettingsAction } from "../actions/reviews";
|
||||
|
||||
export interface ReviewsManagerProps {
|
||||
initialGooglePlaceId: string;
|
||||
googleRating: number | null;
|
||||
googleRatingCount: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Just the Google Place ID + aggregate rating/count sync — no individual
|
||||
* review import/management. Google's Place Details API only ever returns
|
||||
* up to 5 "most relevant" reviews with no pagination to get the rest (see
|
||||
* google-places.provider.ts's header), so a review list here could never
|
||||
* be a real, representative picture — the aggregate rating/count alone is
|
||||
* accurate and is all this app surfaces.
|
||||
*/
|
||||
export function ReviewsManager({ initialGooglePlaceId, googleRating, googleRatingCount }: ReviewsManagerProps) {
|
||||
const router = useRouter();
|
||||
const [googlePlaceId, setGooglePlaceId] = useState(initialGooglePlaceId);
|
||||
const [settingsError, setSettingsError] = useState<string | null>(null);
|
||||
const [settingsSaved, setSettingsSaved] = useState(false);
|
||||
const [syncMessage, setSyncMessage] = useState<string | null>(null);
|
||||
const [isSettingsPending, startSettingsTransition] = useTransition();
|
||||
const [isSyncPending, startSyncTransition] = useTransition();
|
||||
|
||||
const saveSettings = () => {
|
||||
setSettingsError(null);
|
||||
setSettingsSaved(false);
|
||||
startSettingsTransition(async () => {
|
||||
const result = await updateReviewsSettingsAction({ googlePlaceId: googlePlaceId.trim() || null });
|
||||
if (!result.success) {
|
||||
setSettingsError(result.error ?? "Couldn't save review settings.");
|
||||
return;
|
||||
}
|
||||
setSettingsSaved(true);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const syncNow = () => {
|
||||
setSyncMessage(null);
|
||||
startSyncTransition(async () => {
|
||||
const result = await syncReviewsNowAction();
|
||||
setSyncMessage(result.success ? "Synced — refreshing…" : (result.error ?? "Sync failed."));
|
||||
if (result.success) router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<h3 className="text-sm font-bold">Google Reviews Sync</h3>
|
||||
{googleRating !== null && (
|
||||
<p className="text-muted text-sm">
|
||||
Google's aggregate: <span className="text-ink font-bold">{googleRating.toFixed(1)}</span> from{" "}
|
||||
{googleRatingCount ?? 0} ratings.
|
||||
</p>
|
||||
)}
|
||||
<Input
|
||||
label="Google Place ID"
|
||||
value={googlePlaceId}
|
||||
onChange={(e) => setGooglePlaceId(e.target.value)}
|
||||
placeholder="ChIJ…"
|
||||
hint="Used to pull this business's rating and review count from Google. Find it by searching your business on Google Maps, opening Share → Embed a map, and copying the id from the embed code — or search “Google Place ID Finder” for Google's own lookup tool. Leave blank to disable syncing."
|
||||
/>
|
||||
{settingsError && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{settingsError}
|
||||
</p>
|
||||
)}
|
||||
{settingsSaved && !settingsError && (
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button isLoading={isSettingsPending} onClick={saveSettings}>
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="secondary" isLoading={isSyncPending} onClick={syncNow}>
|
||||
<RefreshCw className="h-4 w-4" aria-hidden="true" /> Sync Now
|
||||
</Button>
|
||||
</div>
|
||||
{syncMessage && <p className="text-muted text-xs">{syncMessage}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BottomSheet } from "@/components/ui/bottom-sheet";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MediaPicker } from "@/features/media/components/media-picker";
|
||||
import { createServiceAction, updateServiceAction } from "../actions/catalogue";
|
||||
|
||||
export interface EditableService {
|
||||
id: string;
|
||||
categoryId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
imageUrl: string | null;
|
||||
durationMinutes: number;
|
||||
price: string;
|
||||
bufferBeforeMinutes: number;
|
||||
bufferAfterMinutes: number;
|
||||
allowOnlineBooking: boolean;
|
||||
}
|
||||
|
||||
export function ServiceFormSheet({
|
||||
open,
|
||||
onClose,
|
||||
categories,
|
||||
defaultCategoryId,
|
||||
existing,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
categories: { id: string; name: string }[];
|
||||
defaultCategoryId: string;
|
||||
existing?: EditableService | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [categoryId, setCategoryId] = useState(existing?.categoryId ?? defaultCategoryId);
|
||||
const [name, setName] = useState(existing?.name ?? "");
|
||||
const [description, setDescription] = useState(existing?.description ?? "");
|
||||
const [imageUrl, setImageUrl] = useState(existing?.imageUrl ?? "");
|
||||
const [duration, setDuration] = useState(String(existing?.durationMinutes ?? 30));
|
||||
const [price, setPrice] = useState(existing?.price ?? "");
|
||||
const [bufferBefore, setBufferBefore] = useState(String(existing?.bufferBeforeMinutes ?? 0));
|
||||
const [bufferAfter, setBufferAfter] = useState(String(existing?.bufferAfterMinutes ?? 0));
|
||||
const [allowOnlineBooking, setAllowOnlineBooking] = useState(existing?.allowOnlineBooking ?? true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const submit = () => {
|
||||
setError(null);
|
||||
const durationMinutes = Number(duration);
|
||||
const bufferBeforeMinutes = Number(bufferBefore);
|
||||
const bufferAfterMinutes = Number(bufferAfter);
|
||||
if (!Number.isInteger(durationMinutes) || durationMinutes <= 0) {
|
||||
setError("Duration must be a positive number of minutes.");
|
||||
return;
|
||||
}
|
||||
startTransition(async () => {
|
||||
const input = {
|
||||
categoryId,
|
||||
name,
|
||||
description: description || undefined,
|
||||
imageUrl: imageUrl || undefined,
|
||||
durationMinutes,
|
||||
price,
|
||||
bufferBeforeMinutes,
|
||||
bufferAfterMinutes,
|
||||
allowOnlineBooking,
|
||||
};
|
||||
const result = existing ? await updateServiceAction(existing.id, input) : await createServiceAction(input);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Something went wrong.");
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<BottomSheet open={open} onClose={onClose} title={existing ? "Edit Service" : "New Service"}>
|
||||
<div className="space-y-4 pb-2">
|
||||
<Select label="Category" value={categoryId} onChange={(e) => setCategoryId(e.target.value)}>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Input label="Name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<Textarea label="Description (optional)" value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
<MediaPicker label="Photo (optional)" value={imageUrl} onChange={(url) => setImageUrl(url ?? "")} category="service" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input label="Duration (min)" type="number" min={1} value={duration} onChange={(e) => setDuration(e.target.value)} />
|
||||
<Input label="Price" placeholder="22.50" value={price} onChange={(e) => setPrice(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input label="Buffer Before (min)" type="number" min={0} value={bufferBefore} onChange={(e) => setBufferBefore(e.target.value)} />
|
||||
<Input label="Buffer After (min)" type="number" min={0} value={bufferAfter} onChange={(e) => setBufferAfter(e.target.value)} />
|
||||
</div>
|
||||
<Switch label="Available for online booking" checked={allowOnlineBooking} onCheckedChange={setAllowOnlineBooking} />
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" isLoading={isPending} onClick={submit}>
|
||||
{existing ? "Save Changes" : "Add Service"}
|
||||
</Button>
|
||||
</div>
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import {
|
||||
deactivateStaffAction,
|
||||
reactivateStaffAction,
|
||||
regenerateInvitationAction,
|
||||
setStaffServiceEligibilityAction,
|
||||
updateStaffAction,
|
||||
} from "../actions/staff";
|
||||
|
||||
export interface StaffDetailPanelProps {
|
||||
staffId: string;
|
||||
currentRoleId: string;
|
||||
roles: { id: string; name: string }[];
|
||||
allServices: { id: string; name: string }[];
|
||||
initialServiceIds: string[];
|
||||
status: "ACTIVE" | "ON_BREAK" | "INACTIVE";
|
||||
hasPendingInvitation: boolean;
|
||||
}
|
||||
|
||||
type ConflictItem = { id: string; customerName: string; serviceNames: string; startAt: string };
|
||||
|
||||
export function StaffDetailPanel({
|
||||
staffId,
|
||||
currentRoleId,
|
||||
roles,
|
||||
allServices,
|
||||
initialServiceIds,
|
||||
status,
|
||||
hasPendingInvitation,
|
||||
}: StaffDetailPanelProps) {
|
||||
const router = useRouter();
|
||||
const [roleId, setRoleId] = useState(currentRoleId);
|
||||
const [serviceIds, setServiceIds] = useState(initialServiceIds);
|
||||
const [roleError, setRoleError] = useState<string | null>(null);
|
||||
const [roleSaved, setRoleSaved] = useState(false);
|
||||
const [servicesError, setServicesError] = useState<string | null>(null);
|
||||
const [servicesSaved, setServicesSaved] = useState(false);
|
||||
const [serviceConflicts, setServiceConflicts] = useState<ConflictItem[]>([]);
|
||||
const [deactivateConflicts, setDeactivateConflicts] = useState<ConflictItem[] | null>(null);
|
||||
const [deactivateError, setDeactivateError] = useState<string | null>(null);
|
||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isRolePending, startRoleTransition] = useTransition();
|
||||
const [isServicesPending, startServicesTransition] = useTransition();
|
||||
const [isDeactivatePending, startDeactivateTransition] = useTransition();
|
||||
const [isInvitePending, startInviteTransition] = useTransition();
|
||||
|
||||
const saveRole = () => {
|
||||
setRoleError(null);
|
||||
setRoleSaved(false);
|
||||
startRoleTransition(async () => {
|
||||
const result = await updateStaffAction(staffId, { roleId });
|
||||
if (!result.success) {
|
||||
setRoleError(result.error ?? "Couldn't change role.");
|
||||
return;
|
||||
}
|
||||
setRoleSaved(true);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const toggleService = (id: string) => {
|
||||
setServicesSaved(false);
|
||||
setServiceIds((prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]));
|
||||
};
|
||||
|
||||
const saveServices = (acknowledgeConflicts: boolean) => {
|
||||
setServicesError(null);
|
||||
startServicesTransition(async () => {
|
||||
const result = await setStaffServiceEligibilityAction(staffId, serviceIds, acknowledgeConflicts);
|
||||
if (!result.success) {
|
||||
setServicesError(result.error ?? "Couldn't update services.");
|
||||
setServiceConflicts(result.conflicts ?? []);
|
||||
return;
|
||||
}
|
||||
setServicesSaved(true);
|
||||
setServiceConflicts([]);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const deactivate = (acknowledgeConflicts: boolean) => {
|
||||
setDeactivateError(null);
|
||||
startDeactivateTransition(async () => {
|
||||
const result = await deactivateStaffAction(staffId, acknowledgeConflicts);
|
||||
if (!result.success) {
|
||||
setDeactivateError(result.error ?? "Couldn't deactivate this staff member.");
|
||||
setDeactivateConflicts(result.conflicts ?? []);
|
||||
return;
|
||||
}
|
||||
setDeactivateConflicts(null);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const reactivate = () => {
|
||||
startDeactivateTransition(async () => {
|
||||
await reactivateStaffAction(staffId);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const regenerateLink = () => {
|
||||
startInviteTransition(async () => {
|
||||
const result = await regenerateInvitationAction(staffId);
|
||||
if (result.success && result.inviteToken) {
|
||||
setInviteLink(`${window.location.origin}/invite/accept?token=${result.inviteToken}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const copyLink = async () => {
|
||||
if (!inviteLink) return;
|
||||
await navigator.clipboard.writeText(inviteLink);
|
||||
setCopied(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{hasPendingInvitation && (
|
||||
<section className="rounded-md border-warning/30 bg-warning/10 border p-5">
|
||||
<p className="text-warning-foreground text-sm font-bold">Invitation pending</p>
|
||||
<p className="text-muted mt-1 text-xs">
|
||||
This person hasn't set a password yet. Email delivery isn't wired up — send them the link
|
||||
yourself.
|
||||
</p>
|
||||
{inviteLink ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="border-line bg-white break-all rounded-lg border p-3 text-xs">{inviteLink}</div>
|
||||
<Button size="sm" variant="secondary" onClick={copyLink}>
|
||||
{copied ? "Copied" : "Copy Link"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button size="sm" variant="secondary" className="mt-3" isLoading={isInvitePending} onClick={regenerateLink}>
|
||||
Get Invite Link
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Role & Permissions</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<Select label="Role" value={roleId} onChange={(e) => setRoleId(e.target.value)}>
|
||||
{roles.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{roleError && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{roleError}
|
||||
</p>
|
||||
)}
|
||||
{roleSaved && !roleError && (
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
<Button size="sm" isLoading={isRolePending} onClick={saveRole} disabled={roleId === currentRoleId}>
|
||||
Save Role
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Services Provided</h3>
|
||||
<div className="rounded-md border-line space-y-3 border bg-white p-6">
|
||||
{allServices.map((s) => (
|
||||
<Checkbox key={s.id} label={s.name} checked={serviceIds.includes(s.id)} onChange={() => toggleService(s.id)} />
|
||||
))}
|
||||
|
||||
{serviceConflicts.length > 0 && (
|
||||
<div className="border-line space-y-2 border-t pt-3">
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
This staff member has upcoming appointments for a service you're removing:
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{serviceConflicts.map((c) => (
|
||||
<li key={c.id} className="border-line rounded-lg border bg-white p-3 text-xs">
|
||||
<Link href={`/owner/appointments/${c.id}`} className="font-bold underline">
|
||||
{c.customerName}
|
||||
</Link>
|
||||
<p className="text-muted">{c.serviceNames}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button size="sm" variant="destructive" isLoading={isServicesPending} onClick={() => saveServices(true)}>
|
||||
Save Anyway
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{servicesError && serviceConflicts.length === 0 && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{servicesError}
|
||||
</p>
|
||||
)}
|
||||
{servicesSaved && !servicesError && (
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
{serviceConflicts.length === 0 && (
|
||||
<Button size="sm" isLoading={isServicesPending} onClick={() => saveServices(false)}>
|
||||
Save Services
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Availability</h3>
|
||||
<div className="rounded-md border-line divide-line divide-y overflow-hidden border bg-white">
|
||||
<Link href={`/owner/team/${staffId}/hours`} className="hover:bg-stone flex items-center justify-between px-6 py-4">
|
||||
<span className="text-sm font-bold">Working Hours</span>
|
||||
<span className="text-muted text-xs">Edit</span>
|
||||
</Link>
|
||||
<Link href={`/owner/team/${staffId}/breaks`} className="hover:bg-stone flex items-center justify-between px-6 py-4">
|
||||
<span className="text-sm font-bold">Breaks</span>
|
||||
<span className="text-muted text-xs">Edit</span>
|
||||
</Link>
|
||||
<Link href={`/owner/team/${staffId}/time-off`} className="hover:bg-stone flex items-center justify-between px-6 py-4">
|
||||
<span className="text-sm font-bold">Time Off</span>
|
||||
<span className="text-muted text-xs">Edit</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="pb-2">
|
||||
{status === "INACTIVE" ? (
|
||||
<Button variant="secondary" className="w-full" isLoading={isDeactivatePending} onClick={reactivate}>
|
||||
Reactivate Staff Member
|
||||
</Button>
|
||||
) : deactivateConflicts && deactivateConflicts.length > 0 ? (
|
||||
<div className="border-danger/30 bg-danger/5 space-y-3 rounded-md border p-5">
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
This staff member still has {deactivateConflicts.length} upcoming appointment
|
||||
{deactivateConflicts.length > 1 ? "s" : ""}. Deactivating won't cancel them — they'll need to
|
||||
be reassigned or handled separately.
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{deactivateConflicts.map((c) => (
|
||||
<li key={c.id} className="rounded-lg border border-white bg-white p-3 text-xs">
|
||||
<Link href={`/owner/appointments/${c.id}`} className="font-bold underline">
|
||||
{c.customerName}
|
||||
</Link>
|
||||
<p className="text-muted">{c.serviceNames}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={() => setDeactivateConflicts(null)}>
|
||||
Go Back
|
||||
</Button>
|
||||
<Button variant="destructive" className="flex-1" isLoading={isDeactivatePending} onClick={() => deactivate(true)}>
|
||||
Deactivate Anyway
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{deactivateError && (
|
||||
<p role="alert" className="text-danger mb-2 text-sm font-medium">
|
||||
{deactivateError}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
isLoading={isDeactivatePending}
|
||||
onClick={() => deactivate(false)}
|
||||
>
|
||||
Deactivate Staff Member
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Users } from "lucide-react";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
export interface StaffFilterOption {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string | null;
|
||||
/** Working-hours range for the day (e.g. "10:00 AM – 7:00 PM"), or null if off entirely. */
|
||||
hoursLabel: string | null;
|
||||
}
|
||||
|
||||
const CARD_CLASS = "flex shrink-0 items-center gap-2 rounded-2xl border bg-white px-3 py-2 text-left";
|
||||
|
||||
/** Avatar + name + hours cards for filtering the all-staff calendar by barber — an "All Staff" card plus one per active staff member, matching the day grid's own column-header style so the filter row and the grid read as one system. */
|
||||
export function StaffFilterPills({
|
||||
options,
|
||||
selectedId,
|
||||
hrefFor,
|
||||
}: {
|
||||
options: StaffFilterOption[];
|
||||
/** undefined = "All Staff" selected. */
|
||||
selectedId?: string;
|
||||
hrefFor: (staffId: string | null) => string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-2 overflow-x-auto px-6 py-1">
|
||||
<Link
|
||||
href={hrefFor(null)}
|
||||
aria-current={!selectedId ? "true" : undefined}
|
||||
className={cn(CARD_CLASS, !selectedId ? "border-ink ring-ink ring-1" : "border-line")}
|
||||
>
|
||||
<span className="bg-stone text-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-line">
|
||||
<Users className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<p className="text-sm font-bold">All Staff</p>
|
||||
<p className="text-muted text-[10px]">Everyone</p>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{options.map((option) => (
|
||||
<Link
|
||||
key={option.id}
|
||||
href={hrefFor(option.id)}
|
||||
aria-current={selectedId === option.id ? "true" : undefined}
|
||||
className={cn(CARD_CLASS, selectedId === option.id ? "border-ink ring-ink ring-1" : "border-line")}
|
||||
>
|
||||
{option.avatarUrl ? (
|
||||
<Image
|
||||
src={option.avatarUrl}
|
||||
alt=""
|
||||
width={36}
|
||||
height={36}
|
||||
className="h-9 w-9 shrink-0 rounded-full border border-line object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-stone text-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-line text-sm font-bold">
|
||||
{option.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
<p className="truncate text-sm font-bold">{option.name}</p>
|
||||
<p className="text-muted truncate text-[10px]">{option.hoursLabel ?? "Off today"}</p>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user