amenities and room service apis
This commit is contained in:
parent
618d30aeef
commit
c1f3461952
|
|
@ -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: "/**" },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 “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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
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";
|
||||
|
||||
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";
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
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 = {
|
||||
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])
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
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