booking and amenities

This commit is contained in:
brooktewabe 2026-04-14 15:43:20 +03:00
parent bcc3a8de15
commit 0160816b8e
18 changed files with 1184 additions and 445 deletions

View File

@ -6,9 +6,8 @@ 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";
import { siteConfig } from "@/lib/site-config";
import { createPublicBooking, ensurePropertyId } from "@/lib/public-hotel-api";
export function BookingPageClient() {
const searchParams = useSearchParams();
@ -24,6 +23,9 @@ export function BookingPageClient() {
setPayLaterHold,
selectedRoom,
nights,
rooms,
couponCode,
setLastCreatedBooking,
} = useBooking();
const [pending, setPending] = useState<null | "payment" | "reserve">(null);
@ -32,33 +34,55 @@ export function BookingPageClient() {
useEffect(() => {
const r = searchParams.get("room");
if (r && rooms.some((x) => x.id === r)) setRoomId(r);
}, [searchParams, setRoomId]);
}, [searchParams, setRoomId, rooms]);
const canContinue =
selectedRoom &&
guest.firstName.trim() &&
guest.lastName.trim() &&
guest.email.trim() &&
guest.phone.trim() &&
guest.flightBookingNumber.trim() &&
guest.arrivalTime.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({
const propertyId = await ensurePropertyId();
const booking = await createPublicBooking(propertyId, {
roomId: selectedRoom.id,
email: guest.email,
flightBookingNumber: guest.flightBookingNumber.trim(),
checkIn,
checkOut,
guestCount: guests,
firstName: guest.firstName.trim(),
lastName: guest.lastName.trim(),
email: guest.email.trim().toLowerCase(),
phone: guest.phone.trim(),
flightPnr: guest.flightBookingNumber.trim(),
arrivalTime: guest.arrivalTime.trim(),
discountCode: couponCode.trim() || undefined,
payLaterHold: mode === "reserve",
});
setHoldReference(reference);
const code = booking.bookingCode ?? "";
setHoldReference(code);
setPayLaterHold(mode === "reserve");
const tp =
booking.totalPrice != null
? typeof booking.totalPrice === "string"
? Number.parseFloat(booking.totalPrice)
: booking.totalPrice
: 0;
setLastCreatedBooking({
id: booking.id,
bookingCode: booking.bookingCode,
totalPrice: Number.isFinite(tp) ? tp : 0,
currency: booking.currency ?? "ETB",
});
router.push(mode === "payment" ? "/payment" : "/reserve-held");
} catch {
setError("Something went wrong. Please try again.");
} catch (e) {
setError(e instanceof Error ? e.message : "Something went wrong. Please try again.");
} finally {
setPending(null);
}
@ -69,11 +93,9 @@ export function BookingPageClient() {
<p className="text-xs font-semibold uppercase tracking-widest text-[var(--color-primary)]">
Book your stay
</p>
<h1 className="mt-2 font-heading text-3xl md:text-4xl">
It only takes a moment
</h1>
<h1 className="mt-2 font-heading 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.
Live rates from the hotel. You&apos;ll receive a booking code to sign in and manage your stay.
</p>
<div className="mt-8 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-sm">
@ -206,8 +228,8 @@ export function BookingPageClient() {
{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&apos;re ready.
Pay later keeps your hold; you&apos;ll get a booking code. Payment is completed at the hotel unless
you add card checkout later.
</p>
</div>
</div>

View File

@ -1,10 +1,12 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useState } from "react";
import { RequireAuth } from "@/components/RequireAuth";
import { useAuth } from "@/context/AuthContext";
import { laundryItems } from "@/lib/mocks/laundryCatalog";
import { guestPlaceLaundry } from "@/lib/guest-hotel-api";
import { formatEtb } from "@/lib/format-etb";
import { useGuestActiveBooking } from "@/lib/useGuestActiveBooking";
export function LaundryClient() {
return (
@ -15,58 +17,46 @@ export function LaundryClient() {
}
function LaundryInner() {
const { addOrder } = useAuth();
const [qty, setQty] = useState<Record<string, number>>({});
const [express, setExpress] = useState(false);
const { accessToken } = useAuth();
const { bookingId, loading: bookingLoading, propertyId } = useGuestActiveBooking();
const [notes, setNotes] = useState("");
const [pickupAt, setPickupAt] = useState("");
const [deliverAt, setDeliverAt] = useState("");
const [estimateEtb, setEstimateEtb] = useState("");
const [sent, setSent] = useState(false);
const [submitErr, setSubmitErr] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
function bump(id: string, delta: number) {
setQty((prev) => {
const next = { ...prev };
const n = Math.max(0, (next[id] ?? 0) + delta);
if (n === 0) delete next[id];
else next[id] = n;
return next;
});
}
const canUseApi = !!(propertyId && accessToken && bookingId);
const lines = useMemo(() => {
const out: { id: string; name: string; count: number; unitUsd: number }[] = [];
for (const row of laundryItems) {
const q = qty[row.id];
if (q && q > 0) {
out.push({ id: row.id, name: row.name, count: q, unitUsd: row.priceUsd });
}
async function submit() {
if (!canUseApi) return;
setSubmitErr(null);
setSubmitting(true);
try {
await guestPlaceLaundry(propertyId!, accessToken!, {
bookingId: bookingId!,
items: [],
notes: notes.trim() || undefined,
pickupAt: pickupAt || undefined,
deliverAt: deliverAt || undefined,
total: estimateEtb.trim() || undefined,
});
} catch (e) {
setSubmitErr(e instanceof Error ? e.message : "Could not submit laundry request");
setSubmitting(false);
return;
}
return out;
}, [qty]);
const subtotal = useMemo(() => {
let s = lines.reduce((a, l) => a + l.unitUsd * l.count, 0);
if (express) s += 15;
return s;
}, [lines, express]);
function submit() {
if (lines.length === 0 && !express) return;
const detail = [
...lines.map((l) => `${l.name} ×${l.count}`),
express ? "Express same-day (+$15)" : null,
]
.filter(Boolean)
.join("; ");
addOrder({
category: "laundry",
title: "Laundry · " + (lines.length ? `${lines.length} item type(s)` : "Express only"),
detail,
totalUsd: Math.round(subtotal * 100) / 100,
status: "pending",
});
setQty({});
setExpress(false);
setSubmitting(false);
setNotes("");
setPickupAt("");
setDeliverAt("");
setEstimateEtb("");
setSent(true);
}
const needBooking = !bookingLoading && !bookingId;
return (
<div className="bg-[var(--color-bg)] pb-24 pt-8 md:pt-12">
<div className="mx-auto max-w-7xl px-4 md:px-8">
@ -88,62 +78,78 @@ function LaundryInner() {
Laundry service
</h1>
<p className="mt-2 max-w-xl text-sm text-[var(--color-muted)]">
Select pieces and optional express surcharge. Mock request pickup at reception.
Submit a real laundry request attached to your active booking.
</p>
</div>
<Link href="/profile" className="text-sm font-semibold text-[var(--color-accent)] hover:underline">
<Link
href="/profile"
className="text-sm font-semibold text-[var(--color-accent)] hover:underline"
>
View profile
</Link>
</div>
{needBooking ? (
<div className="mt-6 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
Sign in with a booking code or use a reservation on your account to sync laundry with the
hotel.
</div>
) : null}
{submitErr ? (
<div className="mt-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900">
{submitErr}
</div>
) : null}
{sent ? (
<div className="mt-6 rounded-2xl border border-[var(--color-accent)]/40 bg-[var(--color-accent-soft)] px-4 py-3 text-sm text-[var(--color-primary)]">
Request logged (demo). Our team will confirm timing by phone.
Laundry request submitted successfully.
</div>
) : null}
<div className="mt-10 grid gap-10 lg:grid-cols-[1fr_380px]">
<div className="space-y-2">
{laundryItems.map((row) => (
<div
key={row.id}
className="flex flex-col gap-3 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between"
>
<div>
<p className="font-semibold text-[var(--color-text)]">{row.name}</p>
<p className="text-sm text-[var(--color-muted)]">
{row.description} · ${row.priceUsd}/{row.unit}
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => bump(row.id, -1)}
className="h-9 w-9 rounded-full border border-[var(--color-border)] text-lg font-semibold"
>
</button>
<span className="w-8 text-center font-semibold">{qty[row.id] ?? 0}</span>
<button
type="button"
onClick={() => bump(row.id, 1)}
className="h-9 w-9 rounded-full border border-[var(--color-border)] text-lg font-semibold"
>
+
</button>
</div>
</div>
))}
<label className="mt-4 flex cursor-pointer items-center gap-3 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-muted)] p-4">
<input
type="checkbox"
checked={express}
onChange={(e) => setExpress(e.target.checked)}
className="h-4 w-4 rounded border-[var(--color-border)]"
<div className="space-y-4 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-sm">
<div>
<label className="text-sm font-medium text-[var(--color-text)]">Request details</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={5}
placeholder="Example: 2 shirts, 1 trouser, delicate cycle, no starch."
className="mt-2 w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3 py-2 text-sm"
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="text-sm text-[var(--color-text)]">
Pickup time
<input
type="datetime-local"
value={pickupAt}
onChange={(e) => setPickupAt(e.target.value)}
className="mt-1 w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-2 py-2 text-sm"
/>
</label>
<label className="text-sm text-[var(--color-text)]">
Delivery time
<input
type="datetime-local"
value={deliverAt}
onChange={(e) => setDeliverAt(e.target.value)}
className="mt-1 w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-2 py-2 text-sm"
/>
</label>
</div>
<label className="text-sm text-[var(--color-text)]">
Estimated total (ETB, optional)
<input
type="number"
min="0"
step="1"
value={estimateEtb}
onChange={(e) => setEstimateEtb(e.target.value)}
className="mt-1 w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-2 py-2 text-sm"
/>
<span className="text-sm text-[var(--color-text)]">
Express same-day (+$15 per order)
</span>
</label>
</div>
@ -152,14 +158,16 @@ function LaundryInner() {
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-primary)]">
Summary
</p>
<p className="mt-4 font-heading text-2xl font-semibold">${subtotal.toFixed(2)}</p>
<p className="mt-4 text-sm text-[var(--color-muted)]">
{estimateEtb ? `Estimated: ${formatEtb(Number(estimateEtb), 0)}` : "No estimate entered"}
</p>
<button
type="button"
onClick={submit}
disabled={lines.length === 0 && !express}
onClick={() => void submit()}
disabled={!canUseApi || submitting || bookingLoading || notes.trim().length === 0}
className="btn-mustard mt-6 w-full justify-center py-3 text-sm disabled:cursor-not-allowed disabled:opacity-50"
>
Submit laundry request
{submitting ? "Submitting…" : "Submit laundry request"}
</button>
</div>
</aside>

View File

@ -1,18 +1,15 @@
import type { Metadata } from "next";
import Link from "next/link";
import { siteConfig } from "@/lib/mocks/site";
"use client";
export const metadata: Metadata = {
title: "Guest hub",
description: "Digital room service, laundry, gym, and spa — order during your stay at Shitaye.",
};
import Link from "next/link";
import { siteConfig } from "@/lib/site-config";
import { useAuth } from "@/context/AuthContext";
const tiles = [
{
href: "/guest/room-service",
title: "Digital menu",
subtitle: "Room service",
desc: "Breakfast through late evening — add to tray and send to the kitchen (demo).",
desc: "Breakfast through late evening — send orders directly to the kitchen.",
icon: "🍽",
},
{
@ -39,6 +36,8 @@ const tiles = [
];
export default function GuestHubPage() {
const { session } = useAuth();
return (
<div className="bg-[var(--color-bg)]">
<section className="border-b border-[var(--color-border)] bg-pattern-brand-gold py-14 md:py-20">
@ -54,20 +53,27 @@ export default function GuestHubPage() {
During your stay
</h1>
<p className="mt-5 max-w-2xl text-sm leading-relaxed text-[var(--color-muted)] md:text-base">
Order to your room, schedule laundry, and book gym & spa all in one place. Sign in with
email or{" "}
<span className="font-medium text-[var(--color-text)]">booking reference</span> to track
orders on your profile.
Order to your room, schedule laundry, and book gym & spa all in one place.
{!session && (
<>
{" "}
Sign in with email or{" "}
<span className="font-medium text-[var(--color-text)]">booking reference</span> to track
orders on your profile.
</>
)}
</p>
<div className="mt-8 flex flex-wrap gap-3">
<Link href="/login" className="btn-mustard px-6 py-3 text-sm">
Sign in
</Link>
{!session && (
<Link href="/login" className="btn-mustard px-6 py-3 text-sm">
Sign in
</Link>
)}
<Link
href="/profile"
className="inline-flex items-center rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-6 py-3 text-sm font-semibold text-[var(--color-text)] transition hover:border-[var(--color-accent)]"
>
My stay profile
{session ? "My profile & orders" : "My stay profile"}
</Link>
</div>
</div>

View File

@ -2,15 +2,17 @@
import Image from "next/image";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { RequireAuth } from "@/components/RequireAuth";
import { useAuth } from "@/context/AuthContext";
import {
roomServiceCategories,
roomServiceItems,
type MenuCategory,
type MenuItem,
} from "@/lib/mocks/roomServiceMenu";
import { guestMenuItems, guestPlaceRoomService, type MenuItemRow } from "@/lib/guest-hotel-api";
import { useGuestActiveBooking } from "@/lib/useGuestActiveBooking";
const API_CATEGORY_LABEL: Record<string, string> = {
FOOD: "Food",
BEVERAGE: "Beverages",
EXTRA: "Extras",
};
export function RoomServiceClient() {
return (
@ -21,15 +23,63 @@ export function RoomServiceClient() {
}
function RoomServiceInner() {
const { addOrder } = useAuth();
const [cat, setCat] = useState<MenuCategory>("breakfast");
const { accessToken } = useAuth();
const { bookingId, loading: bookingLoading, propertyId } = useGuestActiveBooking();
const [cat, setCat] = useState<string>("");
const [qty, setQty] = useState<Record<string, number>>({});
const [sent, setSent] = useState(false);
const [apiMenu, setApiMenu] = useState<MenuItemRow[] | null>(null);
const [menuReady, setMenuReady] = useState(false);
const [submitErr, setSubmitErr] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const items = useMemo(
() => roomServiceItems.filter((i) => i.category === cat),
[cat],
);
useEffect(() => {
if (!accessToken || !propertyId) {
setApiMenu(null);
setMenuReady(true);
return;
}
let cancelled = false;
guestMenuItems(propertyId, accessToken)
.then((r) => {
if (!cancelled) setApiMenu(r.data ?? []);
})
.catch(() => {
if (!cancelled) setApiMenu([]);
})
.finally(() => {
if (!cancelled) setMenuReady(true);
});
return () => {
cancelled = true;
};
}, [accessToken, propertyId]);
const useApi = true;
const tabs = useMemo(() => {
if (!apiMenu) return [];
const seen = new Set<string>();
const out: { id: string; label: string }[] = [];
for (const i of apiMenu) {
const c = String(i.category ?? "FOOD");
if (!seen.has(c)) {
seen.add(c);
out.push({ id: c, label: API_CATEGORY_LABEL[c] ?? c });
}
}
return out;
}, [apiMenu]);
useEffect(() => {
if (!tabs.length) return;
if (!cat || !tabs.some((t) => t.id === cat)) setCat(tabs[0].id);
}, [tabs, cat]);
const items = useMemo(() => {
if (!apiMenu) return [];
return apiMenu.filter((i) => String(i.category) === cat);
}, [apiMenu, cat]);
function bump(id: string, delta: number) {
setQty((prev) => {
@ -42,34 +92,42 @@ function RoomServiceInner() {
}
const cartLines = useMemo(() => {
const lines: { item: MenuItem; count: number }[] = [];
const lines: { id: string; name: string; unit: number; count: number }[] = [];
for (const id of Object.keys(qty)) {
const item = roomServiceItems.find((i) => i.id === id);
const row = apiMenu?.find((i) => i.id === id);
const count = qty[id];
if (item && count > 0) lines.push({ item, count });
if (row && count > 0) {
lines.push({ id: row.id, name: row.name, unit: Number(row.unitPrice), count });
}
}
return lines;
}, [qty]);
}, [qty, apiMenu]);
const subtotal = useMemo(
() => cartLines.reduce((s, l) => s + l.item.priceUsd * l.count, 0),
[cartLines],
);
const subtotal = useMemo(() => {
return cartLines.reduce((s, l) => s + l.unit * l.count, 0);
}, [cartLines]);
function submit() {
async function submit() {
if (cartLines.length === 0) return;
const detail = cartLines.map((l) => `${l.item.name} ×${l.count}`).join("; ");
addOrder({
category: "room-service",
title: `Room service · ${cartLines.length} line(s)`,
detail,
totalUsd: Math.round(subtotal * 100) / 100,
status: "pending",
});
setSubmitErr(null);
if (!propertyId || !accessToken || !bookingId) return;
setSubmitting(true);
try {
const lines = cartLines.map((l) => ({ menuItemId: l.id, quantity: l.count }));
await guestPlaceRoomService(propertyId, accessToken, { bookingId, lines });
} catch (e) {
setSubmitErr(e instanceof Error ? e.message : "Could not place order");
setSubmitting(false);
return;
}
setSubmitting(false);
setQty({});
setSent(true);
}
const needBooking = useApi && !bookingLoading && !bookingId;
return (
<div className="bg-[var(--color-bg)] pb-24 pt-8 md:pt-12">
<div className="mx-auto max-w-7xl px-4 md:px-8">
@ -91,23 +149,44 @@ function RoomServiceInner() {
Digital menu
</h1>
<p className="mt-2 max-w-xl text-sm text-[var(--color-muted)]">
Mock ordering your tray appears on your profile under orders. Service charges may
apply.
Orders go to the hotel kitchen when you are checked in with an active booking.
</p>
</div>
<Link href="/profile" className="text-sm font-semibold text-[var(--color-accent)] hover:underline">
<Link
href="/profile"
className="text-sm font-semibold text-[var(--color-accent)] hover:underline"
>
View profile
</Link>
</div>
{!menuReady ? (
<p className="mt-6 text-sm text-[var(--color-muted)]">Loading menu</p>
) : null}
{menuReady && items.length === 0 ? (
<p className="mt-6 text-sm text-[var(--color-muted)]">No menu items are currently available.</p>
) : null}
{needBooking ? (
<div className="mt-6 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
Sign in with a booking code or book a stay so we can attach room service to your reservation.
</div>
) : null}
{submitErr ? (
<div className="mt-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900">
{submitErr}
</div>
) : null}
{sent ? (
<div className="mt-6 rounded-2xl border border-[var(--color-accent)]/40 bg-[var(--color-accent-soft)] px-4 py-3 text-sm text-[var(--color-primary)]">
Order sent to the kitchen queue (demo). Add another round or check your profile.
Order submitted. You can add another round or check your profile for the local tray summary.
</div>
) : null}
<div className="mt-8 flex flex-wrap gap-2">
{roomServiceCategories.map((c) => (
{tabs.map((c) => (
<button
key={c.id}
type="button"
@ -125,47 +204,53 @@ function RoomServiceInner() {
<div className="mt-10 grid gap-10 lg:grid-cols-[1fr_360px]">
<div className="grid gap-4 sm:grid-cols-2">
{items.map((item) => (
<article
key={item.id}
className="flex flex-col rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-4 shadow-sm"
>
<div className="relative aspect-[4/3] overflow-hidden rounded-xl bg-[var(--color-surface-muted)]">
<Image
src="https://images.unsplash.com/photo-1414235077428-338989a2e8c0?w=600&q=80"
alt=""
fill
className="object-cover opacity-90"
sizes="(max-width:1024px) 50vw, 25vw"
/>
</div>
<h2 className="mt-3 font-heading text-lg font-semibold text-[var(--color-text)]">
{item.name}
</h2>
<p className="mt-1 text-sm text-[var(--color-muted)]">{item.description}</p>
<p className="mt-2 text-sm font-semibold text-[var(--color-primary)]">
${item.priceUsd}
</p>
<div className="mt-3 flex items-center gap-2">
<button
type="button"
onClick={() => bump(item.id, -1)}
className="h-9 w-9 rounded-full border border-[var(--color-border)] text-lg font-semibold"
aria-label="Decrease"
>
</button>
<span className="w-8 text-center font-semibold">{qty[item.id] ?? 0}</span>
<button
type="button"
onClick={() => bump(item.id, 1)}
className="h-9 w-9 rounded-full border border-[var(--color-border)] text-lg font-semibold"
aria-label="Increase"
>
+
</button>
</div>
</article>
{items.map((row) => (
<article
key={row.id}
className="flex flex-col rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-4 shadow-sm"
>
<div className="relative aspect-[4/3] overflow-hidden rounded-xl bg-[var(--color-surface-muted)]">
<Image
src="https://images.unsplash.com/photo-1414235077428-338989a2e8c0?w=600&q=80"
alt=""
fill
className="object-cover opacity-90"
sizes="(max-width:1024px) 50vw, 25vw"
/>
</div>
<h2 className="mt-3 font-heading text-lg font-semibold text-[var(--color-text)]">
{row.name}
</h2>
{row.description ? (
<p className="mt-1 text-sm text-[var(--color-muted)]">{row.description}</p>
) : null}
<p className="mt-2 text-sm font-semibold text-[var(--color-primary)]">
{Number(row.unitPrice).toLocaleString(undefined, {
style: "currency",
currency: "ETB",
maximumFractionDigits: 0,
})}
</p>
<div className="mt-3 flex items-center gap-2">
<button
type="button"
onClick={() => bump(row.id, -1)}
className="h-9 w-9 rounded-full border border-[var(--color-border)] text-lg font-semibold"
aria-label="Decrease"
>
</button>
<span className="w-8 text-center font-semibold">{qty[row.id] ?? 0}</span>
<button
type="button"
onClick={() => bump(row.id, 1)}
className="h-9 w-9 rounded-full border border-[var(--color-border)] text-lg font-semibold"
aria-label="Increase"
>
+
</button>
</div>
</article>
))}
</div>
@ -179,26 +264,38 @@ function RoomServiceInner() {
) : (
<ul className="mt-4 space-y-2 text-sm">
{cartLines.map((l) => (
<li key={l.item.id} className="flex justify-between gap-2">
<span className="text-[var(--color-text)]">
{l.item.name} ×{l.count}
</span>
<span className="font-medium">${(l.item.priceUsd * l.count).toFixed(0)}</span>
</li>
<li key={l.id} className="flex justify-between gap-2">
<span className="text-[var(--color-text)]">
{l.name} ×{l.count}
</span>
<span className="font-medium">
{(l.unit * l.count).toLocaleString(undefined, {
style: "currency",
currency: "ETB",
maximumFractionDigits: 0,
})}
</span>
</li>
))}
</ul>
)}
<div className="mt-4 flex flex-wrap items-center justify-between gap-2 border-t border-[var(--color-border)] pt-4">
<span className="text-sm text-[var(--color-muted)]">Subtotal</span>
<span className="font-heading text-xl font-semibold">${subtotal.toFixed(2)}</span>
<span className="font-heading text-xl font-semibold">
{subtotal.toLocaleString(undefined, {
style: "currency",
currency: "ETB",
maximumFractionDigits: 0,
})}
</span>
</div>
<button
type="button"
onClick={submit}
disabled={cartLines.length === 0}
onClick={() => void submit()}
disabled={cartLines.length === 0 || submitting || !!needBooking || bookingLoading}
className="btn-mustard mt-6 w-full justify-center py-3 text-sm disabled:cursor-not-allowed disabled:opacity-50"
>
Send to kitchen
{submitting ? "Sending…" : "Send to kitchen"}
</button>
</div>
</aside>

View File

@ -6,8 +6,9 @@ import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useBooking } from "@/context/BookingContext";
import { useCurrency } from "@/context/CurrencyContext";
import { formatEtb } from "@/lib/format-etb";
import { formatArrivalTimeDisplay } from "@/lib/formatArrivalTime";
import { siteConfig } from "@/lib/mocks/site";
import { siteConfig } from "@/lib/site-config";
export default function ReserveHeldPage() {
const router = useRouter();
@ -60,14 +61,16 @@ export default function ReserveHeldPage() {
</h1>
<p className="mt-3 text-center text-sm text-[var(--color-muted)]">
{guest.firstName}, your room is saved finish payment whenever you&apos;re ready in this
browser session. (Demo: no real hold or email.)
browser session.
</p>
<p className="mt-2 text-center font-mono text-sm text-[var(--color-text)]">
Hold ref: {holdReference}
Booking code: {holdReference}
</p>
<p className="mt-2 text-center text-xs text-[var(--color-muted)]">
Indicative total when you pay:{" "}
<span className="font-semibold text-[var(--color-text)]">{formatUsd(total)}</span>
<span className="font-semibold text-[var(--color-text)]">
{selectedRoom.priceCurrency === "ETB" ? formatEtb(total) : formatUsd(total)}
</span>
</p>
<div className="mt-10 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] text-left shadow-sm">

View File

@ -5,20 +5,20 @@ import { AmenityItem } from "@/components/AmenityItem";
import { FormattedUsd } from "@/components/FormattedUsd";
import { BookRoomButton } from "@/components/BookRoomButton";
import { VirtualTourBlock } from "@/components/VirtualTourBlock";
import { roomAmenities } from "@/lib/mocks/amenities";
import { getAllRoomSlugs, getRoomBySlug } from "@/lib/mocks/rooms";
import { siteConfig } from "@/lib/mocks/site";
import { roomAmenities } from "@/lib/data/amenities";
import { getAllMarketingRoomSlugs, getMarketingRoomBySlug } from "@/lib/data/marketing-room-pages";
import { siteConfig } from "@/lib/site-config";
import type { Metadata } from "next";
type Props = { params: Promise<{ slug: string }> };
export function generateStaticParams() {
return getAllRoomSlugs().map((slug) => ({ slug }));
return getAllMarketingRoomSlugs().map((slug) => ({ slug }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const room = getRoomBySlug(slug);
const room = getMarketingRoomBySlug(slug);
if (!room) return { title: "Room" };
return {
title: room.name,
@ -28,7 +28,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
export default async function RoomPage({ params }: Props) {
const { slug } = await params;
const room = getRoomBySlug(slug);
const room = getMarketingRoomBySlug(slug);
if (!room) notFound();
return (

View File

@ -1,5 +1,5 @@
import { AmenityIcon } from "@/components/icons/AmenityIcon";
import type { AmenityWithIcon } from "@/lib/mocks/amenities";
import type { AmenityWithIcon } from "@/lib/data/amenities";
type Props = {
item: AmenityWithIcon;

View File

@ -2,17 +2,24 @@
import { useRouter } from "next/navigation";
import { useBooking } from "@/context/BookingContext";
import { useAuth } from "@/context/AuthContext";
type Props = { roomId: string; className?: string };
export function BookRoomButton({ roomId, className = "" }: Props) {
const { setRoomId } = useBooking();
const { session } = useAuth();
const router = useRouter();
const hasActiveStay = !!session?.bookingId || !!session?.bookingCode;
return (
<button
type="button"
onClick={() => {
if (hasActiveStay) {
router.push("/profile");
return;
}
setRoomId(roomId);
router.push("/booking");
}}
@ -21,7 +28,7 @@ export function BookRoomButton({ roomId, className = "" }: Props) {
"btn-mustard px-8 py-3.5 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-primary)]"
}
>
Book this room
{hasActiveStay ? "View my stay" : "Book this room"}
</button>
);
}

View File

@ -0,0 +1,31 @@
"use client";
import { RoomCard } from "@/components/RoomCard";
import { useBooking } from "@/context/BookingContext";
export function CatalogRoomsSection() {
const { rooms, roomsLoading, roomsError } = useBooking();
return (
<>
{roomsError ? (
<p className="mt-4 rounded-xl border border-amber-200/80 bg-amber-50 px-4 py-3 text-sm text-amber-950">
{roomsError}
</p>
) : null}
{roomsLoading ? (
<p className="mt-12 text-center text-sm text-[var(--color-muted)]">Loading rooms</p>
) : null}
{!roomsLoading && rooms.length === 0 && !roomsError ? (
<p className="mt-12 text-center text-sm text-[var(--color-muted)]">
No rooms available for booking yet.
</p>
) : null}
<div className="mt-12 grid gap-8 md:grid-cols-2 lg:grid-cols-4">
{rooms.map((room) => (
<RoomCard key={room.id} room={room} />
))}
</div>
</>
);
}

View File

@ -1,218 +1,2 @@
"use client";
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
import type { Room } from "@/lib/mocks/rooms";
import { rooms } from "@/lib/mocks/rooms";
import { siteConfig } from "@/lib/mocks/site";
export type GuestDetails = {
firstName: string;
lastName: string;
email: string;
phone: string;
/** Airline / PNR / booking reference */
flightBookingNumber: string;
/** Local arrival time (24h from time input) */
arrivalTime: string;
};
const defaultDates = () => {
const inD = new Date();
inD.setDate(inD.getDate() + 7);
const outD = new Date(inD);
outD.setDate(outD.getDate() + 3);
return {
checkIn: inD.toISOString().slice(0, 10),
checkOut: outD.toISOString().slice(0, 10),
};
};
function nightsBetween(checkIn: string, checkOut: string): number {
const a = new Date(checkIn).getTime();
const b = new Date(checkOut).getTime();
const n = Math.ceil((b - a) / (1000 * 60 * 60 * 24));
return Math.max(1, n);
}
type BookingContextValue = {
checkIn: string;
checkOut: string;
guests: number;
roomId: string | null;
guest: GuestDetails;
couponCode: string;
couponPercentOff: number;
holdReference: string | null;
/** True when guest chose “reserve now, pay later” (hold without payment yet) */
payLaterHold: boolean;
confirmationId: string | null;
paidAt: string | null;
setDates: (checkIn: string, checkOut: string) => void;
setGuests: (n: number) => void;
setRoomId: (id: string | null) => void;
setGuest: (g: Partial<GuestDetails>) => void;
setCouponCode: (code: string) => void;
applyCoupon: () => void;
setHoldReference: (ref: string | null) => void;
setPayLaterHold: (value: boolean) => void;
setConfirmation: (id: string | null, paidAt: string | null) => void;
resetBooking: () => void;
selectedRoom: Room | null;
nights: number;
subtotal: number;
taxAmount: number;
discountAmount: number;
total: number;
};
const BookingContext = createContext<BookingContextValue | null>(null);
const emptyGuest: GuestDetails = {
firstName: "",
lastName: "",
email: "",
phone: "",
flightBookingNumber: "",
arrivalTime: "",
};
export function BookingProvider({ children }: { children: ReactNode }) {
const d = defaultDates();
const [checkIn, setCheckIn] = useState(d.checkIn);
const [checkOut, setCheckOut] = useState(d.checkOut);
const [guests, setGuestsState] = useState(2);
const [roomId, setRoomIdState] = useState<string | null>(null);
const [guest, setGuestState] = useState<GuestDetails>({ ...emptyGuest });
const [couponCode, setCouponCodeState] = useState("");
const [couponPercentOff, setCouponPercentOff] = useState(0);
const [holdReference, setHoldReference] = useState<string | null>(null);
const [payLaterHold, setPayLaterHoldState] = useState(false);
const [confirmationId, setConfirmationId] = useState<string | null>(null);
const [paidAt, setPaidAt] = useState<string | null>(null);
const setDates = useCallback((ci: string, co: string) => {
setCheckIn(ci);
setCheckOut(co);
}, []);
const setGuests = useCallback((n: number) => {
setGuestsState(Math.min(12, Math.max(1, n)));
}, []);
const setRoomId = useCallback((id: string | null) => {
setRoomIdState(id);
}, []);
const setGuest = useCallback((g: Partial<GuestDetails>) => {
setGuestState((prev) => ({ ...prev, ...g }));
}, []);
const setCouponCode = useCallback((code: string) => {
setCouponCodeState(code);
setCouponPercentOff(0);
}, []);
const applyCoupon = useCallback(() => {
const c = couponCode.trim().toUpperCase();
if (c === "SHITAYE10") setCouponPercentOff(10);
else if (c === "WELCOME5") setCouponPercentOff(5);
else setCouponPercentOff(0);
}, [couponCode]);
const setPayLaterHold = useCallback((value: boolean) => {
setPayLaterHoldState(value);
}, []);
const setConfirmation = useCallback((id: string | null, at: string | null) => {
setConfirmationId(id);
setPaidAt(at);
if (id) setPayLaterHoldState(false);
}, []);
const resetBooking = useCallback(() => {
const nd = defaultDates();
setCheckIn(nd.checkIn);
setCheckOut(nd.checkOut);
setGuestsState(2);
setRoomIdState(null);
setGuestState({ ...emptyGuest });
setCouponCodeState("");
setCouponPercentOff(0);
setHoldReference(null);
setPayLaterHoldState(false);
setConfirmationId(null);
setPaidAt(null);
}, []);
const selectedRoom = useMemo(
() => rooms.find((r) => r.id === roomId) ?? null,
[roomId],
);
const nights = useMemo(
() => nightsBetween(checkIn, checkOut),
[checkIn, checkOut],
);
const subtotal = useMemo(() => {
if (!selectedRoom) return 0;
return selectedRoom.nightlyRate * nights;
}, [selectedRoom, nights]);
const discountAmount = useMemo(
() => Math.round(subtotal * (couponPercentOff / 100) * 100) / 100,
[subtotal, couponPercentOff],
);
const afterDiscount = Math.max(0, subtotal - discountAmount);
const taxAmount =
Math.round(afterDiscount * siteConfig.taxRate * 100) / 100;
const total = Math.round((afterDiscount + taxAmount) * 100) / 100;
const value: BookingContextValue = {
checkIn,
checkOut,
guests,
roomId,
guest,
couponCode,
couponPercentOff,
holdReference,
payLaterHold,
confirmationId,
paidAt,
setDates,
setGuests,
setRoomId,
setGuest,
setCouponCode,
applyCoupon,
setHoldReference,
setPayLaterHold,
setConfirmation,
resetBooking,
selectedRoom,
nights,
subtotal,
taxAmount,
discountAmount,
total,
};
return (
<BookingContext.Provider value={value}>{children}</BookingContext.Provider>
);
}
export function useBooking() {
const ctx = useContext(BookingContext);
if (!ctx) throw new Error("useBooking must be used within BookingProvider");
return ctx;
}
export type { GuestDetails, LastCreatedBooking, BookingView } from "@/stores/booking-store";
export { useBooking, useBookingStore } from "@/stores/booking-store";

View File

@ -0,0 +1,45 @@
export type LaundryItem = {
id: string;
name: string;
description: string;
priceUsd: number;
unit: string;
};
export const laundryItems: LaundryItem[] = [
{
id: "l-1",
name: "Shirt / blouse",
description: "Pressed",
priceUsd: 4,
unit: "each",
},
{
id: "l-2",
name: "Trousers / skirt",
description: "Pressed",
priceUsd: 5,
unit: "each",
},
{
id: "l-3",
name: "Suit (2 pc)",
description: "Clean & press",
priceUsd: 18,
unit: "set",
},
{
id: "l-4",
name: "Dress",
description: "Delicate cycle",
priceUsd: 12,
unit: "each",
},
{
id: "l-5",
name: "Express (same day)",
description: "Surcharge on top of item prices",
priceUsd: 15,
unit: "per order",
},
];

View File

@ -0,0 +1,68 @@
export type MenuCategory = "breakfast" | "mains" | "desserts" | "beverages";
export const roomServiceCategories: { id: MenuCategory; label: string }[] = [
{ id: "breakfast", label: "Breakfast" },
{ id: "mains", label: "Mains & light bites" },
{ id: "desserts", label: "Desserts" },
{ id: "beverages", label: "Beverages" },
];
export type MenuItem = {
id: string;
category: MenuCategory;
name: string;
description: string;
priceUsd: number;
};
export const roomServiceItems: MenuItem[] = [
{
id: "bf-1",
category: "breakfast",
name: "Full American breakfast",
description: "Eggs any style, beef bacon, chicken sausage, beans, toast, juice, coffee.",
priceUsd: 18,
},
{
id: "bf-2",
category: "breakfast",
name: "Ethiopian breakfast platter",
description: "Injera, spiced lentils, fresh cheese, honey, seasonal fruit.",
priceUsd: 14,
},
{
id: "mn-1",
category: "mains",
name: "Grilled salmon",
description: "Herb butter, seasonal vegetables, lemon.",
priceUsd: 28,
},
{
id: "mn-2",
category: "mains",
name: "Beef tibs",
description: "Traditional sauté with peppers, injera or rice.",
priceUsd: 22,
},
{
id: "ds-1",
category: "desserts",
name: "Chocolate fondant",
description: "Warm centre, vanilla ice cream.",
priceUsd: 12,
},
{
id: "bv-1",
category: "beverages",
name: "Fresh juice",
description: "Orange, mango, or mixed.",
priceUsd: 6,
},
{
id: "bv-2",
category: "beverages",
name: "Ethiopian coffee ceremony (2)",
description: "Traditional preparation — allow 20 min.",
priceUsd: 15,
},
];

283
src/lib/guest-hotel-api.ts Normal file
View File

@ -0,0 +1,283 @@
import { apiFetch } from "@/lib/api-client";
export type GuestMeResponse = {
customer: {
id: string;
firstName: string;
lastName: string;
email: string | null;
phone: string | null;
};
balance: number;
};
export async function guestMe(propertyId: string, accessToken: string): Promise<GuestMeResponse> {
return apiFetch<GuestMeResponse>(`/properties/${propertyId}/hotel/guest/me`, {
method: "GET",
accessToken,
});
}
export type PointLedgerRow = {
id: string;
delta: number;
reason: string;
createdAt: string;
sourceKey?: string | null;
};
export async function guestPointsHistory(
propertyId: string,
accessToken: string,
): Promise<{ data: PointLedgerRow[] }> {
return apiFetch(`/properties/${propertyId}/hotel/guest/points/history`, {
method: "GET",
accessToken,
});
}
export type GuestBookingRow = {
id: string;
bookingCode: string | null;
checkIn: string;
checkOut: string;
status: string;
guestCount: number;
totalPrice: string | number | null;
currency: string;
payLaterHold: boolean;
room?: { id: string; name: string; roomType: string; baseRate?: string | number };
};
export async function guestBookings(propertyId: string, accessToken: string) {
return apiFetch<{ data: GuestBookingRow[] }>(`/properties/${propertyId}/hotel/guest/bookings`, {
method: "GET",
accessToken,
});
}
export type MenuItemRow = {
id: string;
name: string;
unitPrice: string | number;
isAvailable: boolean;
category?: string | null;
description?: string | null;
};
export async function guestMenuItems(propertyId: string, accessToken: string) {
return apiFetch<{ data: MenuItemRow[] }>(`/properties/${propertyId}/hotel/guest/menu/items`, {
method: "GET",
accessToken,
});
}
export async function guestPlaceRoomService(
propertyId: string,
accessToken: string,
body: {
bookingId: string;
notes?: string;
lines: { menuItemId: string; quantity: number }[];
},
) {
return apiFetch(`/properties/${propertyId}/hotel/guest/room-service/orders`, {
method: "POST",
accessToken,
body: JSON.stringify(body),
});
}
export async function guestPlaceLaundry(
propertyId: string,
accessToken: string,
body: {
bookingId: string;
items: unknown;
pickupAt?: string;
deliverAt?: string;
notes?: string;
total?: string;
},
) {
return apiFetch(`/properties/${propertyId}/hotel/guest/laundry`, {
method: "POST",
accessToken,
body: JSON.stringify(body),
});
}
export type RoomServiceOrderRow = {
id: string;
bookingId: string;
status: string; // e.g. "PENDING"
total: string; // e.g. "3000"
currency: string; // e.g. "ETB"
notes?: string | null;
createdAt: string;
lines: {
id: string;
quantity: number;
menuItem?: {
id: string;
name: string;
category?: string;
} | null;
}[];
};
export async function guestRoomServiceOrders(
propertyId: string,
accessToken: string,
): Promise<{ data: RoomServiceOrderRow[] }> {
return apiFetch(`/properties/${propertyId}/hotel/guest/room-service/orders`, {
method: "GET",
accessToken,
});
}
export type LaundryOrderRow = {
id: string;
bookingId: string;
status: string;
total: string | null;
notes?: string | null;
createdAt: string;
};
export async function guestLaundryOrders(
propertyId: string,
accessToken: string,
): Promise<{ data: LaundryOrderRow[] }> {
return apiFetch(`/properties/${propertyId}/hotel/guest/laundry`, {
method: "GET",
accessToken,
});
}
export type SpaOfferingRow = {
id: string;
kind: "SPA_SESSION" | "SPA_PACKAGE" | "GYM_PASS";
name: string;
description?: string | null;
price: string | number;
durationMinutes?: number | null;
};
export async function guestSpaOfferings(
propertyId: string,
accessToken: string,
): Promise<{ data: SpaOfferingRow[] }> {
return apiFetch(`/properties/${propertyId}/hotel/guest/spa/offerings`, {
method: "GET",
accessToken,
});
}
export type SpaBookingRow = {
id: string;
bookingId: string;
offeringId: string;
status: string;
total: string | number;
scheduledAt?: string | null;
createdAt: string;
offering?: {
id: string;
name: string;
kind: "SPA_SESSION" | "SPA_PACKAGE" | "GYM_PASS";
} | null;
};
export async function guestSpaBookings(
propertyId: string,
accessToken: string,
): Promise<{ data: SpaBookingRow[] }> {
return apiFetch(`/properties/${propertyId}/hotel/guest/spa/bookings`, {
method: "GET",
accessToken,
});
}
export async function guestCreateSpaBooking(
propertyId: string,
accessToken: string,
body: { bookingId: string; offeringId: string; scheduledAt?: string },
) {
return apiFetch(`/properties/${propertyId}/hotel/guest/spa/bookings`, {
method: "POST",
accessToken,
body: JSON.stringify(body),
});
}
export type UnifiedGuestOrder = {
id: string;
type: "room-service" | "laundry" | "spa" | "gym";
status: string;
total: number;
currency: string;
createdAt: string;
detail: string;
};
export async function guestOrders(
propertyId: string,
accessToken: string,
filters?: { type?: "room-service" | "laundry" | "spa" | "gym"; status?: string },
): Promise<{ data: UnifiedGuestOrder[] }> {
const query = new URLSearchParams();
if (filters?.type) query.set("type", filters.type);
if (filters?.status) query.set("status", filters.status);
const suffix = query.toString() ? `?${query.toString()}` : "";
try {
return await apiFetch<{ data: UnifiedGuestOrder[] }>(
`/properties/${propertyId}/hotel/guest/orders${suffix}`,
{
method: "GET",
accessToken,
},
);
} catch {
const [rs, laundry, spa] = await Promise.all([
guestRoomServiceOrders(propertyId, accessToken),
guestLaundryOrders(propertyId, accessToken),
guestSpaBookings(propertyId, accessToken),
]);
const mapped: UnifiedGuestOrder[] = [
...(rs.data ?? []).map((o) => ({
id: o.id,
type: "room-service" as const,
status: o.status,
total: Number(o.total ?? 0),
currency: o.currency ?? "ETB",
createdAt: o.createdAt,
detail: o.lines.map((l) => `${l.quantity}x ${l.menuItem?.name ?? "Item"}`).join(", "),
})),
...(laundry.data ?? []).map((o) => ({
id: o.id,
type: "laundry" as const,
status: o.status,
total: Number(o.total ?? 0),
currency: "ETB",
createdAt: o.createdAt,
detail: o.notes ?? "Laundry request",
})),
...(spa.data ?? []).map((o) => ({
id: o.id,
type: o.offering?.kind === "GYM_PASS" ? ("gym" as const) : ("spa" as const),
status: o.status,
total: Number(o.total ?? 0),
currency: "ETB",
createdAt: o.createdAt,
detail: o.offering?.name ?? "Spa/Gym booking",
})),
];
const filtered = mapped.filter((r) => {
if (filters?.type && r.type !== filters.type) return false;
if (filters?.status && r.status.toLowerCase() !== filters.status.toLowerCase()) return false;
return true;
});
return { data: filtered.sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt)) };
}
}

View File

@ -0,0 +1,50 @@
"use client";
import { useEffect, useState } from "react";
import { useAuth } from "@/context/AuthContext";
import { guestBookings } from "@/lib/guest-hotel-api";
const ACTIVE = new Set(["HOLD", "CONFIRMED", "CHECKED_IN"]);
export function useGuestActiveBooking() {
const { session, accessToken, isHydrated } = useAuth();
const propertyId = session?.kind === "member" ? session.propertyId : undefined;
const fromSession = session?.kind === "member" ? (session.bookingId ?? null) : null;
const [bookingId, setBookingId] = useState<string | null>(fromSession);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!isHydrated) return;
if (session?.kind !== "member") {
setBookingId(null);
return;
}
if (fromSession) {
setBookingId(fromSession);
return;
}
if (!accessToken || !propertyId) {
setBookingId(null);
return;
}
let cancelled = false;
(async () => {
setLoading(true);
try {
const { data } = await guestBookings(propertyId, accessToken);
const row = data?.find((b) => ACTIVE.has(b.status));
if (!cancelled) setBookingId(row?.id ?? null);
} catch {
if (!cancelled) setBookingId(null);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [isHydrated, session?.kind, fromSession, accessToken, propertyId]);
return { bookingId, loading, propertyId };
}

262
src/stores/booking-store.ts Normal file
View File

@ -0,0 +1,262 @@
import { useMemo } from "react";
import { create } from "zustand";
import { useShallow } from "zustand/react/shallow";
import { getHotelPropertyId } from "@/lib/env";
import { fetchPublicRooms } from "@/lib/public-hotel-api";
import { mapApiRoomToRoom } from "@/lib/room-mapper";
import { siteConfig } from "@/lib/site-config";
import type { Room } from "@/types/room";
export type GuestDetails = {
firstName: string;
lastName: string;
email: string;
phone: string;
flightBookingNumber: string;
arrivalTime: string;
};
export type LastCreatedBooking = {
id: string;
bookingCode: string | null;
totalPrice: number;
currency: string;
};
function defaultDates() {
const inD = new Date();
inD.setDate(inD.getDate() + 7);
const outD = new Date(inD);
outD.setDate(outD.getDate() + 3);
return {
checkIn: inD.toISOString().slice(0, 10),
checkOut: outD.toISOString().slice(0, 10),
};
}
function nightsBetween(checkIn: string, checkOut: string): number {
const a = new Date(checkIn).getTime();
const b = new Date(checkOut).getTime();
const n = Math.ceil((b - a) / (1000 * 60 * 60 * 24));
return Math.max(1, n);
}
const emptyGuest: GuestDetails = {
firstName: "",
lastName: "",
email: "",
phone: "",
flightBookingNumber: "",
arrivalTime: "",
};
type BookingState = {
checkIn: string;
checkOut: string;
guests: number;
roomId: string | null;
guest: GuestDetails;
couponCode: string;
couponPercentOff: number;
holdReference: string | null;
payLaterHold: boolean;
confirmationId: string | null;
paidAt: string | null;
rooms: Room[];
roomsLoading: boolean;
roomsError: string | null;
lastCreatedBooking: LastCreatedBooking | null;
setDates: (checkIn: string, checkOut: string) => void;
setGuests: (n: number) => void;
setRoomId: (id: string | null) => void;
setGuest: (g: Partial<GuestDetails>) => void;
setCouponCode: (code: string) => void;
applyCoupon: () => void;
setHoldReference: (ref: string | null) => void;
setPayLaterHold: (value: boolean) => void;
setConfirmation: (id: string | null, at: string | null) => void;
setLastCreatedBooking: (b: LastCreatedBooking | null) => void;
resetBooking: () => void;
refreshRooms: () => Promise<void>;
};
export type BookingSnapshot = Omit<
BookingState,
| "setDates"
| "setGuests"
| "setRoomId"
| "setGuest"
| "setCouponCode"
| "applyCoupon"
| "setHoldReference"
| "setPayLaterHold"
| "setConfirmation"
| "setLastCreatedBooking"
| "resetBooking"
| "refreshRooms"
>;
export type BookingView = BookingSnapshot & {
selectedRoom: Room | null;
nights: number;
subtotal: number;
taxAmount: number;
discountAmount: number;
total: number;
} & Pick<
BookingState,
| "setDates"
| "setGuests"
| "setRoomId"
| "setGuest"
| "setCouponCode"
| "applyCoupon"
| "setHoldReference"
| "setPayLaterHold"
| "setConfirmation"
| "setLastCreatedBooking"
| "resetBooking"
| "refreshRooms"
>;
const d0 = defaultDates();
export const useBookingStore = create<BookingState>()((set, get) => ({
checkIn: d0.checkIn,
checkOut: d0.checkOut,
guests: 1,
roomId: null,
guest: { ...emptyGuest },
couponCode: "",
couponPercentOff: 0,
holdReference: null,
payLaterHold: false,
confirmationId: null,
paidAt: null,
rooms: [],
roomsLoading: false,
roomsError: null,
lastCreatedBooking: null,
setDates: (checkIn, checkOut) => set({ checkIn, checkOut }),
setGuests: (n) => set({ guests: Math.min(12, Math.max(1, n)) }),
setRoomId: (roomId) => set({ roomId }),
setGuest: (g) => set((s) => ({ guest: { ...s.guest, ...g } })),
setCouponCode: (code) => set({ couponCode: code, couponPercentOff: 0 }),
applyCoupon: () => {
const c = get().couponCode.trim().toUpperCase();
if (c === "SHITAYE10") set({ couponPercentOff: 10 });
else if (c === "WELCOME5") set({ couponPercentOff: 5 });
else set({ couponPercentOff: 0 });
},
setHoldReference: (holdReference) => set({ holdReference }),
setPayLaterHold: (payLaterHold) => set({ payLaterHold }),
setConfirmation: (confirmationId, paidAt) =>
set({
confirmationId,
paidAt,
...(confirmationId ? { payLaterHold: false } : {}),
}),
setLastCreatedBooking: (lastCreatedBooking) => set({ lastCreatedBooking }),
resetBooking: () => {
const nd = defaultDates();
set({
checkIn: nd.checkIn,
checkOut: nd.checkOut,
guests: 2,
roomId: null,
guest: { ...emptyGuest },
couponCode: "",
couponPercentOff: 0,
holdReference: null,
payLaterHold: false,
confirmationId: null,
paidAt: null,
lastCreatedBooking: null,
});
},
refreshRooms: async () => {
const pid = getHotelPropertyId();
if (!pid) {
set({
rooms: [],
roomsError:
"Set NEXT_PUBLIC_HOTEL_PROPERTY_ID to load bookable rooms from the API.",
roomsLoading: false,
});
return;
}
set({ roomsLoading: true, roomsError: null });
try {
const data = await fetchPublicRooms(pid);
set({ rooms: data.map(mapApiRoomToRoom), roomsLoading: false, roomsError: null });
} catch (e) {
set({
rooms: [],
roomsError: e instanceof Error ? e.message : "Could not load rooms from API.",
roomsLoading: false,
});
}
},
}));
function pickBookingActions(s: BookingState) {
return {
setDates: s.setDates,
setGuests: s.setGuests,
setRoomId: s.setRoomId,
setGuest: s.setGuest,
setCouponCode: s.setCouponCode,
applyCoupon: s.applyCoupon,
setHoldReference: s.setHoldReference,
setPayLaterHold: s.setPayLaterHold,
setConfirmation: s.setConfirmation,
setLastCreatedBooking: s.setLastCreatedBooking,
resetBooking: s.resetBooking,
refreshRooms: s.refreshRooms,
};
}
/** Drop-in replacement for the former `useBooking` context hook. */
export function useBooking(): BookingView {
const base = useBookingStore(
useShallow((s) => ({
checkIn: s.checkIn,
checkOut: s.checkOut,
guests: s.guests,
roomId: s.roomId,
guest: s.guest,
couponCode: s.couponCode,
couponPercentOff: s.couponPercentOff,
holdReference: s.holdReference,
payLaterHold: s.payLaterHold,
confirmationId: s.confirmationId,
paidAt: s.paidAt,
rooms: s.rooms,
roomsLoading: s.roomsLoading,
roomsError: s.roomsError,
lastCreatedBooking: s.lastCreatedBooking,
...pickBookingActions(s),
})),
);
return useMemo(() => {
const selectedRoom = base.rooms.find((r) => r.id === base.roomId) ?? null;
const nights = nightsBetween(base.checkIn, base.checkOut);
const subtotal = selectedRoom ? selectedRoom.nightlyRate * nights : 0;
const discountAmount = Math.round(subtotal * (base.couponPercentOff / 100) * 100) / 100;
const afterDiscount = Math.max(0, subtotal - discountAmount);
const taxAmount = Math.round(afterDiscount * siteConfig.taxRate * 100) / 100;
const total = Math.round((afterDiscount + taxAmount) * 100) / 100;
return {
...base,
selectedRoom,
nights,
subtotal,
discountAmount,
taxAmount,
total,
};
}, [base]);
}

View File

@ -0,0 +1,44 @@
import { useMemo } from "react";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
type CurrencyCode,
convertFromUsd,
formatMoneyFromUsd,
isCurrencyCode,
} from "@/lib/currency";
type CurrencyState = {
currency: CurrencyCode;
setCurrency: (c: CurrencyCode) => void;
};
export const useCurrencyStore = create<CurrencyState>()(
persist(
(set) => ({
currency: "USD",
setCurrency: (currency) => set({ currency: isCurrencyCode(currency) ? currency : "USD" }),
}),
{
name: "shitaye-currency",
storage: createJSONStorage(() => localStorage),
partialize: (s) => ({ currency: s.currency }),
skipHydration: true,
},
),
);
export function useCurrency() {
const currency = useCurrencyStore((s) => s.currency);
const setCurrency = useCurrencyStore((s) => s.setCurrency);
return useMemo(
() => ({
currency,
setCurrency,
formatUsd: (amountUsd: number, maximumFractionDigits: 0 | 1 | 2 = 2) =>
formatMoneyFromUsd(amountUsd, currency, maximumFractionDigits),
convertUsd: (amountUsd: number) => convertFromUsd(amountUsd, currency),
}),
[currency, setCurrency],
);
}

View File

@ -0,0 +1,18 @@
import { create } from "zustand";
import type { OrderRecord } from "@/types/guest-order";
type OrdersState = {
orders: OrderRecord[];
/** Append one order to the in-memory state. */
pushOrder: (rec: OrderRecord) => void;
setOrders: (next: OrderRecord[] | ((prev: OrderRecord[]) => OrderRecord[])) => void;
};
export const useOrdersStore = create<OrdersState>((set) => ({
orders: [],
pushOrder: (rec) => set((s) => ({ orders: [rec, ...s.orders] })),
setOrders: (next) =>
set((s) => ({
orders: typeof next === "function" ? next(s.orders) : next,
})),
}));

11
src/types/guest-order.ts Normal file
View File

@ -0,0 +1,11 @@
export type OrderCategory = "room-service" | "laundry" | "gym" | "spa";
export type OrderRecord = {
id: string;
category: OrderCategory;
title: string;
detail: string;
totalUsd: number;
placedAt: string;
status: "pending" | "confirmed" | "completed";
};