feat(guest): refine portal navigation and auth routing
Unify guest login/profile paths, add booking-ref CTA entry points, and introduce responsive logged-in navigation with desktop dropdowns and mobile hamburger menu. Made-with: Cursor
This commit is contained in:
parent
d5c7d56c11
commit
3b41b9052b
|
|
@ -89,13 +89,21 @@ export default function ConfirmationPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-col items-center gap-3 sm:flex-row sm:justify-center">
|
||||
<Link
|
||||
href={`/guest/login?ref=${encodeURIComponent(confirmationId)}&lastName=${encodeURIComponent(guest.lastName)}`}
|
||||
className="inline-flex rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-6 py-3 text-sm font-semibold text-[var(--color-text)] transition hover:border-[var(--color-accent)] hover:text-[var(--color-accent)]"
|
||||
>
|
||||
Open guest portal
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
onClick={() => resetBooking()}
|
||||
className="btn-mustard mt-10 inline-flex px-10 py-3.5 text-sm"
|
||||
className="btn-mustard inline-flex px-10 py-3.5 text-sm"
|
||||
>
|
||||
Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
174
src/app/guest/GuestSpaGymBookingClient.tsx
Normal file
174
src/app/guest/GuestSpaGymBookingClient.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { RequireAuth } from "@/components/RequireAuth";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { spaGymServices, type SpaGymKind, type SpaGymService } from "@/lib/mocks/services";
|
||||
|
||||
const MOCK_SLOTS = ["09:00", "11:00", "14:00", "16:00", "18:00"];
|
||||
|
||||
type Props = { kind: SpaGymKind; title: string; description: string };
|
||||
|
||||
export function GuestSpaGymBookingClient({ kind, title, description }: Props) {
|
||||
return (
|
||||
<RequireAuth redirectTo="/guest/login">
|
||||
<Inner kind={kind} title={title} description={description} />
|
||||
</RequireAuth>
|
||||
);
|
||||
}
|
||||
|
||||
function Inner({ kind, title, description }: Props) {
|
||||
const { addOrder } = useAuth();
|
||||
const [serviceId, setServiceId] = useState<string | null>(null);
|
||||
const [slot, setSlot] = useState(MOCK_SLOTS[0]!);
|
||||
const [note, setNote] = useState("");
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const items = useMemo(() => spaGymServices.filter((s) => s.kind === kind), [kind]);
|
||||
const selected = useMemo(
|
||||
() => items.find((s) => s.id === serviceId) ?? null,
|
||||
[items, serviceId],
|
||||
);
|
||||
|
||||
function book() {
|
||||
if (!selected) return;
|
||||
addOrder({
|
||||
category: kind === "spa" ? "spa" : "gym",
|
||||
title: `${kind === "spa" ? "Spa" : "Gym"} · ${selected.title}`,
|
||||
detail: `${selected.duration} · ${slot}${note ? ` · ${note}` : ""} · $${selected.priceUsd}`,
|
||||
totalUsd: selected.priceUsd,
|
||||
status: "confirmed",
|
||||
});
|
||||
setDone(true);
|
||||
}
|
||||
|
||||
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>
|
||||
<span className="mx-2 opacity-50">/</span>
|
||||
<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)]">{title}</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">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm text-[var(--color-muted)]">{description}</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/services"
|
||||
className="text-sm font-semibold text-[var(--color-accent)] hover:underline"
|
||||
>
|
||||
Full spa & gym menu →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{done ? (
|
||||
<div className="mt-6 rounded-2xl border border-[var(--color-accent)]/40 bg-[var(--color-accent-soft)] px-4 py-3 text-sm text-[var(--color-primary)]">
|
||||
Request recorded (demo). See <Link href="/guest/profile" className="font-semibold underline">My stay</Link> for orders.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-10 grid gap-8 lg:grid-cols-[1fr_400px]">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{items.map((s: SpaGymService) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => setServiceId(s.id)}
|
||||
className={`card-lift overflow-hidden rounded-2xl border text-left shadow-sm transition ${
|
||||
serviceId === s.id
|
||||
? "border-[var(--color-primary)] ring-2 ring-[var(--color-primary)]/25"
|
||||
: "border-[var(--color-border)] bg-[var(--color-surface)]"
|
||||
}`}
|
||||
>
|
||||
<div className="relative aspect-[16/10]">
|
||||
<Image
|
||||
src={s.image}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width:1024px) 50vw, 25vw"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<p className="text-xs font-semibold text-[var(--color-muted)]">{s.duration}</p>
|
||||
<p className="mt-1 font-heading text-lg font-semibold text-[var(--color-text)]">
|
||||
{s.title}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[var(--color-muted)]">{s.description}</p>
|
||||
<p className="mt-2 font-semibold text-[var(--color-primary)]">
|
||||
${s.priceUsd} · {s.priceNote}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<aside className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm lg:sticky lg:top-28">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-primary)]">
|
||||
Book a slot
|
||||
</p>
|
||||
<label className="mt-4 block text-sm font-medium text-[var(--color-text)]">
|
||||
Preferred time
|
||||
<select
|
||||
value={slot}
|
||||
onChange={(e) => setSlot(e.target.value)}
|
||||
className="mt-1.5 w-full rounded-xl border border-[var(--color-border)] px-3 py-2.5 text-sm"
|
||||
>
|
||||
{MOCK_SLOTS.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="mt-4 block text-sm font-medium text-[var(--color-text)]">
|
||||
Note (optional)
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
rows={3}
|
||||
className="mt-1.5 w-full rounded-xl border border-[var(--color-border)] px-3 py-2 text-sm"
|
||||
placeholder="Allergies, focus areas…"
|
||||
/>
|
||||
</label>
|
||||
{selected ? (
|
||||
<div className="mt-4 rounded-xl bg-[var(--color-surface-muted)] p-3 text-sm">
|
||||
<p className="font-semibold text-[var(--color-text)]">{selected.title}</p>
|
||||
<p className="text-[var(--color-muted)]">${selected.priceUsd}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-4 text-sm text-[var(--color-muted)]">Select a service from the grid.</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={book}
|
||||
disabled={!selected}
|
||||
className="btn-mustard mt-6 w-full justify-center py-3 text-sm disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Confirm booking (demo)
|
||||
</button>
|
||||
<Link
|
||||
href="/guest/profile"
|
||||
className="mt-4 block text-center text-sm font-semibold text-[var(--color-primary)] hover:underline"
|
||||
>
|
||||
View my orders
|
||||
</Link>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,16 @@
|
|||
import { redirect } from "next/navigation";
|
||||
import { GuestSpaGymBookingClient } from "../GuestSpaGymBookingClient";
|
||||
|
||||
export const metadata = {
|
||||
title: "Gym",
|
||||
title: "Gym bookings",
|
||||
description: "Book gym sessions and classes during your stay — Shitaye Suite Hotel.",
|
||||
};
|
||||
|
||||
/** Deep-link to spa & gym page with gym filter. */
|
||||
export default function GuestGymRedirectPage() {
|
||||
redirect("/services?kind=gym");
|
||||
export default function GuestGymPage() {
|
||||
return (
|
||||
<GuestSpaGymBookingClient
|
||||
kind="gym"
|
||||
title="Gym bookings"
|
||||
description="Choose a pass, PT, or class — same catalog as the public spa & gym page, with a mock time slot."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { laundryItems } from "@/lib/mocks/laundryCatalog";
|
|||
|
||||
export function LaundryClient() {
|
||||
return (
|
||||
<RequireAuth redirectTo="/login">
|
||||
<RequireAuth redirectTo="/guest/login">
|
||||
<LaundryInner />
|
||||
</RequireAuth>
|
||||
);
|
||||
|
|
@ -91,7 +91,7 @@ function LaundryInner() {
|
|||
Select pieces and optional express surcharge. Mock request — pickup at reception.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/profile" className="text-sm font-semibold text-[var(--color-accent)] hover:underline">
|
||||
<Link href="/guest/profile" className="text-sm font-semibold text-[var(--color-accent)] hover:underline">
|
||||
View profile →
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
|||
39
src/app/guest/layout.tsx
Normal file
39
src/app/guest/layout.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import Link from "next/link";
|
||||
|
||||
export default function GuestLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-full">
|
||||
<div className="border-b border-[var(--color-border)] bg-[var(--color-surface)]/90 backdrop-blur-sm">
|
||||
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-2 px-4 py-2.5 text-xs md:px-8">
|
||||
<span className="font-semibold uppercase tracking-wider text-[var(--color-primary)]">
|
||||
Guest services
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-3 font-medium text-[var(--color-muted)]">
|
||||
<Link href="/guest" className="transition hover:text-[var(--color-accent)]">
|
||||
Hub
|
||||
</Link>
|
||||
<Link href="/guest/room-service" className="transition hover:text-[var(--color-accent)]">
|
||||
Room service
|
||||
</Link>
|
||||
<Link href="/guest/laundry" className="transition hover:text-[var(--color-accent)]">
|
||||
Laundry
|
||||
</Link>
|
||||
<Link href="/guest/gym" className="transition hover:text-[var(--color-accent)]">
|
||||
Gym
|
||||
</Link>
|
||||
<Link href="/guest/spa" className="transition hover:text-[var(--color-accent)]">
|
||||
Spa
|
||||
</Link>
|
||||
<Link href="/guest/profile" className="transition hover:text-[var(--color-accent)]">
|
||||
Profile
|
||||
</Link>
|
||||
<Link href="/guest/login" className="transition hover:text-[var(--color-accent)]">
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
|
||||
type Tab = "otp" | "password" | "social" | "booking";
|
||||
|
|
@ -10,7 +10,7 @@ type Tab = "otp" | "password" | "social" | "booking";
|
|||
export function LoginPageClient() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const nextPath = searchParams.get("next") || "/profile";
|
||||
const nextPath = searchParams.get("next") || "/guest/profile";
|
||||
|
||||
const {
|
||||
requestOtp,
|
||||
|
|
@ -26,9 +26,18 @@ export function LoginPageClient() {
|
|||
const [otpStep, setOtpStep] = useState<1 | 2>(1);
|
||||
const [password, setPassword] = useState("");
|
||||
const [bookingRef, setBookingRef] = useState("");
|
||||
const [bookingLastName, setBookingLastName] = useState("");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const ref = searchParams.get("ref");
|
||||
const ln = searchParams.get("lastName");
|
||||
if (ref) setBookingRef(ref);
|
||||
if (ln) setBookingLastName(ln);
|
||||
if (ref || ln) setTab("booking");
|
||||
}, [searchParams]);
|
||||
|
||||
async function handleSendOtp(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
|
@ -67,7 +76,7 @@ export function LoginPageClient() {
|
|||
function handleBookingRef(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
const r = loginBookingRef(bookingRef);
|
||||
const r = loginBookingRef(bookingRef, bookingLastName || undefined);
|
||||
setMessage(r.message);
|
||||
if (r.ok) router.push(nextPath);
|
||||
}
|
||||
|
|
@ -87,6 +96,10 @@ export function LoginPageClient() {
|
|||
Home
|
||||
</Link>
|
||||
<span className="mx-2 opacity-50">/</span>
|
||||
<Link href="/guest" className="hover:text-[var(--color-accent)]">
|
||||
Guest
|
||||
</Link>
|
||||
<span className="mx-2 opacity-50">/</span>
|
||||
<span className="text-[var(--color-text)]">Sign in</span>
|
||||
</nav>
|
||||
<h1 className="mt-4 font-heading text-3xl font-semibold text-[var(--color-text)] md:text-4xl">
|
||||
|
|
@ -259,18 +272,34 @@ export function LoginPageClient() {
|
|||
{tab === "booking" && (
|
||||
<form onSubmit={handleBookingRef} className="space-y-4">
|
||||
<label className="block text-sm font-medium text-[var(--color-text)]">
|
||||
Booking / confirmation reference
|
||||
Confirmation / hold / booking reference
|
||||
<input
|
||||
type="text"
|
||||
value={bookingRef}
|
||||
onChange={(e) => setBookingRef(e.target.value)}
|
||||
className="mt-1.5 w-full rounded-xl border border-[var(--color-border)] px-4 py-3 font-mono text-sm uppercase focus:border-[var(--color-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20"
|
||||
placeholder="SHITAYE-2026-DEMO"
|
||||
placeholder="PAY-… · SHY-… · SHITAYE-2026-DEMO"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-[var(--color-text)]">
|
||||
Last name{" "}
|
||||
<span className="font-normal text-[var(--color-muted)]">
|
||||
(required for PAY-* / SHY-* refs)
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={bookingLastName}
|
||||
onChange={(e) => setBookingLastName(e.target.value)}
|
||||
className="mt-1.5 w-full rounded-xl border border-[var(--color-border)] px-4 py-3 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20"
|
||||
placeholder="Demo"
|
||||
autoComplete="family-name"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-[var(--color-muted)]">
|
||||
Try <strong>SHITAYE-2026-DEMO</strong> or <strong>GUEST-1234</strong> — no email
|
||||
required. You can place orders and view a limited stay profile.
|
||||
<strong>Legacy codes</strong> (no last name): SHITAYE-2026-DEMO, GUEST-1234.
|
||||
<br />
|
||||
<strong>Mock payment / hold:</strong> PAY-MOCK-CONFIRMED + last name{" "}
|
||||
<strong>Demo</strong> · SHY-MOCK-HOLD + last name <strong>Hold</strong>.
|
||||
</p>
|
||||
<button type="submit" className="btn-mustard w-full justify-center py-3 text-sm">
|
||||
Continue with booking ID
|
||||
|
|
@ -280,7 +309,7 @@ export function LoginPageClient() {
|
|||
|
||||
{message ? (
|
||||
<p
|
||||
className={`mt-4 text-sm ${message.includes("not") || message.includes("Invalid") || message.includes("Incorrect") ? "text-red-700" : "text-[var(--color-primary)]"}`}
|
||||
className={`mt-4 text-sm ${message.includes("not") || message.includes("Invalid") || message.includes("Incorrect") || message.includes("do not match") ? "text-red-700" : "text-[var(--color-primary)]"}`}
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
23
src/app/guest/login/page.tsx
Normal file
23
src/app/guest/login/page.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { Suspense } from "react";
|
||||
import { ShitayeLogoLoader } from "@/components/ShitayeLogoLoader";
|
||||
import { LoginPageClient } from "./LoginPageClient";
|
||||
|
||||
export const metadata = {
|
||||
title: "Sign in",
|
||||
description:
|
||||
"Guest sign-in — OTP, password, social (mock), or booking reference at Shitaye Suite Hotel.",
|
||||
};
|
||||
|
||||
export default function GuestLoginPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-[50vh] items-center justify-center bg-[var(--color-bg)]">
|
||||
<ShitayeLogoLoader label="Loading sign-in…" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LoginPageClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
@ -60,11 +60,11 @@ export default function GuestHubPage() {
|
|||
orders on your profile.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<Link href="/login" className="btn-mustard px-6 py-3 text-sm">
|
||||
<Link href="/guest/login" className="btn-mustard px-6 py-3 text-sm">
|
||||
Sign in
|
||||
</Link>
|
||||
<Link
|
||||
href="/profile"
|
||||
href="/guest/profile"
|
||||
className="inline-flex items-center rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-6 py-3 text-sm font-semibold text-[var(--color-text)] transition hover:border-[var(--color-accent)]"
|
||||
>
|
||||
My stay profile
|
||||
|
|
|
|||
251
src/app/guest/profile/ProfilePageClient.tsx
Normal file
251
src/app/guest/profile/ProfilePageClient.tsx
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { RequireAuth } from "@/components/RequireAuth";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import type { OrderCategory, OrderRecord } from "@/lib/guest/types";
|
||||
import {
|
||||
seedAppointments,
|
||||
seedRewardsHistory,
|
||||
seedShuttle,
|
||||
} from "@/lib/mocks/guestData";
|
||||
import { siteConfig } from "@/lib/mocks/site";
|
||||
|
||||
const orderTabs: { id: OrderCategory | "all"; label: string }[] = [
|
||||
{ id: "all", label: "All" },
|
||||
{ id: "room-service", label: "Room service" },
|
||||
{ id: "laundry", label: "Laundry" },
|
||||
{ id: "gym", label: "Gym" },
|
||||
{ id: "spa", label: "Spa" },
|
||||
];
|
||||
|
||||
function formatWhen(iso: string) {
|
||||
try {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function OrderRow({ o }: { o: OrderRecord }) {
|
||||
return (
|
||||
<li className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-4 py-3 text-sm">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="font-semibold text-[var(--color-text)]">{o.title}</p>
|
||||
<p className="mt-0.5 text-xs text-[var(--color-muted)]">{o.detail}</p>
|
||||
<p className="mt-1 text-xs text-[var(--color-muted)]">{formatWhen(o.placedAt)}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-[var(--color-primary)]">
|
||||
{o.status}
|
||||
</span>
|
||||
<p className="font-semibold text-[var(--color-text)]">${o.totalUsd.toFixed(0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfilePageClient() {
|
||||
return (
|
||||
<RequireAuth redirectTo="/guest/login">
|
||||
<ProfileContent />
|
||||
</RequireAuth>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileContent() {
|
||||
const { session, orders, logout } = useAuth();
|
||||
const [orderFilter, setOrderFilter] = useState<OrderCategory | "all">("all");
|
||||
|
||||
const filteredOrders = useMemo(() => {
|
||||
if (orderFilter === "all") return orders;
|
||||
return orders.filter((o) => o.category === orderFilter);
|
||||
}, [orders, orderFilter]);
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--color-bg)] pb-20 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>
|
||||
<span className="mx-2 opacity-50">/</span>
|
||||
<Link href="/guest" className="hover:text-[var(--color-accent)]">
|
||||
Guest
|
||||
</Link>
|
||||
<span className="mx-2 opacity-50">/</span>
|
||||
<span className="text-[var(--color-text)]">My stay</span>
|
||||
</nav>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-4 border-b border-[var(--color-border)] pb-8 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">
|
||||
{session.kind === "member"
|
||||
? `Hello, ${session.displayName}`
|
||||
: `Welcome, ${session.guestName}`}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-[var(--color-muted)]">
|
||||
{session.kind === "member" ? (
|
||||
<>
|
||||
<span className="font-medium text-[var(--color-text)]">{session.email}</span>
|
||||
{" · "}
|
||||
Signed in via {session.authMethod}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Booking <span className="font-mono font-semibold">{session.bookingRef}</span>
|
||||
{" · "}
|
||||
{session.roomLabel} · checkout {session.checkOut}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href="/guest" className="btn-mustard px-5 py-2.5 text-sm">
|
||||
Guest hub
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => logout()}
|
||||
className="rounded-full border border-[var(--color-border)] px-5 py-2.5 text-sm font-semibold text-[var(--color-muted)] transition hover:bg-[var(--color-surface-muted)]"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid gap-6 lg:grid-cols-3">
|
||||
<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)]">
|
||||
Rewards points
|
||||
</p>
|
||||
{session.kind === "member" ? (
|
||||
<>
|
||||
<p className="mt-3 font-heading text-4xl font-semibold text-[var(--color-text)]">
|
||||
{session.points.toLocaleString()}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[var(--color-muted)]">
|
||||
{session.tier} tier · earn on stays & dining
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-3 text-sm leading-relaxed text-[var(--color-muted)]">
|
||||
Full loyalty points unlock when you sign in with email. Booking-ID access covers
|
||||
orders and stay tools.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm lg:col-span-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-primary)]">
|
||||
Airport shuttle
|
||||
</p>
|
||||
<p className="mt-2 font-heading text-xl text-[var(--color-text)]">
|
||||
Lobby pickup · {seedShuttle.lobbyPickupTime}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--color-muted)]">
|
||||
{new Date(seedShuttle.departureDate).toLocaleDateString(undefined, {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}{" "}
|
||||
· {seedShuttle.airport}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--color-text)]">
|
||||
{seedShuttle.flightLabel}
|
||||
</p>
|
||||
<p className="mt-3 text-xs text-[var(--color-muted)]">{seedShuttle.notes}</p>
|
||||
<a
|
||||
href={`mailto:${siteConfig.email}?subject=Shuttle%20change`}
|
||||
className="mt-4 inline-block text-sm font-semibold text-[var(--color-accent)] hover:underline"
|
||||
>
|
||||
Request a change
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="mt-12">
|
||||
<h2 className="font-heading text-2xl text-[var(--color-text)]">Booked appointments</h2>
|
||||
<ul className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{seedAppointments.map((a) => (
|
||||
<li
|
||||
key={a.id}
|
||||
className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-sm"
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[var(--color-primary)]">
|
||||
{a.status}
|
||||
</p>
|
||||
<p className="mt-2 font-semibold text-[var(--color-text)]">{a.title}</p>
|
||||
<p className="mt-1 text-sm text-[var(--color-muted)]">{a.when}</p>
|
||||
<p className="mt-1 text-sm text-[var(--color-muted)]">{a.where}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mt-12">
|
||||
<h2 className="font-heading text-2xl text-[var(--color-text)]">Orders</h2>
|
||||
<p className="mt-1 text-sm text-[var(--color-muted)]">
|
||||
Room service, laundry, gym, and spa — including demo history and new orders from this
|
||||
device.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{orderTabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setOrderFilter(t.id)}
|
||||
className={`rounded-full border px-4 py-2 text-xs font-semibold transition md:text-sm ${
|
||||
orderFilter === t.id
|
||||
? "border-[var(--color-primary)] bg-[var(--color-primary)] text-[var(--color-on-primary)]"
|
||||
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)] hover:bg-[var(--color-surface-muted)]"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{filteredOrders.length === 0 ? (
|
||||
<p className="mt-6 rounded-xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-muted)] px-4 py-10 text-center text-sm text-[var(--color-muted)]">
|
||||
No orders in this category yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-6 space-y-3">
|
||||
{filteredOrders.map((o) => (
|
||||
<OrderRow key={o.id} o={o} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="mt-12">
|
||||
<h2 className="font-heading text-2xl text-[var(--color-text)]">Rewards earned</h2>
|
||||
<ul className="mt-4 space-y-2">
|
||||
{seedRewardsHistory.map((r) => (
|
||||
<li
|
||||
key={r.id}
|
||||
className="flex items-center justify-between rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-sm"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-[var(--color-text)]">{r.label}</p>
|
||||
<p className="text-xs text-[var(--color-muted)]">{r.earnedAt}</p>
|
||||
</div>
|
||||
<span className="badge-mustard">+{r.points} pts</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
src/app/guest/profile/page.tsx
Normal file
10
src/app/guest/profile/page.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { ProfilePageClient } from "./ProfilePageClient";
|
||||
|
||||
export const metadata = {
|
||||
title: "My stay",
|
||||
description: "Guest profile — rewards, shuttle, appointments, and orders at Shitaye Suite Hotel.",
|
||||
};
|
||||
|
||||
export default function GuestProfilePage() {
|
||||
return <ProfilePageClient />;
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ import {
|
|||
|
||||
export function RoomServiceClient() {
|
||||
return (
|
||||
<RequireAuth redirectTo="/login">
|
||||
<RequireAuth redirectTo="/guest/login">
|
||||
<RoomServiceInner />
|
||||
</RequireAuth>
|
||||
);
|
||||
|
|
@ -95,7 +95,7 @@ function RoomServiceInner() {
|
|||
apply.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/profile" className="text-sm font-semibold text-[var(--color-accent)] hover:underline">
|
||||
<Link href="/guest/profile" className="text-sm font-semibold text-[var(--color-accent)] hover:underline">
|
||||
View profile →
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
import { redirect } from "next/navigation";
|
||||
import { GuestSpaGymBookingClient } from "../GuestSpaGymBookingClient";
|
||||
|
||||
export const metadata = {
|
||||
title: "Spa",
|
||||
title: "Spa bookings",
|
||||
description: "Book spa treatments during your stay — Shitaye Suite Hotel.",
|
||||
};
|
||||
|
||||
export default function GuestSpaRedirectPage() {
|
||||
redirect("/services?kind=spa");
|
||||
export default function GuestSpaPage() {
|
||||
return (
|
||||
<GuestSpaGymBookingClient
|
||||
kind="spa"
|
||||
title="Spa bookings"
|
||||
description="Massages and rituals — aligned with the shared spa catalog on /services."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,17 @@
|
|||
import { Suspense } from "react";
|
||||
import { ShitayeLogoLoader } from "@/components/ShitayeLogoLoader";
|
||||
import { LoginPageClient } from "./LoginPageClient";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata = {
|
||||
title: "Sign in",
|
||||
description: "Sign in to Shitaye Suite Hotel guest portal — OTP, password, social, or booking ID.",
|
||||
type Props = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-[50vh] items-center justify-center bg-[var(--color-bg)]">
|
||||
<ShitayeLogoLoader label="Loading sign-in…" />
|
||||
</div>
|
||||
/** @deprecated Use `/guest/login` */
|
||||
export default async function LegacyLoginRedirect({ searchParams }: Props) {
|
||||
const s = await searchParams;
|
||||
const q = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(s)) {
|
||||
if (typeof v === "string") q.set(k, v);
|
||||
else if (Array.isArray(v)) v.forEach((x) => q.append(k, x));
|
||||
}
|
||||
>
|
||||
<LoginPageClient />
|
||||
</Suspense>
|
||||
);
|
||||
const qs = q.toString();
|
||||
redirect(qs ? `/guest/login?${qs}` : "/guest/login");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,247 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { RequireAuth } from "@/components/RequireAuth";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import type { OrderCategory, OrderRecord } from "@/context/AuthContext";
|
||||
import {
|
||||
seedAppointments,
|
||||
seedRewardsHistory,
|
||||
seedShuttle,
|
||||
} from "@/lib/mocks/guestData";
|
||||
import { siteConfig } from "@/lib/mocks/site";
|
||||
|
||||
const orderTabs: { id: OrderCategory | "all"; label: string }[] = [
|
||||
{ id: "all", label: "All" },
|
||||
{ id: "room-service", label: "Room service" },
|
||||
{ id: "laundry", label: "Laundry" },
|
||||
{ id: "gym", label: "Gym" },
|
||||
{ id: "spa", label: "Spa" },
|
||||
];
|
||||
|
||||
function formatWhen(iso: string) {
|
||||
try {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function OrderRow({ o }: { o: OrderRecord }) {
|
||||
return (
|
||||
<li className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-4 py-3 text-sm">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="font-semibold text-[var(--color-text)]">{o.title}</p>
|
||||
<p className="mt-0.5 text-xs text-[var(--color-muted)]">{o.detail}</p>
|
||||
<p className="mt-1 text-xs text-[var(--color-muted)]">{formatWhen(o.placedAt)}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-[var(--color-primary)]">
|
||||
{o.status}
|
||||
</span>
|
||||
<p className="font-semibold text-[var(--color-text)]">${o.totalUsd.toFixed(0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfilePageClient() {
|
||||
return (
|
||||
<RequireAuth>
|
||||
<ProfileContent />
|
||||
</RequireAuth>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileContent() {
|
||||
const { session, orders, logout } = useAuth();
|
||||
const [orderFilter, setOrderFilter] = useState<OrderCategory | "all">("all");
|
||||
|
||||
const filteredOrders = useMemo(() => {
|
||||
if (orderFilter === "all") return orders;
|
||||
return orders.filter((o) => o.category === orderFilter);
|
||||
}, [orders, orderFilter]);
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--color-bg)] pb-20 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>
|
||||
<span className="mx-2 opacity-50">/</span>
|
||||
<span className="text-[var(--color-text)]">My stay</span>
|
||||
</nav>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-4 border-b border-[var(--color-border)] pb-8 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">
|
||||
{session.kind === "member"
|
||||
? `Hello, ${session.displayName}`
|
||||
: `Welcome, ${session.guestName}`}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-[var(--color-muted)]">
|
||||
{session.kind === "member" ? (
|
||||
<>
|
||||
<span className="font-medium text-[var(--color-text)]">{session.email}</span>
|
||||
{" · "}
|
||||
Signed in via {session.authMethod}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Booking <span className="font-mono font-semibold">{session.bookingRef}</span>
|
||||
{" · "}
|
||||
{session.roomLabel} · checkout {session.checkOut}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href="/guest" className="btn-mustard px-5 py-2.5 text-sm">
|
||||
Guest hub
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => logout()}
|
||||
className="rounded-full border border-[var(--color-border)] px-5 py-2.5 text-sm font-semibold text-[var(--color-muted)] transition hover:bg-[var(--color-surface-muted)]"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid gap-6 lg:grid-cols-3">
|
||||
<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)]">
|
||||
Rewards points
|
||||
</p>
|
||||
{session.kind === "member" ? (
|
||||
<>
|
||||
<p className="mt-3 font-heading text-4xl font-semibold text-[var(--color-text)]">
|
||||
{session.points.toLocaleString()}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[var(--color-muted)]">
|
||||
{session.tier} tier · earn on stays & dining
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-3 text-sm leading-relaxed text-[var(--color-muted)]">
|
||||
Full loyalty points unlock when you sign in with email. Booking-ID access covers
|
||||
orders and stay tools.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm lg:col-span-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-primary)]">
|
||||
Airport shuttle
|
||||
</p>
|
||||
<p className="mt-2 font-heading text-xl text-[var(--color-text)]">
|
||||
Lobby pickup · {seedShuttle.lobbyPickupTime}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--color-muted)]">
|
||||
{new Date(seedShuttle.departureDate).toLocaleDateString(undefined, {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}{" "}
|
||||
· {seedShuttle.airport}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--color-text)]">
|
||||
{seedShuttle.flightLabel}
|
||||
</p>
|
||||
<p className="mt-3 text-xs text-[var(--color-muted)]">{seedShuttle.notes}</p>
|
||||
<a
|
||||
href={`mailto:${siteConfig.email}?subject=Shuttle%20change`}
|
||||
className="mt-4 inline-block text-sm font-semibold text-[var(--color-accent)] hover:underline"
|
||||
>
|
||||
Request a change
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="mt-12">
|
||||
<h2 className="font-heading text-2xl text-[var(--color-text)]">Booked appointments</h2>
|
||||
<ul className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{seedAppointments.map((a) => (
|
||||
<li
|
||||
key={a.id}
|
||||
className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-sm"
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[var(--color-primary)]">
|
||||
{a.status}
|
||||
</p>
|
||||
<p className="mt-2 font-semibold text-[var(--color-text)]">{a.title}</p>
|
||||
<p className="mt-1 text-sm text-[var(--color-muted)]">{a.when}</p>
|
||||
<p className="mt-1 text-sm text-[var(--color-muted)]">{a.where}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mt-12">
|
||||
<h2 className="font-heading text-2xl text-[var(--color-text)]">Orders</h2>
|
||||
<p className="mt-1 text-sm text-[var(--color-muted)]">
|
||||
Room service, laundry, gym, and spa — including demo history and new orders from this
|
||||
device.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{orderTabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setOrderFilter(t.id)}
|
||||
className={`rounded-full border px-4 py-2 text-xs font-semibold transition md:text-sm ${
|
||||
orderFilter === t.id
|
||||
? "border-[var(--color-primary)] bg-[var(--color-primary)] text-[var(--color-on-primary)]"
|
||||
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)] hover:bg-[var(--color-surface-muted)]"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{filteredOrders.length === 0 ? (
|
||||
<p className="mt-6 rounded-xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-muted)] px-4 py-10 text-center text-sm text-[var(--color-muted)]">
|
||||
No orders in this category yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-6 space-y-3">
|
||||
{filteredOrders.map((o) => (
|
||||
<OrderRow key={o.id} o={o} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="mt-12">
|
||||
<h2 className="font-heading text-2xl text-[var(--color-text)]">Rewards earned</h2>
|
||||
<ul className="mt-4 space-y-2">
|
||||
{seedRewardsHistory.map((r) => (
|
||||
<li
|
||||
key={r.id}
|
||||
className="flex items-center justify-between rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-sm"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-[var(--color-text)]">{r.label}</p>
|
||||
<p className="text-xs text-[var(--color-muted)]">{r.earnedAt}</p>
|
||||
</div>
|
||||
<span className="badge-mustard">+{r.points} pts</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,6 @@
|
|||
import { ProfilePageClient } from "./ProfilePageClient";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata = {
|
||||
title: "My stay",
|
||||
description: "Profile, rewards, appointments, shuttle, and orders at Shitaye Suite Hotel.",
|
||||
};
|
||||
|
||||
export default function ProfilePage() {
|
||||
return <ProfilePageClient />;
|
||||
/** @deprecated Use `/guest/profile` */
|
||||
export default function LegacyProfileRedirect() {
|
||||
redirect("/guest/profile");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { AuthProvider } from "@/context/AuthContext";
|
||||
import { GuestSessionProvider } from "@/context/GuestSessionContext";
|
||||
import { BookingProvider } from "@/context/BookingContext";
|
||||
import { CurrencyProvider } from "@/context/CurrencyContext";
|
||||
import type { ReactNode } from "react";
|
||||
|
|
@ -8,9 +8,9 @@ import type { ReactNode } from "react";
|
|||
export function Providers({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<CurrencyProvider>
|
||||
<AuthProvider>
|
||||
<GuestSessionProvider>
|
||||
<BookingProvider>{children}</BookingProvider>
|
||||
</AuthProvider>
|
||||
</GuestSessionProvider>
|
||||
</CurrencyProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,13 @@ export default function ReserveHeldPage() {
|
|||
Complete payment
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={`/guest/login?ref=${encodeURIComponent(holdReference)}&lastName=${encodeURIComponent(guest.lastName)}`}
|
||||
className="mt-4 flex w-full items-center justify-center rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] py-3.5 text-sm font-semibold text-[var(--color-text)] transition hover:border-[var(--color-accent)] hover:text-[var(--color-accent)]"
|
||||
>
|
||||
Open guest portal
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
onClick={() => resetBooking()}
|
||||
|
|
|
|||
|
|
@ -151,12 +151,12 @@ export function Footer() {
|
|||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/login" className="text-stone-200 hover:text-white">
|
||||
<Link href="/guest/login" className="text-stone-200 hover:text-white">
|
||||
Sign in
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/profile" className="text-stone-200 hover:text-white">
|
||||
<Link href="/guest/profile" className="text-stone-200 hover:text-white">
|
||||
My stay
|
||||
</Link>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,11 @@
|
|||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { HeaderAccount } from "@/components/HeaderAccount";
|
||||
import { HeaderNav } from "@/components/HeaderNav";
|
||||
import { CurrencySwitcher } from "@/components/CurrencySwitcher";
|
||||
import { ReviewsMenu } from "@/components/ReviewsMenu";
|
||||
import { siteConfig } from "@/lib/mocks/site";
|
||||
|
||||
const nav = [
|
||||
{ href: "/#rooms", label: "Rooms" },
|
||||
{ href: "/guest", label: "Guest hub" },
|
||||
{ href: "/services", label: "Services" },
|
||||
{ href: "/#wellness", label: "Gym & Spa" },
|
||||
{ href: "/#dining", label: "Dining & venues" },
|
||||
{ href: "/#meetings", label: "Meetings" },
|
||||
{ href: "/#location", label: "Location" },
|
||||
];
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<header className="sticky top-0 z-40">
|
||||
|
|
@ -47,29 +38,11 @@ export function Header() {
|
|||
className="h-9 w-auto shrink-0 sm:h-10 md:h-11"
|
||||
priority
|
||||
/>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="font-nav text-lg tracking-tight text-[var(--color-primary)] sm:text-xl md:text-2xl">
|
||||
{siteConfig.name}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium uppercase tracking-[0.2em] text-[var(--color-muted)] sm:text-[11px]">
|
||||
{siteConfig.city}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
<nav
|
||||
className="hidden items-center gap-6 text-sm font-medium text-[var(--color-text)] lg:flex"
|
||||
aria-label="Main"
|
||||
>
|
||||
{nav.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="transition-colors hover:text-[var(--color-accent)]"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<HeaderNav />
|
||||
<div className="flex shrink-0 items-center gap-2 md:gap-3">
|
||||
<HeaderAccount />
|
||||
<Link
|
||||
|
|
|
|||
|
|
@ -23,24 +23,18 @@ export function HeaderAccount() {
|
|||
return (
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<Link
|
||||
href="/profile"
|
||||
href="/guest/profile"
|
||||
className="hidden max-w-[140px] truncate rounded-full border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3 py-1.5 text-xs font-semibold text-[var(--color-primary)] sm:inline-block"
|
||||
title="Loyalty points"
|
||||
>
|
||||
{points !== "—" ? `${points} pts` : "Stay"}
|
||||
</Link>
|
||||
<Link
|
||||
href="/profile"
|
||||
href="/guest/profile"
|
||||
className="rounded-full border border-[var(--color-border)] px-3 py-2 text-xs font-semibold text-[var(--color-text)] transition hover:border-[var(--color-accent)] hover:text-[var(--color-accent)] sm:px-4 sm:text-sm"
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
<Link
|
||||
href="/guest"
|
||||
className="hidden text-xs font-medium text-[var(--color-muted)] hover:text-[var(--color-accent)] lg:inline"
|
||||
>
|
||||
Guest hub
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -48,7 +42,7 @@ export function HeaderAccount() {
|
|||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/login"
|
||||
href="/guest/login"
|
||||
className="rounded-full px-3 py-2 text-xs font-semibold text-[var(--color-text)] transition hover:text-[var(--color-accent)] sm:text-sm"
|
||||
>
|
||||
Sign in
|
||||
|
|
|
|||
304
src/components/HeaderNav.tsx
Normal file
304
src/components/HeaderNav.tsx
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
|
||||
/** Desktop logged-out: same as before (no city line in header; full list in mobile drawer). */
|
||||
const guestNavDesktop = [
|
||||
{ href: "/#rooms", label: "Rooms" },
|
||||
{ href: "/guest", label: "Guest hub" },
|
||||
{ href: "/services", label: "Services" },
|
||||
{ href: "/#wellness", label: "Gym & Spa" },
|
||||
{ href: "/#dining", label: "Dining & venues" },
|
||||
{ href: "/#meetings", label: "Meetings" },
|
||||
{ href: "/#location", label: "Location" },
|
||||
];
|
||||
|
||||
const guestNavMobile = [
|
||||
{ href: "/", label: "Home" },
|
||||
...guestNavDesktop,
|
||||
{ href: "/#tour", label: "3D tour" },
|
||||
];
|
||||
|
||||
const servicesLinks = [
|
||||
{ href: "/guest", label: "Guest hub overview" },
|
||||
{ href: "/guest/room-service", label: "Room service" },
|
||||
{ href: "/guest/laundry", label: "Laundry" },
|
||||
{ href: "/guest/gym", label: "Gym" },
|
||||
{ href: "/guest/spa", label: "Spa" },
|
||||
{ href: "/services", label: "Spa & gym (services)" },
|
||||
];
|
||||
|
||||
const amenitiesLinks = [
|
||||
{ href: "/#wellness", label: "Gym & wellness" },
|
||||
{ href: "/#dining", label: "Dining & venues" },
|
||||
{ href: "/#meetings", label: "Meetings & events" },
|
||||
{ href: "/#location", label: "Location" },
|
||||
{ href: "/#tour", label: "3D tour" },
|
||||
];
|
||||
|
||||
function NavChevron() {
|
||||
return (
|
||||
<svg
|
||||
className="ml-0.5 h-3.5 w-3.5 shrink-0 opacity-60"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M3 4.5L6 7.5L9 4.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function NavDropdown({
|
||||
label,
|
||||
items,
|
||||
id,
|
||||
}: {
|
||||
label: string;
|
||||
items: { href: string; label: string }[];
|
||||
id: string;
|
||||
}) {
|
||||
return (
|
||||
<details className="group relative">
|
||||
<summary
|
||||
className="flex cursor-pointer list-none items-center gap-0.5 rounded-md px-1 py-1 text-sm font-medium text-[var(--color-text)] transition-colors hover:text-[var(--color-accent)] [&::-webkit-details-marker]:hidden"
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
{label}
|
||||
<NavChevron />
|
||||
</summary>
|
||||
<div
|
||||
id={id}
|
||||
role="menu"
|
||||
className="absolute left-0 top-full z-50 mt-1.5 min-w-[14rem] rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] py-1.5 shadow-lg ring-1 ring-black/5"
|
||||
>
|
||||
<ul className="max-h-[min(70vh,22rem)] overflow-y-auto py-0.5">
|
||||
{items.map((item) => (
|
||||
<li key={item.href + item.label}>
|
||||
<Link
|
||||
role="menuitem"
|
||||
href={item.href}
|
||||
className="block px-4 py-2.5 text-sm text-[var(--color-text)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-accent)]"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function HamburgerIcon() {
|
||||
return (
|
||||
<span className="relative block h-5 w-6" aria-hidden>
|
||||
<span className="absolute left-0 top-1 block h-0.5 w-6 rounded-full bg-current" />
|
||||
<span className="absolute left-0 top-[9px] block h-0.5 w-6 rounded-full bg-current" />
|
||||
<span className="absolute left-0 top-[17px] block h-0.5 w-6 rounded-full bg-current" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileNavDrawer({
|
||||
open,
|
||||
onClose,
|
||||
session,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
session: ReturnType<typeof useAuth>["session"];
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
onClose();
|
||||
}, [pathname, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] lg:hidden" role="dialog" aria-modal="true" aria-label="Site menu">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 bg-black/45 backdrop-blur-[2px]"
|
||||
aria-label="Close menu"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex w-[min(100%,20rem)] flex-col border-l border-[var(--color-border)] bg-[var(--color-surface)] shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-4 py-3">
|
||||
<span className="font-heading text-lg font-semibold text-[var(--color-text)]">Menu</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full p-2 text-[var(--color-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-text)]"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto overscroll-contain px-2 py-3" aria-label="Main mobile">
|
||||
{session ? (
|
||||
<>
|
||||
<Link
|
||||
href="/#rooms"
|
||||
onClick={onClose}
|
||||
className="block rounded-lg px-3 py-3 text-base font-semibold text-[var(--color-text)] hover:bg-[var(--color-surface-muted)]"
|
||||
>
|
||||
Rooms
|
||||
</Link>
|
||||
<p className="mt-2 px-3 pb-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-[var(--color-muted)]">
|
||||
Services
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{servicesLinks.map((item) => (
|
||||
<li key={item.href + item.label}>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={onClose}
|
||||
className="block rounded-lg px-3 py-2.5 text-sm text-[var(--color-text)] hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-accent)]"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-4 px-3 pb-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-[var(--color-muted)]">
|
||||
Amenities
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{amenitiesLinks.map((item) => (
|
||||
<li key={item.href + item.label}>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={onClose}
|
||||
className="block rounded-lg px-3 py-2.5 text-sm text-[var(--color-text)] hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-accent)]"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{guestNavMobile.map((item) => (
|
||||
<li key={item.href + item.label}>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={onClose}
|
||||
className="block rounded-lg px-3 py-2.5 text-sm font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-accent)]"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeaderNav() {
|
||||
const { session, isHydrated } = useAuth();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
const closeMobile = useCallback(() => setMobileOpen(false), []);
|
||||
|
||||
if (!isHydrated) {
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
className="hidden items-center gap-6 text-sm font-medium text-[var(--color-text)] lg:flex"
|
||||
aria-label="Main"
|
||||
aria-hidden
|
||||
>
|
||||
<span className="h-4 w-48 animate-pulse rounded bg-[var(--color-surface-muted)]" />
|
||||
</nav>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center lg:hidden" aria-hidden />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop */}
|
||||
{session ? (
|
||||
<nav
|
||||
className="hidden items-center gap-5 text-sm font-medium text-[var(--color-text)] xl:gap-6 lg:flex"
|
||||
aria-label="Main"
|
||||
>
|
||||
<Link href="/#rooms" className="transition-colors hover:text-[var(--color-accent)]">
|
||||
Rooms
|
||||
</Link>
|
||||
<NavDropdown label="Services" items={servicesLinks} id="nav-services-menu" />
|
||||
<NavDropdown label="Amenities" items={amenitiesLinks} id="nav-amenities-menu" />
|
||||
</nav>
|
||||
) : (
|
||||
<nav
|
||||
className="hidden items-center gap-6 text-sm font-medium text-[var(--color-text)] lg:flex"
|
||||
aria-label="Main"
|
||||
>
|
||||
{guestNavDesktop.map((item) => (
|
||||
<Link
|
||||
key={item.href + item.label}
|
||||
href={item.href}
|
||||
className="transition-colors hover:text-[var(--color-accent)]"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Mobile hamburger */}
|
||||
<div className="flex shrink-0 items-center lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full text-[var(--color-text)] transition hover:bg-[var(--color-surface-muted)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-primary)]"
|
||||
aria-expanded={mobileOpen}
|
||||
aria-controls="mobile-nav-drawer"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<HamburgerIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="mobile-nav-drawer">
|
||||
<MobileNavDrawer open={mobileOpen} onClose={closeMobile} session={session} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ import { ShitayeLogoLoader } from "@/components/ShitayeLogoLoader";
|
|||
|
||||
type Props = { children: React.ReactNode; redirectTo?: string };
|
||||
|
||||
export function RequireAuth({ children, redirectTo = "/login" }: Props) {
|
||||
export function RequireAuth({ children, redirectTo = "/guest/login" }: Props) {
|
||||
const { session, isHydrated } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
|
|
@ -35,7 +35,10 @@ export function RequireAuth({ children, redirectTo = "/login" }: Props) {
|
|||
return (
|
||||
<div className="mx-auto max-w-lg px-4 py-20 text-center">
|
||||
<p className="text-sm text-[var(--color-muted)]">Redirecting to sign-in…</p>
|
||||
<Link href="/login" className="mt-4 inline-block text-sm font-semibold text-[var(--color-accent)]">
|
||||
<Link
|
||||
href="/guest/login"
|
||||
className="mt-4 inline-block text-sm font-semibold text-[var(--color-accent)]"
|
||||
>
|
||||
Continue manually
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,314 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { DEMO_BOOKING_REFS } from "@/lib/mocks/guestData";
|
||||
|
||||
const STORAGE_SESSION = "shitaye_session_v1";
|
||||
const STORAGE_ORDERS = "shitaye_orders_v1";
|
||||
|
||||
export type OrderCategory = "room-service" | "laundry" | "gym" | "spa";
|
||||
|
||||
export type OrderRecord = {
|
||||
id: string;
|
||||
category: OrderCategory;
|
||||
title: string;
|
||||
detail: string;
|
||||
totalUsd: number;
|
||||
placedAt: string;
|
||||
status: "pending" | "confirmed" | "completed";
|
||||
};
|
||||
|
||||
export type MemberSession = {
|
||||
kind: "member";
|
||||
email: string;
|
||||
displayName: string;
|
||||
points: number;
|
||||
tier: "Gold" | "Silver";
|
||||
/** How they signed in — for display only */
|
||||
authMethod: "otp" | "password" | "google" | "apple" | "facebook";
|
||||
};
|
||||
|
||||
export type BookingRefSession = {
|
||||
kind: "bookingRef";
|
||||
bookingRef: string;
|
||||
guestName: string;
|
||||
roomLabel: string;
|
||||
checkOut: string;
|
||||
};
|
||||
|
||||
export type GuestSession = MemberSession | BookingRefSession;
|
||||
|
||||
type AuthContextValue = {
|
||||
session: GuestSession | null;
|
||||
orders: OrderRecord[];
|
||||
isHydrated: boolean;
|
||||
/** Demo OTP is always 123456 */
|
||||
requestOtp: (email: string) => Promise<{ ok: boolean; message: string }>;
|
||||
verifyOtp: (email: string, code: string) => Promise<{ ok: boolean; message: string }>;
|
||||
loginPassword: (email: string, password: string) => Promise<{ ok: boolean; message: string }>;
|
||||
loginSocial: (provider: "google" | "apple" | "facebook") => void;
|
||||
loginBookingRef: (ref: string) => { ok: boolean; message: string };
|
||||
logout: () => void;
|
||||
addOrder: (o: Omit<OrderRecord, "id" | "placedAt" | "status"> & { status?: OrderRecord["status"] }) => void;
|
||||
awardPoints: (points: number) => void;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
function loadOrders(): OrderRecord[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_ORDERS);
|
||||
if (!raw) return seedOrders();
|
||||
const parsed = JSON.parse(raw) as OrderRecord[];
|
||||
return Array.isArray(parsed) ? parsed : seedOrders();
|
||||
} catch {
|
||||
return seedOrders();
|
||||
}
|
||||
}
|
||||
|
||||
function seedOrders(): OrderRecord[] {
|
||||
return [
|
||||
{
|
||||
id: "seed-rs-1",
|
||||
category: "room-service",
|
||||
title: "Room service · American breakfast ×2",
|
||||
detail: "Delivered 07:15 · Room charge",
|
||||
totalUsd: 36,
|
||||
placedAt: new Date(Date.now() - 86400000 * 2).toISOString(),
|
||||
status: "completed",
|
||||
},
|
||||
{
|
||||
id: "seed-l-1",
|
||||
category: "laundry",
|
||||
title: "Laundry · Express + 3 shirts",
|
||||
detail: "Returned same evening",
|
||||
totalUsd: 27,
|
||||
placedAt: new Date(Date.now() - 86400000).toISOString(),
|
||||
status: "completed",
|
||||
},
|
||||
{
|
||||
id: "seed-sp-1",
|
||||
category: "spa",
|
||||
title: "Spa · Signature Swedish 60 min",
|
||||
detail: "Apr 4 · 15:00",
|
||||
totalUsd: 85,
|
||||
placedAt: new Date(Date.now() - 86400000 * 3).toISOString(),
|
||||
status: "confirmed",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function loadSession(): GuestSession | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_SESSION);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as GuestSession;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function persistSession(s: GuestSession | null) {
|
||||
if (typeof window === "undefined") return;
|
||||
if (s) localStorage.setItem(STORAGE_SESSION, JSON.stringify(s));
|
||||
else localStorage.removeItem(STORAGE_SESSION);
|
||||
}
|
||||
|
||||
function persistOrders(orders: OrderRecord[]) {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(STORAGE_ORDERS, JSON.stringify(orders));
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [session, setSession] = useState<GuestSession | null>(null);
|
||||
const [orders, setOrders] = useState<OrderRecord[]>([]);
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSession(loadSession());
|
||||
setOrders(loadOrders());
|
||||
setIsHydrated(true);
|
||||
}, []);
|
||||
|
||||
const requestOtp = useCallback(async (email: string) => {
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return { ok: false, message: "Enter a valid email address." };
|
||||
}
|
||||
return { ok: true, message: "Demo code sent. Use OTP 123456 to continue." };
|
||||
}, []);
|
||||
|
||||
const verifyOtp = useCallback(async (email: string, code: string) => {
|
||||
const trimmed = code.replace(/\s/g, "");
|
||||
if (trimmed !== "123456") {
|
||||
return { ok: false, message: "Invalid code. Demo OTP is 123456." };
|
||||
}
|
||||
const local = email.split("@")[0] ?? "Guest";
|
||||
const name = local.charAt(0).toUpperCase() + local.slice(1);
|
||||
const next: MemberSession = {
|
||||
kind: "member",
|
||||
email: email.toLowerCase(),
|
||||
displayName: name,
|
||||
points: 2400,
|
||||
tier: "Gold",
|
||||
authMethod: "otp",
|
||||
};
|
||||
setSession(next);
|
||||
persistSession(next);
|
||||
return { ok: true, message: "Signed in." };
|
||||
}, []);
|
||||
|
||||
const loginPassword = useCallback(async (email: string, password: string) => {
|
||||
if (!email || !password) {
|
||||
return { ok: false, message: "Email and password required." };
|
||||
}
|
||||
if (password !== "shitaye" && password !== "demo123") {
|
||||
return {
|
||||
ok: false,
|
||||
message: "Incorrect password. Try demo password: shitaye",
|
||||
};
|
||||
}
|
||||
const local = email.split("@")[0] ?? "Guest";
|
||||
const name = local.charAt(0).toUpperCase() + local.slice(1);
|
||||
const next: MemberSession = {
|
||||
kind: "member",
|
||||
email: email.toLowerCase(),
|
||||
displayName: name,
|
||||
points: 2400,
|
||||
tier: "Gold",
|
||||
authMethod: "password",
|
||||
};
|
||||
setSession(next);
|
||||
persistSession(next);
|
||||
return { ok: true, message: "Signed in." };
|
||||
}, []);
|
||||
|
||||
const loginSocial = useCallback((provider: "google" | "apple" | "facebook") => {
|
||||
const names: Record<typeof provider, string> = {
|
||||
google: "Google Guest",
|
||||
apple: "Apple Guest",
|
||||
facebook: "Facebook Guest",
|
||||
};
|
||||
const next: MemberSession = {
|
||||
kind: "member",
|
||||
email: `guest.${provider}@shitaye.demo`,
|
||||
displayName: names[provider],
|
||||
points: 2100,
|
||||
tier: "Silver",
|
||||
authMethod: provider,
|
||||
};
|
||||
setSession(next);
|
||||
persistSession(next);
|
||||
}, []);
|
||||
|
||||
const loginBookingRef = useCallback((ref: string) => {
|
||||
const key = ref.trim().toUpperCase();
|
||||
const row = DEMO_BOOKING_REFS[key];
|
||||
if (!row) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "Reference not found. Try SHITAYE-2026-DEMO or GUEST-1234.",
|
||||
};
|
||||
}
|
||||
const next: BookingRefSession = {
|
||||
kind: "bookingRef",
|
||||
bookingRef: key,
|
||||
guestName: row.guestName,
|
||||
roomLabel: row.room,
|
||||
checkOut: row.checkOut,
|
||||
};
|
||||
setSession(next);
|
||||
persistSession(next);
|
||||
return { ok: true, message: "Linked to your stay." };
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
setSession(null);
|
||||
persistSession(null);
|
||||
}, []);
|
||||
|
||||
const addOrder = useCallback(
|
||||
(
|
||||
o: Omit<OrderRecord, "id" | "placedAt" | "status"> & {
|
||||
status?: OrderRecord["status"];
|
||||
},
|
||||
) => {
|
||||
const rec: OrderRecord = {
|
||||
...o,
|
||||
id: `ord-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
placedAt: new Date().toISOString(),
|
||||
status: o.status ?? "pending",
|
||||
};
|
||||
setOrders((prev) => {
|
||||
const next = [rec, ...prev];
|
||||
persistOrders(next);
|
||||
return next;
|
||||
});
|
||||
if (session?.kind === "member") {
|
||||
const bonus = Math.min(150, Math.round(o.totalUsd * 2));
|
||||
setSession((s) => {
|
||||
if (!s || s.kind !== "member") return s;
|
||||
const u = { ...s, points: s.points + bonus };
|
||||
persistSession(u);
|
||||
return u;
|
||||
});
|
||||
}
|
||||
},
|
||||
[session],
|
||||
);
|
||||
|
||||
const awardPoints = useCallback((points: number) => {
|
||||
setSession((s) => {
|
||||
if (!s || s.kind !== "member") return s;
|
||||
const u = { ...s, points: s.points + points };
|
||||
persistSession(u);
|
||||
return u;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
session,
|
||||
orders,
|
||||
isHydrated,
|
||||
requestOtp,
|
||||
verifyOtp,
|
||||
loginPassword,
|
||||
loginSocial,
|
||||
loginBookingRef,
|
||||
logout,
|
||||
addOrder,
|
||||
awardPoints,
|
||||
}),
|
||||
[
|
||||
session,
|
||||
orders,
|
||||
isHydrated,
|
||||
requestOtp,
|
||||
verifyOtp,
|
||||
loginPassword,
|
||||
loginSocial,
|
||||
loginBookingRef,
|
||||
logout,
|
||||
addOrder,
|
||||
awardPoints,
|
||||
],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
|
||||
return ctx;
|
||||
}
|
||||
/**
|
||||
* Back-compat re-exports — canonical implementation is GuestSessionContext.
|
||||
*/
|
||||
export {
|
||||
GuestSessionProvider,
|
||||
GuestSessionProvider as AuthProvider,
|
||||
useAuth,
|
||||
useGuestSession,
|
||||
} from "./GuestSessionContext";
|
||||
export type {
|
||||
BookingRefSession,
|
||||
GuestSession,
|
||||
MemberSession,
|
||||
OrderCategory,
|
||||
OrderRecord,
|
||||
} from "./GuestSessionContext";
|
||||
|
|
|
|||
269
src/context/GuestSessionContext.tsx
Normal file
269
src/context/GuestSessionContext.tsx
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
loadOrders as repoLoadOrders,
|
||||
loadSession as repoLoadSession,
|
||||
saveOrders as repoSaveOrders,
|
||||
saveSession as repoSaveSession,
|
||||
} from "@/lib/guest/repository";
|
||||
import type {
|
||||
BookingRefSession,
|
||||
GuestSession,
|
||||
MemberSession,
|
||||
OrderRecord,
|
||||
} from "@/lib/guest/types";
|
||||
import { DEMO_BOOKING_REFS } from "@/lib/mocks/guestData";
|
||||
import { lookupGuestBooking } from "@/lib/mocks/guestBookings";
|
||||
|
||||
export type {
|
||||
BookingRefSession,
|
||||
GuestSession,
|
||||
MemberSession,
|
||||
OrderCategory,
|
||||
OrderRecord,
|
||||
} from "@/lib/guest/types";
|
||||
|
||||
type GuestSessionContextValue = {
|
||||
session: GuestSession | null;
|
||||
orders: OrderRecord[];
|
||||
isHydrated: boolean;
|
||||
requestOtp: (email: string) => Promise<{ ok: boolean; message: string }>;
|
||||
verifyOtp: (email: string, code: string) => Promise<{ ok: boolean; message: string }>;
|
||||
loginPassword: (email: string, password: string) => Promise<{ ok: boolean; message: string }>;
|
||||
loginSocial: (provider: "google" | "apple" | "facebook") => void;
|
||||
loginBookingRef: (ref: string, lastName?: string) => { ok: boolean; message: string };
|
||||
logout: () => void;
|
||||
addOrder: (
|
||||
o: Omit<OrderRecord, "id" | "placedAt" | "status"> & { status?: OrderRecord["status"] },
|
||||
) => void;
|
||||
awardPoints: (points: number) => void;
|
||||
};
|
||||
|
||||
const GuestSessionContext = createContext<GuestSessionContextValue | null>(null);
|
||||
|
||||
export function GuestSessionProvider({ children }: { children: ReactNode }) {
|
||||
const [session, setSession] = useState<GuestSession | null>(null);
|
||||
const [orders, setOrders] = useState<OrderRecord[]>([]);
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSession(repoLoadSession());
|
||||
setOrders(repoLoadOrders());
|
||||
setIsHydrated(true);
|
||||
}, []);
|
||||
|
||||
const requestOtp = useCallback(async (email: string) => {
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return { ok: false, message: "Enter a valid email address." };
|
||||
}
|
||||
return { ok: true, message: "Demo code sent. Use OTP 123456 to continue." };
|
||||
}, []);
|
||||
|
||||
const verifyOtp = useCallback(async (email: string, code: string) => {
|
||||
const trimmed = code.replace(/\s/g, "");
|
||||
if (trimmed !== "123456") {
|
||||
return { ok: false, message: "Invalid code. Demo OTP is 123456." };
|
||||
}
|
||||
const local = email.split("@")[0] ?? "Guest";
|
||||
const name = local.charAt(0).toUpperCase() + local.slice(1);
|
||||
const next: MemberSession = {
|
||||
kind: "member",
|
||||
email: email.toLowerCase(),
|
||||
displayName: name,
|
||||
points: 2400,
|
||||
tier: "Gold",
|
||||
authMethod: "otp",
|
||||
};
|
||||
setSession(next);
|
||||
repoSaveSession(next);
|
||||
return { ok: true, message: "Signed in." };
|
||||
}, []);
|
||||
|
||||
const loginPassword = useCallback(async (email: string, password: string) => {
|
||||
if (!email || !password) {
|
||||
return { ok: false, message: "Email and password required." };
|
||||
}
|
||||
if (password !== "shitaye" && password !== "demo123") {
|
||||
return {
|
||||
ok: false,
|
||||
message: "Incorrect password. Try demo password: shitaye",
|
||||
};
|
||||
}
|
||||
const local = email.split("@")[0] ?? "Guest";
|
||||
const name = local.charAt(0).toUpperCase() + local.slice(1);
|
||||
const next: MemberSession = {
|
||||
kind: "member",
|
||||
email: email.toLowerCase(),
|
||||
displayName: name,
|
||||
points: 2400,
|
||||
tier: "Gold",
|
||||
authMethod: "password",
|
||||
};
|
||||
setSession(next);
|
||||
repoSaveSession(next);
|
||||
return { ok: true, message: "Signed in." };
|
||||
}, []);
|
||||
|
||||
const loginSocial = useCallback((provider: "google" | "apple" | "facebook") => {
|
||||
const names: Record<typeof provider, string> = {
|
||||
google: "Google Guest",
|
||||
apple: "Apple Guest",
|
||||
facebook: "Facebook Guest",
|
||||
};
|
||||
const next: MemberSession = {
|
||||
kind: "member",
|
||||
email: `guest.${provider}@shitaye.demo`,
|
||||
displayName: names[provider],
|
||||
points: 2100,
|
||||
tier: "Silver",
|
||||
authMethod: provider,
|
||||
};
|
||||
setSession(next);
|
||||
repoSaveSession(next);
|
||||
}, []);
|
||||
|
||||
const loginBookingRef = useCallback((refRaw: string, lastName?: string) => {
|
||||
const key = refRaw.trim().toUpperCase();
|
||||
|
||||
if (key.startsWith("PAY-") || key.startsWith("SHY-")) {
|
||||
if (!lastName?.trim()) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "Last name is required for confirmation / hold references.",
|
||||
};
|
||||
}
|
||||
const row = lookupGuestBooking(refRaw, lastName);
|
||||
if (!row) {
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
"Reference and last name do not match. Try PAY-MOCK-CONFIRMED + last name Demo, or SHY-MOCK-HOLD + Hold.",
|
||||
};
|
||||
}
|
||||
const next: BookingRefSession = {
|
||||
kind: "bookingRef",
|
||||
bookingRef: row.ref,
|
||||
guestName: row.guestName,
|
||||
roomLabel: row.roomLabel,
|
||||
checkOut: row.checkOut,
|
||||
};
|
||||
setSession(next);
|
||||
repoSaveSession(next);
|
||||
return { ok: true, message: "Linked to your stay." };
|
||||
}
|
||||
|
||||
const legacy = DEMO_BOOKING_REFS[key];
|
||||
if (!legacy) {
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
"Reference not found. Use SHITAYE-2026-DEMO, GUEST-1234, or PAY-MOCK-CONFIRMED with last name Demo.",
|
||||
};
|
||||
}
|
||||
const next: BookingRefSession = {
|
||||
kind: "bookingRef",
|
||||
bookingRef: key,
|
||||
guestName: legacy.guestName,
|
||||
roomLabel: legacy.room,
|
||||
checkOut: legacy.checkOut,
|
||||
};
|
||||
setSession(next);
|
||||
repoSaveSession(next);
|
||||
return { ok: true, message: "Linked to your stay." };
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
setSession(null);
|
||||
repoSaveSession(null);
|
||||
}, []);
|
||||
|
||||
const addOrder = useCallback(
|
||||
(
|
||||
o: Omit<OrderRecord, "id" | "placedAt" | "status"> & {
|
||||
status?: OrderRecord["status"];
|
||||
},
|
||||
) => {
|
||||
const rec: OrderRecord = {
|
||||
...o,
|
||||
id: `ord-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
placedAt: new Date().toISOString(),
|
||||
status: o.status ?? "pending",
|
||||
};
|
||||
setOrders((prev) => {
|
||||
const next = [rec, ...prev];
|
||||
repoSaveOrders(next);
|
||||
return next;
|
||||
});
|
||||
setSession((s) => {
|
||||
if (!s || s.kind !== "member") return s;
|
||||
const bonus = Math.min(150, Math.round(o.totalUsd * 2));
|
||||
const u = { ...s, points: s.points + bonus };
|
||||
repoSaveSession(u);
|
||||
return u;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const awardPoints = useCallback((points: number) => {
|
||||
setSession((s) => {
|
||||
if (!s || s.kind !== "member") return s;
|
||||
const u = { ...s, points: s.points + points };
|
||||
repoSaveSession(u);
|
||||
return u;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const value = useMemo<GuestSessionContextValue>(
|
||||
() => ({
|
||||
session,
|
||||
orders,
|
||||
isHydrated,
|
||||
requestOtp,
|
||||
verifyOtp,
|
||||
loginPassword,
|
||||
loginSocial,
|
||||
loginBookingRef,
|
||||
logout,
|
||||
addOrder,
|
||||
awardPoints,
|
||||
}),
|
||||
[
|
||||
session,
|
||||
orders,
|
||||
isHydrated,
|
||||
requestOtp,
|
||||
verifyOtp,
|
||||
loginPassword,
|
||||
loginSocial,
|
||||
loginBookingRef,
|
||||
logout,
|
||||
addOrder,
|
||||
awardPoints,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<GuestSessionContext.Provider value={value}>{children}</GuestSessionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useGuestSession() {
|
||||
const ctx = useContext(GuestSessionContext);
|
||||
if (!ctx) throw new Error("useGuestSession must be used within GuestSessionProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** @deprecated Prefer useGuestSession — alias kept for existing imports */
|
||||
export function useAuth() {
|
||||
return useGuestSession();
|
||||
}
|
||||
77
src/lib/guest/repository.ts
Normal file
77
src/lib/guest/repository.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* localStorage persistence for guest session and orders.
|
||||
* Replace with D1 / REST / Supabase behind the same interface in Phase 2.
|
||||
*/
|
||||
import type { GuestSession, OrderRecord } from "./types";
|
||||
|
||||
export const STORAGE_SESSION_KEY = "shitaye_session_v1";
|
||||
export const STORAGE_ORDERS_KEY = "shitaye_orders_v1";
|
||||
|
||||
export function loadSession(): GuestSession | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_SESSION_KEY);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as GuestSession;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSession(session: GuestSession | null): void {
|
||||
if (typeof window === "undefined") return;
|
||||
if (session) {
|
||||
localStorage.setItem(STORAGE_SESSION_KEY, JSON.stringify(session));
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_SESSION_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function loadOrders(): OrderRecord[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_ORDERS_KEY);
|
||||
if (!raw) return seedOrders();
|
||||
const parsed = JSON.parse(raw) as OrderRecord[];
|
||||
return Array.isArray(parsed) ? parsed : seedOrders();
|
||||
} catch {
|
||||
return seedOrders();
|
||||
}
|
||||
}
|
||||
|
||||
export function saveOrders(orders: OrderRecord[]): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(STORAGE_ORDERS_KEY, JSON.stringify(orders));
|
||||
}
|
||||
|
||||
function seedOrders(): OrderRecord[] {
|
||||
return [
|
||||
{
|
||||
id: "seed-rs-1",
|
||||
category: "room-service",
|
||||
title: "Room service · American breakfast ×2",
|
||||
detail: "Delivered 07:15 · Room charge",
|
||||
totalUsd: 36,
|
||||
placedAt: new Date(Date.now() - 86400000 * 2).toISOString(),
|
||||
status: "completed",
|
||||
},
|
||||
{
|
||||
id: "seed-l-1",
|
||||
category: "laundry",
|
||||
title: "Laundry · Express + 3 shirts",
|
||||
detail: "Returned same evening",
|
||||
totalUsd: 27,
|
||||
placedAt: new Date(Date.now() - 86400000).toISOString(),
|
||||
status: "completed",
|
||||
},
|
||||
{
|
||||
id: "seed-sp-1",
|
||||
category: "spa",
|
||||
title: "Spa · Signature Swedish 60 min",
|
||||
detail: "Apr 4 · 15:00",
|
||||
totalUsd: 85,
|
||||
placedAt: new Date(Date.now() - 86400000 * 3).toISOString(),
|
||||
status: "confirmed",
|
||||
},
|
||||
];
|
||||
}
|
||||
34
src/lib/guest/types.ts
Normal file
34
src/lib/guest/types.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/** Guest portal order and session types (Phase 1: client-only; swap repository for API later). */
|
||||
|
||||
export type OrderCategory = "room-service" | "laundry" | "gym" | "spa";
|
||||
|
||||
export type OrderRecord = {
|
||||
id: string;
|
||||
category: OrderCategory;
|
||||
title: string;
|
||||
detail: string;
|
||||
totalUsd: number;
|
||||
placedAt: string;
|
||||
status: "pending" | "confirmed" | "completed";
|
||||
};
|
||||
|
||||
/** Full account — email / OTP / password / social */
|
||||
export type MemberSession = {
|
||||
kind: "member";
|
||||
email: string;
|
||||
displayName: string;
|
||||
points: number;
|
||||
tier: "Gold" | "Silver";
|
||||
authMethod: "otp" | "password" | "google" | "apple" | "facebook";
|
||||
};
|
||||
|
||||
/** Limited scope — booking reference only */
|
||||
export type BookingRefSession = {
|
||||
kind: "bookingRef";
|
||||
bookingRef: string;
|
||||
guestName: string;
|
||||
roomLabel: string;
|
||||
checkOut: string;
|
||||
};
|
||||
|
||||
export type GuestSession = MemberSession | BookingRefSession;
|
||||
49
src/lib/mocks/guestBookings.ts
Normal file
49
src/lib/mocks/guestBookings.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* Mock allowlist for confirmation / hold references (PAY-*, SHY-*) + last name check.
|
||||
* Aligns with mock IDs from `src/lib/mocks/api.ts` style.
|
||||
*/
|
||||
export type GuestBookingRecord = {
|
||||
ref: string;
|
||||
/** Normalized uppercase last name for demo validation */
|
||||
lastNameKey: string;
|
||||
guestName: string;
|
||||
roomLabel: string;
|
||||
checkOut: string;
|
||||
};
|
||||
|
||||
export const guestBookingAllowlist: GuestBookingRecord[] = [
|
||||
{
|
||||
ref: "PAY-MOCK-CONFIRMED",
|
||||
lastNameKey: "DEMO",
|
||||
guestName: "Demo Guest",
|
||||
roomLabel: "Junior Studio · 1204",
|
||||
checkOut: "2026-04-12",
|
||||
},
|
||||
{
|
||||
ref: "SHY-MOCK-HOLD",
|
||||
lastNameKey: "HOLD",
|
||||
guestName: "Hold Guest",
|
||||
roomLabel: "Standard King · 602",
|
||||
checkOut: "2026-04-10",
|
||||
},
|
||||
];
|
||||
|
||||
function normalize(s: string): string {
|
||||
return s.trim().toUpperCase().replace(/[^A-Z]/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Match booking reference + last name against allowlist.
|
||||
*/
|
||||
export function lookupGuestBooking(
|
||||
refRaw: string,
|
||||
lastNameRaw: string,
|
||||
): GuestBookingRecord | null {
|
||||
const ref = refRaw.trim().toUpperCase();
|
||||
const last = normalize(lastNameRaw);
|
||||
if (!ref || !last) return null;
|
||||
return (
|
||||
guestBookingAllowlist.find((r) => r.ref.toUpperCase() === ref && r.lastNameKey === last) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user