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
+901 -616

No files matched your search

@@ -1,5 +1,30 @@
"use client";
import Link from "next/link";
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
@@ -8,8 +33,16 @@ import { Bell } from "lucide-react";
* only ever reflects "as of this page load/navigation". Real-time badge
* updates without a reload would need a persistent connection this phase
* 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 }) {
const pathname = usePathname();
if (PAGES_WITH_INLINE_BELL.includes(pathname)) return null;
return (
<Link
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"
>
<Bell className="h-4 w-4" aria-hidden="true" />
{unreadCount > 0 && (
<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>
)}
{badge(unreadCount)}
</Link>
);
}
/** 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>
);
}
@@ -57,9 +57,9 @@ export function OwnerBlockTimeButton({ staffId, staffName, date }: { staffId: st
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" />
Block Time
<span className="hidden sm:inline">Block Time</span>
</Button>
<BottomSheet open={open} onClose={handleClose} title={`Block Time — ${staffName}`}>
<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 { Modal } from "@/components/ui/modal";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils/cn";
import { addLoyaltyStampAction } from "../actions/loyalty-actions";
export function AddStampButton({
customerId,
appointmentId,
className,
size = "sm",
}: {
customerId: string;
appointmentId?: string;
className?: string;
size?: "sm" | "md" | "lg";
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
@@ -57,7 +62,12 @@ export function AddStampButton({
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" />
Add Loyalty Stamp
</Button>
@@ -54,10 +54,13 @@ export function AppointmentStatusActions({
appointmentId,
status,
actions = DEFAULT_ACTIONS,
layout = "wrap",
}: {
appointmentId: string;
status: RawStatus;
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 [isPending, startTransition] = useTransition();
@@ -121,10 +124,11 @@ export function AppointmentStatusActions({
{error}
</p>
)}
<div className="flex flex-wrap gap-2">
<div className={layout === "stack" ? "flex flex-col gap-2" : "flex flex-wrap gap-2"}>
{status === "PENDING" && (
<Button
size="sm"
size={layout === "stack" ? undefined : "sm"}
className={layout === "stack" ? "w-full" : undefined}
isLoading={isPending}
onClick={() => runQuiet(() => actions.confirm(appointmentId))}
>
@@ -133,7 +137,8 @@ export function AppointmentStatusActions({
)}
{status === "CONFIRMED" && (
<Button
size="sm"
size={layout === "stack" ? undefined : "sm"}
className={layout === "stack" ? "w-full" : undefined}
isLoading={isPending}
onClick={() => runQuiet(() => actions.checkIn(appointmentId))}
>
@@ -142,7 +147,8 @@ export function AppointmentStatusActions({
)}
{status === "CHECKED_IN" && (
<Button
size="sm"
size={layout === "stack" ? undefined : "sm"}
className={layout === "stack" ? "w-full" : undefined}
isLoading={isPending}
onClick={() => runQuiet(() => actions.start(appointmentId))}
>
@@ -150,17 +156,31 @@ export function AppointmentStatusActions({
</Button>
)}
{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
</Button>
)}
{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
</Button>
)}
{(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
</Button>
)}
@@ -62,9 +62,9 @@ export function BlockTimeButton({ date }: { date: string }) {
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" />
Block Time
<span className="hidden sm:inline">Block Time</span>
</Button>
<BottomSheet open={open} onClose={handleClose} title="Block Time">
<div className="space-y-4 pb-2">
@@ -2,15 +2,22 @@
import { useTransition } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils/cn";
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();
return (
<Button
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}
onClick={() => startTransition(() => startStaffFollowUpAction(appointmentId))}
>
@@ -7,10 +7,17 @@ import { Button } from "@/components/ui/button";
import { Modal } from "@/components/ui/modal";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils/cn";
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). */
export function CorrectStampButton({ loyaltyAccountId }: { loyaltyAccountId: string }) {
export function CorrectStampButton({
loyaltyAccountId,
className,
}: {
loyaltyAccountId: string;
className?: string;
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [amount, setAmount] = useState("1");
@@ -52,7 +59,7 @@ export function CorrectStampButton({ loyaltyAccountId }: { loyaltyAccountId: str
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" />
Correct Stamp
</Button>
+4 -4
View File
@@ -42,15 +42,15 @@ export function DateStrip({
return (
<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
href={hrefFor(addDays(selectedDate, -1))}
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" />
</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>
{selectedDate === today && <p className="text-muted text-xs">{label}</p>}
{selectedDate !== today && (
@@ -65,7 +65,7 @@ export function DateStrip({
<Link
href={hrefFor(addDays(selectedDate, 1))}
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" />
</Link>
+17 -4
View File
@@ -29,6 +29,14 @@ function formatCompactTime(minutes: number): string {
const displayHour = h % 12 === 0 ? 12 : h % 12;
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 {
id: string;
@@ -104,7 +112,7 @@ export function DayCalendar({
currentTimeMinutes !== null && currentTimeMinutes >= RANGE_START_MINUTES && currentTimeMinutes <= RANGE_END_MINUTES;
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. */}
<div className="w-11 shrink-0" style={{ height: HEADER_HEIGHT + gridHeight }}>
<div style={{ height: HEADER_HEIGHT }} aria-hidden="true" />
@@ -126,11 +134,16 @@ export function DayCalendar({
),
)}
{showCurrentTime && (
<span
className="bg-danger absolute right-0 top-0 h-2 w-2 -translate-y-1/2 rounded-full"
<div
className="absolute inset-x-0 z-40 flex items-center justify-end gap-1"
style={{ top: topFor(currentTimeMinutes!) }}
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>
@@ -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>
);
}