UI Updates

Updated some pages pages to be more consistent.
This commit is contained in:
Stefan Lazic committed 2026-08-06 19:21:31 +01:00
1 parent 90ec663e6e
commit a702320d4e
24 files changed
+461 -176

No files matched your search

+11 -6
View File
@@ -55,21 +55,26 @@ test.describe.serial("owner administration journeys", () => {
await page.goto("/owner/calendar"); await page.goto("/owner/calendar");
await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible();
await expect(page.getByRole("link", { name: "Marcus Reed", exact: true })).toBeVisible(); // The staff filter pills are avatar+name+hours cards now, so their
await expect(page.getByRole("link", { name: "Leo Vance", exact: true })).toBeVisible(); // accessible name is "Marcus Reed 10:00 AM – 7:00 PM" etc — a
// substring match (Playwright's default, no `exact`) still uniquely
// identifies each pill's link.
await expect(page.getByRole("link", { name: "Marcus Reed" })).toBeVisible();
await expect(page.getByRole("link", { name: "Leo Vance" })).toBeVisible();
// Filtering to one staff member narrows the day view to a single column. // Filtering to one staff member narrows the day view to a single column.
await page.getByRole("link", { name: "Leo Vance", exact: true }).click(); await page.getByRole("link", { name: "Leo Vance" }).click();
await expect(page).toHaveURL(/staffId=/); await expect(page).toHaveURL(/staffId=/);
// The single remaining day-view column is headed by a <p> with the staff member's exact name // The single remaining day-view column is headed by a <p> with the staff member's exact name
// (the filter pill link has the same text, so scope to the paragraph specifically). // — the staff filter pill above it is *also* an avatar+name+hours card with its own <p> of the
await expect(page.locator("p").getByText("Leo Vance", { exact: true })).toBeVisible(); // same text now, so scope to the grid itself (data-testid="calendar-grid") to disambiguate.
await expect(page.getByTestId("calendar-grid").locator("p").getByText("Leo Vance", { exact: true })).toBeVisible();
await page.getByRole("link", { name: "Agenda", exact: true }).click(); await page.getByRole("link", { name: "Agenda", exact: true }).click();
await expect(page).toHaveURL(/view=agenda/); await expect(page).toHaveURL(/view=agenda/);
await expect(page.getByRole("link", { name: "Day", exact: true })).toBeVisible(); await expect(page.getByRole("link", { name: "Day", exact: true })).toBeVisible();
await page.getByRole("link", { name: "All Staff", exact: true }).click(); await page.getByRole("link", { name: "All Staff" }).click();
await expect(page).not.toHaveURL(/staffId=/); await expect(page).not.toHaveURL(/staffId=/);
}); });
+20 -7
View File
@@ -6,11 +6,14 @@ import { AppointmentCard } from "@/components/ui/appointment-card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EmptyState } from "@/components/feedback/empty-state"; import { EmptyState } from "@/components/feedback/empty-state";
import { TopNav } from "@/components/navigation/top-nav";
import { getPrimaryBusiness } from "@/lib/business/get-primary-business"; import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import { listAppointments } from "@/services/scheduling/appointment.service"; import { listAppointments } from "@/services/scheduling/appointment.service";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
import { BookAgainButton } from "@/features/booking/components/book-again-button"; import { BookAgainButton } from "@/features/booking/components/book-again-button";
import { CancelAppointmentDialog } from "@/features/booking/components/cancel-appointment-dialog"; import { CancelAppointmentDialog } from "@/features/booking/components/cancel-appointment-dialog";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
import type { AppointmentStatus } from "@/types/domain"; import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "Appointments" }; export const metadata: Metadata = { title: "Appointments" };
@@ -36,8 +39,9 @@ export default async function AppointmentsPage() {
if (!session?.user) { if (!session?.user) {
return ( return (
<div className="mx-auto max-w-2xl px-6 pt-12"> <div className="pb-12">
<h1 className="mb-6 text-2xl font-bold tracking-tight">Appointments</h1> <TopNav title="Appointments" sticky className="bg-stone" />
<div className="mx-auto max-w-2xl px-6 pt-6">
<EmptyState <EmptyState
icon={LogIn} icon={LogIn}
title="Sign in to see your appointments" title="Sign in to see your appointments"
@@ -49,18 +53,24 @@ export default async function AppointmentsPage() {
} }
/> />
</div> </div>
</div>
); );
} }
const business = await getPrimaryBusiness(); const [business, unreadNotificationCount] = await Promise.all([
getPrimaryBusiness(),
getUnreadNotificationCount(prisma, session.user.id),
]);
const customerProfile = await prisma.customerProfile.findUnique({ const customerProfile = await prisma.customerProfile.findUnique({
where: { userId: session.user.id }, where: { userId: session.user.id },
}); });
const bell = <InlineNotificationBell href="/notifications" unreadCount={unreadNotificationCount} />;
if (!customerProfile) { if (!customerProfile) {
return ( return (
<div className="mx-auto max-w-2xl px-6 pt-12"> <div className="pb-12">
<h1 className="mb-6 text-2xl font-bold tracking-tight">Appointments</h1> <TopNav title="Appointments" sticky className="bg-stone" action={bell} />
<div className="mx-auto max-w-2xl px-6 pt-6">
<EmptyState <EmptyState
icon={CalendarPlus} icon={CalendarPlus}
title="No appointments yet" title="No appointments yet"
@@ -72,6 +82,7 @@ export default async function AppointmentsPage() {
} }
/> />
</div> </div>
</div>
); );
} }
@@ -153,8 +164,9 @@ export default async function AppointmentsPage() {
}; };
return ( return (
<div className="mx-auto max-w-2xl px-6 pt-12"> <div className="pb-12">
<h1 className="mb-6 text-2xl font-bold tracking-tight">Appointments</h1> <TopNav title="Appointments" sticky className="bg-stone" action={bell} />
<div className="mx-auto max-w-2xl px-6 pt-6">
<Tabs defaultValue="upcoming"> <Tabs defaultValue="upcoming">
<TabsList> <TabsList>
<TabsTrigger value="upcoming">Upcoming</TabsTrigger> <TabsTrigger value="upcoming">Upcoming</TabsTrigger>
@@ -166,5 +178,6 @@ export default async function AppointmentsPage() {
<TabsContent value="cancelled">{renderList(cancelled, "cancelled")}</TabsContent> <TabsContent value="cancelled">{renderList(cancelled, "cancelled")}</TabsContent>
</Tabs> </Tabs>
</div> </div>
</div>
); );
} }
+13 -7
View File
@@ -10,7 +10,9 @@ import { TopNav } from "@/components/navigation/top-nav";
import { getPrimaryBusiness } from "@/lib/business/get-primary-business"; import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service"; import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold"; import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
export const metadata: Metadata = { title: "Loyalty" }; export const metadata: Metadata = { title: "Loyalty" };
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -20,8 +22,9 @@ export default async function LoyaltyPage() {
if (!session?.user) { if (!session?.user) {
return ( return (
<div className="mx-auto max-w-2xl px-6 pt-12"> <div className="pb-12">
<h1 className="mb-6 text-2xl font-bold tracking-tight">Loyalty</h1> <TopNav title="Loyalty" sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<EmptyState <EmptyState
icon={LogIn} icon={LogIn}
title="Sign in to see your loyalty card" title="Sign in to see your loyalty card"
@@ -32,13 +35,16 @@ export default async function LoyaltyPage() {
</Button> </Button>
} }
/> />
</main>
</div> </div>
); );
} }
const customerProfile = await prisma.customerProfile.findUnique({ const [customerProfile, unreadNotificationCount] = await Promise.all([
where: { userId: session.user.id }, prisma.customerProfile.findUnique({ where: { userId: session.user.id } }),
}); getUnreadNotificationCount(prisma, session.user.id),
]);
const bell = <InlineNotificationBell href="/notifications" unreadCount={unreadNotificationCount} />;
const loyalty = customerProfile const loyalty = customerProfile
? await getLoyaltySummary(prisma, { customerId: customerProfile.id, businessId: business.id }) ? await getLoyaltySummary(prisma, { customerId: customerProfile.id, businessId: business.id })
@@ -47,7 +53,7 @@ export default async function LoyaltyPage() {
if (!customerProfile || !loyalty?.isEnabled) { if (!customerProfile || !loyalty?.isEnabled) {
return ( return (
<div className="pb-12"> <div className="pb-12">
<TopNav title="Loyalty" onBack sticky className="bg-stone" /> <TopNav title="Loyalty" sticky className="bg-stone" action={bell} />
<main className="mx-auto max-w-2xl px-6 py-6"> <main className="mx-auto max-w-2xl px-6 py-6">
<EmptyState <EmptyState
icon={Gift} icon={Gift}
@@ -72,7 +78,7 @@ export default async function LoyaltyPage() {
return ( return (
<div className="pb-12"> <div className="pb-12">
<TopNav title="Loyalty" onBack sticky className="bg-stone" /> <TopNav title="Loyalty" sticky className="bg-stone" action={bell} />
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6"> <main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
<LoyaltyCard <LoyaltyCard
+18 -5
View File
@@ -5,10 +5,13 @@ import { auth } from "@/auth";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/feedback/empty-state"; import { EmptyState } from "@/components/feedback/empty-state";
import { ProfileCard } from "@/components/ui/profile-card"; import { ProfileCard } from "@/components/ui/profile-card";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import { getBranding } from "@/lib/business/branding"; import { getBranding } from "@/lib/business/branding";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
import { SignOutButton } from "@/features/auth/components/sign-out-button"; import { SignOutButton } from "@/features/auth/components/sign-out-button";
import { InstallAppButton } from "@/features/pwa/components/install-app-button"; import { InstallAppButton } from "@/features/pwa/components/install-app-button";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
export const metadata: Metadata = { title: "Settings" }; export const metadata: Metadata = { title: "Settings" };
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -24,8 +27,9 @@ export default async function CustomerSettingsPage() {
if (!session?.user) { if (!session?.user) {
return ( return (
<div className="mx-auto max-w-2xl px-6 pt-12"> <div className="pb-12">
<h1 className="mb-6 text-2xl font-bold tracking-tight">Settings</h1> <TopNav title="Settings" sticky className="bg-stone" />
<div className="mx-auto max-w-2xl px-6 pt-6">
<EmptyState <EmptyState
icon={LogIn} icon={LogIn}
title="Sign in to manage your account" title="Sign in to manage your account"
@@ -37,18 +41,26 @@ export default async function CustomerSettingsPage() {
} }
/> />
</div> </div>
</div>
); );
} }
const [customerProfile, user, branding] = await Promise.all([ const [customerProfile, user, branding, unreadNotificationCount] = 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 } }), prisma.user.findUnique({ where: { id: session.user.id }, select: { image: true } }),
getBranding(), getBranding(),
getUnreadNotificationCount(prisma, session.user.id),
]); ]);
return ( return (
<div className="mx-auto max-w-2xl space-y-6 px-6 pt-12"> <div className="pb-12">
<h1 className="text-2xl font-bold tracking-tight">Settings</h1> <TopNav
title="Settings"
sticky
className="bg-stone"
action={<InlineNotificationBell href="/notifications" unreadCount={unreadNotificationCount} />}
/>
<div className="mx-auto max-w-2xl space-y-6 px-6 pt-6">
<ProfileCard <ProfileCard
name={ name={
customerProfile customerProfile
@@ -76,5 +88,6 @@ export default async function CustomerSettingsPage() {
</div> </div>
<SignOutButton /> <SignOutButton />
</div> </div>
</div>
); );
} }
+38 -45
View File
@@ -6,6 +6,7 @@ import { auth } from "@/auth";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/feedback/empty-state"; import { EmptyState } from "@/components/feedback/empty-state";
import { AppointmentCard } from "@/components/ui/appointment-card"; import { AppointmentCard } from "@/components/ui/appointment-card";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions"; import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums"; import { Permission } from "@/generated/prisma/enums";
@@ -19,11 +20,15 @@ import {
import type { CalendarDate } from "@/lib/time/business-time"; import type { CalendarDate } from "@/lib/time/business-time";
import { getAvailableRewardFlags } from "@/services/loyalty/loyalty.service"; import { getAvailableRewardFlags } from "@/services/loyalty/loyalty.service";
import { listAppointments } from "@/services/scheduling/appointment.service"; import { listAppointments } from "@/services/scheduling/appointment.service";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
import { DateStrip } from "@/features/staff/components/date-strip"; import { DateStrip } from "@/features/staff/components/date-strip";
import { DayCalendar } from "@/features/staff/components/day-calendar"; import { DayCalendar } from "@/features/staff/components/day-calendar";
import type { CalendarAppointment, CalendarBlock, CalendarColumn } from "@/features/staff/components/day-calendar"; import type { CalendarAppointment, CalendarBlock, CalendarColumn } from "@/features/staff/components/day-calendar";
import { ViewSwitch } from "@/features/staff/components/view-switch";
import { OwnerBlockTimeButton } from "@/features/owner/components/owner-block-time-button"; import { OwnerBlockTimeButton } from "@/features/owner/components/owner-block-time-button";
import { cn } from "@/lib/utils/cn"; import { StaffFilterPills } from "@/features/owner/components/staff-filter-pills";
import type { StaffFilterOption } from "@/features/owner/components/staff-filter-pills";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
import type { AppointmentStatus } from "@/types/domain"; import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "All-Staff Calendar" }; export const metadata: Metadata = { title: "All-Staff Calendar" };
@@ -64,6 +69,8 @@ export default async function OwnerCalendarPage({
const context = await getActingBusinessContext(); const context = await getActingBusinessContext();
if (!context) redirect("/staff"); if (!context) redirect("/staff");
const unreadNotificationCount = await getUnreadNotificationCount(prisma, session.user.id);
const { date: dateParam, view: viewParam, staffId: staffIdParam } = await searchParams; const { date: dateParam, view: viewParam, staffId: staffIdParam } = await searchParams;
const today = todayIn(context.businessTimezone); const today = todayIn(context.businessTimezone);
const date: CalendarDate = dateParam ?? today; const date: CalendarDate = dateParam ?? today;
@@ -173,10 +180,28 @@ export default async function OwnerCalendarPage({
}; };
}); });
// Every active staff member gets a filter card (not just staffToRender,
// which narrows to the current selection) — the filter row needs to show
// everyone to filter *between*.
const staffFilterOptions: StaffFilterOption[] = allStaff.map((s) => {
const workingHours = workingHoursAll.find((h) => h.staffId === s.id);
const hoursLabel =
workingHours && !workingHours.isOff && workingHours.startMinutes !== null && workingHours.endMinutes !== null
? formatRange(workingHours.startMinutes, workingHours.endMinutes)
: null;
return { id: s.id, name: s.user.name ?? "Staff", avatarUrl: s.user.image, hoursLabel };
});
const staffFilterHrefFor = (staffId: string | null) =>
`/owner/calendar?date=${date}&view=${view}${staffId ? `&staffId=${staffId}` : ""}`;
return ( return (
<div className="pb-6"> <div className="pb-6">
<div className="flex items-center justify-between px-6 pt-12"> <TopNav
<h1 className="text-2xl font-bold tracking-tight">Calendar</h1> title="Calendar"
sticky
className="bg-stone"
leftAction={<InlineNotificationBell href="/owner/notifications" unreadCount={unreadNotificationCount} />}
action={
<div className="flex gap-2"> <div className="flex gap-2">
{selectedStaff && <OwnerBlockTimeButton staffId={selectedStaff.id} staffName={selectedStaff.user.name ?? "Staff"} date={date} />} {selectedStaff && <OwnerBlockTimeButton staffId={selectedStaff.id} staffName={selectedStaff.user.name ?? "Staff"} date={date} />}
<Button asChild size="sm"> <Button asChild size="sm">
@@ -189,7 +214,8 @@ export default async function OwnerCalendarPage({
</Link> </Link>
</Button> </Button>
</div> </div>
</div> }
/>
<DateStrip <DateStrip
selectedDate={date} selectedDate={date}
@@ -199,49 +225,16 @@ export default async function OwnerCalendarPage({
extraParams={selectedStaff ? { staffId: selectedStaff.id } : undefined} extraParams={selectedStaff ? { staffId: selectedStaff.id } : undefined}
/> />
<div className="mt-4 flex flex-wrap items-center gap-2 px-6"> <div className="mt-4 px-6">
<Link <ViewSwitch
href={`/owner/calendar?date=${date}&view=${view}`} view={view}
className={cn( dayHref={`/owner/calendar?date=${date}&view=day${selectedStaff ? `&staffId=${selectedStaff.id}` : ""}`}
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase", agendaHref={`/owner/calendar?date=${date}&view=agenda${selectedStaff ? `&staffId=${selectedStaff.id}` : ""}`}
!selectedStaff ? "bg-ink text-white" : "border-line text-muted border bg-white", />
)}
>
All Staff
</Link>
{allStaff.map((s) => (
<Link
key={s.id}
href={`/owner/calendar?date=${date}&view=${view}&staffId=${s.id}`}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
selectedStaff?.id === s.id ? "bg-ink text-white" : "border-line text-muted border bg-white",
)}
>
{s.user.name}
</Link>
))}
</div> </div>
<div className="mt-4 flex gap-2 px-6"> <div className="mt-4">
<Link <StaffFilterPills options={staffFilterOptions} selectedId={selectedStaff?.id} hrefFor={staffFilterHrefFor} />
href={`/owner/calendar?date=${date}&view=day${selectedStaff ? `&staffId=${selectedStaff.id}` : ""}`}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
view === "day" ? "bg-ink text-white" : "border-line text-muted border bg-white",
)}
>
Day
</Link>
<Link
href={`/owner/calendar?date=${date}&view=agenda${selectedStaff ? `&staffId=${selectedStaff.id}` : ""}`}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
view === "agenda" ? "bg-ink text-white" : "border-line text-muted border bg-white",
)}
>
Agenda
</Link>
</div> </div>
<div className="mt-4"> <div className="mt-4">
+18 -6
View File
@@ -22,12 +22,15 @@ import {
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions"; import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums"; import { Permission } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context"; import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { todayIn } from "@/lib/time/business-time"; import { todayIn } from "@/lib/time/business-time";
import { getDashboardSummary } from "@/services/reporting/owner-reporting.service"; import { getDashboardSummary } from "@/services/reporting/owner-reporting.service";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
export const metadata: Metadata = { title: "Business Dashboard" }; export const metadata: Metadata = { title: "Business Dashboard" };
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -105,18 +108,26 @@ export default async function OwnerDashboardPage() {
if (!context) redirect("/staff"); if (!context) redirect("/staff");
const today = todayIn(context.businessTimezone); const today = todayIn(context.businessTimezone);
const summary = await getDashboardSummary(prisma, { const [summary, unreadNotificationCount] = await Promise.all([
getDashboardSummary(prisma, {
businessId: context.businessId, businessId: context.businessId,
businessTimezone: context.businessTimezone, businessTimezone: context.businessTimezone,
date: today, date: today,
}); }),
getUnreadNotificationCount(prisma, session.user.id),
]);
return ( return (
<div className="space-y-6 px-6 pt-12 pb-12"> <div className="pb-12">
<div> <TopNav
<h1 className="text-2xl font-bold tracking-tight">Manage</h1> title="Manage"
sticky
className="bg-stone"
leftAction={<InlineNotificationBell href="/owner/notifications" unreadCount={unreadNotificationCount} />}
/>
<div className="space-y-6 px-6 pt-6">
<p className="text-muted text-sm">{context.businessName} — today&apos;s overview</p> <p className="text-muted text-sm">{context.businessName} — today&apos;s overview</p>
</div>
{summary.warnings.length > 0 && ( {summary.warnings.length > 0 && (
<section className="space-y-2"> <section className="space-y-2">
@@ -231,5 +242,6 @@ export default async function OwnerDashboardPage() {
))} ))}
</div> </div>
</div> </div>
</div>
); );
} }
+19 -16
View File
@@ -180,18 +180,18 @@ export default async function StaffCustomerDetailsPage({
{customer.phone && ( {customer.phone && (
<a <a
href={`tel:${customer.phone}`} href={`tel:${customer.phone}`}
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold" className="border-line flex h-12 min-w-[104px] flex-1 items-center justify-center gap-2 rounded-md border bg-white px-4 text-xs font-bold"
> >
<Phone className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Call <Phone className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Call
</a> </a>
)} )}
<a <a
href={`mailto:${customer.email}`} href={`mailto:${customer.email}`}
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold" className="border-line flex h-12 min-w-[104px] flex-1 items-center justify-center gap-2 rounded-md border bg-white px-4 text-xs font-bold"
> >
<Mail className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Email <Mail className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Email
</a> </a>
<Button asChild size="sm"> <Button asChild size="md" className="min-w-[104px] flex-1">
<Link href={`/staff/book?customerId=${customer.id}`}>Book Appointment</Link> <Link href={`/staff/book?customerId=${customer.id}`}>Book Appointment</Link>
</Button> </Button>
</div> </div>
@@ -208,8 +208,11 @@ export default async function StaffCustomerDetailsPage({
)} )}
</p> </p>
<div className="mb-3 flex flex-wrap gap-2"> <div className="mb-3 flex flex-wrap gap-2">
<AddStampButton customerId={id} /> <AddStampButton customerId={id} className="min-w-[140px] flex-1" />
<CorrectStampButton loyaltyAccountId={loyalty.accountId} /> <CorrectStampButton
loyaltyAccountId={loyalty.accountId}
className="min-w-[140px] flex-1"
/>
</div> </div>
{loyaltyTransactions.length > 0 && ( {loyaltyTransactions.length > 0 && (
<ul className="space-y-1"> <ul className="space-y-1">
@@ -246,6 +249,17 @@ export default async function StaffCustomerDetailsPage({
</Card> </Card>
<div className="mt-4 space-y-4 lg:mt-0"> <div className="mt-4 space-y-4 lg:mt-0">
<Card className="p-6">
<h2 className="text-muted mb-4 text-sm font-bold tracking-widest uppercase">Notes</h2>
{/* A Super Administrator has no StaffProfile/staffId — passing "" is a
deliberate never-matches sentinel (see CustomerNotes' isOwn check),
so they can view every note but own none. Adding a note as a
Super Administrator fails gracefully server-side (see
addCustomerNoteAction: "You must be signed in as staff.") rather
than fabricating a staff identity for them. */}
<CustomerNotes customerId={customer.id} notes={notes} actingStaffId={context.staffId ?? ""} />
</Card>
<Tabs defaultValue="upcoming"> <Tabs defaultValue="upcoming">
<TabsList> <TabsList>
<TabsTrigger value="upcoming">Upcoming</TabsTrigger> <TabsTrigger value="upcoming">Upcoming</TabsTrigger>
@@ -260,17 +274,6 @@ export default async function StaffCustomerDetailsPage({
{renderList(cancelled, "No cancelled appointments")} {renderList(cancelled, "No cancelled appointments")}
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<Card className="p-6">
<h2 className="text-muted mb-4 text-sm font-bold tracking-widest uppercase">Notes</h2>
{/* A Super Administrator has no StaffProfile/staffId — passing "" is a
deliberate never-matches sentinel (see CustomerNotes' isOwn check),
so they can view every note but own none. Adding a note as a
Super Administrator fails gracefully server-side (see
addCustomerNoteAction: "You must be signed in as staff.") rather
than fabricating a staff identity for them. */}
<CustomerNotes customerId={customer.id} notes={notes} actingStaffId={context.staffId ?? ""} />
</Card>
</div> </div>
</main> </main>
</div> </div>
+14 -2
View File
@@ -3,10 +3,13 @@ import { Search, Users } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { EmptyState } from "@/components/feedback/empty-state"; import { EmptyState } from "@/components/feedback/empty-state";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context"; import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
import { matchesCustomerSearch } from "@/services/customer/customer.service"; import { matchesCustomerSearch } from "@/services/customer/customer.service";
import { getLoyaltySummariesForCustomers } from "@/services/loyalty/loyalty.service"; import { getLoyaltySummariesForCustomers } from "@/services/loyalty/loyalty.service";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
import { cn } from "@/lib/utils/cn"; import { cn } from "@/lib/utils/cn";
export const metadata: Metadata = { title: "Clients" }; export const metadata: Metadata = { title: "Clients" };
@@ -24,6 +27,8 @@ export default async function StaffClientsPage({
const { q = "", sort } = await searchParams; const { q = "", sort } = await searchParams;
const unreadNotificationCount = await getUnreadNotificationCount(prisma, context.userId);
// Scoped via the appointment relation, not "every CustomerProfile row" — // Scoped via the appointment relation, not "every CustomerProfile row" —
// a real business boundary even though this is a single-business v1 app // 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 // (see docs/phase-5-summary.md). `select`-scoped rather than the full
@@ -67,9 +72,15 @@ export default async function StaffClientsPage({
const now = new Date(); const now = new Date();
return ( return (
<div className="px-6 pt-12 pb-8 lg:max-w-5xl"> <div className="pb-8 lg:max-w-5xl">
<h1 className="mb-6 text-2xl font-bold tracking-tight">Clients</h1> <TopNav
title="Clients"
sticky
className="bg-stone"
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
/>
<div className="px-6 pt-6">
<form action="/staff/clients" method="GET" className="relative mb-4"> <form action="/staff/clients" method="GET" className="relative mb-4">
{sort && <input type="hidden" name="sort" value={sort} />} {sort && <input type="hidden" name="sort" value={sort} />}
<Search <Search
@@ -168,5 +179,6 @@ export default async function StaffClientsPage({
</div> </div>
)} )}
</div> </div>
</div>
); );
} }
+31 -9
View File
@@ -5,11 +5,13 @@ import { redirect } from "next/navigation";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { StatusPill } from "@/components/ui/status-pill"; import { StatusPill } from "@/components/ui/status-pill";
import { EmptyState } from "@/components/feedback/empty-state"; import { EmptyState } from "@/components/feedback/empty-state";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff"; import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { todayIn } from "@/lib/time/business-time"; import { todayIn } from "@/lib/time/business-time";
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service"; import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold"; import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
import { import {
listAppointments, listAppointments,
selectCurrentAppointment, selectCurrentAppointment,
@@ -17,6 +19,7 @@ import {
import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions"; import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions";
import { BookFollowUpButton } from "@/features/staff/components/book-follow-up-button"; import { BookFollowUpButton } from "@/features/staff/components/book-follow-up-button";
import { AddStampButton } from "@/features/staff/components/add-stamp-button"; import { AddStampButton } from "@/features/staff/components/add-stamp-button";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
import type { AppointmentStatus } from "@/types/domain"; import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "Current Appointment" }; export const metadata: Metadata = { title: "Current Appointment" };
@@ -46,17 +49,25 @@ export default async function StaffCurrentPage() {
}); });
const current = selectCurrentAppointment(todaysAppointments, new Date()); const current = selectCurrentAppointment(todaysAppointments, new Date());
const unreadNotificationCount = await getUnreadNotificationCount(prisma, staff.user.id);
if (!current) { if (!current) {
return ( return (
<div className="px-6 pt-12"> <div className="pb-8">
<h1 className="mb-6 text-2xl font-bold tracking-tight">Current Appointment</h1> <TopNav
title="Current Appointment"
sticky
className="bg-stone"
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
/>
<div className="px-6 pt-6">
<EmptyState <EmptyState
icon={Scissors} icon={Scissors}
title="Nothing scheduled right now" title="Nothing scheduled right now"
description="Your next appointment today will show up here." description="Your next appointment today will show up here."
/> />
</div> </div>
</div>
); );
} }
@@ -80,9 +91,14 @@ export default async function StaffCurrentPage() {
const tz = staff.business.timezone; const tz = staff.business.timezone;
return ( return (
<div className="space-y-4 px-6 pt-12 pb-8"> <div className="pb-8">
<h1 className="text-2xl font-bold tracking-tight">Current Appointment</h1> <TopNav
title="Current Appointment"
sticky
className="bg-stone"
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
/>
<div className="space-y-4 px-6 pt-6">
<Card className="space-y-5 p-6"> <Card className="space-y-5 p-6">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
@@ -214,13 +230,19 @@ export default async function StaffCurrentPage() {
)} )}
</Card> </Card>
<AppointmentStatusActions appointmentId={current.id} status={current.status} /> <AppointmentStatusActions appointmentId={current.id} status={current.status} layout="stack" />
<div className="flex flex-wrap gap-3"> <div className="flex flex-col gap-2">
{loyalty?.isEnabled && ( {loyalty?.isEnabled && (
<AddStampButton customerId={current.customerId} appointmentId={current.id} /> <AddStampButton
customerId={current.customerId}
appointmentId={current.id}
size="lg"
className="w-full"
/>
)} )}
<BookFollowUpButton appointmentId={current.id} /> <BookFollowUpButton appointmentId={current.id} className="w-full" />
</div>
</div> </div>
</div> </div>
); );
+16 -23
View File
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/feedback/empty-state"; import { EmptyState } from "@/components/feedback/empty-state";
import { AppointmentCard } from "@/components/ui/appointment-card"; import { AppointmentCard } from "@/components/ui/appointment-card";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff"; import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { import {
@@ -16,11 +17,13 @@ import {
import type { CalendarDate } from "@/lib/time/business-time"; import type { CalendarDate } from "@/lib/time/business-time";
import { getAvailableRewardFlags } from "@/services/loyalty/loyalty.service"; import { getAvailableRewardFlags } from "@/services/loyalty/loyalty.service";
import { listAppointments, selectCurrentAppointment } from "@/services/scheduling/appointment.service"; import { listAppointments, selectCurrentAppointment } from "@/services/scheduling/appointment.service";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
import { DateStrip } from "@/features/staff/components/date-strip"; import { DateStrip } from "@/features/staff/components/date-strip";
import { DayCalendar } from "@/features/staff/components/day-calendar"; import { DayCalendar } from "@/features/staff/components/day-calendar";
import type { CalendarAppointment, CalendarBlock, CalendarColumn } from "@/features/staff/components/day-calendar"; import type { CalendarAppointment, CalendarBlock, CalendarColumn } from "@/features/staff/components/day-calendar";
import { ViewSwitch } from "@/features/staff/components/view-switch";
import { BlockTimeButton } from "@/features/staff/components/block-time-button"; import { BlockTimeButton } from "@/features/staff/components/block-time-button";
import { cn } from "@/lib/utils/cn"; import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
import type { AppointmentStatus } from "@/types/domain"; import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "Calendar" }; export const metadata: Metadata = { title: "Calendar" };
@@ -142,10 +145,16 @@ export default async function StaffCalendarPage({
// "Responsive Design"). Only meaningful when viewing today. // "Responsive Design"). Only meaningful when viewing today.
const rightNow = date === today ? selectCurrentAppointment(appointments, new Date()) : null; const rightNow = date === today ? selectCurrentAppointment(appointments, new Date()) : null;
const unreadNotificationCount = await getUnreadNotificationCount(prisma, staff.user.id);
return ( return (
<div className="pb-6"> <div className="pb-6">
<div className="flex items-center justify-between px-6 pt-12"> <TopNav
<h1 className="text-2xl font-bold tracking-tight">Calendar</h1> title="Calendar"
sticky
className="bg-stone"
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
action={
<div className="flex gap-2"> <div className="flex gap-2">
<BlockTimeButton date={date} /> <BlockTimeButton date={date} />
<Button asChild size="sm"> <Button asChild size="sm">
@@ -155,7 +164,8 @@ export default async function StaffCalendarPage({
</Link> </Link>
</Button> </Button>
</div> </div>
</div> }
/>
<DateStrip <DateStrip
selectedDate={date} selectedDate={date}
@@ -164,25 +174,8 @@ export default async function StaffCalendarPage({
view={view} view={view}
/> />
<div className="mt-4 flex gap-2 px-6"> <div className="mt-4 px-6">
<Link <ViewSwitch view={view} dayHref={`/staff?date=${date}&view=day`} agendaHref={`/staff?date=${date}&view=agenda`} />
href={`/staff?date=${date}&view=day`}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
view === "day" ? "bg-ink text-white" : "border-line text-muted border bg-white",
)}
>
Day
</Link>
<Link
href={`/staff?date=${date}&view=agenda`}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
view === "agenda" ? "bg-ink text-white" : "border-line text-muted border bg-white",
)}
>
Agenda
</Link>
</div> </div>
<div className="mt-4 lg:flex lg:items-start lg:gap-6 lg:px-6"> <div className="mt-4 lg:flex lg:items-start lg:gap-6 lg:px-6">
+34 -18
View File
@@ -1,17 +1,18 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { Bell, CalendarClock, CalendarOff, CalendarSync, Clock, UserCircle } from "lucide-react"; import { Bell, CalendarClock, CalendarOff, CalendarSync, Clock, type LucideIcon } from "lucide-react";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { ProfileCard } from "@/components/ui/profile-card"; import { ProfileCard } from "@/components/ui/profile-card";
import { TopNav } from "@/components/navigation/top-nav";
import { SignOutButton } from "@/features/auth/components/sign-out-button"; import { SignOutButton } from "@/features/auth/components/sign-out-button";
import { getNavRole } from "@/lib/auth/permissions"; import { getNavRole } from "@/lib/auth/permissions";
import { getBranding } from "@/lib/business/branding"; import { getBranding } from "@/lib/business/branding";
import { InstallAppButton } from "@/features/pwa/components/install-app-button"; import { InstallAppButton } from "@/features/pwa/components/install-app-button";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
import { prisma } from "@/lib/db/prisma";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
const ACCOUNT_LINKS = [ const ACCOUNT_LINKS = [{ href: "/staff/settings/notifications", label: "Notifications", icon: Bell }] as const;
{ href: "/staff/settings/profile", label: "Profile", icon: UserCircle },
{ href: "/staff/settings/notifications", label: "Notifications", icon: Bell },
] as const;
const WORKING_PATTERN_LINKS = [ const WORKING_PATTERN_LINKS = [
{ href: "/staff/settings/working-hours", label: "Working Hours", icon: Clock }, { href: "/staff/settings/working-hours", label: "Working Hours", icon: Clock },
@@ -26,7 +27,7 @@ function SettingsSection({
links, links,
}: { }: {
title: string; title: string;
links: ReadonlyArray<{ href: string; label: string; icon: typeof UserCircle }>; links: ReadonlyArray<{ href: string; label: string; icon: LucideIcon }>;
}) { }) {
return ( return (
<div> <div>
@@ -54,28 +55,42 @@ export const metadata: Metadata = { title: "Settings" };
* lives behind the "Manage" nav item, see (owner)/owner/page.tsx). Working * lives behind the "Manage" nav item, see (owner)/owner/page.tsx). Working
* Pattern and Integrations only apply to a real personal barber schedule, * Pattern and Integrations only apply to a real personal barber schedule,
* so a Super Administrator (who deliberately has no StaffProfile — see * so a Super Administrator (who deliberately has no StaffProfile — see
* isSuperAdmin) sees only Notifications and Sign Out; their identity is * isSuperAdmin) sees only Notifications and Sign Out. Profile editing is
* shown via the ProfileCard above rather than a "Profile" link, since the * the ProfileCard's own edit-pencil (matching the customer settings hub)
* profile-edit page below is staff-bio-specific and a Super Administrator * rather than a menu link — withheld for a Super Administrator since the
* has no StaffProfile to edit. * profile-edit page below is staff-bio-specific and they have no
* StaffProfile to edit.
*/ */
export default async function StaffSettingsPage() { export default async function StaffSettingsPage() {
const session = await auth(); const session = await auth();
if (!session?.user) return null; if (!session?.user) return null;
const [role, branding] = await Promise.all([getNavRole(session.user.id), getBranding()]); const [role, branding, unreadNotificationCount] = await Promise.all([
getNavRole(session.user.id),
getBranding(),
getUnreadNotificationCount(prisma, session.user.id),
]);
const isSuperAdmin = role === "superadmin"; const isSuperAdmin = role === "superadmin";
return ( return (
<div className="space-y-6 px-6 pt-12 pb-12"> <div className="pb-12">
<h1 className="text-2xl font-bold tracking-tight">Settings</h1> <TopNav
<ProfileCard name={session.user.name ?? "Account"} meta={session.user.email} avatarUrl={session.user.image ?? undefined} /> title="Settings"
sticky
className="bg-stone"
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
/>
<div className="space-y-6 px-6 pt-6">
<ProfileCard
name={session.user.name ?? "Account"}
meta={session.user.email}
avatarUrl={session.user.image ?? undefined}
editHref={isSuperAdmin ? undefined : "/staff/settings/profile"}
/>
<div className="space-y-5"> <div className="space-y-5">
<SettingsSection <SettingsSection title="Account" links={ACCOUNT_LINKS} />
title="Account"
links={isSuperAdmin ? ACCOUNT_LINKS.filter((l) => l.label === "Notifications") : ACCOUNT_LINKS}
/>
{!isSuperAdmin && ( {!isSuperAdmin && (
<> <>
@@ -96,5 +111,6 @@ export default async function StaffSettingsPage() {
</div> </div>
</div> </div>
</div> </div>
</div>
); );
} }
+6 -2
View File
@@ -9,6 +9,8 @@ export interface TopNavProps {
title: string; title: string;
/** Show a back button that calls router.back(), or a custom href/handler. */ /** Show a back button that calls router.back(), or a custom href/handler. */
onBack?: (() => void) | true; onBack?: (() => void) | true;
/** Rendered in the back button's place when `onBack` isn't given — e.g. a notification bell on a top-level page that has no "back" to go to. Ignored if `onBack` is set. */
leftAction?: ReactNode;
action?: ReactNode; action?: ReactNode;
sticky?: boolean; sticky?: boolean;
className?: string; className?: string;
@@ -19,7 +21,7 @@ export interface TopNavProps {
* variations, across nearly every template screen — consolidated into one * variations, across nearly every template screen — consolidated into one
* component (see docs/implementation-plan.md §7, "TopHeader"). * component (see docs/implementation-plan.md §7, "TopHeader").
*/ */
export function TopNav({ title, onBack, action, sticky = true, className }: TopNavProps) { export function TopNav({ title, onBack, leftAction, action, sticky = true, className }: TopNavProps) {
const router = useRouter(); const router = useRouter();
return ( return (
@@ -31,7 +33,7 @@ export function TopNav({ title, onBack, action, sticky = true, className }: TopN
)} )}
> >
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{onBack && ( {onBack ? (
<button <button
type="button" type="button"
onClick={onBack === true ? () => router.back() : onBack} onClick={onBack === true ? () => router.back() : onBack}
@@ -40,6 +42,8 @@ export function TopNav({ title, onBack, action, sticky = true, className }: TopN
> >
<ArrowLeft className="h-4 w-4" aria-hidden="true" /> <ArrowLeft className="h-4 w-4" aria-hidden="true" />
</button> </button>
) : (
leftAction
)} )}
<h1 className="text-xl font-bold tracking-tight">{title}</h1> <h1 className="text-xl font-bold tracking-tight">{title}</h1>
</div> </div>
+1 -1
View File
@@ -14,7 +14,7 @@ export const Tabs = TabsPrimitive.Root;
export function TabsList({ className, ...props }: TabsPrimitive.TabsListProps) { export function TabsList({ className, ...props }: TabsPrimitive.TabsListProps) {
return ( return (
<TabsPrimitive.List <TabsPrimitive.List
className={cn("flex gap-1 rounded-full bg-white p-1", className)} className={cn("flex gap-1 rounded-md bg-white p-1", className)}
{...props} {...props}
/> />
); );
@@ -1,5 +1,30 @@
"use client";
import Link from "next/link"; import Link from "next/link";
import { Bell } from "lucide-react"; import { Bell } from "lucide-react";
import { usePathname } from "next/navigation";
/** Pages that render their own inline bell (see InlineNotificationBell) as part of a proper header — the shell's own floating bell steps aside there instead of sitting on top of it. */
const PAGES_WITH_INLINE_BELL = [
"/staff",
"/owner/calendar",
"/staff/current",
"/staff/clients",
"/staff/settings",
"/appointments",
"/loyalty",
"/settings",
"/owner",
];
function badge(unreadCount: number) {
if (unreadCount === 0) return null;
return (
<span className="bg-danger absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-[10px] font-bold text-white">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
);
}
/** /**
* Fixed top-right on every screen inside the shell that renders it (see * Fixed top-right on every screen inside the shell that renders it (see
@@ -8,8 +33,16 @@ import { Bell } from "lucide-react";
* only ever reflects "as of this page load/navigation". Real-time badge * only ever reflects "as of this page load/navigation". Real-time badge
* updates without a reload would need a persistent connection this phase * updates without a reload would need a persistent connection this phase
* doesn't build (see docs/phase-7-summary.md, "Known limitations"). * doesn't build (see docs/phase-7-summary.md, "Known limitations").
*
* Steps aside (renders nothing) on pages that build their own proper
* header — a fixed top-right bell has nowhere sensible to sit once a page
* already has its own top-right control (e.g. Calendar's "New" button) —
* see InlineNotificationBell below, used in exactly those pages' headers.
*/ */
export function NotificationBell({ href, unreadCount }: { href: string; unreadCount: number }) { export function NotificationBell({ href, unreadCount }: { href: string; unreadCount: number }) {
const pathname = usePathname();
if (PAGES_WITH_INLINE_BELL.includes(pathname)) return null;
return ( return (
<Link <Link
href={href} href={href}
@@ -17,11 +50,21 @@ export function NotificationBell({ href, unreadCount }: { href: string; unreadCo
className="fixed top-4 right-4 z-40 flex h-10 w-10 items-center justify-center rounded-full border border-line bg-white shadow-sm" className="fixed top-4 right-4 z-40 flex h-10 w-10 items-center justify-center rounded-full border border-line bg-white shadow-sm"
> >
<Bell className="h-4 w-4" aria-hidden="true" /> <Bell className="h-4 w-4" aria-hidden="true" />
{unreadCount > 0 && ( {badge(unreadCount)}
<span className="bg-danger absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-[10px] font-bold text-white"> </Link>
{unreadCount > 9 ? "9+" : unreadCount} );
</span> }
)}
/** Same look as the fixed bell above, but a normal in-flow element — for a page's own TopNav `leftAction`, not the shell's floating corner. */
export function InlineNotificationBell({ href, unreadCount }: { href: string; unreadCount: number }) {
return (
<Link
href={href}
aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : "Notifications"}
className="relative flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-line"
>
<Bell className="h-4 w-4" aria-hidden="true" />
{badge(unreadCount)}
</Link> </Link>
); );
} }
@@ -57,9 +57,9 @@ export function OwnerBlockTimeButton({ staffId, staffName, date }: { staffId: st
return ( return (
<> <>
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}> <Button variant="secondary" size="sm" aria-label="Block Time" onClick={() => setOpen(true)}>
<CalendarOff className="h-4 w-4" aria-hidden="true" /> <CalendarOff className="h-4 w-4" aria-hidden="true" />
Block Time <span className="hidden sm:inline">Block Time</span>
</Button> </Button>
<BottomSheet open={open} onClose={handleClose} title={`Block Time — ${staffName}`}> <BottomSheet open={open} onClose={handleClose} title={`Block Time — ${staffName}`}>
<div className="space-y-4 pb-2"> <div className="space-y-4 pb-2">
@@ -0,0 +1,71 @@
import Image from "next/image";
import Link from "next/link";
import { Users } from "lucide-react";
import { cn } from "@/lib/utils/cn";
export interface StaffFilterOption {
id: string;
name: string;
avatarUrl?: string | null;
/** Working-hours range for the day (e.g. "10:00 AM – 7:00 PM"), or null if off entirely. */
hoursLabel: string | null;
}
const CARD_CLASS = "flex shrink-0 items-center gap-2 rounded-2xl border bg-white px-3 py-2 text-left";
/** Avatar + name + hours cards for filtering the all-staff calendar by barber — an "All Staff" card plus one per active staff member, matching the day grid's own column-header style so the filter row and the grid read as one system. */
export function StaffFilterPills({
options,
selectedId,
hrefFor,
}: {
options: StaffFilterOption[];
/** undefined = "All Staff" selected. */
selectedId?: string;
hrefFor: (staffId: string | null) => string;
}) {
return (
<div className="flex gap-2 overflow-x-auto px-6 py-1">
<Link
href={hrefFor(null)}
aria-current={!selectedId ? "true" : undefined}
className={cn(CARD_CLASS, !selectedId ? "border-ink ring-ink ring-1" : "border-line")}
>
<span className="bg-stone text-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-line">
<Users className="h-4 w-4" aria-hidden="true" />
</span>
<span className="min-w-0">
<p className="text-sm font-bold">All Staff</p>
<p className="text-muted text-[10px]">Everyone</p>
</span>
</Link>
{options.map((option) => (
<Link
key={option.id}
href={hrefFor(option.id)}
aria-current={selectedId === option.id ? "true" : undefined}
className={cn(CARD_CLASS, selectedId === option.id ? "border-ink ring-ink ring-1" : "border-line")}
>
{option.avatarUrl ? (
<Image
src={option.avatarUrl}
alt=""
width={36}
height={36}
className="h-9 w-9 shrink-0 rounded-full border border-line object-cover"
/>
) : (
<div className="bg-stone text-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-line text-sm font-bold">
{option.name.charAt(0)}
</div>
)}
<span className="min-w-0">
<p className="truncate text-sm font-bold">{option.name}</p>
<p className="text-muted truncate text-[10px]">{option.hoursLabel ?? "Off today"}</p>
</span>
</Link>
))}
</div>
);
}
@@ -6,14 +6,19 @@ import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Modal } from "@/components/ui/modal"; import { Modal } from "@/components/ui/modal";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils/cn";
import { addLoyaltyStampAction } from "../actions/loyalty-actions"; import { addLoyaltyStampAction } from "../actions/loyalty-actions";
export function AddStampButton({ export function AddStampButton({
customerId, customerId,
appointmentId, appointmentId,
className,
size = "sm",
}: { }: {
customerId: string; customerId: string;
appointmentId?: string; appointmentId?: string;
className?: string;
size?: "sm" | "md" | "lg";
}) { }) {
const router = useRouter(); const router = useRouter();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -57,7 +62,12 @@ export function AddStampButton({
return ( return (
<> <>
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}> <Button
variant="secondary"
size={size}
className={cn("shrink-0", className)}
onClick={() => setOpen(true)}
>
<Stamp className="h-4 w-4" aria-hidden="true" /> <Stamp className="h-4 w-4" aria-hidden="true" />
Add Loyalty Stamp Add Loyalty Stamp
</Button> </Button>
@@ -54,10 +54,13 @@ export function AppointmentStatusActions({
appointmentId, appointmentId,
status, status,
actions = DEFAULT_ACTIONS, actions = DEFAULT_ACTIONS,
layout = "wrap",
}: { }: {
appointmentId: string; appointmentId: string;
status: RawStatus; status: RawStatus;
actions?: AppointmentActionSet; actions?: AppointmentActionSet;
/** "stack" renders one full-width button per row (Current Appointment); "wrap" (default, used by Appointment Details) keeps the original inline small buttons. */
layout?: "wrap" | "stack";
}) { }) {
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@@ -121,10 +124,11 @@ export function AppointmentStatusActions({
{error} {error}
</p> </p>
)} )}
<div className="flex flex-wrap gap-2"> <div className={layout === "stack" ? "flex flex-col gap-2" : "flex flex-wrap gap-2"}>
{status === "PENDING" && ( {status === "PENDING" && (
<Button <Button
size="sm" size={layout === "stack" ? undefined : "sm"}
className={layout === "stack" ? "w-full" : undefined}
isLoading={isPending} isLoading={isPending}
onClick={() => runQuiet(() => actions.confirm(appointmentId))} onClick={() => runQuiet(() => actions.confirm(appointmentId))}
> >
@@ -133,7 +137,8 @@ export function AppointmentStatusActions({
)} )}
{status === "CONFIRMED" && ( {status === "CONFIRMED" && (
<Button <Button
size="sm" size={layout === "stack" ? undefined : "sm"}
className={layout === "stack" ? "w-full" : undefined}
isLoading={isPending} isLoading={isPending}
onClick={() => runQuiet(() => actions.checkIn(appointmentId))} onClick={() => runQuiet(() => actions.checkIn(appointmentId))}
> >
@@ -142,7 +147,8 @@ export function AppointmentStatusActions({
)} )}
{status === "CHECKED_IN" && ( {status === "CHECKED_IN" && (
<Button <Button
size="sm" size={layout === "stack" ? undefined : "sm"}
className={layout === "stack" ? "w-full" : undefined}
isLoading={isPending} isLoading={isPending}
onClick={() => runQuiet(() => actions.start(appointmentId))} onClick={() => runQuiet(() => actions.start(appointmentId))}
> >
@@ -150,17 +156,31 @@ export function AppointmentStatusActions({
</Button> </Button>
)} )}
{status === "IN_PROGRESS" && ( {status === "IN_PROGRESS" && (
<Button size="sm" onClick={() => setConfirmKind("complete")}> <Button
size={layout === "stack" ? undefined : "sm"}
className={layout === "stack" ? "w-full" : undefined}
onClick={() => setConfirmKind("complete")}
>
Complete Appointment Complete Appointment
</Button> </Button>
)} )}
{status === "CONFIRMED" && ( {status === "CONFIRMED" && (
<Button size="sm" variant="secondary" onClick={() => setConfirmKind("no_show")}> <Button
size={layout === "stack" ? undefined : "sm"}
className={layout === "stack" ? "w-full" : undefined}
variant="secondary"
onClick={() => setConfirmKind("no_show")}
>
Mark No-Show Mark No-Show
</Button> </Button>
)} )}
{(status === "PENDING" || status === "CONFIRMED" || status === "CHECKED_IN") && ( {(status === "PENDING" || status === "CONFIRMED" || status === "CHECKED_IN") && (
<Button size="sm" variant="destructive" onClick={() => setConfirmKind("cancel")}> <Button
size={layout === "stack" ? undefined : "sm"}
className={layout === "stack" ? "w-full" : undefined}
variant="destructive"
onClick={() => setConfirmKind("cancel")}
>
Cancel Cancel
</Button> </Button>
)} )}
@@ -62,9 +62,9 @@ export function BlockTimeButton({ date }: { date: string }) {
return ( return (
<> <>
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}> <Button variant="secondary" size="sm" aria-label="Block Time" onClick={() => setOpen(true)}>
<CalendarOff className="h-4 w-4" aria-hidden="true" /> <CalendarOff className="h-4 w-4" aria-hidden="true" />
Block Time <span className="hidden sm:inline">Block Time</span>
</Button> </Button>
<BottomSheet open={open} onClose={handleClose} title="Block Time"> <BottomSheet open={open} onClose={handleClose} title="Block Time">
<div className="space-y-4 pb-2"> <div className="space-y-4 pb-2">
@@ -2,15 +2,22 @@
import { useTransition } from "react"; import { useTransition } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils/cn";
import { startStaffFollowUpAction } from "../actions/staff-booking"; import { startStaffFollowUpAction } from "../actions/staff-booking";
export function BookFollowUpButton({ appointmentId }: { appointmentId: string }) { export function BookFollowUpButton({
appointmentId,
className,
}: {
appointmentId: string;
className?: string;
}) {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
return ( return (
<Button <Button
variant="secondary" variant="secondary"
className="rounded-md border-line border bg-white px-5 py-3 text-sm font-bold" className={cn("rounded-md border-line border bg-white px-5 py-3 text-sm font-bold", className)}
isLoading={isPending} isLoading={isPending}
onClick={() => startTransition(() => startStaffFollowUpAction(appointmentId))} onClick={() => startTransition(() => startStaffFollowUpAction(appointmentId))}
> >
@@ -7,10 +7,17 @@ import { Button } from "@/components/ui/button";
import { Modal } from "@/components/ui/modal"; import { Modal } from "@/components/ui/modal";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils/cn";
import { correctLoyaltyStampAction } from "../actions/loyalty-actions"; import { correctLoyaltyStampAction } from "../actions/loyalty-actions";
/** Removes stamps via a compensating ledger entry — never edits or deletes the original transaction. A reason is required (enforced both here and server-side in LoyaltyService.correctStamp). */ /** Removes stamps via a compensating ledger entry — never edits or deletes the original transaction. A reason is required (enforced both here and server-side in LoyaltyService.correctStamp). */
export function CorrectStampButton({ loyaltyAccountId }: { loyaltyAccountId: string }) { export function CorrectStampButton({
loyaltyAccountId,
className,
}: {
loyaltyAccountId: string;
className?: string;
}) {
const router = useRouter(); const router = useRouter();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [amount, setAmount] = useState("1"); const [amount, setAmount] = useState("1");
@@ -52,7 +59,7 @@ export function CorrectStampButton({ loyaltyAccountId }: { loyaltyAccountId: str
return ( return (
<> <>
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}> <Button variant="secondary" size="sm" className={cn(className)} onClick={() => setOpen(true)}>
<Undo2 className="h-4 w-4" aria-hidden="true" /> <Undo2 className="h-4 w-4" aria-hidden="true" />
Correct Stamp Correct Stamp
</Button> </Button>
+4 -4
View File
@@ -42,15 +42,15 @@ export function DateStrip({
return ( return (
<div className="space-y-3 px-6 pt-4"> <div className="space-y-3 px-6 pt-4">
<div className="flex items-center justify-between"> <div className="border-line flex items-center overflow-hidden rounded-full border bg-white">
<Link <Link
href={hrefFor(addDays(selectedDate, -1))} href={hrefFor(addDays(selectedDate, -1))}
aria-label="Previous day" aria-label="Previous day"
className="border-line flex h-9 w-9 items-center justify-center rounded-full border bg-white text-white" className="hover:bg-stone flex h-11 w-12 shrink-0 items-center justify-center"
> >
<ChevronLeft className="text-ink h-4 w-4" aria-hidden="true" /> <ChevronLeft className="text-ink h-4 w-4" aria-hidden="true" />
</Link> </Link>
<div className="text-center"> <div className="flex-1 py-1.5 text-center">
<p className="text-sm font-bold">{selectedDate === today ? "Today" : label}</p> <p className="text-sm font-bold">{selectedDate === today ? "Today" : label}</p>
{selectedDate === today && <p className="text-muted text-xs">{label}</p>} {selectedDate === today && <p className="text-muted text-xs">{label}</p>}
{selectedDate !== today && ( {selectedDate !== today && (
@@ -65,7 +65,7 @@ export function DateStrip({
<Link <Link
href={hrefFor(addDays(selectedDate, 1))} href={hrefFor(addDays(selectedDate, 1))}
aria-label="Next day" aria-label="Next day"
className="border-line flex h-9 w-9 items-center justify-center rounded-full border bg-white" className="hover:bg-stone flex h-11 w-12 shrink-0 items-center justify-center"
> >
<ChevronRight className="text-ink h-4 w-4" aria-hidden="true" /> <ChevronRight className="text-ink h-4 w-4" aria-hidden="true" />
</Link> </Link>
+17 -4
View File
@@ -29,6 +29,14 @@ function formatCompactTime(minutes: number): string {
const displayHour = h % 12 === 0 ? 12 : h % 12; const displayHour = h % 12 === 0 ? 12 : h % 12;
return `${displayHour}:${String(m).padStart(2, "0")}`; return `${displayHour}:${String(m).padStart(2, "0")}`;
} }
/** "2:47 pm" — the current-time indicator's own label, read out next to its dot on the axis. */
function formatCurrentTimeLabel(minutes: number): string {
const h = Math.floor(minutes / MINUTES_PER_HOUR);
const m = minutes % MINUTES_PER_HOUR;
const period = h >= 12 ? "pm" : "am";
const displayHour = h % 12 === 0 ? 12 : h % 12;
return `${displayHour}:${String(m).padStart(2, "0")} ${period}`;
}
export interface CalendarAppointment { export interface CalendarAppointment {
id: string; id: string;
@@ -104,7 +112,7 @@ export function DayCalendar({
currentTimeMinutes !== null && currentTimeMinutes >= RANGE_START_MINUTES && currentTimeMinutes <= RANGE_END_MINUTES; currentTimeMinutes !== null && currentTimeMinutes >= RANGE_START_MINUTES && currentTimeMinutes <= RANGE_END_MINUTES;
return ( return (
<div className="flex gap-2 px-6 pb-8"> <div className="flex gap-2 px-6 pb-8" data-testid="calendar-grid">
{/* Time axis — a plain sibling of the scrolling columns strip below, not part of its horizontal scroll at all, so it's immune to any scroll-snap/sticky edge cases: it simply never moves. */} {/* Time axis — a plain sibling of the scrolling columns strip below, not part of its horizontal scroll at all, so it's immune to any scroll-snap/sticky edge cases: it simply never moves. */}
<div className="w-11 shrink-0" style={{ height: HEADER_HEIGHT + gridHeight }}> <div className="w-11 shrink-0" style={{ height: HEADER_HEIGHT + gridHeight }}>
<div style={{ height: HEADER_HEIGHT }} aria-hidden="true" /> <div style={{ height: HEADER_HEIGHT }} aria-hidden="true" />
@@ -126,11 +134,16 @@ export function DayCalendar({
), ),
)} )}
{showCurrentTime && ( {showCurrentTime && (
<span <div
className="bg-danger absolute right-0 top-0 h-2 w-2 -translate-y-1/2 rounded-full" className="absolute inset-x-0 z-40 flex items-center justify-end gap-1"
style={{ top: topFor(currentTimeMinutes!) }} style={{ top: topFor(currentTimeMinutes!) }}
aria-hidden="true" aria-hidden="true"
/> >
<span className="bg-danger h-2 w-2 shrink-0 -translate-y-1/2 rounded-full" />
<span className="bg-danger absolute left-full -translate-y-1/2 rounded px-1.5 py-0.5 text-[9px] font-bold whitespace-nowrap text-white">
{formatCurrentTimeLabel(currentTimeMinutes!)}
</span>
</div>
)} )}
</div> </div>
</div> </div>
@@ -0,0 +1,22 @@
import Link from "next/link";
import { cn } from "@/lib/utils/cn";
/** Day/Agenda as one segmented switch (a single bordered pill, active side filled) rather than two separate floating pills. Plain Links — same "no client JS needed just to change a view" reasoning as DateStrip. */
export function ViewSwitch({ dayHref, agendaHref, view }: { dayHref: string; agendaHref: string; view: "day" | "agenda" }) {
const segmentClass = (active: boolean) =>
cn(
"flex-1 rounded-full px-4 py-1.5 text-center text-xs font-bold tracking-widest uppercase transition-colors",
active ? "bg-ink text-white" : "text-muted",
);
return (
<div className="border-line inline-flex rounded-full border bg-white p-1">
<Link href={dayHref} aria-current={view === "day" ? "true" : undefined} className={segmentClass(view === "day")}>
Day
</Link>
<Link href={agendaHref} aria-current={view === "agenda" ? "true" : undefined} className={segmentClass(view === "agenda")}>
Agenda
</Link>
</div>
);
}