Customer Profile
Customer profile update with image upload.
This commit is contained in:
11 files changed
+761
-25
No files matched your search
@@ -1,5 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Bell, Gift, LogIn } from "lucide-react";
|
import { Bell, ChevronRight, Gift, LogIn, User } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -13,6 +13,12 @@ import { InstallAppButton } from "@/features/pwa/components/install-app-button";
|
|||||||
export const metadata: Metadata = { title: "Settings" };
|
export const metadata: Metadata = { title: "Settings" };
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const SETTINGS_LINKS = [
|
||||||
|
{ href: "/settings/preferred-barber", label: "Preferred Barber", icon: User },
|
||||||
|
{ href: "/loyalty", label: "Loyalty Card", icon: Gift },
|
||||||
|
{ href: "/settings/notifications", label: "Notifications", icon: Bell },
|
||||||
|
] as const;
|
||||||
|
|
||||||
export default async function CustomerSettingsPage() {
|
export default async function CustomerSettingsPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
@@ -34,8 +40,9 @@ export default async function CustomerSettingsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [customerProfile, branding] = await Promise.all([
|
const [customerProfile, user, branding] = await Promise.all([
|
||||||
prisma.customerProfile.findUnique({ where: { userId: session.user.id } }),
|
prisma.customerProfile.findUnique({ where: { userId: session.user.id } }),
|
||||||
|
prisma.user.findUnique({ where: { id: session.user.id }, select: { image: true } }),
|
||||||
getBranding(),
|
getBranding(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -49,22 +56,20 @@ export default async function CustomerSettingsPage() {
|
|||||||
: (session.user.name ?? "Signed-in customer")
|
: (session.user.name ?? "Signed-in customer")
|
||||||
}
|
}
|
||||||
meta={customerProfile?.email ?? session.user.email}
|
meta={customerProfile?.email ?? session.user.email}
|
||||||
avatarUrl={session.user.image ?? undefined}
|
avatarUrl={user?.image ?? undefined}
|
||||||
|
editHref="/settings/profile"
|
||||||
/>
|
/>
|
||||||
<div className="space-y-2">
|
<ul className="divide-line divide-y rounded-md border border-line bg-white">
|
||||||
<Link
|
{SETTINGS_LINKS.map(({ href, label, icon: Icon }) => (
|
||||||
href="/loyalty"
|
<li key={href}>
|
||||||
className="border-line flex items-center gap-3 rounded-xl border bg-white px-4 py-3 text-sm font-bold"
|
<Link href={href} className="hover:bg-stone flex items-center gap-3 px-5 py-4 text-sm font-bold">
|
||||||
>
|
<Icon className="text-muted h-5 w-5" aria-hidden="true" />
|
||||||
<Gift className="h-4 w-4" aria-hidden="true" /> Loyalty card
|
<span className="flex-1">{label}</span>
|
||||||
</Link>
|
<ChevronRight className="text-muted h-4 w-4" aria-hidden="true" />
|
||||||
<Link
|
</Link>
|
||||||
href="/settings/notifications"
|
</li>
|
||||||
className="border-line flex items-center gap-3 rounded-xl border bg-white px-4 py-3 text-sm font-bold"
|
))}
|
||||||
>
|
</ul>
|
||||||
<Bell className="h-4 w-4" aria-hidden="true" /> Notifications
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="border-line rounded-md border bg-white p-5">
|
<div className="border-line rounded-md border bg-white p-5">
|
||||||
<p className="mb-3 text-sm font-bold">Get the app</p>
|
<p className="mb-3 text-sm font-bold">Get the app</p>
|
||||||
<InstallAppButton businessName={branding.name} />
|
<InstallAppButton businessName={branding.name} />
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
|
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
||||||
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
import { PreferredBarberPicker } from "@/features/customer/components/preferred-barber-picker";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Preferred Barber" };
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function PreferredBarberPage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/sign-in?callbackUrl=/settings/preferred-barber");
|
||||||
|
|
||||||
|
const [business, customerProfile] = await Promise.all([
|
||||||
|
getPrimaryBusiness(),
|
||||||
|
prisma.customerProfile.findUnique({ where: { userId: session.user.id } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const staff = await prisma.staffProfile.findMany({
|
||||||
|
where: { businessId: business.id, status: "ACTIVE", isBookableOnline: true },
|
||||||
|
include: { user: { select: { name: true, image: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pb-12">
|
||||||
|
<TopNav title="Preferred Barber" onBack sticky className="bg-stone" />
|
||||||
|
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
|
||||||
|
<p className="text-muted text-sm">Chosen for you by default when booking — you can still pick anyone else.</p>
|
||||||
|
<PreferredBarberPicker
|
||||||
|
staff={staff.map((s) => ({ id: s.id, name: s.user.name ?? "Barber", avatarUrl: s.user.image ?? undefined }))}
|
||||||
|
initialPreferredId={customerProfile?.preferredBarberId ?? null}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
import { CustomerProfileForm } from "@/features/customer/components/customer-profile-form";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Edit Profile" };
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function CustomerEditProfilePage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/sign-in?callbackUrl=/settings/profile");
|
||||||
|
|
||||||
|
const [customerProfile, user] = await Promise.all([
|
||||||
|
prisma.customerProfile.findUnique({ where: { userId: session.user.id } }),
|
||||||
|
prisma.user.findUnique({ where: { id: session.user.id }, select: { image: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [sessionFirstName, ...sessionLastNameParts] = (session.user.name ?? "").trim().split(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pb-12">
|
||||||
|
<TopNav title="Edit Profile" onBack sticky className="bg-stone" />
|
||||||
|
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6">
|
||||||
|
<CustomerProfileForm
|
||||||
|
initial={{
|
||||||
|
firstName: customerProfile?.firstName ?? sessionFirstName ?? "",
|
||||||
|
lastName: customerProfile?.lastName ?? sessionLastNameParts.join(" "),
|
||||||
|
email: customerProfile?.email ?? session.user.email ?? "",
|
||||||
|
phone: customerProfile?.phone ?? "",
|
||||||
|
image: user?.image ?? "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
import { Pencil } from "lucide-react";
|
import { Pencil } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
@@ -8,10 +9,14 @@ export interface ProfileCardProps {
|
|||||||
meta?: ReactNode;
|
meta?: ReactNode;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
|
/** Renders the edit-pencil as a `<Link>` instead of a button — lets a Server Component page use the edit affordance without becoming a Client Component. Ignored if `onEdit` is also passed. */
|
||||||
|
editHref?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Avatar + name + meta + edit-pencil pattern reused across nearly every settings/profile screen. */
|
/** Avatar + name + meta + edit-pencil pattern reused across nearly every settings/profile screen. */
|
||||||
export function ProfileCard({ name, meta, avatarUrl, onEdit }: ProfileCardProps) {
|
export function ProfileCard({ name, meta, avatarUrl, onEdit, editHref }: ProfileCardProps) {
|
||||||
|
const editButtonClassName = "flex h-10 w-10 items-center justify-center rounded-full bg-stone text-ink";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="flex items-center gap-4 p-5">
|
<Card className="flex items-center gap-4 p-5">
|
||||||
{avatarUrl ? (
|
{avatarUrl ? (
|
||||||
@@ -31,15 +36,16 @@ export function ProfileCard({ name, meta, avatarUrl, onEdit }: ProfileCardProps)
|
|||||||
<h2 className="text-lg font-bold">{name}</h2>
|
<h2 className="text-lg font-bold">{name}</h2>
|
||||||
{meta && <div className="mt-0.5 text-xs text-muted">{meta}</div>}
|
{meta && <div className="mt-0.5 text-xs text-muted">{meta}</div>}
|
||||||
</div>
|
</div>
|
||||||
{onEdit && (
|
{onEdit ? (
|
||||||
<button
|
<button type="button" onClick={onEdit} aria-label={`Edit ${name}`} className={editButtonClassName}>
|
||||||
type="button"
|
|
||||||
onClick={onEdit}
|
|
||||||
aria-label={`Edit ${name}`}
|
|
||||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-stone text-ink"
|
|
||||||
>
|
|
||||||
<Pencil className="h-3.5 w-3.5" aria-hidden="true" />
|
<Pencil className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
editHref && (
|
||||||
|
<Link href={editHref} aria-label={`Edit ${name}`} className={editButtonClassName}>
|
||||||
|
<Pencil className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
import { customerProfileSchema } from "@/features/customer/schemas";
|
||||||
|
import {
|
||||||
|
CUSTOMER_AVATAR_CATEGORY,
|
||||||
|
setCustomerAvatar,
|
||||||
|
updateCustomerProfile,
|
||||||
|
updatePreferredBarber,
|
||||||
|
} from "@/services/customer/customer.service";
|
||||||
|
import { deleteMediaFile, isAllowedMediaMimeType, MEDIA_MAX_UPLOAD_BYTES, storeMediaFile } from "@/lib/media/media-storage";
|
||||||
|
|
||||||
|
export interface CustomerProfileActionResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
/** Per-field errors, keyed by field name, for the form to surface inline. */
|
||||||
|
fieldErrors?: Record<string, string[] | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCustomerProfileAction(input: {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
}): Promise<CustomerProfileActionResult> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return { success: false, error: "You must be signed in." };
|
||||||
|
|
||||||
|
const parsed = customerProfileSchema.safeParse(input);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { success: false, error: "Please fix the highlighted fields.", fieldErrors: parsed.error.flatten().fieldErrors };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await updateCustomerProfile(prisma, { userId: session.user.id, ...parsed.data, phone: parsed.data.phone ?? "" });
|
||||||
|
if (!result.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: result.error,
|
||||||
|
fieldErrors: result.field ? { [result.field]: [result.error ?? ""] } : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePreferredBarberAction(staffId: string | null): Promise<CustomerProfileActionResult> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return { success: false, error: "You must be signed in." };
|
||||||
|
|
||||||
|
return updatePreferredBarber(prisma, { userId: session.user.id, staffId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerAvatarActionResult {
|
||||||
|
success: boolean;
|
||||||
|
url?: string | null;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads a customer's own profile photo straight from the device file
|
||||||
|
* picker — no Media Manager/library involved, unlike staff/service/branding
|
||||||
|
* images (see MediaPicker). Stored under its own category so it's never
|
||||||
|
* inserted as a MediaAsset row and therefore never browsable in that
|
||||||
|
* library. Takes effect immediately (writes User.image right away) rather
|
||||||
|
* than waiting for the rest of the profile form's Save, and deletes the
|
||||||
|
* previous photo file once the new one is safely persisted — but only if
|
||||||
|
* that previous file was one we stored (an OAuth-provided avatar URL is
|
||||||
|
* left alone).
|
||||||
|
*/
|
||||||
|
export async function uploadCustomerAvatarAction(formData: FormData): Promise<CustomerAvatarActionResult> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return { success: false, error: "You must be signed in." };
|
||||||
|
|
||||||
|
const file = formData.get("file");
|
||||||
|
if (!(file instanceof File)) return { success: false, error: "No file provided." };
|
||||||
|
if (!isAllowedMediaMimeType(file.type)) {
|
||||||
|
return { success: false, error: "Unsupported image type — use PNG, JPEG, WEBP, or GIF." };
|
||||||
|
}
|
||||||
|
if (file.size > MEDIA_MAX_UPLOAD_BYTES) {
|
||||||
|
return { success: false, error: `File is too large — max ${Math.floor(MEDIA_MAX_UPLOAD_BYTES / 1_000_000)}MB.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const stored = await storeMediaFile({ buffer, mimeType: file.type, category: CUSTOMER_AVATAR_CATEGORY });
|
||||||
|
|
||||||
|
const { previousUrl } = await setCustomerAvatar(prisma, { userId: session.user.id, url: stored.url });
|
||||||
|
if (previousUrl?.includes(`/${CUSTOMER_AVATAR_CATEGORY}/`)) {
|
||||||
|
await deleteMediaFile(previousUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, url: stored.url };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeCustomerAvatarAction(): Promise<CustomerAvatarActionResult> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return { success: false, error: "You must be signed in." };
|
||||||
|
|
||||||
|
const { previousUrl } = await setCustomerAvatar(prisma, { userId: session.user.id, url: null });
|
||||||
|
if (previousUrl?.includes(`/${CUSTOMER_AVATAR_CATEGORY}/`)) {
|
||||||
|
await deleteMediaFile(previousUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, url: null };
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState, useTransition } from "react";
|
||||||
|
import { ImageOff, X } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { removeCustomerAvatarAction, uploadCustomerAvatarAction } from "@/features/customer/actions/profile";
|
||||||
|
|
||||||
|
export interface CustomerAvatarPickerProps {
|
||||||
|
value: string | null;
|
||||||
|
onChange: (url: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customer's own profile photo — opens the device's native file picker
|
||||||
|
* directly and uploads immediately, unlike MediaPicker (staff/service/
|
||||||
|
* branding images), which opens the shared Media Manager library. Kept
|
||||||
|
* deliberately separate: a customer's photo is personal, not something an
|
||||||
|
* owner should be able to browse/reuse/reassign to something else.
|
||||||
|
*/
|
||||||
|
export function CustomerAvatarPicker({ value, onChange }: CustomerAvatarPickerProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set("file", file);
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await uploadCustomerAvatarAction(formData);
|
||||||
|
if (!result.success) {
|
||||||
|
setError(result.error ?? "Couldn't upload your photo.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange(result.url ?? null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemove() {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await removeCustomerAvatarAction();
|
||||||
|
if (!result.success) {
|
||||||
|
setError(result.error ?? "Couldn't remove your photo.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<p className="text-muted mb-2 ml-1 text-[10px] font-bold tracking-widest uppercase">Photo</p>
|
||||||
|
<div className="border-line flex items-center gap-4 rounded-xl border bg-white p-4">
|
||||||
|
<div className="bg-stone flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg">
|
||||||
|
{value ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URLs, same pattern used app-wide
|
||||||
|
<img src={value} alt="" className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<ImageOff className="text-muted h-5 w-5" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-wrap gap-2">
|
||||||
|
<Button type="button" size="sm" variant="secondary" isLoading={isPending} onClick={() => inputRef.current?.click()}>
|
||||||
|
{value ? "Change" : "Choose image"}
|
||||||
|
</Button>
|
||||||
|
{value && (
|
||||||
|
<Button type="button" size="sm" variant="ghost" disabled={isPending} onClick={handleRemove}>
|
||||||
|
<X className="h-3.5 w-3.5" aria-hidden="true" /> Remove
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||||
|
className="sr-only"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-danger mt-1.5 ml-1 text-xs font-medium">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { CustomerAvatarPicker } from "@/features/customer/components/customer-avatar-picker";
|
||||||
|
import { updateCustomerProfileAction } from "@/features/customer/actions/profile";
|
||||||
|
|
||||||
|
export interface CustomerProfileFormInitial {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
image: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomerProfileForm({ initial }: { initial: CustomerProfileFormInitial }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [firstName, setFirstName] = useState(initial.firstName);
|
||||||
|
const [lastName, setLastName] = useState(initial.lastName);
|
||||||
|
const [email, setEmail] = useState(initial.email);
|
||||||
|
const [phone, setPhone] = useState(initial.phone);
|
||||||
|
const [image, setImage] = useState(initial.image);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string | undefined>>({});
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
setError(null);
|
||||||
|
setFieldErrors({});
|
||||||
|
setSaved(false);
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await updateCustomerProfileAction({ firstName, lastName, email, phone });
|
||||||
|
if (!result.success) {
|
||||||
|
setError(result.error ?? "Couldn't save your profile.");
|
||||||
|
if (result.fieldErrors) {
|
||||||
|
setFieldErrors(Object.fromEntries(Object.entries(result.fieldErrors).map(([key, value]) => [key, value?.[0]])));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaved(true);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<CustomerAvatarPicker value={image || null} onChange={(url) => setImage(url ?? "")} />
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="First Name"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
error={fieldErrors.firstName}
|
||||||
|
/>
|
||||||
|
<Input label="Last Name" value={lastName} onChange={(e) => setLastName(e.target.value)} error={fieldErrors.lastName} />
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="Email Address"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
error={fieldErrors.email}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Phone Number"
|
||||||
|
type="tel"
|
||||||
|
placeholder="+44 7700 900000"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
error={fieldErrors.phone}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-danger text-sm font-medium">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{saved && !error && (
|
||||||
|
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||||
|
Saved.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { updatePreferredBarberAction } from "@/features/customer/actions/profile";
|
||||||
|
|
||||||
|
export interface PreferredBarberOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tap-to-select, saves immediately (no separate Save step) — same "pick one" pattern as the Reviews manager's hide/unhide toggle: one action per row, optimistic update, revert on failure. */
|
||||||
|
export function PreferredBarberPicker({
|
||||||
|
staff,
|
||||||
|
initialPreferredId,
|
||||||
|
}: {
|
||||||
|
staff: PreferredBarberOption[];
|
||||||
|
initialPreferredId: string | null;
|
||||||
|
}) {
|
||||||
|
const [preferredId, setPreferredId] = useState(initialPreferredId);
|
||||||
|
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const select = (id: string | null) => {
|
||||||
|
if (id === preferredId) return;
|
||||||
|
const previous = preferredId;
|
||||||
|
setError(null);
|
||||||
|
setPreferredId(id);
|
||||||
|
setPendingId(id ?? "none");
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await updatePreferredBarberAction(id);
|
||||||
|
if (!result.success) {
|
||||||
|
setPreferredId(previous);
|
||||||
|
setError(result.error ?? "Couldn't save your preference.");
|
||||||
|
}
|
||||||
|
setPendingId(null);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-danger text-sm font-medium">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<ul className="divide-line divide-y rounded-md border border-line bg-white">
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => select(null)}
|
||||||
|
disabled={pendingId !== null}
|
||||||
|
className="hover:bg-stone flex w-full items-center gap-3 px-5 py-4 text-left text-sm font-bold disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<div className="bg-stone flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-muted">—</div>
|
||||||
|
<span className="flex-1">No preference</span>
|
||||||
|
{preferredId === null && <Check className="text-accent h-4 w-4" aria-hidden="true" />}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{staff.map((barber) => (
|
||||||
|
<li key={barber.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => select(barber.id)}
|
||||||
|
disabled={pendingId !== null}
|
||||||
|
className="hover:bg-stone flex w-full items-center gap-3 px-5 py-4 text-left text-sm font-bold disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{barber.avatarUrl ? (
|
||||||
|
<Image
|
||||||
|
src={barber.avatarUrl}
|
||||||
|
alt=""
|
||||||
|
width={44}
|
||||||
|
height={44}
|
||||||
|
className="h-11 w-11 shrink-0 rounded-full border border-line object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="bg-stone text-muted flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-sm font-bold">
|
||||||
|
{barber.name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="flex-1">{barber.name}</span>
|
||||||
|
{preferredId === barber.id && <Check className="text-accent h-4 w-4" aria-hidden="true" />}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/** Shared between the customer profile-edit form and its server action, same rationale as auth/schemas.ts. */
|
||||||
|
export const customerProfileSchema = z.object({
|
||||||
|
firstName: z.string().trim().min(1, "First name is required"),
|
||||||
|
lastName: z.string().trim().min(1, "Last name is required"),
|
||||||
|
email: z.email("Enter a valid email address"),
|
||||||
|
phone: z.string().trim().optional().or(z.literal("")),
|
||||||
|
});
|
||||||
|
export type CustomerProfileInput = z.infer<typeof customerProfileSchema>;
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { setCustomerAvatar, updateCustomerProfile, updatePreferredBarber } from "@/services/customer/customer.service";
|
||||||
|
import {
|
||||||
|
cleanupTestBusiness,
|
||||||
|
createTestBusiness,
|
||||||
|
createTestCustomer,
|
||||||
|
createTestStaff,
|
||||||
|
prisma,
|
||||||
|
} from "@/services/scheduling/__tests__/fixtures";
|
||||||
|
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||||
|
|
||||||
|
describe("customer.service (integration)", () => {
|
||||||
|
let business: TestBusiness;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
business = await createTestBusiness();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await cleanupTestBusiness(business);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateCustomerProfile", () => {
|
||||||
|
it("updates both User and CustomerProfile", async () => {
|
||||||
|
const { userId } = await createTestCustomer(business);
|
||||||
|
|
||||||
|
// Distinct from the phone number, not just the email — User.phone has
|
||||||
|
// its own unique constraint, so a hardcoded literal here would
|
||||||
|
// collide with any other row (seed data, another test run) that
|
||||||
|
// happens to use the same number.
|
||||||
|
const phone = `+44 7700 ${Math.floor(100000 + Math.random() * 900000)}`;
|
||||||
|
const result = await updateCustomerProfile(prisma, {
|
||||||
|
userId,
|
||||||
|
firstName: "Jamie",
|
||||||
|
lastName: "Fraser",
|
||||||
|
email: `jamie-${userId}@test.local`,
|
||||||
|
phone,
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
|
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||||
|
expect(user.name).toBe("Jamie Fraser");
|
||||||
|
expect(user.email).toBe(`jamie-${userId}@test.local`);
|
||||||
|
expect(user.phone).toBe(phone);
|
||||||
|
|
||||||
|
const profile = await prisma.customerProfile.findUniqueOrThrow({ where: { userId } });
|
||||||
|
expect(profile.firstName).toBe("Jamie");
|
||||||
|
expect(profile.lastName).toBe("Fraser");
|
||||||
|
expect(profile.phone).toBe(phone);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a CustomerProfile via upsert for a user who has never booked", async () => {
|
||||||
|
const user = await prisma.user.create({ data: { email: `never-booked-${Date.now()}@test.local`, name: "New User" } });
|
||||||
|
business.createdUserIds.push(user.id);
|
||||||
|
|
||||||
|
const result = await updateCustomerProfile(prisma, {
|
||||||
|
userId: user.id,
|
||||||
|
firstName: "New",
|
||||||
|
lastName: "User",
|
||||||
|
email: user.email!,
|
||||||
|
phone: "",
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
|
const profile = await prisma.customerProfile.findUniqueOrThrow({ where: { userId: user.id } });
|
||||||
|
expect(profile.firstName).toBe("New");
|
||||||
|
expect(profile.isGuest).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an email already used by another user", async () => {
|
||||||
|
const { userId } = await createTestCustomer(business);
|
||||||
|
const other = await createTestCustomer(business);
|
||||||
|
const otherUser = await prisma.user.findUniqueOrThrow({ where: { id: other.userId } });
|
||||||
|
|
||||||
|
const result = await updateCustomerProfile(prisma, {
|
||||||
|
userId,
|
||||||
|
firstName: "Jamie",
|
||||||
|
lastName: "Fraser",
|
||||||
|
email: otherUser.email!,
|
||||||
|
phone: "",
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.field).toBe("email");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setCustomerAvatar", () => {
|
||||||
|
it("sets User.image and returns the previous value", async () => {
|
||||||
|
const { userId } = await createTestCustomer(business);
|
||||||
|
|
||||||
|
const first = await setCustomerAvatar(prisma, { userId, url: "/api/media/customer-avatars/a.jpg" });
|
||||||
|
expect(first.previousUrl).toBeNull();
|
||||||
|
let user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||||
|
expect(user.image).toBe("/api/media/customer-avatars/a.jpg");
|
||||||
|
|
||||||
|
const second = await setCustomerAvatar(prisma, { userId, url: "/api/media/customer-avatars/b.jpg" });
|
||||||
|
expect(second.previousUrl).toBe("/api/media/customer-avatars/a.jpg");
|
||||||
|
user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||||
|
expect(user.image).toBe("/api/media/customer-avatars/b.jpg");
|
||||||
|
|
||||||
|
const cleared = await setCustomerAvatar(prisma, { userId, url: null });
|
||||||
|
expect(cleared.previousUrl).toBe("/api/media/customer-avatars/b.jpg");
|
||||||
|
user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||||
|
expect(user.image).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updatePreferredBarber", () => {
|
||||||
|
it("sets and clears a preferred barber on an existing CustomerProfile", async () => {
|
||||||
|
const { userId } = await createTestCustomer(business);
|
||||||
|
const { staffId } = await createTestStaff(business);
|
||||||
|
|
||||||
|
const set = await updatePreferredBarber(prisma, { userId, staffId });
|
||||||
|
expect(set.success).toBe(true);
|
||||||
|
let profile = await prisma.customerProfile.findUniqueOrThrow({ where: { userId } });
|
||||||
|
expect(profile.preferredBarberId).toBe(staffId);
|
||||||
|
|
||||||
|
const cleared = await updatePreferredBarber(prisma, { userId, staffId: null });
|
||||||
|
expect(cleared.success).toBe(true);
|
||||||
|
profile = await prisma.customerProfile.findUniqueOrThrow({ where: { userId } });
|
||||||
|
expect(profile.preferredBarberId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a CustomerProfile from session User data when none exists yet", async () => {
|
||||||
|
const user = await prisma.user.create({ data: { email: `no-profile-${Date.now()}@test.local`, name: "Casey Rivers" } });
|
||||||
|
business.createdUserIds.push(user.id);
|
||||||
|
const { staffId } = await createTestStaff(business);
|
||||||
|
|
||||||
|
const result = await updatePreferredBarber(prisma, { userId: user.id, staffId });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
|
const profile = await prisma.customerProfile.findUniqueOrThrow({ where: { userId: user.id } });
|
||||||
|
expect(profile.firstName).toBe("Casey");
|
||||||
|
expect(profile.lastName).toBe("Rivers");
|
||||||
|
expect(profile.preferredBarberId).toBe(staffId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a staffId that doesn't exist", async () => {
|
||||||
|
const { userId } = await createTestCustomer(business);
|
||||||
|
const result = await updatePreferredBarber(prisma, { userId, staffId: "not-a-real-staff-id" });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -149,3 +149,120 @@ export function matchesCustomerSearch(
|
|||||||
(customer.phone ?? "").toLowerCase().includes(normalized)
|
(customer.phone ?? "").toLowerCase().includes(normalized)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateCustomerProfileParams {
|
||||||
|
userId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCustomerProfileResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
/** Set only for a validation failure the caller should surface on a specific field (currently just a taken email). */
|
||||||
|
field?: "email";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A signed-in customer editing their own name/email/phone — see
|
||||||
|
* features/customer/actions/profile.ts, the only caller. Writes both User
|
||||||
|
* (the actual auth/session record, same split staff profile editing uses
|
||||||
|
* in staff.service.ts's updateStaffProfile) and CustomerProfile (the
|
||||||
|
* denormalized firstName/lastName/email/phone copy read everywhere else —
|
||||||
|
* see this file's own header on why the two can drift). A customer who has
|
||||||
|
* never booked yet has no CustomerProfile row at all, so this upserts
|
||||||
|
* rather than assuming one exists. Photo is handled separately by
|
||||||
|
* setCustomerAvatar below — it uploads/deletes immediately rather than
|
||||||
|
* waiting for this form's Save.
|
||||||
|
*/
|
||||||
|
export async function updateCustomerProfile(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
params: UpdateCustomerProfileParams,
|
||||||
|
): Promise<UpdateCustomerProfileResult> {
|
||||||
|
const emailTaken = await prisma.user.findUnique({ where: { email: params.email }, select: { id: true } });
|
||||||
|
if (emailTaken && emailTaken.id !== params.userId) {
|
||||||
|
return { success: false, error: "That email is already in use.", field: "email" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.user.update({
|
||||||
|
where: { id: params.userId },
|
||||||
|
data: {
|
||||||
|
name: `${params.firstName} ${params.lastName}`.trim(),
|
||||||
|
email: params.email,
|
||||||
|
phone: params.phone || null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.customerProfile.upsert({
|
||||||
|
where: { userId: params.userId },
|
||||||
|
create: {
|
||||||
|
userId: params.userId,
|
||||||
|
firstName: params.firstName,
|
||||||
|
lastName: params.lastName,
|
||||||
|
email: params.email,
|
||||||
|
phone: params.phone || null,
|
||||||
|
isGuest: false,
|
||||||
|
},
|
||||||
|
update: { firstName: params.firstName, lastName: params.lastName, email: params.email, phone: params.phone || null },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Couldn't save your profile." };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directory customer avatar uploads are stored under — deliberately never
|
||||||
|
* inserted as a MediaAsset row (see features/customer/actions/profile.ts's
|
||||||
|
* uploadCustomerAvatarAction), so these files never show up in the staff/
|
||||||
|
* owner Media Manager library. Exported so the "is this old image one of
|
||||||
|
* ours to delete" check in that action uses the exact same value.
|
||||||
|
*/
|
||||||
|
export const CUSTOMER_AVATAR_CATEGORY = "customer-avatars";
|
||||||
|
|
||||||
|
/** Swaps User.image, returning the previous value so the caller (uploadCustomerAvatarAction/removeCustomerAvatarAction) can delete the old file from disk after the DB write succeeds. */
|
||||||
|
export async function setCustomerAvatar(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
params: { userId: string; url: string | null },
|
||||||
|
): Promise<{ previousUrl: string | null }> {
|
||||||
|
const previous = await prisma.user.findUniqueOrThrow({ where: { id: params.userId }, select: { image: true } });
|
||||||
|
await prisma.user.update({ where: { id: params.userId }, data: { image: params.url } });
|
||||||
|
return { previousUrl: previous.image };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets or clears (`staffId: null`) the customer's preferred barber — see Home's "Preferred Barber" display, the only other place this is read. */
|
||||||
|
export async function updatePreferredBarber(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
params: { userId: string; staffId: string | null },
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (params.staffId) {
|
||||||
|
const staff = await prisma.staffProfile.findUnique({ where: { id: params.staffId }, select: { id: true } });
|
||||||
|
if (!staff) return { success: false, error: "That barber couldn't be found." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.customerProfile.findUnique({ where: { userId: params.userId } });
|
||||||
|
if (existing) {
|
||||||
|
await prisma.customerProfile.update({ where: { userId: params.userId }, data: { preferredBarberId: params.staffId } });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUniqueOrThrow({ where: { id: params.userId } });
|
||||||
|
const [firstName, ...rest] = (user.name ?? "").trim().split(/\s+/).filter(Boolean);
|
||||||
|
await prisma.customerProfile.create({
|
||||||
|
data: {
|
||||||
|
userId: params.userId,
|
||||||
|
firstName: firstName || "Customer",
|
||||||
|
lastName: rest.join(" "),
|
||||||
|
email: user.email ?? "",
|
||||||
|
phone: user.phone ?? undefined,
|
||||||
|
preferredBarberId: params.staffId,
|
||||||
|
isGuest: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
Reference in new issue
Block a user