amenities and room service apis
This commit is contained in:
parent
618d30aeef
commit
c1f3461952
|
|
@ -4,6 +4,7 @@ const nextConfig: NextConfig = {
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{ protocol: "https", hostname: "images.unsplash.com", pathname: "/**" },
|
{ protocol: "https", hostname: "images.unsplash.com", pathname: "/**" },
|
||||||
|
{ protocol: "http", hostname: "82.112.253.199", pathname: "/**" },
|
||||||
{ protocol: "https", hostname: "images.pexels.com", pathname: "/**" },
|
{ protocol: "https", hostname: "images.pexels.com", pathname: "/**" },
|
||||||
{ protocol: "https", hostname: "cf.bstatic.com", pathname: "/**" },
|
{ protocol: "https", hostname: "cf.bstatic.com", pathname: "/**" },
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { RequireAuth } from "@/components/RequireAuth";
|
import { RequireAuth } from "@/components/RequireAuth";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import { guestPlaceLaundry } from "@/lib/guest-hotel-api";
|
import { guestPlaceLaundry } from "@/lib/guest-hotel-api";
|
||||||
import { formatEtb } from "@/lib/format-etb";
|
import { formatEtb } from "@/lib/format-etb";
|
||||||
import { useGuestActiveBooking } from "@/lib/useGuestActiveBooking";
|
import { useGuestActiveBooking } from "@/lib/useGuestActiveBooking";
|
||||||
|
import { laundryItems, type LaundryCartItem, SAME_DAY_SURCHARGE } from "@/lib/data/laundryCatalog";
|
||||||
|
|
||||||
export function LaundryClient() {
|
export function LaundryClient() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -19,87 +20,115 @@ export function LaundryClient() {
|
||||||
function LaundryInner() {
|
function LaundryInner() {
|
||||||
const { accessToken } = useAuth();
|
const { accessToken } = useAuth();
|
||||||
const { bookingId, loading: bookingLoading, propertyId } = useGuestActiveBooking();
|
const { bookingId, loading: bookingLoading, propertyId } = useGuestActiveBooking();
|
||||||
const [notes, setNotes] = useState("");
|
|
||||||
|
// Form states
|
||||||
|
const [cart, setCart] = useState<Record<string, number>>({});
|
||||||
|
const [sameDay, setSameDay] = useState(false);
|
||||||
const [pickupAt, setPickupAt] = useState("");
|
const [pickupAt, setPickupAt] = useState("");
|
||||||
const [deliverAt, setDeliverAt] = useState("");
|
const [deliverAt, setDeliverAt] = useState("");
|
||||||
const [estimateEtb, setEstimateEtb] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [sent, setSent] = useState(false);
|
const [sent, setSent] = useState(false);
|
||||||
const [submitErr, setSubmitErr] = useState<string | null>(null);
|
const [submitErr, setSubmitErr] = useState<string | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const canUseApi = !!(propertyId && accessToken && bookingId);
|
const canUseApi = !!(propertyId && accessToken && bookingId);
|
||||||
|
|
||||||
|
// Compute total
|
||||||
|
const total = useCallback(() => {
|
||||||
|
let sum = 0;
|
||||||
|
for (const [label, qty] of Object.entries(cart)) {
|
||||||
|
if (qty > 0) {
|
||||||
|
const price = laundryItems.find(item => item.label.toLowerCase() === label.toLowerCase())?.price || 0;
|
||||||
|
sum += price * qty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sameDay) sum += SAME_DAY_SURCHARGE;
|
||||||
|
return sum;
|
||||||
|
}, [cart, sameDay]);
|
||||||
|
|
||||||
|
const [displayTotal, setDisplayTotal] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
setDisplayTotal(total());
|
||||||
|
}, [total]);
|
||||||
|
|
||||||
|
// Build items Json
|
||||||
|
const buildItems = (): LaundryCartItem[] =>
|
||||||
|
Object.entries(cart)
|
||||||
|
.filter(([, qty]) => qty > 0)
|
||||||
|
.map(([label, quantity]) => ({ label: laundryItems.find(i => i.label.toLowerCase() === label.toLowerCase())?.label || label, quantity }));
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!canUseApi) return;
|
if (!canUseApi || buildItems().length === 0) {
|
||||||
|
setSubmitErr("Please select at least one item.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSubmitErr(null);
|
setSubmitErr(null);
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await guestPlaceLaundry(propertyId!, accessToken!, {
|
await guestPlaceLaundry(propertyId!, accessToken!, {
|
||||||
bookingId: bookingId!,
|
bookingId: bookingId!,
|
||||||
items: [],
|
items: buildItems(),
|
||||||
|
sameDay,
|
||||||
|
total: displayTotal,
|
||||||
|
// currency: "ETB",
|
||||||
notes: notes.trim() || undefined,
|
notes: notes.trim() || undefined,
|
||||||
pickupAt: pickupAt || undefined,
|
pickupAt: pickupAt || undefined,
|
||||||
deliverAt: deliverAt || undefined,
|
deliverAt: deliverAt || undefined,
|
||||||
total: estimateEtb.trim() || undefined,
|
|
||||||
});
|
});
|
||||||
|
setSubmitting(false);
|
||||||
|
setCart({});
|
||||||
|
setSameDay(false);
|
||||||
|
setPickupAt("");
|
||||||
|
setDeliverAt("");
|
||||||
|
setNotes("");
|
||||||
|
setSent(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setSubmitErr(e instanceof Error ? e.message : "Could not submit laundry request");
|
setSubmitErr(e instanceof Error ? e.message : "Could not submit laundry request");
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
setSubmitting(false);
|
|
||||||
setNotes("");
|
|
||||||
setPickupAt("");
|
|
||||||
setDeliverAt("");
|
|
||||||
setEstimateEtb("");
|
|
||||||
setSent(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const needBooking = !bookingLoading && !bookingId;
|
const needBooking = !bookingLoading && !bookingId;
|
||||||
|
const hasItems = buildItems().length > 0;
|
||||||
|
|
||||||
|
const updateQty = (label: string, delta: number) => {
|
||||||
|
setCart(prev => {
|
||||||
|
const current = prev[label] || 0;
|
||||||
|
const newQty = Math.max(0, current + delta);
|
||||||
|
const newCart = { ...prev };
|
||||||
|
if (newQty === 0) delete newCart[label];
|
||||||
|
else newCart[label] = newQty;
|
||||||
|
return newCart;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--color-bg)] pb-24 pt-8 md:pt-12">
|
<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">
|
<div className="mx-auto max-w-7xl px-4 md:px-8">
|
||||||
<nav className="text-xs font-medium text-[var(--color-muted)]">
|
<nav className="text-xs font-medium text-[var(--color-muted)]">
|
||||||
<Link href="/" className="hover:text-[var(--color-accent)]">
|
<Link href="/" className="hover:text-[var(--color-accent)]">Home</Link>
|
||||||
Home
|
|
||||||
</Link>
|
|
||||||
<span className="mx-2 opacity-50">/</span>
|
<span className="mx-2 opacity-50">/</span>
|
||||||
<Link href="/guest" className="hover:text-[var(--color-accent)]">
|
<Link href="/guest" className="hover:text-[var(--color-accent)]">Guest hub</Link>
|
||||||
Guest hub
|
|
||||||
</Link>
|
|
||||||
<span className="mx-2 opacity-50">/</span>
|
<span className="mx-2 opacity-50">/</span>
|
||||||
<span className="text-[var(--color-text)]">Laundry</span>
|
<span className="text-[var(--color-text)]">Laundry</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="mt-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
<div className="mt-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-heading text-3xl font-semibold text-[var(--color-text)] md:text-4xl">
|
<h1 className="font-heading text-3xl font-semibold text-[var(--color-text)] md:text-4xl">Laundry service</h1>
|
||||||
Laundry service
|
<p className="mt-2 max-w-xl text-sm text-[var(--color-muted)]">Submit a laundry request attached to your active booking.</p>
|
||||||
</h1>
|
|
||||||
<p className="mt-2 max-w-xl text-sm text-[var(--color-muted)]">
|
|
||||||
Submit a real laundry request attached to your active booking.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link href="/profile" className="text-sm font-semibold text-[var(--color-accent)] hover:underline">View profile →</Link>
|
||||||
href="/profile"
|
|
||||||
className="text-sm font-semibold text-[var(--color-accent)] hover:underline"
|
|
||||||
>
|
|
||||||
View profile →
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{needBooking ? (
|
{needBooking ? (
|
||||||
<div className="mt-6 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
<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
|
Sign in with a booking code or use a reservation to sync laundry with the hotel.
|
||||||
hotel.
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{submitErr ? (
|
{submitErr ? (
|
||||||
<div className="mt-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900">
|
<div className="mt-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900">{submitErr}</div>
|
||||||
{submitErr}
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{sent ? (
|
{sent ? (
|
||||||
|
|
@ -108,70 +137,97 @@ function LaundryInner() {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="mt-10 grid gap-10 lg:grid-cols-[1fr_380px]">
|
{!sent && (
|
||||||
<div className="space-y-4 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-sm">
|
<div className="mt-10 grid gap-10 lg:grid-cols-[1fr_380px]">
|
||||||
<div>
|
{/* Items Selection */}
|
||||||
<label className="text-sm font-medium text-[var(--color-text)]">Request details</label>
|
<div className="space-y-6 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm">
|
||||||
|
<label className="text-base font-semibold text-[var(--color-text)] block mb-2">Select items</label>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{laundryItems.map((item) => {
|
||||||
|
const qty = cart[item.label.toLowerCase()] || 0;
|
||||||
|
return (
|
||||||
|
<div key={item.label} className="border border-[var(--color-border)] rounded-xl p-4 bg-[var(--color-surface-muted)]">
|
||||||
|
<div className="font-medium text-[var(--color-text)] mb-1">{item.label}</div>
|
||||||
|
<div className="text-sm text-[var(--color-muted)] mb-3">{formatEtb(item.price)} / each</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateQty(item.label.toLowerCase(), -1)}
|
||||||
|
disabled={qty === 0}
|
||||||
|
className="w-10 h-10 rounded-lg border bg-[var(--color-surface)] text-[var(--color-text)] hover:bg-[var(--color-accent-soft)] disabled:opacity-50 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<span className="w-16 text-center font-mono text-lg font-semibold">{qty}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateQty(item.label.toLowerCase(), 1)}
|
||||||
|
className="w-10 h-10 rounded-lg border bg-[var(--color-accent)] text-white hover:bg-[var(--color-accent-dark)] flex items-center justify-center"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary & Form */}
|
||||||
|
<div className="space-y-4 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm">
|
||||||
|
<div className="text-xl font-bold text-[var(--color-text)]">
|
||||||
|
{formatEtb(displayTotal)} {sameDay && <span className="text-sm text-[var(--color-accent)]">(incl. same-day)</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={sameDay}
|
||||||
|
onChange={(e) => setSameDay(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-[var(--color-text)]">Express same-day (+{formatEtb(SAME_DAY_SURCHARGE)})</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium uppercase tracking-wide text-[var(--color-muted)] mb-1">Pickup</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={pickupAt}
|
||||||
|
onChange={(e) => setPickupAt(e.target.value)}
|
||||||
|
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium uppercase tracking-wide text-[var(--color-muted)] mb-1">Delivery</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={deliverAt}
|
||||||
|
onChange={(e) => setDeliverAt(e.target.value)}
|
||||||
|
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
value={notes}
|
value={notes}
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
rows={5}
|
rows={3}
|
||||||
placeholder="Example: 2 shirts, 1 trouser, delicate cycle, no starch."
|
placeholder="Special instructions (e.g., no starch, delicate)..."
|
||||||
className="mt-2 w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3 py-2 text-sm"
|
className="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"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside className="lg:sticky lg:top-28">
|
|
||||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-primary)]">
|
|
||||||
Summary
|
|
||||||
</p>
|
|
||||||
<p className="mt-4 text-sm text-[var(--color-muted)]">
|
|
||||||
{estimateEtb ? `Estimated: ${formatEtb(Number(estimateEtb), 0)}` : "No estimate entered"}
|
|
||||||
</p>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
onClick={submit}
|
||||||
onClick={() => void submit()}
|
disabled={submitting || !hasItems || !canUseApi}
|
||||||
disabled={!canUseApi || submitting || bookingLoading || notes.trim().length === 0}
|
className="w-full rounded-xl bg-[var(--color-accent)] px-6 py-3 text-base font-semibold text-white disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
className="btn-mustard mt-6 w-full justify-center py-3 text-sm disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
{submitting ? "Submitting…" : "Submit laundry request"}
|
{submitting ? "Submitting..." : `Place laundry order (${formatEtb(displayTotal)})`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -211,7 +211,7 @@ function RoomServiceInner() {
|
||||||
>
|
>
|
||||||
<div className="relative aspect-[4/3] overflow-hidden rounded-xl bg-[var(--color-surface-muted)]">
|
<div className="relative aspect-[4/3] overflow-hidden rounded-xl bg-[var(--color-surface-muted)]">
|
||||||
<Image
|
<Image
|
||||||
src="https://images.unsplash.com/photo-1414235077428-338989a2e8c0?w=600&q=80"
|
src={row.image}
|
||||||
alt=""
|
alt=""
|
||||||
fill
|
fill
|
||||||
className="object-cover opacity-90"
|
className="object-cover opacity-90"
|
||||||
|
|
|
||||||
|
|
@ -1,167 +1,25 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import {
|
import { formatEtb } from "@/lib/format-etb";
|
||||||
spaGymFilters,
|
import { guestCreateSpaBooking, guestSpaBookings, guestSpaOfferings, type SpaBookingRow, type SpaOfferingRow } from "@/lib/guest-hotel-api";
|
||||||
spaGymServices,
|
import { useGuestActiveBooking } from "@/lib/useGuestActiveBooking";
|
||||||
type SpaGymFilterId,
|
import { spaGymFilters } from "@/lib/data/services";
|
||||||
type SpaGymService,
|
|
||||||
} from "@/lib/mocks/services";
|
|
||||||
import { siteConfig } from "@/lib/mocks/site";
|
|
||||||
|
|
||||||
function ServiceCard({
|
|
||||||
service,
|
|
||||||
selected,
|
|
||||||
onToggle,
|
|
||||||
}: {
|
|
||||||
service: SpaGymService;
|
|
||||||
selected: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
}) {
|
|
||||||
const kindLabel = service.kind === "spa" ? "Spa" : "Gym";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article className="card-lift flex flex-col overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] shadow-sm">
|
|
||||||
<div className="relative aspect-[16/10] overflow-hidden">
|
|
||||||
<Image
|
|
||||||
src={service.image}
|
|
||||||
alt=""
|
|
||||||
fill
|
|
||||||
className="object-cover transition duration-500"
|
|
||||||
sizes="(max-width:640px) 100vw, (max-width:1024px) 50vw, 33vw"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
|
|
||||||
<span className="absolute left-3 top-3 rounded-full bg-[var(--color-surface)]/95 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-[var(--color-primary)] shadow-sm backdrop-blur-sm">
|
|
||||||
{kindLabel}
|
|
||||||
</span>
|
|
||||||
<span className="absolute bottom-3 right-3 rounded-full bg-[var(--color-primary)] px-3 py-1 text-xs font-bold text-[var(--color-on-primary)] shadow-md">
|
|
||||||
${service.priceUsd}
|
|
||||||
<span className="font-normal opacity-90"> · {service.priceNote}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-1 flex-col p-5 md:p-6">
|
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
|
||||||
{service.duration}
|
|
||||||
</p>
|
|
||||||
<h3 className="mt-2 font-heading text-lg font-semibold text-[var(--color-text)] md:text-xl">
|
|
||||||
{service.title}
|
|
||||||
</h3>
|
|
||||||
<p className="mt-2 flex-1 text-sm leading-relaxed text-[var(--color-muted)]">
|
|
||||||
{service.description}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onToggle}
|
|
||||||
aria-pressed={selected}
|
|
||||||
className={`mt-5 w-full rounded-full border-2 border-transparent px-4 py-2.5 text-sm font-semibold transition md:mt-6 ${
|
|
||||||
selected
|
|
||||||
? "bg-[var(--color-primary)] text-[var(--color-on-primary)] shadow-md"
|
|
||||||
: "bg-[var(--color-accent-soft)] text-[var(--color-primary)] ring-1 ring-[var(--color-accent)]/40 hover:bg-[var(--color-accent)]/15"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{selected ? "Added — tap to remove" : "Add to selection"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectionPanel({
|
|
||||||
items,
|
|
||||||
onRemove,
|
|
||||||
onClear,
|
|
||||||
}: {
|
|
||||||
items: SpaGymService[];
|
|
||||||
onRemove: (id: string) => void;
|
|
||||||
onClear: () => void;
|
|
||||||
}) {
|
|
||||||
const total = useMemo(
|
|
||||||
() => items.reduce((sum, s) => sum + s.priceUsd, 0),
|
|
||||||
[items],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-sm md:p-6">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-primary)]">
|
|
||||||
Your selection
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-[var(--color-muted)]">
|
|
||||||
Mock basket — pick services to preview a request (no real payment).
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<p className="mt-6 rounded-xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-muted)] px-4 py-8 text-center text-sm text-[var(--color-muted)]">
|
|
||||||
Tap “Add to selection” on any spa or gym service to build your list.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<ul className="mt-5 max-h-[min(320px,50vh)] space-y-3 overflow-y-auto pr-1">
|
|
||||||
{items.map((s) => (
|
|
||||||
<li
|
|
||||||
key={s.id}
|
|
||||||
className="flex items-start justify-between gap-3 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3 py-2.5 text-sm"
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="font-semibold text-[var(--color-text)]">{s.title}</p>
|
|
||||||
<p className="text-xs text-[var(--color-muted)]">
|
|
||||||
{s.kind === "spa" ? "Spa" : "Gym"} · {s.duration}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
<span className="font-semibold text-[var(--color-primary)]">${s.priceUsd}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onRemove(s.id)}
|
|
||||||
className="rounded-full p-1 text-[var(--color-muted)] transition hover:bg-[var(--color-border)]/50 hover:text-[var(--color-text)]"
|
|
||||||
aria-label={`Remove ${s.title}`}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{items.length > 0 ? (
|
|
||||||
<div className="mt-5 border-t border-[var(--color-border)] pt-4">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-[var(--color-muted)]">Subtotal (mock)</span>
|
|
||||||
<span className="font-heading text-xl font-semibold text-[var(--color-text)]">
|
|
||||||
${total.toFixed(0)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 flex flex-col gap-2">
|
|
||||||
<a
|
|
||||||
href={`mailto:${siteConfig.email}?subject=Spa%20%26%20Gym%20request&body=${encodeURIComponent(
|
|
||||||
`Selected services:\n${items.map((s) => `- ${s.title} ($${s.priceUsd})`).join("\n")}\n\nTotal (estimate): $${total}`,
|
|
||||||
)}`}
|
|
||||||
className="btn-mustard px-4 py-3 text-center text-sm"
|
|
||||||
>
|
|
||||||
Email request
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClear}
|
|
||||||
className="rounded-full border border-[var(--color-border)] py-2.5 text-sm font-semibold text-[var(--color-muted)] transition hover:bg-[var(--color-surface-muted)]"
|
|
||||||
>
|
|
||||||
Clear selection
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ServicesPageClient() {
|
export function ServicesPageClient() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { session, addOrder } = useAuth();
|
const { session, accessToken } = useAuth();
|
||||||
const [filter, setFilter] = useState<SpaGymFilterId>("all");
|
const { bookingId, propertyId } = useGuestActiveBooking();
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [filter, setFilter] = useState<"all" | "spa" | "gym">("all");
|
||||||
|
const [offerings, setOfferings] = useState<SpaOfferingRow[]>([]);
|
||||||
|
const [bookings, setBookings] = useState<SpaBookingRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const k = searchParams.get("kind");
|
const k = searchParams.get("kind");
|
||||||
|
|
@ -170,49 +28,47 @@ export function ServicesPageClient() {
|
||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
useEffect(() => {
|
||||||
if (filter === "all") return spaGymServices;
|
if (!accessToken || !propertyId) return;
|
||||||
return spaGymServices.filter((s) => s.kind === filter);
|
let cancelled = false;
|
||||||
}, [filter]);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
const selectedItems = useMemo(
|
Promise.all([guestSpaOfferings(propertyId, accessToken), guestSpaBookings(propertyId, accessToken)])
|
||||||
() => spaGymServices.filter((s) => selected.has(s.id)),
|
.then(([off, b]) => {
|
||||||
[selected],
|
if (!cancelled) {
|
||||||
);
|
setOfferings(off.data ?? []);
|
||||||
|
setBookings(b.data ?? []);
|
||||||
function toggle(id: string) {
|
}
|
||||||
setSelected((prev) => {
|
})
|
||||||
const next = new Set(prev);
|
.catch((e) => {
|
||||||
if (next.has(id)) next.delete(id);
|
if (!cancelled) setError(e instanceof Error ? e.message : "Failed to load services.");
|
||||||
else next.add(id);
|
})
|
||||||
return next;
|
.finally(() => {
|
||||||
});
|
if (!cancelled) setLoading(false);
|
||||||
}
|
|
||||||
|
|
||||||
function remove(id: string) {
|
|
||||||
setSelected((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function clear() {
|
|
||||||
setSelected(new Set());
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveSelectionToProfile() {
|
|
||||||
if (!session || selectedItems.length === 0) return;
|
|
||||||
for (const s of selectedItems) {
|
|
||||||
addOrder({
|
|
||||||
category: s.kind === "spa" ? "spa" : "gym",
|
|
||||||
title: `${s.kind === "spa" ? "Spa" : "Gym"} · ${s.title}`,
|
|
||||||
detail: `${s.duration} · $${s.priceUsd} (${s.priceNote})`,
|
|
||||||
totalUsd: s.priceUsd,
|
|
||||||
status: "pending",
|
|
||||||
});
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [accessToken, propertyId]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (filter === "all") return offerings;
|
||||||
|
return offerings.filter((o) => (filter === "spa" ? o.kind !== "GYM_PASS" : o.kind === "GYM_PASS"));
|
||||||
|
}, [filter, offerings]);
|
||||||
|
|
||||||
|
async function book(offering: SpaOfferingRow) {
|
||||||
|
if (!accessToken || !propertyId || !bookingId) return;
|
||||||
|
setBusyId(offering.id);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await guestCreateSpaBooking(propertyId, accessToken, { bookingId, offeringId: offering.id });
|
||||||
|
const latest = await guestSpaBookings(propertyId, accessToken);
|
||||||
|
setBookings(latest.data ?? []);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Could not book service.");
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
}
|
}
|
||||||
clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -230,18 +86,12 @@ export function ServicesPageClient() {
|
||||||
Spa & gym services
|
Spa & gym services
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-4 max-w-2xl text-sm leading-relaxed text-[var(--color-muted)] md:text-base">
|
<p className="mt-4 max-w-2xl text-sm leading-relaxed text-[var(--color-muted)] md:text-base">
|
||||||
Choose treatments and gym sessions — your selection is shown on the right (desktop) or
|
Book treatments and gym passes directly from live hotel offerings.
|
||||||
below on mobile. This is a demo flow; confirm times and pricing at the desk.
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-xs text-[var(--color-muted)]">
|
|
||||||
Taxes and service charges may apply. Prices shown in USD (mock).
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
<section className="mx-auto max-w-7xl px-4 py-10 md:px-8 md:py-14">
|
||||||
className={`mx-auto max-w-7xl px-4 py-10 md:px-8 md:py-14 ${selectedItems.length > 0 ? "pb-28 lg:pb-14" : ""}`}
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap justify-center gap-2 md:justify-start md:gap-2.5">
|
<div className="flex flex-wrap justify-center gap-2 md:justify-start md:gap-2.5">
|
||||||
{spaGymFilters.map((f) => {
|
{spaGymFilters.map((f) => {
|
||||||
const active = filter === f.id;
|
const active = filter === f.id;
|
||||||
|
|
@ -262,29 +112,95 @@ export function ServicesPageClient() {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="mt-6 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{!bookingId && session ? (
|
||||||
|
<p className="mt-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||||
|
Sign in with a booking code (or create a booking) to place gym/spa appointments.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<div className="mt-10 grid gap-10 lg:grid-cols-[1fr_380px] lg:items-start lg:gap-12">
|
<div className="mt-10 grid gap-10 lg:grid-cols-[1fr_380px] lg:items-start lg:gap-12">
|
||||||
<div className="grid gap-6 sm:grid-cols-2">
|
<div className="grid gap-6 sm:grid-cols-2">
|
||||||
{filtered.map((service) => (
|
{loading ? (
|
||||||
<ServiceCard
|
<p className="text-sm text-[var(--color-muted)]">Loading services…</p>
|
||||||
key={service.id}
|
) : null}
|
||||||
service={service}
|
|
||||||
selected={selected.has(service.id)}
|
{!loading && filtered.length === 0 ? (
|
||||||
onToggle={() => toggle(service.id)}
|
<p className="text-sm text-[var(--color-muted)]">No offerings published yet.</p>
|
||||||
/>
|
) : null}
|
||||||
))}
|
|
||||||
</div>
|
{filtered.map((service) => (
|
||||||
|
<article
|
||||||
|
key={service.id}
|
||||||
|
className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-sm"
|
||||||
|
>
|
||||||
|
{service.image && (
|
||||||
|
<div className="mb-4 overflow-hidden rounded-xl">
|
||||||
|
<Image
|
||||||
|
src={service.image}
|
||||||
|
alt={service.name}
|
||||||
|
width={400}
|
||||||
|
height={160}
|
||||||
|
className="h-40 w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-primary)]">
|
||||||
|
{service.kind === "GYM_PASS" ? "Gym" : "Spa"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="mt-2 font-heading text-xl text-[var(--color-text)]">
|
||||||
|
{service.name}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="mt-2 text-sm text-[var(--color-muted)]">
|
||||||
|
{service.description ?? "—"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-3 text-sm font-semibold text-[var(--color-text)]">
|
||||||
|
{formatEtb(Number(service.price ?? 0), 0)}
|
||||||
|
{service.durationMinutes ? ` · ${service.durationMinutes} min` : ""}
|
||||||
|
</p>
|
||||||
|
|
||||||
<aside className="lg:sticky lg:top-28">
|
|
||||||
<SelectionPanel items={selectedItems} onRemove={remove} onClear={clear} />
|
|
||||||
{session && selectedItems.length > 0 ? (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={saveSelectionToProfile}
|
disabled={!bookingId || busyId === service.id}
|
||||||
className="mt-4 w-full rounded-full border-2 border-[var(--color-primary)] bg-[var(--color-surface)] py-3 text-sm font-semibold text-[var(--color-primary)] transition hover:bg-[var(--color-primary)] hover:text-[var(--color-on-primary)]"
|
onClick={() => void book(service)}
|
||||||
|
className="btn-mustard mt-4 w-full justify-center py-2.5 text-sm disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Save selection to my stay
|
{busyId === service.id ? "Booking..." : "Book appointment"}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="lg:sticky lg:top-28">
|
||||||
|
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-sm md:p-6">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-primary)]">
|
||||||
|
Your appointments
|
||||||
|
</p>
|
||||||
|
{bookings.length === 0 ? (
|
||||||
|
<p className="mt-4 text-sm text-[var(--color-muted)]">No bookings yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-4 space-y-2 text-sm">
|
||||||
|
{bookings.map((b) => (
|
||||||
|
<li
|
||||||
|
key={b.id}
|
||||||
|
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3 py-2"
|
||||||
|
>
|
||||||
|
<p className="font-medium text-[var(--color-text)]">{b.offering?.name ?? "Service"}</p>
|
||||||
|
<p className="text-xs text-[var(--color-muted)]">
|
||||||
|
{b.status} · {new Date(b.scheduledAt ?? b.createdAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/guest"
|
href="/guest"
|
||||||
className="mt-3 block text-center text-sm font-medium text-[var(--color-muted)] hover:text-[var(--color-accent)]"
|
className="mt-3 block text-center text-sm font-medium text-[var(--color-muted)] hover:text-[var(--color-accent)]"
|
||||||
|
|
@ -299,24 +215,6 @@ export function ServicesPageClient() {
|
||||||
</Link>
|
</Link>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile sticky summary bar */}
|
|
||||||
{selectedItems.length > 0 ? (
|
|
||||||
<div className="fixed bottom-0 left-0 right-0 z-30 border-t border-[var(--color-border)] bg-[var(--color-surface)]/95 p-4 shadow-[0_-8px_30px_rgba(0,0,0,0.08)] backdrop-blur-md lg:hidden">
|
|
||||||
<div className="mx-auto flex max-w-lg items-center justify-between gap-3">
|
|
||||||
<p className="text-sm text-[var(--color-muted)]">
|
|
||||||
<span className="font-semibold text-[var(--color-text)]">{selectedItems.length}</span>{" "}
|
|
||||||
selected
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href={`mailto:${siteConfig.email}?subject=Spa%20%26%20Gym%20request`}
|
|
||||||
className="btn-mustard shrink-0 px-5 py-2.5 text-sm"
|
|
||||||
>
|
|
||||||
Email request
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { ServicesPageClient } from "./ServicesPageClient";
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Spa & gym services",
|
title: "Spa & gym services",
|
||||||
description:
|
description:
|
||||||
"Browse spa treatments and gym sessions at Shitaye Suite Hotel — build a mock selection and send a request.",
|
"Browse and book live spa treatments and gym sessions at Shitaye Suite Hotel.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ServicesPage() {
|
export default function ServicesPage() {
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { FormattedUsd } from "@/components/FormattedUsd";
|
import { RoomPrice } from "@/components/RoomPrice";
|
||||||
import type { Room } from "@/lib/mocks/rooms";
|
import type { Room } from "@/types/room";
|
||||||
import { rooms } from "@/lib/mocks/rooms";
|
import { useBooking } from "@/context/BookingContext";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
selected: Room | null;
|
selected: Room | null;
|
||||||
|
|
@ -13,6 +13,7 @@ type Props = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RoomSelectBooking({ selected, onSelect }: Props) {
|
export function RoomSelectBooking({ selected, onSelect }: Props) {
|
||||||
|
const { rooms } = useBooking();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
|
@ -32,7 +33,7 @@ export function RoomSelectBooking({ selected, onSelect }: Props) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((o) => !o)}
|
onClick={() => setOpen((o) => !o)}
|
||||||
className="flex w-full items-center gap-3 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3 text-left shadow-sm transition hover:border-[var(--color-primary)]/40 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-primary)]"
|
className="flex w-full items-center gap-3 rounded-2xl border border-(--color-border) bg-[var(--color-surface)] p-3 text-left shadow-sm transition hover:border-[var(--color-primary)]/40 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-primary)]"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
>
|
>
|
||||||
|
|
@ -50,7 +51,7 @@ export function RoomSelectBooking({ selected, onSelect }: Props) {
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="font-semibold text-[var(--color-text)]">{selected.name}</p>
|
<p className="font-semibold text-[var(--color-text)]">{selected.name}</p>
|
||||||
<p className="text-xs text-[var(--color-muted)]">
|
<p className="text-xs text-[var(--color-muted)]">
|
||||||
From ${selected.nightlyRate} / night
|
From <RoomPrice room={selected} maximumFractionDigits={0} /> / night
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
@ -89,7 +90,7 @@ export function RoomSelectBooking({ selected, onSelect }: Props) {
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-semibold text-[var(--color-text)]">{room.name}</p>
|
<p className="text-sm font-semibold text-[var(--color-text)]">{room.name}</p>
|
||||||
<p className="text-xs text-[var(--color-muted)]">
|
<p className="text-xs text-[var(--color-muted)]">
|
||||||
<FormattedUsd amountUsd={room.nightlyRate} maximumFractionDigits={0} />
|
<RoomPrice room={room} maximumFractionDigits={0} />
|
||||||
/night · max {room.maxGuests} guests
|
/night · max {room.maxGuests} guests
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -101,10 +102,16 @@ export function RoomSelectBooking({ selected, onSelect }: Props) {
|
||||||
|
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/rooms/${selected.slug}`}
|
href={
|
||||||
|
/^[0-9a-f-]{36}$/i.test(selected.id)
|
||||||
|
? `/booking?room=${encodeURIComponent(selected.id)}`
|
||||||
|
: `/rooms/${selected.slug}`
|
||||||
|
}
|
||||||
className="mt-2 inline-block text-sm font-semibold text-[var(--color-primary)] hover:underline"
|
className="mt-2 inline-block text-sm font-semibold text-[var(--color-primary)] hover:underline"
|
||||||
>
|
>
|
||||||
View full room details & amenities
|
{/^[0-9a-f-]{36}$/i.test(selected.id)
|
||||||
|
? "Back to room selection"
|
||||||
|
: "View full room details & amenities"}
|
||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
15
src/components/StoreHydration.tsx
Normal file
15
src/components/StoreHydration.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useBookingStore } from "@/stores/booking-store";
|
||||||
|
import { useCurrencyStore } from "@/stores/currency-store";
|
||||||
|
import { useOrdersStore } from "@/stores/orders-store";
|
||||||
|
|
||||||
|
/** Rehydrate persisted stores and kick async loads (rooms) once on the client. */
|
||||||
|
export function StoreHydration() {
|
||||||
|
useEffect(() => {
|
||||||
|
void useCurrencyStore.persist.rehydrate();
|
||||||
|
void useBookingStore.getState().refreshRooms();
|
||||||
|
}, []);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -1,87 +1 @@
|
||||||
"use client";
|
export { useCurrency, useCurrencyStore } from "@/stores/currency-store";
|
||||||
|
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useMemo,
|
|
||||||
useSyncExternalStore,
|
|
||||||
type ReactNode,
|
|
||||||
} from "react";
|
|
||||||
import {
|
|
||||||
type CurrencyCode,
|
|
||||||
convertFromUsd,
|
|
||||||
formatMoneyFromUsd,
|
|
||||||
isCurrencyCode,
|
|
||||||
} from "@/lib/currency";
|
|
||||||
|
|
||||||
const STORAGE_KEY = "shitaye-currency";
|
|
||||||
const CURRENCY_EVENT = "shitaye-currency-change";
|
|
||||||
|
|
||||||
type CurrencyContextValue = {
|
|
||||||
currency: CurrencyCode;
|
|
||||||
setCurrency: (c: CurrencyCode) => void;
|
|
||||||
formatUsd: (amountUsd: number, maximumFractionDigits?: 0 | 1 | 2) => string;
|
|
||||||
convertUsd: (amountUsd: number) => number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const CurrencyContext = createContext<CurrencyContextValue | null>(null);
|
|
||||||
|
|
||||||
function readCurrency(): CurrencyCode {
|
|
||||||
if (typeof window === "undefined") return "USD";
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (raw && isCurrencyCode(raw)) return raw;
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
return "USD";
|
|
||||||
}
|
|
||||||
|
|
||||||
function subscribe(onChange: () => void) {
|
|
||||||
if (typeof window === "undefined") return () => {};
|
|
||||||
const handler = () => onChange();
|
|
||||||
window.addEventListener("storage", handler);
|
|
||||||
window.addEventListener(CURRENCY_EVENT, handler);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("storage", handler);
|
|
||||||
window.removeEventListener(CURRENCY_EVENT, handler);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CurrencyProvider({ children }: { children: ReactNode }) {
|
|
||||||
const currency = useSyncExternalStore(
|
|
||||||
subscribe,
|
|
||||||
readCurrency,
|
|
||||||
() => "USD" as CurrencyCode,
|
|
||||||
) as CurrencyCode;
|
|
||||||
|
|
||||||
const setCurrency = useCallback((c: CurrencyCode) => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEY, c);
|
|
||||||
window.dispatchEvent(new Event(CURRENCY_EVENT));
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const value = useMemo((): CurrencyContextValue => {
|
|
||||||
return {
|
|
||||||
currency,
|
|
||||||
setCurrency,
|
|
||||||
formatUsd: (amountUsd, maximumFractionDigits = 2) =>
|
|
||||||
formatMoneyFromUsd(amountUsd, currency, maximumFractionDigits),
|
|
||||||
convertUsd: (amountUsd) => convertFromUsd(amountUsd, currency),
|
|
||||||
};
|
|
||||||
}, [currency, setCurrency]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CurrencyContext.Provider value={value}>{children}</CurrencyContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCurrency() {
|
|
||||||
const ctx = useContext(CurrencyContext);
|
|
||||||
if (!ctx) throw new Error("useCurrency must be used within CurrencyProvider");
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
export type CurrencyCode = "USD" | "EUR" | "GBP" | "AED";
|
export type CurrencyCode = "USD" | "EUR" | "GBP" | "AED" | "ETB";
|
||||||
|
|
||||||
export const CURRENCY_OPTIONS: { code: CurrencyCode; shortLabel: string }[] = [
|
export const CURRENCY_OPTIONS: { code: CurrencyCode; shortLabel: string }[] = [
|
||||||
|
{ code: "ETB", shortLabel: "ETB" },
|
||||||
{ code: "USD", shortLabel: "USD" },
|
{ code: "USD", shortLabel: "USD" },
|
||||||
{ code: "EUR", shortLabel: "EUR" },
|
{ code: "EUR", shortLabel: "EUR" },
|
||||||
{ code: "GBP", shortLabel: "GBP" },
|
{ code: "GBP", shortLabel: "GBP" },
|
||||||
{ code: "AED", shortLabel: "AED" },
|
{ code: "AED", shortLabel: "AED" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Display amount = catalog USD × rate (illustrative mock rates). */
|
|
||||||
export const USD_TO: Record<CurrencyCode, number> = {
|
export const USD_TO: Record<CurrencyCode, number> = {
|
||||||
|
ETB: 1,
|
||||||
USD: 1,
|
USD: 1,
|
||||||
EUR: 0.93,
|
EUR: 0.93,
|
||||||
GBP: 0.79,
|
GBP: 0.79,
|
||||||
|
|
@ -16,7 +17,7 @@ export const USD_TO: Record<CurrencyCode, number> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isCurrencyCode(v: string): v is CurrencyCode {
|
export function isCurrencyCode(v: string): v is CurrencyCode {
|
||||||
return v === "USD" || v === "EUR" || v === "GBP" || v === "AED";
|
return v === "USD" || v === "EUR" || v === "GBP" || v === "AED" || v === "ETB";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function convertFromUsd(usd: number, code: CurrencyCode): number {
|
export function convertFromUsd(usd: number, code: CurrencyCode): number {
|
||||||
|
|
|
||||||
62
src/lib/data/bookingReviews.ts
Normal file
62
src/lib/data/bookingReviews.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
/**
|
||||||
|
* Illustrative guest reviews shown in the nav — style inspired by Booking.com.
|
||||||
|
* Replace copy/URLs with live data from your Booking.com property page when available.
|
||||||
|
*/
|
||||||
|
export type BookingStyleReview = {
|
||||||
|
id: string;
|
||||||
|
author: string;
|
||||||
|
country: string;
|
||||||
|
rating: number;
|
||||||
|
maxRating: number;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
stayDate: string;
|
||||||
|
roomType: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const bookingStyleReviews: BookingStyleReview[] = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
author: "Sarah M.",
|
||||||
|
country: "United Kingdom",
|
||||||
|
rating: 9.2,
|
||||||
|
maxRating: 10,
|
||||||
|
title: "Exceptional stay in Addis",
|
||||||
|
text: "Spotless suites, attentive team, and a perfect base for meetings. Breakfast at FeastVille was a highlight — we’ll return.",
|
||||||
|
stayDate: "October 2025",
|
||||||
|
roomType: "Connecting Suite",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
author: "Daniel K.",
|
||||||
|
country: "Germany",
|
||||||
|
rating: 8.8,
|
||||||
|
maxRating: 10,
|
||||||
|
title: "Great location & comfort",
|
||||||
|
text: "Quiet rooms, strong Wi‑Fi, and easy access to the city. The junior studio had everything we needed for a week of work.",
|
||||||
|
stayDate: "September 2025",
|
||||||
|
roomType: "Junior Studio",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
author: "Hanna T.",
|
||||||
|
country: "Ethiopia",
|
||||||
|
rating: 9.6,
|
||||||
|
maxRating: 10,
|
||||||
|
title: "Family trip made easy",
|
||||||
|
text: "We booked the penthouse for a celebration — space, views, and service exceeded expectations. Kids loved the IPTV selection.",
|
||||||
|
stayDate: "August 2025",
|
||||||
|
roomType: "4 Bedroom Penthouse",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function averageBookingStyleRating(
|
||||||
|
list: BookingStyleReview[] = bookingStyleReviews,
|
||||||
|
): number {
|
||||||
|
if (!list.length) return 0;
|
||||||
|
const sum = list.reduce((a, r) => a + r.rating, 0);
|
||||||
|
return Math.round((sum / list.length) * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aggregate score shown in the reviews dialog (out of 5), with circle “star” row */
|
||||||
|
export const overallRatingOutOfFive = 4.5;
|
||||||
71
src/lib/data/guestData.ts
Normal file
71
src/lib/data/guestData.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
export type MockAppointment = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
when: string;
|
||||||
|
where: string;
|
||||||
|
status: "confirmed" | "pending";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MockShuttle = {
|
||||||
|
/** ISO date */
|
||||||
|
departureDate: string;
|
||||||
|
/** e.g. "04:15" */
|
||||||
|
lobbyPickupTime: string;
|
||||||
|
/** e.g. "Bole International (ADD)" */
|
||||||
|
airport: string;
|
||||||
|
flightLabel: string;
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MockReward = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
points: number;
|
||||||
|
earnedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const seedAppointments: MockAppointment[] = [
|
||||||
|
{
|
||||||
|
id: "a1",
|
||||||
|
title: "Deep tissue massage",
|
||||||
|
when: "Today · 16:30",
|
||||||
|
where: "Spa · Treatment suite B",
|
||||||
|
status: "confirmed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "a2",
|
||||||
|
title: "Small-group HIIT",
|
||||||
|
when: "Tomorrow · 07:00",
|
||||||
|
where: "Fitness centre · Studio",
|
||||||
|
status: "confirmed",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const seedShuttle: MockShuttle = {
|
||||||
|
departureDate: "2026-04-11",
|
||||||
|
lobbyPickupTime: "04:15",
|
||||||
|
airport: "Bole International (ADD)",
|
||||||
|
flightLabel: "ET 302 · Addis → Frankfurt",
|
||||||
|
notes: "Please be in the lobby 10 minutes early. Shuttle is complimentary for this stay.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const seedRewardsHistory: MockReward[] = [
|
||||||
|
{
|
||||||
|
id: "r1",
|
||||||
|
label: "Welcome bonus — direct booking",
|
||||||
|
points: 500,
|
||||||
|
earnedAt: "2026-04-01",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r2",
|
||||||
|
label: "Room service order",
|
||||||
|
points: 40,
|
||||||
|
earnedAt: "2026-04-03",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r3",
|
||||||
|
label: "Spa visit",
|
||||||
|
points: 120,
|
||||||
|
earnedAt: "2026-04-04",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -1,45 +1,34 @@
|
||||||
export type LaundryItem = {
|
export type LaundryCartItem = {
|
||||||
id: string;
|
label: string;
|
||||||
name: string;
|
quantity: number;
|
||||||
description: string;
|
|
||||||
priceUsd: number;
|
|
||||||
unit: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const laundryItems: LaundryItem[] = [
|
export const laundryItems = [
|
||||||
{
|
{
|
||||||
id: "l-1",
|
label: "Shirt / blouse",
|
||||||
name: "Shirt / blouse",
|
price: 50, // ETB
|
||||||
description: "Pressed",
|
|
||||||
priceUsd: 4,
|
|
||||||
unit: "each",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "l-2",
|
label: "Pants / trousers",
|
||||||
name: "Trousers / skirt",
|
price: 60, // ETB
|
||||||
description: "Pressed",
|
|
||||||
priceUsd: 5,
|
|
||||||
unit: "each",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "l-3",
|
label: "Suit (2 pc)",
|
||||||
name: "Suit (2 pc)",
|
price: 120, // ETB
|
||||||
description: "Clean & press",
|
|
||||||
priceUsd: 18,
|
|
||||||
unit: "set",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "l-4",
|
label: "Dress",
|
||||||
name: "Dress",
|
price: 80, // ETB
|
||||||
description: "Delicate cycle",
|
|
||||||
priceUsd: 12,
|
|
||||||
unit: "each",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "l-5",
|
label: "Jacket",
|
||||||
name: "Express (same day)",
|
price: 70, // ETB
|
||||||
description: "Surcharge on top of item prices",
|
|
||||||
priceUsd: 15,
|
|
||||||
unit: "per order",
|
|
||||||
},
|
},
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
|
export const SAME_DAY_SURCHARGE = 100; // ETB per order
|
||||||
|
|
||||||
|
export const laundryPrices: Record<string, number> = Object.fromEntries(
|
||||||
|
laundryItems.map(({ label, price }) => [label.toLowerCase(), price])
|
||||||
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ export async function guestBookings(propertyId: string, accessToken: string) {
|
||||||
|
|
||||||
export type MenuItemRow = {
|
export type MenuItemRow = {
|
||||||
id: string;
|
id: string;
|
||||||
|
image: string;
|
||||||
name: string;
|
name: string;
|
||||||
unitPrice: string | number;
|
unitPrice: string | number;
|
||||||
isAvailable: boolean;
|
isAvailable: boolean;
|
||||||
|
|
@ -97,7 +98,8 @@ export async function guestPlaceLaundry(
|
||||||
pickupAt?: string;
|
pickupAt?: string;
|
||||||
deliverAt?: string;
|
deliverAt?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
total?: string;
|
total?: string | number;
|
||||||
|
sameDay?: boolean;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
return apiFetch(`/properties/${propertyId}/hotel/guest/laundry`, {
|
return apiFetch(`/properties/${propertyId}/hotel/guest/laundry`, {
|
||||||
|
|
@ -157,6 +159,7 @@ export async function guestLaundryOrders(
|
||||||
|
|
||||||
export type SpaOfferingRow = {
|
export type SpaOfferingRow = {
|
||||||
id: string;
|
id: string;
|
||||||
|
image: string;
|
||||||
kind: "SPA_SESSION" | "SPA_PACKAGE" | "GYM_PASS";
|
kind: "SPA_SESSION" | "SPA_PACKAGE" | "GYM_PASS";
|
||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
|
|
||||||
65
src/lib/public-hotel-api.ts
Normal file
65
src/lib/public-hotel-api.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { apiFetch } from "@/lib/api-client";
|
||||||
|
import { getHotelPropertyId } from "@/lib/env";
|
||||||
|
|
||||||
|
/** Matches `HotelPublicService.listRooms` select — Nest serializes `Decimal` as string. */
|
||||||
|
export type HotelPublicRoom = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
roomType: string;
|
||||||
|
maxGuests: number;
|
||||||
|
baseRate: string | number;
|
||||||
|
imageKeys: string[];
|
||||||
|
operationalStatus?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchPublicRooms(propertyId: string): Promise<HotelPublicRoom[]> {
|
||||||
|
const res = await apiFetch<{ data: HotelPublicRoom[] }>(
|
||||||
|
`/properties/${propertyId}/hotel/public/rooms`,
|
||||||
|
{ method: "GET" },
|
||||||
|
);
|
||||||
|
return res.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PublicBookingResponse = {
|
||||||
|
id: string;
|
||||||
|
bookingCode: string | null;
|
||||||
|
totalPrice: string | number | null;
|
||||||
|
currency: string;
|
||||||
|
checkIn: string;
|
||||||
|
checkOut: string;
|
||||||
|
status: string;
|
||||||
|
payLaterHold: boolean;
|
||||||
|
room?: { id: string; name: string; roomType: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createPublicBooking(
|
||||||
|
propertyId: string,
|
||||||
|
body: {
|
||||||
|
roomId: string;
|
||||||
|
checkIn: string;
|
||||||
|
checkOut: string;
|
||||||
|
guestCount?: number;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string;
|
||||||
|
flightPnr?: string;
|
||||||
|
arrivalTime?: string;
|
||||||
|
discountCode?: string;
|
||||||
|
referralCode?: string;
|
||||||
|
payLaterHold?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<PublicBookingResponse> {
|
||||||
|
return apiFetch<PublicBookingResponse>(`/properties/${propertyId}/hotel/public/bookings`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensurePropertyId(): Promise<string> {
|
||||||
|
const id = getHotelPropertyId();
|
||||||
|
if (!id) {
|
||||||
|
throw new Error("Set NEXT_PUBLIC_HOTEL_PROPERTY_ID for the hotel client");
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
46
src/lib/room-mapper.ts
Normal file
46
src/lib/room-mapper.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import type { Room } from "@/types/room";
|
||||||
|
import type { HotelPublicRoom } from "@/lib/public-hotel-api";
|
||||||
|
|
||||||
|
const PLACEHOLDER =
|
||||||
|
"https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=1200&q=80&auto=format&fit=crop";
|
||||||
|
|
||||||
|
function slugify(name: string, id: string): string {
|
||||||
|
const base = name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "");
|
||||||
|
return base || id.slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapApiRoomToRoom(api: HotelPublicRoom): Room {
|
||||||
|
const nightlyRate =
|
||||||
|
typeof api.baseRate === "string" ? Number.parseFloat(api.baseRate) : api.baseRate;
|
||||||
|
const origin = process.env.NEXT_PUBLIC_MEDIA_ORIGIN?.replace(/\/$/, "") ?? "";
|
||||||
|
const gallery =
|
||||||
|
api.imageKeys?.length > 0
|
||||||
|
? api.imageKeys.map((k) => {
|
||||||
|
if (k.startsWith("http")) return k;
|
||||||
|
if (origin) return `${origin}/${k.replace(/^\//, "")}`;
|
||||||
|
return PLACEHOLDER;
|
||||||
|
})
|
||||||
|
: [PLACEHOLDER];
|
||||||
|
|
||||||
|
const slug = slugify(api.name, api.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: api.id,
|
||||||
|
slug,
|
||||||
|
name: api.name,
|
||||||
|
shortDescription: api.roomType,
|
||||||
|
longDescription: `${api.name} — ${api.roomType}. Max ${api.maxGuests} guests.`,
|
||||||
|
nightlyRate: Number.isFinite(nightlyRate) ? nightlyRate : 0,
|
||||||
|
maxGuests: api.maxGuests,
|
||||||
|
beds: api.roomType,
|
||||||
|
sizeSqM: 0,
|
||||||
|
view: "",
|
||||||
|
highlights: [],
|
||||||
|
gallery: gallery.filter(Boolean).length ? gallery : [PLACEHOLDER],
|
||||||
|
tourEmbedUrl: null,
|
||||||
|
priceCurrency: "ETB",
|
||||||
|
};
|
||||||
|
}
|
||||||
14
src/stores/guest-ui-store.ts
Normal file
14
src/stores/guest-ui-store.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
/** Local-only loyalty bonus shown in the demo profile (not server points). */
|
||||||
|
type GuestUiState = {
|
||||||
|
localBonusPoints: number;
|
||||||
|
addLocalBonus: (n: number) => void;
|
||||||
|
resetLocalBonus: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGuestUiStore = create<GuestUiState>()((set) => ({
|
||||||
|
localBonusPoints: 0,
|
||||||
|
addLocalBonus: (n) => set((s) => ({ localBonusPoints: s.localBonusPoints + n })),
|
||||||
|
resetLocalBonus: () => set({ localBonusPoints: 0 }),
|
||||||
|
}));
|
||||||
Loading…
Reference in New Issue
Block a user