adds a button to add a new client from the barber menu
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Search, Users } from "lucide-react";
|
||||
import { Plus, Search, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { EmptyState } from "@/components/feedback/empty-state";
|
||||
@@ -78,6 +78,15 @@ export default async function StaffClientsPage({
|
||||
sticky
|
||||
className="bg-stone"
|
||||
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">
|
||||
|
||||
@@ -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'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 { setCustomerAvatar, updateCustomerProfile, updatePreferredBarber } from "@/services/customer/customer.service";
|
||||
import { createCustomerByStaff, setCustomerAvatar, updateCustomerProfile, updatePreferredBarber } from "@/services/customer/customer.service";
|
||||
import {
|
||||
cleanupTestBusiness,
|
||||
createTestBusiness,
|
||||
@@ -141,4 +141,54 @@ describe("customer.service (integration)", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -125,6 +125,69 @@ async function resolveGuestCustomer(
|
||||
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
|
||||
* plain in-memory filter, not a Prisma `contains`/`mode: "insensitive"`
|
||||
|
||||
Reference in new issue
Block a user