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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user