Customer Profile
Customer profile update with image upload.
This commit is contained in:
11 files changed
+761
-25
No files matched your search
@@ -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>;
|
||||
Reference in new issue
Block a user