Initial PHP rebuild of the app
This commit is contained in:
690 files changed
+33944
-12046
No files matched your search
@@ -0,0 +1,183 @@
|
||||
import type { Metadata } from "next";
|
||||
import { CalendarPlus, LogIn } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { auth } from "@/auth";
|
||||
import { AppointmentCard } from "@/components/ui/appointment-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { EmptyState } from "@/components/feedback/empty-state";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { listAppointments } from "@/services/scheduling/appointment.service";
|
||||
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
|
||||
import { BookAgainButton } from "@/features/booking/components/book-again-button";
|
||||
import { CancelAppointmentDialog } from "@/features/booking/components/cancel-appointment-dialog";
|
||||
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
|
||||
import type { AppointmentStatus } from "@/types/domain";
|
||||
|
||||
export const metadata: Metadata = { title: "Appointments" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const STATUS_MAP: Record<string, AppointmentStatus> = {
|
||||
PENDING: "pending_approval",
|
||||
CONFIRMED: "confirmed",
|
||||
CHECKED_IN: "arrived",
|
||||
IN_PROGRESS: "in_progress",
|
||||
COMPLETED: "completed",
|
||||
CANCELLED: "cancelled",
|
||||
NO_SHOW: "no_show",
|
||||
};
|
||||
|
||||
const UPCOMING_STATUSES = ["PENDING", "CONFIRMED", "CHECKED_IN", "IN_PROGRESS"] as const;
|
||||
const CANCELLED_STATUSES = ["CANCELLED", "NO_SHOW"] as const;
|
||||
const RESCHEDULABLE_STATUSES = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]);
|
||||
const CANCELLABLE_STATUSES = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]);
|
||||
|
||||
export default async function AppointmentsPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Appointments" sticky className="bg-stone" />
|
||||
<div className="mx-auto max-w-2xl px-6 pt-6">
|
||||
<EmptyState
|
||||
icon={LogIn}
|
||||
title="Sign in to see your appointments"
|
||||
description="Create an account or sign in to view, reschedule, and cancel your bookings."
|
||||
action={
|
||||
<Button asChild>
|
||||
<Link href="/sign-in?callbackUrl=/appointments">Sign In</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const [business, unreadNotificationCount] = await Promise.all([
|
||||
getPrimaryBusiness(),
|
||||
getUnreadNotificationCount(prisma, session.user.id),
|
||||
]);
|
||||
const customerProfile = await prisma.customerProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
});
|
||||
const bell = <InlineNotificationBell href="/notifications" unreadCount={unreadNotificationCount} />;
|
||||
|
||||
if (!customerProfile) {
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Appointments" sticky className="bg-stone" action={bell} />
|
||||
<div className="mx-auto max-w-2xl px-6 pt-6">
|
||||
<EmptyState
|
||||
icon={CalendarPlus}
|
||||
title="No appointments yet"
|
||||
description="Book your first appointment to see it here."
|
||||
action={
|
||||
<Button asChild>
|
||||
<Link href="/book">Book Now</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const [upcoming, past, cancelled] = await Promise.all([
|
||||
listAppointments(prisma, { customerId: customerProfile.id, statuses: [...UPCOMING_STATUSES] }),
|
||||
listAppointments(prisma, { customerId: customerProfile.id, statuses: ["COMPLETED"] }),
|
||||
listAppointments(prisma, { customerId: customerProfile.id, statuses: [...CANCELLED_STATUSES] }),
|
||||
]);
|
||||
|
||||
const renderList = (appointments: typeof upcoming, kind: "upcoming" | "past" | "cancelled") => {
|
||||
if (appointments.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={CalendarPlus}
|
||||
title={
|
||||
kind === "upcoming"
|
||||
? "No upcoming appointments"
|
||||
: kind === "past"
|
||||
? "No past appointments"
|
||||
: "No cancelled appointments"
|
||||
}
|
||||
description={kind === "upcoming" ? "Book your next visit in a few taps." : undefined}
|
||||
action={
|
||||
kind === "upcoming" ? (
|
||||
<Button asChild>
|
||||
<Link href="/book">Book Now</Link>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{appointments.map((appointment) => (
|
||||
<AppointmentCard
|
||||
key={appointment.id}
|
||||
status={STATUS_MAP[appointment.status] ?? "confirmed"}
|
||||
serviceName={appointment.services.map((s) => s.service.name).join(" + ")}
|
||||
person={{ id: appointment.staffId, name: appointment.staff.user.name ?? "Barber" }}
|
||||
formattedDate={appointment.startAt.toLocaleDateString("en-GB", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
timeZone: business.timezone,
|
||||
})}
|
||||
formattedTime={appointment.startAt.toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: business.timezone,
|
||||
})}
|
||||
actions={
|
||||
<div className="flex flex-1 flex-wrap gap-2">
|
||||
<Link
|
||||
href={`/appointments/${appointment.id}`}
|
||||
className="border-line rounded-lg border px-4 py-2 text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
{kind === "upcoming" && RESCHEDULABLE_STATUSES.has(appointment.status) && (
|
||||
<Link
|
||||
href={`/appointments/${appointment.id}/reschedule`}
|
||||
className="border-line rounded-lg border px-4 py-2 text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
Reschedule
|
||||
</Link>
|
||||
)}
|
||||
{kind === "upcoming" && CANCELLABLE_STATUSES.has(appointment.status) && (
|
||||
<CancelAppointmentDialog appointmentId={appointment.id} />
|
||||
)}
|
||||
{kind !== "upcoming" && <BookAgainButton appointmentId={appointment.id} />}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Appointments" sticky className="bg-stone" action={bell} />
|
||||
<div className="mx-auto max-w-2xl px-6 pt-6">
|
||||
<Tabs defaultValue="upcoming">
|
||||
<TabsList>
|
||||
<TabsTrigger value="upcoming">Upcoming</TabsTrigger>
|
||||
<TabsTrigger value="past">Past</TabsTrigger>
|
||||
<TabsTrigger value="cancelled">Cancelled</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="upcoming">{renderList(upcoming, "upcoming")}</TabsContent>
|
||||
<TabsContent value="past">{renderList(past, "past")}</TabsContent>
|
||||
<TabsContent value="cancelled">{renderList(cancelled, "cancelled")}</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user