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:
“kirukib” 2026-04-27 20:08:05 +03:00
parent d5c7d56c11
commit 3b41b9052b
27 changed files with 1367 additions and 664 deletions

View File

@ -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>
);
}

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

View File

@ -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."
/>
);
}

View File

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

View File

@ -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>

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

View File

@ -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

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

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

View File

@ -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>

View File

@ -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."
/>
);
}

View File

@ -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");
}

View File

@ -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>
);
}

View File

@ -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");
}

View File

@ -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>
);
}

View File

@ -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()}

View File

@ -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>

View File

@ -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

View File

@ -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

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

View File

@ -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>

View File

@ -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";

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

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

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