Files
NextChair/src/app/(customer)/home/page.tsx
T
Stefan Lazic 92f062a861 Layout Changes
Small tweaks to the layout
2026-08-05 22:23:14 +01:00

298 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}