UI Updates
Updated some pages pages to be more consistent.
This commit is contained in:
24 files changed
+901
-616
No files matched your search
@@ -3,10 +3,13 @@ import { Search, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { EmptyState } from "@/components/feedback/empty-state";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
|
||||
import { matchesCustomerSearch } from "@/services/customer/customer.service";
|
||||
import { getLoyaltySummariesForCustomers } from "@/services/loyalty/loyalty.service";
|
||||
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
export const metadata: Metadata = { title: "Clients" };
|
||||
@@ -24,6 +27,8 @@ export default async function StaffClientsPage({
|
||||
|
||||
const { q = "", sort } = await searchParams;
|
||||
|
||||
const unreadNotificationCount = await getUnreadNotificationCount(prisma, context.userId);
|
||||
|
||||
// Scoped via the appointment relation, not "every CustomerProfile row" —
|
||||
// a real business boundary even though this is a single-business v1 app
|
||||
// (see docs/phase-5-summary.md). `select`-scoped rather than the full
|
||||
@@ -67,106 +72,113 @@ export default async function StaffClientsPage({
|
||||
const now = new Date();
|
||||
|
||||
return (
|
||||
<div className="px-6 pt-12 pb-8 lg:max-w-5xl">
|
||||
<h1 className="mb-6 text-2xl font-bold tracking-tight">Clients</h1>
|
||||
<div className="pb-8 lg:max-w-5xl">
|
||||
<TopNav
|
||||
title="Clients"
|
||||
sticky
|
||||
className="bg-stone"
|
||||
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
|
||||
/>
|
||||
|
||||
<form action="/staff/clients" method="GET" className="relative mb-4">
|
||||
{sort && <input type="hidden" name="sort" value={sort} />}
|
||||
<Search
|
||||
className="text-muted absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<label htmlFor="client-search" className="sr-only">
|
||||
Search clients by name, email, or phone
|
||||
</label>
|
||||
<input
|
||||
id="client-search"
|
||||
type="search"
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder="Search by name, email, or phone"
|
||||
className="border-line focus:ring-ink w-full rounded-xl border bg-white py-3 pr-4 pl-11 text-sm outline-none focus:ring-2"
|
||||
/>
|
||||
</form>
|
||||
<div className="px-6 pt-6">
|
||||
<form action="/staff/clients" method="GET" className="relative mb-4">
|
||||
{sort && <input type="hidden" name="sort" value={sort} />}
|
||||
<Search
|
||||
className="text-muted absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<label htmlFor="client-search" className="sr-only">
|
||||
Search clients by name, email, or phone
|
||||
</label>
|
||||
<input
|
||||
id="client-search"
|
||||
type="search"
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder="Search by name, email, or phone"
|
||||
className="border-line focus:ring-ink w-full rounded-xl border bg-white py-3 pr-4 pl-11 text-sm outline-none focus:ring-2"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="mb-4 flex gap-2">
|
||||
<Link
|
||||
href={`/staff/clients?q=${encodeURIComponent(q)}&sort=name`}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
sort !== "recent" ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
Alphabetical
|
||||
</Link>
|
||||
<Link
|
||||
href={`/staff/clients?q=${encodeURIComponent(q)}&sort=recent`}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
sort === "recent" ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
Recent
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{sorted.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title={q ? "No matching clients" : "No clients yet"}
|
||||
description={
|
||||
q
|
||||
? "Try a different name, email, or phone number."
|
||||
: "Clients will show up here once they've booked with you."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{sorted.map((customer) => {
|
||||
const completedVisits = customer.appointments.filter(
|
||||
(a) => a.status === "COMPLETED",
|
||||
).length;
|
||||
const lastVisit = customer.appointments.find((a) => a.status === "COMPLETED");
|
||||
const upcoming = customer.appointments.find(
|
||||
(a) => UPCOMING_STATUSES.includes(a.status) && a.startAt > now,
|
||||
);
|
||||
const loyalty = loyaltySummaries.get(customer.id);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={customer.id}
|
||||
href={`/staff/clients/${customer.id}`}
|
||||
className="rounded-md border-line hover:bg-stone flex items-center gap-3 border bg-white px-5 py-4"
|
||||
>
|
||||
<div className="bg-stone text-muted flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-sm font-bold">
|
||||
{customer.firstName.charAt(0)}
|
||||
{customer.lastName.charAt(0)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-bold">
|
||||
{customer.firstName} {customer.lastName}
|
||||
</p>
|
||||
<p className="text-muted truncate text-xs">
|
||||
{customer.phone ?? customer.email}
|
||||
{lastVisit &&
|
||||
` · Last visit ${lastVisit.startAt.toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`}
|
||||
</p>
|
||||
<p className="text-muted mt-0.5 text-[10px] font-bold tracking-widest uppercase">
|
||||
{completedVisits} visit{completedVisits === 1 ? "" : "s"}
|
||||
{upcoming && <span className="text-accent"> · Upcoming</span>}
|
||||
{loyalty && (
|
||||
<span className={loyalty.hasAvailableReward ? "text-accent-loyalty" : ""}>
|
||||
{" "}
|
||||
· {loyalty.currentStamps}/{loyalty.rewardThreshold} stamps
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<div className="mb-4 flex gap-2">
|
||||
<Link
|
||||
href={`/staff/clients?q=${encodeURIComponent(q)}&sort=name`}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
sort !== "recent" ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
Alphabetical
|
||||
</Link>
|
||||
<Link
|
||||
href={`/staff/clients?q=${encodeURIComponent(q)}&sort=recent`}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
sort === "recent" ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
Recent
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sorted.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title={q ? "No matching clients" : "No clients yet"}
|
||||
description={
|
||||
q
|
||||
? "Try a different name, email, or phone number."
|
||||
: "Clients will show up here once they've booked with you."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{sorted.map((customer) => {
|
||||
const completedVisits = customer.appointments.filter(
|
||||
(a) => a.status === "COMPLETED",
|
||||
).length;
|
||||
const lastVisit = customer.appointments.find((a) => a.status === "COMPLETED");
|
||||
const upcoming = customer.appointments.find(
|
||||
(a) => UPCOMING_STATUSES.includes(a.status) && a.startAt > now,
|
||||
);
|
||||
const loyalty = loyaltySummaries.get(customer.id);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={customer.id}
|
||||
href={`/staff/clients/${customer.id}`}
|
||||
className="rounded-md border-line hover:bg-stone flex items-center gap-3 border bg-white px-5 py-4"
|
||||
>
|
||||
<div className="bg-stone text-muted flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-sm font-bold">
|
||||
{customer.firstName.charAt(0)}
|
||||
{customer.lastName.charAt(0)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-bold">
|
||||
{customer.firstName} {customer.lastName}
|
||||
</p>
|
||||
<p className="text-muted truncate text-xs">
|
||||
{customer.phone ?? customer.email}
|
||||
{lastVisit &&
|
||||
` · Last visit ${lastVisit.startAt.toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`}
|
||||
</p>
|
||||
<p className="text-muted mt-0.5 text-[10px] font-bold tracking-widest uppercase">
|
||||
{completedVisits} visit{completedVisits === 1 ? "" : "s"}
|
||||
{upcoming && <span className="text-accent"> · Upcoming</span>}
|
||||
{loyalty && (
|
||||
<span className={loyalty.hasAvailableReward ? "text-accent-loyalty" : ""}>
|
||||
{" "}
|
||||
· {loyalty.currentStamps}/{loyalty.rewardThreshold} stamps
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user