booking and amenities
This commit is contained in:
parent
bcc3a8de15
commit
0160816b8e
|
|
@ -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'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're ready.
|
||||
Pay later keeps your hold; you'll get a booking code. Payment is completed at the hotel unless
|
||||
you add card checkout later.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'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">
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
31
src/components/CatalogRoomsSection.tsx
Normal file
31
src/components/CatalogRoomsSection.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
45
src/lib/data/laundryCatalog.ts
Normal file
45
src/lib/data/laundryCatalog.ts
Normal 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",
|
||||
},
|
||||
];
|
||||
68
src/lib/data/roomServiceMenu.ts
Normal file
68
src/lib/data/roomServiceMenu.ts
Normal 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
283
src/lib/guest-hotel-api.ts
Normal 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)) };
|
||||
}
|
||||
}
|
||||
50
src/lib/useGuestActiveBooking.ts
Normal file
50
src/lib/useGuestActiveBooking.ts
Normal 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
262
src/stores/booking-store.ts
Normal 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]);
|
||||
}
|
||||
44
src/stores/currency-store.ts
Normal file
44
src/stores/currency-store.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
18
src/stores/orders-store.ts
Normal file
18
src/stores/orders-store.ts
Normal 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
11
src/types/guest-order.ts
Normal 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";
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user