amenities and room service apis

This commit is contained in:
brooktewabe 2026-04-15 10:45:13 +03:00
parent 618d30aeef
commit c1f3461952
16 changed files with 606 additions and 464 deletions

View File

@ -4,6 +4,7 @@ const nextConfig: NextConfig = {
images: {
remotePatterns: [
{ 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: "cf.bstatic.com", pathname: "/**" },
],

View File

@ -1,12 +1,13 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { useState, useEffect, useCallback } from "react";
import { RequireAuth } from "@/components/RequireAuth";
import { useAuth } from "@/context/AuthContext";
import { guestPlaceLaundry } from "@/lib/guest-hotel-api";
import { formatEtb } from "@/lib/format-etb";
import { useGuestActiveBooking } from "@/lib/useGuestActiveBooking";
import { laundryItems, type LaundryCartItem, SAME_DAY_SURCHARGE } from "@/lib/data/laundryCatalog";
export function LaundryClient() {
return (
@ -19,87 +20,115 @@ export function LaundryClient() {
function LaundryInner() {
const { accessToken } = useAuth();
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 [deliverAt, setDeliverAt] = useState("");
const [estimateEtb, setEstimateEtb] = useState("");
const [notes, setNotes] = useState("");
const [sent, setSent] = useState(false);
const [submitErr, setSubmitErr] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
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() {
if (!canUseApi) return;
if (!canUseApi || buildItems().length === 0) {
setSubmitErr("Please select at least one item.");
return;
}
setSubmitErr(null);
setSubmitting(true);
try {
await guestPlaceLaundry(propertyId!, accessToken!, {
bookingId: bookingId!,
items: [],
items: buildItems(),
sameDay,
total: displayTotal,
// currency: "ETB",
notes: notes.trim() || undefined,
pickupAt: pickupAt || undefined,
deliverAt: deliverAt || undefined,
total: estimateEtb.trim() || undefined,
});
setSubmitting(false);
setCart({});
setSameDay(false);
setPickupAt("");
setDeliverAt("");
setNotes("");
setSent(true);
} catch (e) {
setSubmitErr(e instanceof Error ? e.message : "Could not submit laundry request");
setSubmitting(false);
return;
}
setSubmitting(false);
setNotes("");
setPickupAt("");
setDeliverAt("");
setEstimateEtb("");
setSent(true);
}
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 (
<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">
<nav className="text-xs font-medium text-[var(--color-muted)]">
<Link href="/" className="hover:text-[var(--color-accent)]">
Home
</Link>
<Link href="/" className="hover:text-[var(--color-accent)]">Home</Link>
<span className="mx-2 opacity-50">/</span>
<Link href="/guest" className="hover:text-[var(--color-accent)]">
Guest hub
</Link>
<Link href="/guest" className="hover:text-[var(--color-accent)]">Guest hub</Link>
<span className="mx-2 opacity-50">/</span>
<span className="text-[var(--color-text)]">Laundry</span>
</nav>
<div className="mt-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
<h1 className="font-heading text-3xl font-semibold text-[var(--color-text)] md:text-4xl">
Laundry service
</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>
<h1 className="font-heading text-3xl font-semibold text-[var(--color-text)] md:text-4xl">Laundry service</h1>
<p className="mt-2 max-w-xl text-sm text-[var(--color-muted)]">Submit a laundry request attached to your active booking.</p>
</div>
<Link
href="/profile"
className="text-sm font-semibold text-[var(--color-accent)] hover:underline"
>
View profile
</Link>
<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.
Sign in with a booking code or use a reservation 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>
<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 ? (
@ -108,70 +137,97 @@ function LaundryInner() {
</div>
) : null}
<div className="mt-10 grid gap-10 lg:grid-cols-[1fr_380px]">
<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>
{!sent && (
<div className="mt-10 grid gap-10 lg:grid-cols-[1fr_380px]">
{/* Items Selection */}
<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
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"
rows={3}
placeholder="Special instructions (e.g., no starch, delicate)..."
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
type="button"
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"
onClick={submit}
disabled={submitting || !hasItems || !canUseApi}
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"
>
{submitting ? "Submitting…" : "Submit laundry request"}
{submitting ? "Submitting..." : `Place laundry order (${formatEtb(displayTotal)})`}
</button>
</div>
</aside>
</div>
</div>
)}
</div>
</div>
);

View File

@ -211,7 +211,7 @@ function RoomServiceInner() {
>
<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"
src={row.image}
alt=""
fill
className="object-cover opacity-90"

View File

@ -1,167 +1,25 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import Image from "next/image";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/context/AuthContext";
import {
spaGymFilters,
spaGymServices,
type SpaGymFilterId,
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 &ldquo;Add to selection&rdquo; 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>
);
}
import { formatEtb } from "@/lib/format-etb";
import { guestCreateSpaBooking, guestSpaBookings, guestSpaOfferings, type SpaBookingRow, type SpaOfferingRow } from "@/lib/guest-hotel-api";
import { useGuestActiveBooking } from "@/lib/useGuestActiveBooking";
import { spaGymFilters } from "@/lib/data/services";
export function ServicesPageClient() {
const searchParams = useSearchParams();
const { session, addOrder } = useAuth();
const [filter, setFilter] = useState<SpaGymFilterId>("all");
const [selected, setSelected] = useState<Set<string>>(new Set());
const { session, accessToken } = useAuth();
const { bookingId, propertyId } = useGuestActiveBooking();
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(() => {
const k = searchParams.get("kind");
@ -170,49 +28,47 @@ export function ServicesPageClient() {
}
}, [searchParams]);
const filtered = useMemo(() => {
if (filter === "all") return spaGymServices;
return spaGymServices.filter((s) => s.kind === filter);
}, [filter]);
const selectedItems = useMemo(
() => spaGymServices.filter((s) => selected.has(s.id)),
[selected],
);
function toggle(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
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",
useEffect(() => {
if (!accessToken || !propertyId) return;
let cancelled = false;
setLoading(true);
setError(null);
Promise.all([guestSpaOfferings(propertyId, accessToken), guestSpaBookings(propertyId, accessToken)])
.then(([off, b]) => {
if (!cancelled) {
setOfferings(off.data ?? []);
setBookings(b.data ?? []);
}
})
.catch((e) => {
if (!cancelled) setError(e instanceof Error ? e.message : "Failed to load services.");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
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 (
@ -230,18 +86,12 @@ export function ServicesPageClient() {
Spa & gym services
</h1>
<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
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).
Book treatments and gym passes directly from live hotel offerings.
</p>
</div>
</section>
<section
className={`mx-auto max-w-7xl px-4 py-10 md:px-8 md:py-14 ${selectedItems.length > 0 ? "pb-28 lg:pb-14" : ""}`}
>
<section className="mx-auto max-w-7xl px-4 py-10 md:px-8 md:py-14">
<div className="flex flex-wrap justify-center gap-2 md:justify-start md:gap-2.5">
{spaGymFilters.map((f) => {
const active = filter === f.id;
@ -262,29 +112,95 @@ export function ServicesPageClient() {
})}
</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="grid gap-6 sm:grid-cols-2">
{filtered.map((service) => (
<ServiceCard
key={service.id}
service={service}
selected={selected.has(service.id)}
onToggle={() => toggle(service.id)}
/>
))}
</div>
<div className="grid gap-6 sm:grid-cols-2">
{loading ? (
<p className="text-sm text-[var(--color-muted)]">Loading services</p>
) : null}
{!loading && filtered.length === 0 ? (
<p className="text-sm text-[var(--color-muted)]">No offerings published yet.</p>
) : null}
{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
type="button"
onClick={saveSelectionToProfile}
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)]"
disabled={!bookingId || busyId === service.id}
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>
) : 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
href="/guest"
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>
</aside>
</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>
</div>
);

View File

@ -6,7 +6,7 @@ import { ServicesPageClient } from "./ServicesPageClient";
export const metadata: Metadata = {
title: "Spa & gym services",
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() {

View File

@ -3,9 +3,9 @@
import Image from "next/image";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { FormattedUsd } from "@/components/FormattedUsd";
import type { Room } from "@/lib/mocks/rooms";
import { rooms } from "@/lib/mocks/rooms";
import { RoomPrice } from "@/components/RoomPrice";
import type { Room } from "@/types/room";
import { useBooking } from "@/context/BookingContext";
type Props = {
selected: Room | null;
@ -13,6 +13,7 @@ type Props = {
};
export function RoomSelectBooking({ selected, onSelect }: Props) {
const { rooms } = useBooking();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
@ -32,7 +33,7 @@ export function RoomSelectBooking({ selected, onSelect }: Props) {
<button
type="button"
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-haspopup="listbox"
>
@ -50,7 +51,7 @@ export function RoomSelectBooking({ selected, onSelect }: Props) {
<div className="min-w-0 flex-1">
<p className="font-semibold text-[var(--color-text)]">{selected.name}</p>
<p className="text-xs text-[var(--color-muted)]">
From ${selected.nightlyRate} / night
From <RoomPrice room={selected} maximumFractionDigits={0} /> / night
</p>
</div>
</>
@ -89,7 +90,7 @@ export function RoomSelectBooking({ selected, onSelect }: Props) {
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-[var(--color-text)]">{room.name}</p>
<p className="text-xs text-[var(--color-muted)]">
<FormattedUsd amountUsd={room.nightlyRate} maximumFractionDigits={0} />
<RoomPrice room={room} maximumFractionDigits={0} />
/night · max {room.maxGuests} guests
</p>
</div>
@ -101,10 +102,16 @@ export function RoomSelectBooking({ selected, onSelect }: Props) {
{selected ? (
<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"
>
View full room details & amenities
{/^[0-9a-f-]{36}$/i.test(selected.id)
? "Back to room selection"
: "View full room details & amenities"}
</Link>
) : null}
</div>

View 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;
}

View File

@ -1,87 +1 @@
"use client";
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;
}
export { useCurrency, useCurrencyStore } from "@/stores/currency-store";

View File

@ -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 }[] = [
{ code: "ETB", shortLabel: "ETB" },
{ code: "USD", shortLabel: "USD" },
{ code: "EUR", shortLabel: "EUR" },
{ code: "GBP", shortLabel: "GBP" },
{ code: "AED", shortLabel: "AED" },
];
/** Display amount = catalog USD × rate (illustrative mock rates). */
export const USD_TO: Record<CurrencyCode, number> = {
ETB: 1,
USD: 1,
EUR: 0.93,
GBP: 0.79,
@ -16,7 +17,7 @@ export const USD_TO: Record<CurrencyCode, number> = {
};
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 {

View 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 — well 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 WiFi, 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
View 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",
},
];

View File

@ -1,45 +1,34 @@
export type LaundryItem = {
id: string;
name: string;
description: string;
priceUsd: number;
unit: string;
export type LaundryCartItem = {
label: string;
quantity: number;
};
export const laundryItems: LaundryItem[] = [
export const laundryItems = [
{
id: "l-1",
name: "Shirt / blouse",
description: "Pressed",
priceUsd: 4,
unit: "each",
label: "Shirt / blouse",
price: 50, // ETB
},
{
id: "l-2",
name: "Trousers / skirt",
description: "Pressed",
priceUsd: 5,
unit: "each",
label: "Pants / trousers",
price: 60, // ETB
},
{
id: "l-3",
name: "Suit (2 pc)",
description: "Clean & press",
priceUsd: 18,
unit: "set",
label: "Suit (2 pc)",
price: 120, // ETB
},
{
id: "l-4",
name: "Dress",
description: "Delicate cycle",
priceUsd: 12,
unit: "each",
label: "Dress",
price: 80, // ETB
},
{
id: "l-5",
name: "Express (same day)",
description: "Surcharge on top of item prices",
priceUsd: 15,
unit: "per order",
label: "Jacket",
price: 70, // ETB
},
];
] 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])
);

View File

@ -58,6 +58,7 @@ export async function guestBookings(propertyId: string, accessToken: string) {
export type MenuItemRow = {
id: string;
image: string;
name: string;
unitPrice: string | number;
isAvailable: boolean;
@ -97,7 +98,8 @@ export async function guestPlaceLaundry(
pickupAt?: string;
deliverAt?: string;
notes?: string;
total?: string;
total?: string | number;
sameDay?: boolean;
},
) {
return apiFetch(`/properties/${propertyId}/hotel/guest/laundry`, {
@ -157,6 +159,7 @@ export async function guestLaundryOrders(
export type SpaOfferingRow = {
id: string;
image: string;
kind: "SPA_SESSION" | "SPA_PACKAGE" | "GYM_PASS";
name: string;
description?: string | null;

View 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
View 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",
};
}

View 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 }),
}));