Customer Profile

Customer profile update with image upload.
This commit is contained in:
Stefan Lazic committed 2026-08-06 12:11:36 +01:00
1 parent f07c30da0d
commit 973f18f64c
11 files changed
+761 -25

No files matched your search

+104
View File
@@ -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>
);
}
+10
View File
@@ -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>;