Compare commits

..

2 Commits

Author SHA1 Message Date
“kirukib”
202897e83f merge: integrate origin/main with guest portal updates
Resolve merge conflicts between upstream auth/data refactors and local guest portal navigation updates, then align imports and room data so the combined branch builds successfully.

Made-with: Cursor
2026-04-27 20:15:43 +03:00
“kirukib”
3b41b9052b 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
2026-04-27 20:08:05 +03:00
26 changed files with 1150 additions and 454 deletions

25
package-lock.json generated
View File

@ -1929,7 +1929,6 @@
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@ -2602,7 +2601,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -2625,7 +2623,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -2648,7 +2645,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -2665,7 +2661,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -2682,7 +2677,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -2699,7 +2693,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -2716,7 +2709,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -2733,7 +2725,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -2750,7 +2741,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -2767,7 +2757,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -2784,7 +2773,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -2801,7 +2789,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -2818,7 +2805,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -2841,7 +2827,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -2864,7 +2849,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -2887,7 +2871,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -2910,7 +2893,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -2933,7 +2915,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -2956,7 +2937,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -2979,7 +2959,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -3002,7 +2981,6 @@
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@ -3022,7 +3000,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later", "license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -3042,7 +3019,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later", "license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -3062,7 +3038,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later", "license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [

View File

@ -102,13 +102,21 @@ export default function ConfirmationPage() {
</div> </div>
</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 <Link
href="/" href="/"
onClick={() => resetBooking()} 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 Back to home
</Link> </Link>
</div> </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/data/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 = { 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 GuestGymPage() {
export default function GuestGymRedirectPage() { return (
redirect("/services?kind=gym"); <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

@ -11,7 +11,7 @@ import { laundryItems, type LaundryCartItem, SAME_DAY_SURCHARGE } from "@/lib/da
export function LaundryClient() { export function LaundryClient() {
return ( return (
<RequireAuth redirectTo="/login"> <RequireAuth redirectTo="/guest/login">
<LaundryInner /> <LaundryInner />
</RequireAuth> </RequireAuth>
); );
@ -118,7 +118,9 @@ function LaundryInner() {
<h1 className="font-heading text-3xl font-semibold text-[var(--color-text)] md:text-4xl">Laundry service</h1> <h1 className="font-heading text-3xl font-semibold text-[var(--color-text)] md:text-4xl">Laundry service</h1>
<p className="mt-2 max-w-xl text-sm text-[var(--color-muted)]">Submit a laundry request attached to your active booking.</p> <p className="mt-2 max-w-xl text-sm text-[var(--color-muted)]">Submit a laundry request attached to your active booking.</p>
</div> </div>
<Link href="/profile" className="text-sm font-semibold text-[var(--color-accent)] hover:underline">View profile </Link> <Link href="/guest/profile" className="text-sm font-semibold text-[var(--color-accent)] hover:underline">
View profile
</Link>
</div> </div>
{needBooking ? ( {needBooking ? (

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 Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react"; import { useEffect, useState } from "react";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
type Tab = "otp" | "password" | "social" | "booking"; type Tab = "otp" | "password" | "social" | "booking";
@ -10,7 +10,7 @@ type Tab = "otp" | "password" | "social" | "booking";
export function LoginPageClient() { export function LoginPageClient() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const nextPath = searchParams.get("next") || "/profile"; const nextPath = searchParams.get("next") || "/guest/profile";
const { requestOtp, verifyOtp, loginPassword, loginGoogle, loginBookingRef } = useAuth(); const { requestOtp, verifyOtp, loginPassword, loginGoogle, loginBookingRef } = useAuth();
@ -20,9 +20,18 @@ export function LoginPageClient() {
const [otpStep, setOtpStep] = useState<1 | 2>(1); const [otpStep, setOtpStep] = useState<1 | 2>(1);
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [bookingRef, setBookingRef] = useState(""); const [bookingRef, setBookingRef] = useState("");
const [bookingLastName, setBookingLastName] = useState("");
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const [loading, setLoading] = useState(false); 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) { async function handleSendOtp(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
@ -83,6 +92,10 @@ export function LoginPageClient() {
Home Home
</Link> </Link>
<span className="mx-2 opacity-50">/</span> <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> <span className="text-[var(--color-text)]">Sign in</span>
</nav> </nav>
<h1 className="mt-4 font-heading text-3xl font-semibold text-[var(--color-text)] md:text-4xl"> <h1 className="mt-4 font-heading text-3xl font-semibold text-[var(--color-text)] md:text-4xl">
@ -243,13 +256,27 @@ export function LoginPageClient() {
{tab === "booking" && ( {tab === "booking" && (
<form onSubmit={handleBookingRef} className="space-y-4"> <form onSubmit={handleBookingRef} className="space-y-4">
<label className="block text-sm font-medium text-[var(--color-text)]"> <label className="block text-sm font-medium text-[var(--color-text)]">
Booking / confirmation reference Confirmation / hold / booking reference
<input <input
type="text" type="text"
value={bookingRef} value={bookingRef}
onChange={(e) => setBookingRef(e.target.value)} 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" 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> </label>
<p className="text-xs text-[var(--color-muted)]"> <p className="text-xs text-[var(--color-muted)]">
@ -268,7 +295,7 @@ export function LoginPageClient() {
{message ? ( {message ? (
<p <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} {message}
</p> </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

@ -65,12 +65,12 @@ export default function GuestHubPage() {
</p> </p>
<div className="mt-8 flex flex-wrap gap-3"> <div className="mt-8 flex flex-wrap gap-3">
{!session && ( {!session && (
<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 Sign in
</Link> </Link>
)} )}
<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)]" 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)]"
> >
{session ? "My profile & orders" : "My stay profile"} {session ? "My profile & orders" : "My stay profile"}

View File

@ -0,0 +1,232 @@
"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/data/guestData";
import { siteConfig } from "@/lib/site-config";
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">
{`Hello, ${session.displayName}`}
</h1>
<p className="mt-2 text-sm text-[var(--color-muted)]">
<span className="font-medium text-[var(--color-text)]">{session.email}</span>
{" · "}
Signed in via {session.authMethod}
</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>
<>
<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)]">
Loyalty balance from your signed-in guest account
</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

@ -16,7 +16,7 @@ const API_CATEGORY_LABEL: Record<string, string> = {
export function RoomServiceClient() { export function RoomServiceClient() {
return ( return (
<RequireAuth redirectTo="/login"> <RequireAuth redirectTo="/guest/login">
<RoomServiceInner /> <RoomServiceInner />
</RequireAuth> </RequireAuth>
); );
@ -153,7 +153,7 @@ function RoomServiceInner() {
</p> </p>
</div> </div>
<Link <Link
href="/profile" href="/guest/profile"
className="text-sm font-semibold text-[var(--color-accent)] hover:underline" className="text-sm font-semibold text-[var(--color-accent)] hover:underline"
> >
View profile View profile

View File

@ -1,9 +1,16 @@
import { redirect } from "next/navigation"; import { GuestSpaGymBookingClient } from "../GuestSpaGymBookingClient";
export const metadata = { export const metadata = {
title: "Spa", title: "Spa bookings",
description: "Book spa treatments during your stay — Shitaye Suite Hotel.",
}; };
export default function GuestSpaRedirectPage() { export default function GuestSpaPage() {
redirect("/services?kind=spa"); 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 { redirect } from "next/navigation";
import { ShitayeLogoLoader } from "@/components/ShitayeLogoLoader";
import { LoginPageClient } from "./LoginPageClient";
export const metadata = { type Props = {
title: "Sign in", searchParams: Promise<Record<string, string | string[] | undefined>>;
description: "Sign in to Shitaye Suite Hotel guest portal — OTP, password, social, or booking ID.",
}; };
export default function LoginPage() { /** @deprecated Use `/guest/login` */
return ( export default async function LegacyLoginRedirect({ searchParams }: Props) {
<Suspense const s = await searchParams;
fallback={ const q = new URLSearchParams();
<div className="flex min-h-[50vh] items-center justify-center bg-[var(--color-bg)]"> for (const [k, v] of Object.entries(s)) {
<ShitayeLogoLoader label="Loading sign-in…" /> if (typeof v === "string") q.set(k, v);
</div> else if (Array.isArray(v)) v.forEach((x) => q.append(k, x));
} }
> const qs = q.toString();
<LoginPageClient /> redirect(qs ? `/guest/login?${qs}` : "/guest/login");
</Suspense>
);
} }

View File

@ -1,332 +0,0 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { RequireAuth } from "@/components/RequireAuth";
import { useAuth } from "@/context/AuthContext";
import type { OrderCategory, OrderRecord } from "@/context/AuthContext";
import {
guestMe,
guestOrders,
guestPointsHistory,
guestSpaBookings,
guestShuttles,
type PointLedgerRow,
type SpaBookingRow,
type ShuttleRow,
} from "@/lib/guest-hotel-api";
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, logout, accessToken } = useAuth();
const [orderFilter, setOrderFilter] = useState<OrderCategory | "all">("all");
const [apiBalance, setApiBalance] = useState<number | null>(null);
const [apiLedger, setApiLedger] = useState<PointLedgerRow[]>([]);
const [apiOrders, setApiOrders] = useState<OrderRecord[]>([]);
const [appointments, setAppointments] = useState<SpaBookingRow[]>([]);
const [apiShuttles, setApiShuttles] = useState<ShuttleRow[]>([]);
useEffect(() => {
if (!accessToken || !session) return;
const pid = session.propertyId;
if (!pid) return;
let cancelled = false;
(async () => {
try {
const me = await guestMe(pid, accessToken);
const ph = await guestPointsHistory(pid, accessToken);
const ord = await guestOrders(pid, accessToken);
const spa = await guestSpaBookings(pid, accessToken);
let shuttles: ShuttleRow[] = [];
if (session.bookingId) {
try {
const sh = await guestShuttles(pid, session.bookingId, accessToken);
shuttles = (sh.data ?? []).sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
} catch {
// Ignore if shuttles fail (e.g. no access or 404)
}
}
if (!cancelled) {
setApiBalance(me.balance);
setApiLedger(ph.data ?? []);
setApiOrders(
(ord.data ?? []).map((o) => ({
id: o.id,
category: o.type,
title:
o.type === "room-service"
? "Room Service Order"
: o.type === "laundry"
? "Laundry Request"
: o.type === "gym"
? "Gym Booking"
: "Spa Booking",
detail: o.detail,
totalUsd: Number(o.total ?? 0),
placedAt: o.createdAt,
status: (["pending", "confirmed", "completed"].includes(o.status.toLowerCase())
? o.status.toLowerCase()
: "pending") as OrderRecord["status"],
})),
);
setAppointments(spa.data ?? []);
setApiShuttles(shuttles);
}
} catch {
if (!cancelled) {
setApiOrders([]);
setAppointments([]);
setApiShuttles([]);
}
}
})();
return () => {
cancelled = true;
};
}, [accessToken, session]);
const filteredOrders = useMemo(() => {
if (orderFilter === "all") return apiOrders;
return apiOrders.filter((o) => o.category === orderFilter);
}, [apiOrders, 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">
Hello, {session.displayName}
</h1>
<p className="mt-2 text-sm text-[var(--color-muted)]">
<span className="font-medium text-[var(--color-text)]">{session.email}</span>
{session.bookingCode ? (
<>
{" · "}
Booking code{" "}
<span className="font-mono font-semibold text-[var(--color-text)]">
{session.bookingCode}
</span>
</>
) : null}
</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>
<>
<p className="mt-3 font-heading text-4xl font-semibold text-[var(--color-text)]">
{(apiBalance ?? session.points).toLocaleString()}
</p>
<p className="mt-1 text-sm text-[var(--color-muted)]">
{apiBalance != null ? "Balance" : "Balance unavailable"}
</p>
</>
</div>
</div>
<section className="mt-12">
<h2 className="font-heading text-2xl text-[var(--color-text)]">Booked appointments</h2>
{appointments.length === 0 ? (
<p className="mt-4 rounded-xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-muted)] px-4 py-8 text-sm text-[var(--color-muted)]">
No gym/spa bookings found.
</p>
) : (
<ul className="mt-4 grid gap-3 md:grid-cols-2">
{appointments.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.offering?.name ?? "Spa/Gym booking"}
</p>
<p className="mt-1 text-sm text-[var(--color-muted)]">
{formatWhen(a.scheduledAt ?? a.createdAt)}
</p>
</li>
))}
</ul>
)}
</section>
{session.bookingId && (
<section className="mt-12">
<h2 className="font-heading text-2xl text-[var(--color-text)]">Airport shuttle</h2>
{apiShuttles.length === 0 ? (
<p className="mt-4 rounded-xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-muted)] px-4 py-8 text-sm text-[var(--color-muted)]">
No airport shuttles requested.
</p>
) : (
<ul className="mt-4 grid gap-3 md:grid-cols-2">
{apiShuttles.map((s) => (
<li
key={s.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)]">
{s.status}
</p>
<p className="mt-2 font-semibold text-[var(--color-text)]">
{s.direction === "AIRPORT_TO_HOTEL" ? "Airport pickup (to hotel)" : s.direction === "HOTEL_TO_AIRPORT" ? "Hotel drop-off (to airport)" : s.direction.replace(/_/g, " ")}
</p>
<p className="mt-1 text-sm text-[var(--color-muted)]">
Requested for: {formatWhen(s.requestedAt)}
</p>
{s.flightRef && (
<p className="mt-1 text-sm text-[var(--color-muted)]">Flight: {s.flightRef}</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
</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 history</h2>
<ul className="mt-4 space-y-2">
{apiLedger.length > 0
? apiLedger.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.reason.replace(/_/g, " ")}</p>
<p className="text-xs text-[var(--color-muted)]">{formatWhen(r.createdAt)}</p>
</div>
<span
className={
r.delta >= 0 ? "badge-mustard" : "rounded-full bg-red-100 px-3 py-1 text-xs font-semibold text-red-800"
}
>
{r.delta >= 0 ? "+" : ""}
{r.delta} pts
</span>
</li>
))
: null}
</ul>
{apiLedger.length === 0 ? (
<p className="mt-4 text-sm text-[var(--color-muted)]">No rewards history returned yet.</p>
) : null}
</section>
</div>
</div>
);
}

View File

@ -1,10 +1,6 @@
import { ProfilePageClient } from "./ProfilePageClient"; import { redirect } from "next/navigation";
export const metadata = { /** @deprecated Use `/guest/profile` */
title: "My stay", export default function LegacyProfileRedirect() {
description: "Profile, rewards, appointments, shuttle, and orders at Shitaye Suite Hotel.", redirect("/guest/profile");
};
export default function ProfilePage() {
return <ProfilePageClient />;
} }

View File

@ -103,6 +103,13 @@ export default function ReserveHeldPage() {
Complete payment Complete payment
</Link> </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 <Link
href="/" href="/"
onClick={() => resetBooking()} onClick={() => resetBooking()}

View File

@ -151,12 +151,12 @@ export function Footer() {
</Link> </Link>
</li> </li>
<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 Sign in
</Link> </Link>
</li> </li>
<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 My stay
</Link> </Link>
</li> </li>

View File

@ -3,21 +3,12 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { HeaderAccount } from "@/components/HeaderAccount"; import { HeaderAccount } from "@/components/HeaderAccount";
import { HeaderNav } from "@/components/HeaderNav";
import { CurrencySwitcher } from "@/components/CurrencySwitcher"; import { CurrencySwitcher } from "@/components/CurrencySwitcher";
import { ReviewsMenu } from "@/components/ReviewsMenu"; import { ReviewsMenu } from "@/components/ReviewsMenu";
import { siteConfig } from "@/lib/site-config"; import { siteConfig } from "@/lib/site-config";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
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() { export function Header() {
const { session } = useAuth(); const { session } = useAuth();
@ -52,29 +43,11 @@ export function Header() {
className="h-9 w-auto shrink-0 sm:h-10 md:h-11" className="h-9 w-auto shrink-0 sm:h-10 md:h-11"
priority 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"> <span className="font-nav text-lg tracking-tight text-[var(--color-primary)] sm:text-xl md:text-2xl">
{siteConfig.name} {siteConfig.name}
</span> </span>
<span className="text-[10px] font-medium uppercase tracking-[0.2em] text-[var(--color-muted)] sm:text-[11px]">
{siteConfig.city}
</span>
</span>
</Link> </Link>
<nav <HeaderNav />
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>
<div className="flex shrink-0 items-center gap-2 md:gap-3"> <div className="flex shrink-0 items-center gap-2 md:gap-3">
<HeaderAccount /> <HeaderAccount />
{!session && ( {!session && (

View File

@ -19,24 +19,18 @@ export function HeaderAccount() {
return ( return (
<div className="flex items-center gap-2 sm:gap-3"> <div className="flex items-center gap-2 sm:gap-3">
<Link <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" 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" title="Loyalty points"
> >
{`${points} pts`} {`${points} pts`}
</Link> </Link>
<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" 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} {label}
</Link> </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> </div>
); );
} }
@ -44,7 +38,7 @@ export function HeaderAccount() {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <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" 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 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 }; 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 { session, isHydrated } = useAuth();
const router = useRouter(); const router = useRouter();
@ -35,7 +35,10 @@ export function RequireAuth({ children, redirectTo = "/login" }: Props) {
return ( return (
<div className="mx-auto max-w-lg px-4 py-20 text-center"> <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> <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 Continue manually
</Link> </Link>
</div> </div>

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

View File

@ -0,0 +1,93 @@
import type { Room } from "@/types/room";
const marketingRooms: Room[] = [
{
id: "penthouse",
slug: "four-bedroom-penthouse",
name: "The 4 Bedroom Penthouse",
shortDescription: "Our flagship residence with panoramic views and full kitchenette.",
longDescription:
"Experience elevated living in our four-bedroom penthouse — expansive layouts, state-of-the-art kitchenette, and amazing views over Addis Ababa. Ideal for extended stays and distinguished guests who expect space, privacy, and premium finishes.",
nightlyRate: 485,
priceCurrency: "USD",
maxGuests: 8,
beds: "4 bedrooms — mix of king and twin configurations",
sizeSqM: 220,
view: "City skyline",
highlights: ["Private routers", "IPTV", "Mini bar", "In-room safe"],
gallery: [
"https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=1200&q=80&auto=format&fit=crop",
"https://images.unsplash.com/photo-1631049307264-da0ec9d70304?w=1200&q=80&auto=format&fit=crop",
"https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=1200&q=80&auto=format&fit=crop",
],
tourEmbedUrl: null,
},
{
id: "standard",
slug: "standard-rooms",
name: "Standard Rooms",
shortDescription: "Refined comfort with every essential amenity.",
longDescription:
"Our standard rooms combine restful design with practical luxury: premium bedding, dedicated workspace, IPTV, and seamless Wi-Fi / LAN. Perfect for business and leisure travellers who value consistency and calm.",
nightlyRate: 120,
priceCurrency: "USD",
maxGuests: 2,
beds: "1 King or 2 Twin",
sizeSqM: 28,
view: "City or courtyard",
highlights: ["B/B fast", "Iron & board", "Laundry (paid)", "Safe box"],
gallery: [
"https://images.unsplash.com/photo-1590490360182-c33d57733427?w=1200&q=80",
"https://images.unsplash.com/photo-1566665797739-1674de7a215a?w=1200&q=80",
],
tourEmbedUrl: null,
},
{
id: "connecting-suite",
slug: "connecting-suite",
name: "Connecting Suite",
shortDescription: "Flexible suites — convert to a spacious family layout.",
longDescription:
"Connecting suite rooms with the option of converting to family suites. Enjoy separate living and sleeping zones, kitchenette access where applicable, and the same premium amenities found across the property.",
nightlyRate: 210,
priceCurrency: "USD",
maxGuests: 5,
beds: "1 King + connecting twin room",
sizeSqM: 55,
view: "City",
highlights: ["Family-friendly layout", "Kitchenette", "IPTV", "Shuttle"],
gallery: [
"https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=1200&q=80",
"https://images.unsplash.com/photo-1591088398332-8a7791972843?w=1200&q=80",
],
tourEmbedUrl: null,
},
{
id: "junior-studio",
slug: "junior-studios",
name: "Junior Studios",
shortDescription: "Compact sophistication for solo travellers and short stays.",
longDescription:
"Junior studios offer a smart open plan with kitchenette, premium Wi-Fi, IPTV, and efficient storage — designed for guests who want independence without sacrificing hotel service.",
nightlyRate: 95,
priceCurrency: "USD",
maxGuests: 2,
beds: "1 Queen",
sizeSqM: 32,
view: "Urban",
highlights: ["Kitchenette", "Mini bar", "Private router option"],
gallery: [
"https://images.unsplash.com/photo-1522771739844-6a9f6d5f14af?w=1200&q=80",
"https://images.unsplash.com/photo-1502672260266-1c1ef2d93688?w=1200&q=80",
],
tourEmbedUrl: null,
},
];
export function getMarketingRoomBySlug(slug: string): Room | undefined {
return marketingRooms.find((room) => room.slug === slug);
}
export function getAllMarketingRoomSlugs(): string[] {
return marketingRooms.map((room) => room.slug);
}

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;