298 lines
12 KiB
TypeScript
298 lines
12 KiB
TypeScript
import type { Metadata } from "next";
|
||
import { Star } 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 { Card } from "@/components/ui/card";
|
||
import { ImageGallery } from "@/components/ui/image-gallery";
|
||
import { GalleryPlacement, Weekday } from "@/generated/prisma/enums";
|
||
import type { Prisma } from "@/generated/prisma/client";
|
||
import { computeOpeningStatus } from "@/lib/business/opening-status";
|
||
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
||
import { prisma } from "@/lib/db/prisma";
|
||
import { getAvailableServices } from "@/services/scheduling/availability.service";
|
||
import { listGalleryImages } from "@/services/gallery/gallery.service";
|
||
import { getLoyaltySummary, type LoyaltySummary } from "@/services/loyalty/loyalty.service";
|
||
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
|
||
import { getBusinessReviews } from "@/services/reviews/reviews.service";
|
||
import { HomeTabs } from "@/features/home/components/home-tabs";
|
||
import type { AppointmentStatus } from "@/types/domain";
|
||
|
||
export const metadata: Metadata = { title: "Home" };
|
||
export const dynamic = "force-dynamic";
|
||
|
||
const WEEKDAY_ORDER: Weekday[] = [
|
||
Weekday.MONDAY,
|
||
Weekday.TUESDAY,
|
||
Weekday.WEDNESDAY,
|
||
Weekday.THURSDAY,
|
||
Weekday.FRIDAY,
|
||
Weekday.SATURDAY,
|
||
Weekday.SUNDAY,
|
||
];
|
||
const WEEKDAY_LABEL: Record<Weekday, string> = {
|
||
MONDAY: "Monday",
|
||
TUESDAY: "Tuesday",
|
||
WEDNESDAY: "Wednesday",
|
||
THURSDAY: "Thursday",
|
||
FRIDAY: "Friday",
|
||
SATURDAY: "Saturday",
|
||
SUNDAY: "Sunday",
|
||
};
|
||
|
||
const STATUS_MAP: Record<string, AppointmentStatus> = {
|
||
PENDING: "pending_approval",
|
||
CONFIRMED: "confirmed",
|
||
CHECKED_IN: "arrived",
|
||
IN_PROGRESS: "in_progress",
|
||
};
|
||
|
||
export default async function CustomerHomePage() {
|
||
const [business, session] = await Promise.all([getPrimaryBusiness(), auth()]);
|
||
|
||
const [services, categories, staff, reviews, heroImages, portfolioImages] = await Promise.all([
|
||
getAvailableServices(prisma, { businessId: business.id }),
|
||
prisma.serviceCategory.findMany({ where: { businessId: business.id, isActive: true }, orderBy: { displayOrder: "asc" } }),
|
||
prisma.staffProfile.findMany({
|
||
where: { businessId: business.id, status: "ACTIVE", isBookableOnline: true },
|
||
include: { user: { select: { name: true, image: true } } },
|
||
}),
|
||
getBusinessReviews(business.id),
|
||
listGalleryImages(prisma, { businessId: business.id, placement: GalleryPlacement.HERO, activeOnly: true }),
|
||
listGalleryImages(prisma, { businessId: business.id, placement: GalleryPlacement.PORTFOLIO, activeOnly: true }),
|
||
]);
|
||
|
||
const openingStatus = computeOpeningStatus(business.hours, business.timezone);
|
||
const location = business.locations[0];
|
||
|
||
// Hero: owner-managed gallery first; falls back to a single cover/logo
|
||
// image (still fully functional with zero gallery rows configured —
|
||
// see docs/phase-9-summary.md, "Home Screen Redesign").
|
||
const heroGalleryImages =
|
||
heroImages.length > 0
|
||
? heroImages.map((i) => ({ id: i.id, url: i.imageUrl, caption: i.caption }))
|
||
: business.coverImageUrl || business.logoUrl
|
||
? [{ id: "fallback", url: (business.coverImageUrl ?? business.logoUrl)!, caption: null }]
|
||
: [];
|
||
|
||
const hoursRows = WEEKDAY_ORDER.map((day) => {
|
||
const dayHours = business.hours.find((h) => h.dayOfWeek === day);
|
||
const hoursLabel =
|
||
!dayHours || dayHours.isClosed || dayHours.opensAtMinutes === null || dayHours.closesAtMinutes === null
|
||
? "Closed"
|
||
: `${String(Math.floor(dayHours.opensAtMinutes / 60)).padStart(2, "0")}:${String(dayHours.opensAtMinutes % 60).padStart(2, "0")} – ${String(Math.floor(dayHours.closesAtMinutes / 60)).padStart(2, "0")}:${String(dayHours.closesAtMinutes % 60).padStart(2, "0")}`;
|
||
return { label: WEEKDAY_LABEL[day], hoursLabel };
|
||
});
|
||
|
||
// Keyless Google Maps embed — no API key/SDK needed for a simple pinned
|
||
// map, unlike the JS Maps SDK. Prefers exact coordinates; falls back to
|
||
// the street address when they're not set.
|
||
const mapQuery =
|
||
location?.latitude && location?.longitude
|
||
? `${location.latitude},${location.longitude}`
|
||
: location
|
||
? `${location.addressLine1}, ${location.city} ${location.postalCode}`
|
||
: null;
|
||
const mapEmbedSrc = mapQuery ? `https://www.google.com/maps?q=${encodeURIComponent(mapQuery)}&output=embed` : null;
|
||
|
||
type UpcomingAppointment = Prisma.AppointmentGetPayload<{
|
||
include: { services: { include: { service: true } }; staff: { include: { user: true } } };
|
||
}>;
|
||
|
||
let customerProfile: Awaited<ReturnType<typeof prisma.customerProfile.findUnique>> = null;
|
||
let upcomingAppointment: UpcomingAppointment | null = null;
|
||
let loyalty: LoyaltySummary | null = null;
|
||
let preferredBarberName: string | null = null;
|
||
let lastCompletedAppointment: Awaited<ReturnType<typeof prisma.appointment.findFirst>> = null;
|
||
|
||
if (session?.user?.id) {
|
||
customerProfile = await prisma.customerProfile.findUnique({
|
||
where: { userId: session.user.id },
|
||
});
|
||
|
||
if (customerProfile) {
|
||
[upcomingAppointment, loyalty, lastCompletedAppointment] = await Promise.all([
|
||
prisma.appointment.findFirst({
|
||
where: {
|
||
customerId: customerProfile.id,
|
||
status: { in: ["PENDING", "CONFIRMED", "CHECKED_IN"] },
|
||
startAt: { gte: new Date() },
|
||
},
|
||
orderBy: { startAt: "asc" },
|
||
include: { services: { include: { service: true } }, staff: { include: { user: true } } },
|
||
}),
|
||
getLoyaltySummary(prisma, { customerId: customerProfile.id, businessId: business.id }),
|
||
prisma.appointment.findFirst({
|
||
where: { customerId: customerProfile.id, status: "COMPLETED" },
|
||
orderBy: { startAt: "desc" },
|
||
}),
|
||
]);
|
||
|
||
if (customerProfile.preferredBarberId) {
|
||
const preferred = await prisma.staffProfile.findUnique({
|
||
where: { id: customerProfile.preferredBarberId },
|
||
include: { user: { select: { name: true } } },
|
||
});
|
||
preferredBarberName = preferred?.user.name ?? null;
|
||
}
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
{/* Hero — an owner-managed gallery (see /owner/settings/gallery), matching the content width rather than bleeding edge-to-edge. */}
|
||
{heroGalleryImages.length > 0 && (
|
||
<div className="mx-auto max-w-2xl pb-1 bg-white border-line border-b">
|
||
<ImageGallery images={heroGalleryImages} />
|
||
</div>
|
||
)}
|
||
|
||
<main className="mx-auto max-w-2xl bg-white px-6 pt-3 pb-6">
|
||
{/* {session?.user && (
|
||
<p className="text-muted mb-1 text-sm">
|
||
Good to see you, {session.user.name?.split(" ")[0] ?? "there"}
|
||
</p>
|
||
)} */}
|
||
<h1 className="mb-1 text-2xl font-bold tracking-tight">{business.name}</h1>
|
||
{location && (
|
||
<p className="text-muted mb-3 text-xs">
|
||
{location.addressLine1}, {location.city} {location.postalCode}
|
||
</p>
|
||
)}
|
||
<div className="mb-6 flex items-center gap-3 text-sm">
|
||
<div className="text-accent-loyalty flex items-center gap-1">
|
||
<Star className="h-4 w-4 fill-current" aria-hidden="true" />
|
||
<span className="text-ink font-bold">{reviews.averageRating.toFixed(1)}</span>
|
||
</div>
|
||
<span className="text-muted">· {reviews.totalReviews.toLocaleString()} reviews</span>
|
||
<span
|
||
className={openingStatus.isOpen ? "text-accent-foreground font-bold" : "text-danger font-bold"}
|
||
>
|
||
{openingStatus.label}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Upcoming appointment */}
|
||
{upcomingAppointment && (
|
||
<section className="mb-6">
|
||
{session?.user && (
|
||
<h2 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">
|
||
{session.user.name?.split(" ")[0] ?? "Your Next Appointment"}, Your Next Appointment
|
||
</h2>
|
||
)}
|
||
<AppointmentCard
|
||
status={STATUS_MAP[upcomingAppointment.status] ?? "confirmed"}
|
||
serviceName={upcomingAppointment.services.map((s) => s.service.name).join(" + ")}
|
||
person={{
|
||
id: upcomingAppointment.staffId,
|
||
name: upcomingAppointment.staff.user.name ?? "Barber",
|
||
}}
|
||
formattedDate={upcomingAppointment.startAt.toLocaleDateString("en-GB", {
|
||
weekday: "short",
|
||
day: "numeric",
|
||
month: "short",
|
||
timeZone: business.timezone,
|
||
})}
|
||
formattedTime={upcomingAppointment.startAt.toLocaleTimeString("en-GB", {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
timeZone: business.timezone,
|
||
})}
|
||
actions={
|
||
<Link
|
||
href="/appointments"
|
||
className="text-ink text-xs font-bold tracking-widest uppercase underline"
|
||
>
|
||
View details
|
||
</Link>
|
||
}
|
||
/>
|
||
</section>
|
||
)}
|
||
|
||
{/* Loyalty summary */}
|
||
{loyalty?.isEnabled && loyalty.accountId && (
|
||
<Link
|
||
href="/loyalty"
|
||
className={
|
||
loyalty.availableReward
|
||
? "rounded-md border-accent-loyalty bg-accent-loyalty/10 mb-6 block border p-5 shadow-sm"
|
||
: "rounded-md border-line mb-6 block border bg-white p-5 shadow-sm"
|
||
}
|
||
>
|
||
{loyalty.availableReward ? (
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-sm font-bold">Reward available — {loyalty.availableReward.label}</p>
|
||
<span className="text-accent-loyalty-foreground text-xs font-bold uppercase tracking-widest">View</span>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<p className="text-sm font-bold">Loyalty Progress</p>
|
||
<span className="text-muted text-xs">
|
||
{loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps
|
||
</span>
|
||
</div>
|
||
<div className="bg-stone h-2 w-full overflow-hidden rounded-full">
|
||
<div
|
||
className="bg-accent-loyalty h-full rounded-full"
|
||
style={{
|
||
width: `${Math.min(100, (loyalty.currentStamps / resolveRewardThreshold(loyalty)) * 100)}%`,
|
||
}}
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
</Link>
|
||
)}
|
||
|
||
{/* Preferred barber + book again */}
|
||
{(preferredBarberName || lastCompletedAppointment) && (
|
||
<div className="mb-6 grid grid-cols-2 gap-3">
|
||
{preferredBarberName && (
|
||
<Card className="p-4">
|
||
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">
|
||
Preferred Barber
|
||
</p>
|
||
<p className="mt-1 text-sm font-bold">{preferredBarberName}</p>
|
||
</Card>
|
||
)}
|
||
{/* {lastCompletedAppointment && (
|
||
<Button asChild variant="secondary" className="h-full w-full">
|
||
<Link href="/appointments">Book Again</Link>
|
||
</Button>
|
||
)} */}
|
||
</div>
|
||
)}
|
||
|
||
{/* Booking CTA */}
|
||
<Button asChild className="mb-8 w-full">
|
||
<Link href="/book">Book an Appointment</Link>
|
||
</Button>
|
||
|
||
<HomeTabs
|
||
services={services}
|
||
categories={categories.map((c) => ({ id: c.id, name: c.name }))}
|
||
portfolioImages={portfolioImages}
|
||
reviews={reviews}
|
||
description={business.description}
|
||
phone={business.phone}
|
||
email={business.email}
|
||
website={business.website}
|
||
instagramUrl={business.instagramUrl}
|
||
facebookUrl={business.facebookUrl}
|
||
hoursRows={hoursRows}
|
||
staff={staff.map((s) => ({
|
||
id: s.id,
|
||
name: s.user.name ?? "Barber",
|
||
avatarUrl: s.user.image ?? undefined,
|
||
isPreferred: customerProfile?.preferredBarberId === s.id,
|
||
}))}
|
||
mapEmbedSrc={mapEmbedSrc}
|
||
/>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|