237 lines
9.8 KiB
TypeScript
237 lines
9.8 KiB
TypeScript
"use client";
|
|
|
|
import Image from "next/image";
|
|
import Link from "next/link";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
import { useEffect, useState } from "react";
|
|
import { RoomSelectBooking } from "@/components/RoomSelectBooking";
|
|
import { useBooking } from "@/context/BookingContext";
|
|
import { rooms } from "@/lib/mocks/rooms";
|
|
import { siteConfig } from "@/lib/mocks/site";
|
|
import { submitBookingHold } from "@/lib/mocks/api";
|
|
|
|
export function BookingPageClient() {
|
|
const searchParams = useSearchParams();
|
|
const router = useRouter();
|
|
const {
|
|
checkIn,
|
|
checkOut,
|
|
guests,
|
|
guest,
|
|
setRoomId,
|
|
setGuest,
|
|
setHoldReference,
|
|
setPayLaterHold,
|
|
selectedRoom,
|
|
nights,
|
|
} = useBooking();
|
|
|
|
const [pending, setPending] = useState<null | "payment" | "reserve">(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
const r = searchParams.get("room");
|
|
if (r && rooms.some((x) => x.id === r)) setRoomId(r);
|
|
}, [searchParams, setRoomId]);
|
|
|
|
const canContinue =
|
|
selectedRoom &&
|
|
guest.firstName.trim() &&
|
|
guest.lastName.trim() &&
|
|
guest.email.trim() &&
|
|
guest.phone.trim() &&
|
|
guest.flightBookingNumber.trim() &&
|
|
guest.arrivalTime.trim();
|
|
|
|
async function placeHold(mode: "payment" | "reserve") {
|
|
if (!canContinue || !selectedRoom) return;
|
|
setError(null);
|
|
setPending(mode);
|
|
try {
|
|
const { reference } = await submitBookingHold({
|
|
roomId: selectedRoom.id,
|
|
email: guest.email,
|
|
flightBookingNumber: guest.flightBookingNumber.trim(),
|
|
arrivalTime: guest.arrivalTime.trim(),
|
|
});
|
|
setHoldReference(reference);
|
|
setPayLaterHold(mode === "reserve");
|
|
router.push(mode === "payment" ? "/payment" : "/reserve-held");
|
|
} catch {
|
|
setError("Something went wrong. Please try again.");
|
|
} finally {
|
|
setPending(null);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto max-w-2xl px-4 py-12 md:py-16">
|
|
<p className="text-xs font-semibold uppercase tracking-widest text-[var(--color-primary)]">
|
|
Book your stay
|
|
</p>
|
|
<h1 className="mt-2 font-display text-3xl md:text-4xl">
|
|
It only takes a moment
|
|
</h1>
|
|
<p className="mt-2 text-sm text-[var(--color-muted)]">
|
|
Pay now, or reserve first and complete payment later in this session — mock only.
|
|
</p>
|
|
|
|
<div className="mt-8 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-sm">
|
|
<RoomSelectBooking selected={selectedRoom} onSelect={setRoomId} />
|
|
</div>
|
|
|
|
{selectedRoom ? (
|
|
<div className="mt-8 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-muted)]">
|
|
<div className="relative aspect-[2/1] w-full">
|
|
<Image
|
|
src={selectedRoom.gallery[0]!}
|
|
alt={selectedRoom.name}
|
|
fill
|
|
className="object-cover"
|
|
sizes="(max-width:768px) 100vw, 672px"
|
|
/>
|
|
</div>
|
|
<div className="space-y-3 p-6 text-sm">
|
|
<Row label="Hotel" value={siteConfig.name} />
|
|
<Row label="Room" value={selectedRoom.name} />
|
|
<Row label="Guests" value={`${guests} guest${guests !== 1 ? "s" : ""}`} />
|
|
<Row label="Check-in" value={formatDate(checkIn)} />
|
|
<Row label="Check-out" value={formatDate(checkOut)} />
|
|
<Row label="Nights" value={String(nights)} />
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="mt-8 rounded-2xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface)] p-8 text-center">
|
|
<p className="text-[var(--color-muted)]">Select a room to continue.</p>
|
|
<Link
|
|
href="/#rooms"
|
|
className="mt-4 inline-block text-sm font-semibold text-[var(--color-primary)] hover:underline"
|
|
>
|
|
Browse rooms
|
|
</Link>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-10 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm">
|
|
<h2 className="text-lg font-semibold">Who's checking in?</h2>
|
|
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
|
<label className="block text-sm">
|
|
<span className="mb-1 block text-[var(--color-muted)]">First name</span>
|
|
<input
|
|
value={guest.firstName}
|
|
onChange={(e) => setGuest({ firstName: e.target.value })}
|
|
className="w-full rounded-xl border border-[var(--color-border)] px-3 py-2.5 focus:border-[var(--color-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20"
|
|
autoComplete="given-name"
|
|
/>
|
|
</label>
|
|
<label className="block text-sm">
|
|
<span className="mb-1 block text-[var(--color-muted)]">Last name</span>
|
|
<input
|
|
value={guest.lastName}
|
|
onChange={(e) => setGuest({ lastName: e.target.value })}
|
|
className="w-full rounded-xl border border-[var(--color-border)] px-3 py-2.5 focus:border-[var(--color-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20"
|
|
autoComplete="family-name"
|
|
/>
|
|
</label>
|
|
<label className="block text-sm sm:col-span-2">
|
|
<span className="mb-1 block text-[var(--color-muted)]">Email</span>
|
|
<input
|
|
type="email"
|
|
value={guest.email}
|
|
onChange={(e) => setGuest({ email: e.target.value })}
|
|
className="w-full rounded-xl border border-[var(--color-border)] px-3 py-2.5 focus:border-[var(--color-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20"
|
|
autoComplete="email"
|
|
/>
|
|
</label>
|
|
<label className="block text-sm sm:col-span-2">
|
|
<span className="mb-1 block text-[var(--color-muted)]">Phone</span>
|
|
<input
|
|
type="tel"
|
|
value={guest.phone}
|
|
onChange={(e) => setGuest({ phone: e.target.value })}
|
|
className="w-full rounded-xl border border-[var(--color-border)] px-3 py-2.5 focus:border-[var(--color-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20"
|
|
autoComplete="tel"
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-8 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm">
|
|
<h2 className="text-lg font-semibold">Flight arrival</h2>
|
|
<p className="mt-1 text-sm text-[var(--color-muted)]">
|
|
So we can coordinate your airport shuttle and room readiness.
|
|
</p>
|
|
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
|
<label className="block text-sm sm:col-span-2">
|
|
<span className="mb-1 block text-[var(--color-muted)]">Flight booking / PNR / ticket number</span>
|
|
<input
|
|
value={guest.flightBookingNumber}
|
|
onChange={(e) => setGuest({ flightBookingNumber: e.target.value })}
|
|
placeholder="e.g. ABC123 or airline record locator"
|
|
className="w-full rounded-xl border border-[var(--color-border)] px-3 py-2.5 focus:border-[var(--color-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20"
|
|
autoComplete="off"
|
|
/>
|
|
</label>
|
|
<label className="block text-sm sm:col-span-2">
|
|
<span className="mb-1 block text-[var(--color-muted)]">Arrival time (local)</span>
|
|
<input
|
|
type="time"
|
|
value={guest.arrivalTime}
|
|
onChange={(e) => setGuest({ arrivalTime: e.target.value })}
|
|
className="w-full max-w-[12rem] rounded-xl border border-[var(--color-border)] px-3 py-2.5 focus:border-[var(--color-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20"
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{error ? <p className="mt-4 text-sm text-red-700">{error}</p> : null}
|
|
|
|
<div className="mt-8 flex flex-col gap-3">
|
|
<button
|
|
type="button"
|
|
disabled={!canContinue || pending !== null}
|
|
aria-busy={pending === "payment"}
|
|
onClick={() => placeHold("payment")}
|
|
className="w-full rounded-full bg-[var(--color-text)] py-4 text-sm font-semibold text-white transition hover:bg-[var(--color-primary)] disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
{pending === "payment" ? "Please wait…" : "Continue to payment"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={!canContinue || pending !== null}
|
|
aria-busy={pending === "reserve"}
|
|
onClick={() => placeHold("reserve")}
|
|
className="w-full rounded-full border-2 border-[var(--color-primary)] bg-transparent py-3.5 text-sm font-semibold text-[var(--color-primary)] transition hover:bg-[var(--color-primary)] hover:text-[var(--color-on-primary)] disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
{pending === "reserve" ? "Saving your hold…" : "Reserve now — pay later"}
|
|
</button>
|
|
<p className="text-center text-xs text-[var(--color-muted)]">
|
|
Pay later keeps your details and hold reference; finish checkout from the next screen
|
|
whenever you're ready.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Row({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div className="flex justify-between gap-4 border-b border-[var(--color-border)]/80 pb-2 last:border-0">
|
|
<span className="text-[var(--color-muted)]">{label}</span>
|
|
<span className="text-right font-medium text-[var(--color-text)]">{value}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatDate(iso: string) {
|
|
try {
|
|
return new Intl.DateTimeFormat("en-GB", {
|
|
day: "numeric",
|
|
month: "short",
|
|
year: "numeric",
|
|
}).format(new Date(iso + "T12:00:00"));
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|