add client
CI / ci (push) Canceled after 0s

adds a button to add a new client from the barber menu
This commit is contained in:
Stefan Lazic committed 2026-09-14 12:25:45 +01:00
1 parent 91f3fd77d7
commit 0d821ce1f8
6 files changed
+287 -2

No files matched your search

@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { TopNav } from "@/components/navigation/top-nav";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { AddClientForm } from "@/features/staff/components/add-client-form";
export const metadata: Metadata = { title: "Add Client" };
export const dynamic = "force-dynamic";
export default async function AddClientPage() {
const context = await getActingBusinessContext();
if (!context) redirect("/home");
return (
<div className="pb-12">
<TopNav title="Add Client" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<AddClientForm />
</main>
</div>
);
}
+10 -1
View File
@@ -1,5 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Search, Users } from "lucide-react"; import { Plus, Search, Users } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { EmptyState } from "@/components/feedback/empty-state"; import { EmptyState } from "@/components/feedback/empty-state";
@@ -78,6 +78,15 @@ export default async function StaffClientsPage({
sticky sticky
className="bg-stone" className="bg-stone"
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />} leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
action={
<Link
href="/staff/clients/add"
className="bg-ink flex h-10 w-10 items-center justify-center rounded-full text-white"
aria-label="Add client"
>
<Plus className="h-4 w-4" aria-hidden="true" />
</Link>
}
/> />
<div className="px-6 pt-6"> <div className="px-6 pt-6">
+56
View File
@@ -0,0 +1,56 @@
"use server";
import { revalidatePath } from "next/cache";
import { registerRequestSchema } from "@/features/auth/schemas";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
import { prisma } from "@/lib/db/prisma";
import { createCustomerByStaff } from "@/services/customer/customer.service";
export interface AddClientInput {
firstName: string;
lastName: string;
email: string;
phone?: string;
}
export interface AddClientResult {
success: boolean;
error?: string;
customerId?: string;
}
/**
* Open to any signed-in staff/owner/super-admin — the same gate the
* Clients page itself uses (see (staff)/staff/clients/page.tsx), not
* owner-only: a walk-in's details are just as often taken by whoever's
* on the floor as by the owner.
*/
export async function addClientAction(input: AddClientInput): Promise<AddClientResult> {
const context = await getActingBusinessContext();
if (!context) return { success: false, error: "You must be signed in as staff." };
const parsed = registerRequestSchema.safeParse(input);
if (!parsed.success) {
return { success: false, error: parsed.error.issues[0]?.message ?? "Please check the form." };
}
const result = await createCustomerByStaff(prisma, parsed.data);
if (!result.success) return result;
await writeAuditLog(prisma, {
actorUserId: context.userId,
action: "CUSTOMER_ADDED_BY_STAFF",
entityType: "CustomerProfile",
entityId: result.customerId!,
metadata: { email: parsed.data.email },
});
// The new client won't actually show up in the Clients list until they
// have an appointment (see that page's own "belongs to this business is
// via the appointment relation" note) — revalidated anyway so the list
// is fresh the moment that happens, without a stale cache lingering.
revalidatePath("/staff/clients");
return result;
}
@@ -0,0 +1,85 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { addClientAction } from "../actions/clients";
/**
* A walk-in's details taken over the counter — creates a real account
* (see createCustomerByStaff) rather than a guest profile, so the
* customer can sign in later with just their email, no password. They're
* never emailed anything about this; the app only reaches out once they
* ask to sign in themselves.
*/
export function AddClientForm() {
const router = useRouter();
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
const [error, setError] = useState<string | null>(null);
const [added, setAdded] = useState<{ customerId: string; name: string } | null>(null);
const [isPending, startTransition] = useTransition();
const submit = () => {
setError(null);
startTransition(async () => {
const result = await addClientAction({ firstName, lastName, email, phone: phone || undefined });
if (!result.success || !result.customerId) {
setError(result.error ?? "Couldn't add this client.");
return;
}
setAdded({ customerId: result.customerId, name: `${firstName} ${lastName}`.trim() });
});
};
if (added) {
return (
<div className="space-y-4 rounded-md border-line border bg-white p-6">
<p className="text-sm font-bold">{added.name} has been added.</p>
<p className="text-muted text-sm">
They can sign in anytime with just their email — no password, no invite email sent. They&apos;ll show up
in Clients once they have an appointment.
</p>
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={() => router.push("/staff/clients")}>
Done
</Button>
<Button asChild className="flex-1">
<Link href={`/staff/book?customerId=${added.customerId}`}>Book Appointment</Link>
</Button>
</div>
</div>
);
}
return (
<div className="space-y-4">
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<div className="grid grid-cols-2 gap-3">
<Input label="First Name" required value={firstName} onChange={(e) => setFirstName(e.target.value)} />
<Input label="Last Name" required value={lastName} onChange={(e) => setLastName(e.target.value)} />
</div>
<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)} />
</div>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<Button
className="w-full"
isLoading={isPending}
disabled={!firstName.trim() || !lastName.trim() || !email.trim()}
onClick={submit}
>
Add Client
</Button>
</div>
);
}
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { setCustomerAvatar, updateCustomerProfile, updatePreferredBarber } from "@/services/customer/customer.service"; import { createCustomerByStaff, setCustomerAvatar, updateCustomerProfile, updatePreferredBarber } from "@/services/customer/customer.service";
import { import {
cleanupTestBusiness, cleanupTestBusiness,
createTestBusiness, createTestBusiness,
@@ -141,4 +141,54 @@ describe("customer.service (integration)", () => {
expect(result.success).toBe(false); expect(result.success).toBe(false);
}); });
}); });
describe("createCustomerByStaff", () => {
async function trackForCleanup(customerId: string) {
const profile = await prisma.customerProfile.findUniqueOrThrow({ where: { id: customerId } });
business.createdUserIds.push(profile.userId!);
}
it("creates a registered (non-guest) account with emailVerified already set", async () => {
const email = `walkin-${Date.now()}@test.local`;
const result = await createCustomerByStaff(prisma, { firstName: "Alex", lastName: "Kim", email, phone: "+44 7700 900123" });
expect(result.success).toBe(true);
await trackForCleanup(result.customerId!);
const profile = await prisma.customerProfile.findUniqueOrThrow({ where: { id: result.customerId } });
expect(profile.firstName).toBe("Alex");
expect(profile.lastName).toBe("Kim");
expect(profile.email).toBe(email);
expect(profile.phone).toBe("+44 7700 900123");
expect(profile.isGuest).toBe(false);
expect(profile.userId).toBeTruthy();
const user = await prisma.user.findUniqueOrThrow({ where: { id: profile.userId! } });
expect(user.email).toBe(email);
// Must be set, not null — requestOtp's onlyIfUserExists (see
// otp.service.ts) refuses to ever send a login code otherwise,
// which would silently and permanently lock this customer out.
expect(user.emailVerified).not.toBeNull();
});
it("lowercases and trims the email, and normalizes a blank phone to null", async () => {
const email = ` Walkin-${Date.now()}@Test.local `;
const result = await createCustomerByStaff(prisma, { firstName: " Alex ", lastName: " Kim ", email, phone: "" });
expect(result.success).toBe(true);
await trackForCleanup(result.customerId!);
const profile = await prisma.customerProfile.findUniqueOrThrow({ where: { id: result.customerId } });
expect(profile.email).toBe(email.trim().toLowerCase());
expect(profile.firstName).toBe("Alex");
expect(profile.phone).toBeNull();
});
it("rejects an email already in use by another account", async () => {
const { userId } = await createTestCustomer(business);
const existingUser = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
const result = await createCustomerByStaff(prisma, { firstName: "Alex", lastName: "Kim", email: existingUser.email! });
expect(result.success).toBe(false);
expect(result.error).toMatch(/already exists/i);
});
});
}); });
+63
View File
@@ -125,6 +125,69 @@ async function resolveGuestCustomer(
return { customerId: created.id, isNewProfile: true }; return { customerId: created.id, isNewProfile: true };
} }
export interface CreateCustomerByStaffParams {
firstName: string;
lastName: string;
email: string;
phone?: string;
}
export interface CreateCustomerByStaffResult {
success: boolean;
error?: string;
customerId?: string;
}
/**
* A staff/owner member adding a client who isn't self-registering right
* now — e.g. a walk-in's details taken over the counter (see
* features/staff/actions/clients.ts, the only caller). Produces the exact
* same shape a real self-service registration does (register.ts) —
* `isGuest: false`, `emailVerified` already set — specifically so the
* customer can sign in immediately afterward on the normal sign-in page.
* requestOtp's `onlyIfUserExists` check (see otp.service.ts) refuses to
* ever send a login code for a User whose `emailVerified` isn't set, so
* leaving that null here would silently and permanently lock them out —
* not a partial/pending account the way a staff invitation is.
*
* Deliberately does not call notify()/requestOtp() at all — the customer
* hears nothing until they themselves ask to sign in later. Does not
* attempt to match/attach an existing guest CustomerProfile with the same
* email/phone either — full guest-to-registered account claiming is an
* explicit out-of-scope case for this service (see this file's own
* header); a plain email-uniqueness check against User is enough here,
* same as register.ts.
*/
export async function createCustomerByStaff(
prisma: PrismaClient,
params: CreateCustomerByStaffParams,
): Promise<CreateCustomerByStaffResult> {
const email = params.email.trim().toLowerCase();
const firstName = params.firstName.trim();
const lastName = params.lastName.trim();
const phone = params.phone?.trim() || null;
const existing = await prisma.user.findUnique({ where: { email }, select: { id: true } });
if (existing) {
return { success: false, error: "A customer with this email already exists." };
}
const user = await prisma.user.create({
data: {
email,
phone,
name: `${firstName} ${lastName}`.trim(),
emailVerified: new Date(),
customerProfile: {
create: { firstName, lastName, email, phone, isGuest: false },
},
},
include: { customerProfile: true },
});
return { success: true, customerId: user.customerProfile!.id };
}
/** /**
* Staff-directory search (src/app/(staff)/staff/clients). Deliberately a * Staff-directory search (src/app/(staff)/staff/clients). Deliberately a
* plain in-memory filter, not a Prisma `contains`/`mode: "insensitive"` * plain in-memory filter, not a Prisma `contains`/`mode: "insensitive"`