Compare commits
2 Commits
8f9705e648
...
202897e83f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
202897e83f | ||
|
|
3b41b9052b |
25
package-lock.json
generated
25
package-lock.json
generated
|
|
@ -1929,7 +1929,6 @@
|
|||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
|
||||
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
|
|
@ -2602,7 +2601,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2625,7 +2623,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2648,7 +2645,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2665,7 +2661,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2682,7 +2677,6 @@
|
|||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2699,7 +2693,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2716,7 +2709,6 @@
|
|||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2733,7 +2725,6 @@
|
|||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2750,7 +2741,6 @@
|
|||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2767,7 +2757,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2784,7 +2773,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2801,7 +2789,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2818,7 +2805,6 @@
|
|||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2841,7 +2827,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2864,7 +2849,6 @@
|
|||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2887,7 +2871,6 @@
|
|||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2910,7 +2893,6 @@
|
|||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2933,7 +2915,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2956,7 +2937,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2979,7 +2959,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3002,7 +2981,6 @@
|
|||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
|
|
@ -3022,7 +3000,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3042,7 +3019,6 @@
|
|||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3062,7 +3038,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
|
|||
|
|
@ -102,13 +102,21 @@ export default function ConfirmationPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-col items-center gap-3 sm:flex-row sm:justify-center">
|
||||
<Link
|
||||
href={`/guest/login?ref=${encodeURIComponent(confirmationId)}&lastName=${encodeURIComponent(guest.lastName)}`}
|
||||
className="inline-flex rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-6 py-3 text-sm font-semibold text-[var(--color-text)] transition hover:border-[var(--color-accent)] hover:text-[var(--color-accent)]"
|
||||
>
|
||||
Open guest portal
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
onClick={() => resetBooking()}
|
||||
className="btn-mustard mt-10 inline-flex px-10 py-3.5 text-sm"
|
||||
className="btn-mustard inline-flex px-10 py-3.5 text-sm"
|
||||
>
|
||||
Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
174
src/app/guest/GuestSpaGymBookingClient.tsx
Normal file
174
src/app/guest/GuestSpaGymBookingClient.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { RequireAuth } from "@/components/RequireAuth";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { spaGymServices, type SpaGymKind, type SpaGymService } from "@/lib/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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { laundryItems, type LaundryCartItem, SAME_DAY_SURCHARGE } from "@/lib/da
|
|||
|
||||
export function LaundryClient() {
|
||||
return (
|
||||
<RequireAuth redirectTo="/login">
|
||||
<RequireAuth redirectTo="/guest/login">
|
||||
<LaundryInner />
|
||||
</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>
|
||||
<p className="mt-2 max-w-xl text-sm text-[var(--color-muted)]">Submit a laundry request attached to your active booking.</p>
|
||||
</div>
|
||||
<Link href="/profile" className="text-sm font-semibold text-[var(--color-accent)] hover:underline">View profile →</Link>
|
||||
<Link href="/guest/profile" className="text-sm font-semibold text-[var(--color-accent)] hover:underline">
|
||||
View profile →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{needBooking ? (
|
||||
|
|
|
|||
39
src/app/guest/layout.tsx
Normal file
39
src/app/guest/layout.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import Link from "next/link";
|
||||
|
||||
export default function GuestLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-full">
|
||||
<div className="border-b border-[var(--color-border)] bg-[var(--color-surface)]/90 backdrop-blur-sm">
|
||||
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-2 px-4 py-2.5 text-xs md:px-8">
|
||||
<span className="font-semibold uppercase tracking-wider text-[var(--color-primary)]">
|
||||
Guest services
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-3 font-medium text-[var(--color-muted)]">
|
||||
<Link href="/guest" className="transition hover:text-[var(--color-accent)]">
|
||||
Hub
|
||||
</Link>
|
||||
<Link href="/guest/room-service" className="transition hover:text-[var(--color-accent)]">
|
||||
Room service
|
||||
</Link>
|
||||
<Link href="/guest/laundry" className="transition hover:text-[var(--color-accent)]">
|
||||
Laundry
|
||||
</Link>
|
||||
<Link href="/guest/gym" className="transition hover:text-[var(--color-accent)]">
|
||||
Gym
|
||||
</Link>
|
||||
<Link href="/guest/spa" className="transition hover:text-[var(--color-accent)]">
|
||||
Spa
|
||||
</Link>
|
||||
<Link href="/guest/profile" className="transition hover:text-[var(--color-accent)]">
|
||||
Profile
|
||||
</Link>
|
||||
<Link href="/guest/login" className="transition hover:text-[var(--color-accent)]">
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
|
||||
type Tab = "otp" | "password" | "social" | "booking";
|
||||
|
|
@ -10,7 +10,7 @@ type Tab = "otp" | "password" | "social" | "booking";
|
|||
export function LoginPageClient() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const nextPath = searchParams.get("next") || "/profile";
|
||||
const nextPath = searchParams.get("next") || "/guest/profile";
|
||||
|
||||
const { requestOtp, verifyOtp, loginPassword, loginGoogle, loginBookingRef } = useAuth();
|
||||
|
||||
|
|
@ -20,9 +20,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);
|
||||
|
|
@ -83,6 +92,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">
|
||||
|
|
@ -243,13 +256,27 @@ 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)]">
|
||||
|
|
@ -268,7 +295,7 @@ export function LoginPageClient() {
|
|||
|
||||
{message ? (
|
||||
<p
|
||||
className={`mt-4 text-sm ${message.includes("not") || message.includes("Invalid") || message.includes("Incorrect") ? "text-red-700" : "text-[var(--color-primary)]"}`}
|
||||
className={`mt-4 text-sm ${message.includes("not") || message.includes("Invalid") || message.includes("Incorrect") || message.includes("do not match") ? "text-red-700" : "text-[var(--color-primary)]"}`}
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
23
src/app/guest/login/page.tsx
Normal file
23
src/app/guest/login/page.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { Suspense } from "react";
|
||||
import { ShitayeLogoLoader } from "@/components/ShitayeLogoLoader";
|
||||
import { LoginPageClient } from "./LoginPageClient";
|
||||
|
||||
export const metadata = {
|
||||
title: "Sign in",
|
||||
description:
|
||||
"Guest sign-in — OTP, password, social (mock), or booking reference at Shitaye Suite Hotel.",
|
||||
};
|
||||
|
||||
export default function GuestLoginPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-[50vh] items-center justify-center bg-[var(--color-bg)]">
|
||||
<ShitayeLogoLoader label="Loading sign-in…" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LoginPageClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
@ -65,12 +65,12 @@ export default function GuestHubPage() {
|
|||
</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
{!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
|
||||
</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)]"
|
||||
>
|
||||
{session ? "My profile & orders" : "My stay profile"}
|
||||
|
|
|
|||
232
src/app/guest/profile/ProfilePageClient.tsx
Normal file
232
src/app/guest/profile/ProfilePageClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
src/app/guest/profile/page.tsx
Normal file
10
src/app/guest/profile/page.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { ProfilePageClient } from "./ProfilePageClient";
|
||||
|
||||
export const metadata = {
|
||||
title: "My stay",
|
||||
description: "Guest profile — rewards, shuttle, appointments, and orders at Shitaye Suite Hotel.",
|
||||
};
|
||||
|
||||
export default function GuestProfilePage() {
|
||||
return <ProfilePageClient />;
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ const API_CATEGORY_LABEL: Record<string, string> = {
|
|||
|
||||
export function RoomServiceClient() {
|
||||
return (
|
||||
<RequireAuth redirectTo="/login">
|
||||
<RequireAuth redirectTo="/guest/login">
|
||||
<RoomServiceInner />
|
||||
</RequireAuth>
|
||||
);
|
||||
|
|
@ -153,7 +153,7 @@ function RoomServiceInner() {
|
|||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/profile"
|
||||
href="/guest/profile"
|
||||
className="text-sm font-semibold text-[var(--color-accent)] hover:underline"
|
||||
>
|
||||
View profile →
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
import { redirect } from "next/navigation";
|
||||
import { GuestSpaGymBookingClient } from "../GuestSpaGymBookingClient";
|
||||
|
||||
export const metadata = {
|
||||
title: "Spa",
|
||||
title: "Spa bookings",
|
||||
description: "Book spa treatments during your stay — Shitaye Suite Hotel.",
|
||||
};
|
||||
|
||||
export default function GuestSpaRedirectPage() {
|
||||
redirect("/services?kind=spa");
|
||||
export default function GuestSpaPage() {
|
||||
return (
|
||||
<GuestSpaGymBookingClient
|
||||
kind="spa"
|
||||
title="Spa bookings"
|
||||
description="Massages and rituals — aligned with the shared spa catalog on /services."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,17 @@
|
|||
import { Suspense } from "react";
|
||||
import { ShitayeLogoLoader } from "@/components/ShitayeLogoLoader";
|
||||
import { LoginPageClient } from "./LoginPageClient";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata = {
|
||||
title: "Sign in",
|
||||
description: "Sign in to Shitaye Suite Hotel guest portal — OTP, password, social, or booking ID.",
|
||||
type Props = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-[50vh] items-center justify-center bg-[var(--color-bg)]">
|
||||
<ShitayeLogoLoader label="Loading sign-in…" />
|
||||
</div>
|
||||
/** @deprecated Use `/guest/login` */
|
||||
export default async function LegacyLoginRedirect({ searchParams }: Props) {
|
||||
const s = await searchParams;
|
||||
const q = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(s)) {
|
||||
if (typeof v === "string") q.set(k, v);
|
||||
else if (Array.isArray(v)) v.forEach((x) => q.append(k, x));
|
||||
}
|
||||
>
|
||||
<LoginPageClient />
|
||||
</Suspense>
|
||||
);
|
||||
const qs = q.toString();
|
||||
redirect(qs ? `/guest/login?${qs}` : "/guest/login");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,6 +103,13 @@ export default function ReserveHeldPage() {
|
|||
Complete payment
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={`/guest/login?ref=${encodeURIComponent(holdReference)}&lastName=${encodeURIComponent(guest.lastName)}`}
|
||||
className="mt-4 flex w-full items-center justify-center rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] py-3.5 text-sm font-semibold text-[var(--color-text)] transition hover:border-[var(--color-accent)] hover:text-[var(--color-accent)]"
|
||||
>
|
||||
Open guest portal
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
onClick={() => resetBooking()}
|
||||
|
|
|
|||
|
|
@ -151,12 +151,12 @@ export function Footer() {
|
|||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/login" className="text-stone-200 hover:text-white">
|
||||
<Link href="/guest/login" className="text-stone-200 hover:text-white">
|
||||
Sign in
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/profile" className="text-stone-200 hover:text-white">
|
||||
<Link href="/guest/profile" className="text-stone-200 hover:text-white">
|
||||
My stay
|
||||
</Link>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -3,21 +3,12 @@
|
|||
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/site-config";
|
||||
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() {
|
||||
const { session } = useAuth();
|
||||
|
||||
|
|
@ -52,29 +43,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 />
|
||||
{!session && (
|
||||
|
|
|
|||
|
|
@ -19,24 +19,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} pts`}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -44,7 +38,7 @@ export function HeaderAccount() {
|
|||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/login"
|
||||
href="/guest/login"
|
||||
className="rounded-full px-3 py-2 text-xs font-semibold text-[var(--color-text)] transition hover:text-[var(--color-accent)] sm:text-sm"
|
||||
>
|
||||
Sign in
|
||||
|
|
|
|||
304
src/components/HeaderNav.tsx
Normal file
304
src/components/HeaderNav.tsx
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
|
||||
/** Desktop logged-out: same as before (no city line in header; full list in mobile drawer). */
|
||||
const guestNavDesktop = [
|
||||
{ href: "/#rooms", label: "Rooms" },
|
||||
{ href: "/guest", label: "Guest hub" },
|
||||
{ href: "/services", label: "Services" },
|
||||
{ href: "/#wellness", label: "Gym & Spa" },
|
||||
{ href: "/#dining", label: "Dining & venues" },
|
||||
{ href: "/#meetings", label: "Meetings" },
|
||||
{ href: "/#location", label: "Location" },
|
||||
];
|
||||
|
||||
const guestNavMobile = [
|
||||
{ href: "/", label: "Home" },
|
||||
...guestNavDesktop,
|
||||
{ href: "/#tour", label: "3D tour" },
|
||||
];
|
||||
|
||||
const servicesLinks = [
|
||||
{ href: "/guest", label: "Guest hub overview" },
|
||||
{ href: "/guest/room-service", label: "Room service" },
|
||||
{ href: "/guest/laundry", label: "Laundry" },
|
||||
{ href: "/guest/gym", label: "Gym" },
|
||||
{ href: "/guest/spa", label: "Spa" },
|
||||
{ href: "/services", label: "Spa & gym (services)" },
|
||||
];
|
||||
|
||||
const amenitiesLinks = [
|
||||
{ href: "/#wellness", label: "Gym & wellness" },
|
||||
{ href: "/#dining", label: "Dining & venues" },
|
||||
{ href: "/#meetings", label: "Meetings & events" },
|
||||
{ href: "/#location", label: "Location" },
|
||||
{ href: "/#tour", label: "3D tour" },
|
||||
];
|
||||
|
||||
function NavChevron() {
|
||||
return (
|
||||
<svg
|
||||
className="ml-0.5 h-3.5 w-3.5 shrink-0 opacity-60"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M3 4.5L6 7.5L9 4.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function NavDropdown({
|
||||
label,
|
||||
items,
|
||||
id,
|
||||
}: {
|
||||
label: string;
|
||||
items: { href: string; label: string }[];
|
||||
id: string;
|
||||
}) {
|
||||
return (
|
||||
<details className="group relative">
|
||||
<summary
|
||||
className="flex cursor-pointer list-none items-center gap-0.5 rounded-md px-1 py-1 text-sm font-medium text-[var(--color-text)] transition-colors hover:text-[var(--color-accent)] [&::-webkit-details-marker]:hidden"
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
{label}
|
||||
<NavChevron />
|
||||
</summary>
|
||||
<div
|
||||
id={id}
|
||||
role="menu"
|
||||
className="absolute left-0 top-full z-50 mt-1.5 min-w-[14rem] rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] py-1.5 shadow-lg ring-1 ring-black/5"
|
||||
>
|
||||
<ul className="max-h-[min(70vh,22rem)] overflow-y-auto py-0.5">
|
||||
{items.map((item) => (
|
||||
<li key={item.href + item.label}>
|
||||
<Link
|
||||
role="menuitem"
|
||||
href={item.href}
|
||||
className="block px-4 py-2.5 text-sm text-[var(--color-text)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-accent)]"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function HamburgerIcon() {
|
||||
return (
|
||||
<span className="relative block h-5 w-6" aria-hidden>
|
||||
<span className="absolute left-0 top-1 block h-0.5 w-6 rounded-full bg-current" />
|
||||
<span className="absolute left-0 top-[9px] block h-0.5 w-6 rounded-full bg-current" />
|
||||
<span className="absolute left-0 top-[17px] block h-0.5 w-6 rounded-full bg-current" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileNavDrawer({
|
||||
open,
|
||||
onClose,
|
||||
session,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
session: ReturnType<typeof useAuth>["session"];
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
onClose();
|
||||
}, [pathname, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] lg:hidden" role="dialog" aria-modal="true" aria-label="Site menu">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 bg-black/45 backdrop-blur-[2px]"
|
||||
aria-label="Close menu"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex w-[min(100%,20rem)] flex-col border-l border-[var(--color-border)] bg-[var(--color-surface)] shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-4 py-3">
|
||||
<span className="font-heading text-lg font-semibold text-[var(--color-text)]">Menu</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full p-2 text-[var(--color-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-text)]"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto overscroll-contain px-2 py-3" aria-label="Main mobile">
|
||||
{session ? (
|
||||
<>
|
||||
<Link
|
||||
href="/#rooms"
|
||||
onClick={onClose}
|
||||
className="block rounded-lg px-3 py-3 text-base font-semibold text-[var(--color-text)] hover:bg-[var(--color-surface-muted)]"
|
||||
>
|
||||
Rooms
|
||||
</Link>
|
||||
<p className="mt-2 px-3 pb-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-[var(--color-muted)]">
|
||||
Services
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{servicesLinks.map((item) => (
|
||||
<li key={item.href + item.label}>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={onClose}
|
||||
className="block rounded-lg px-3 py-2.5 text-sm text-[var(--color-text)] hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-accent)]"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-4 px-3 pb-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-[var(--color-muted)]">
|
||||
Amenities
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{amenitiesLinks.map((item) => (
|
||||
<li key={item.href + item.label}>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={onClose}
|
||||
className="block rounded-lg px-3 py-2.5 text-sm text-[var(--color-text)] hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-accent)]"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{guestNavMobile.map((item) => (
|
||||
<li key={item.href + item.label}>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={onClose}
|
||||
className="block rounded-lg px-3 py-2.5 text-sm font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-accent)]"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeaderNav() {
|
||||
const { session, isHydrated } = useAuth();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
const closeMobile = useCallback(() => setMobileOpen(false), []);
|
||||
|
||||
if (!isHydrated) {
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
className="hidden items-center gap-6 text-sm font-medium text-[var(--color-text)] lg:flex"
|
||||
aria-label="Main"
|
||||
aria-hidden
|
||||
>
|
||||
<span className="h-4 w-48 animate-pulse rounded bg-[var(--color-surface-muted)]" />
|
||||
</nav>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center lg:hidden" aria-hidden />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop */}
|
||||
{session ? (
|
||||
<nav
|
||||
className="hidden items-center gap-5 text-sm font-medium text-[var(--color-text)] xl:gap-6 lg:flex"
|
||||
aria-label="Main"
|
||||
>
|
||||
<Link href="/#rooms" className="transition-colors hover:text-[var(--color-accent)]">
|
||||
Rooms
|
||||
</Link>
|
||||
<NavDropdown label="Services" items={servicesLinks} id="nav-services-menu" />
|
||||
<NavDropdown label="Amenities" items={amenitiesLinks} id="nav-amenities-menu" />
|
||||
</nav>
|
||||
) : (
|
||||
<nav
|
||||
className="hidden items-center gap-6 text-sm font-medium text-[var(--color-text)] lg:flex"
|
||||
aria-label="Main"
|
||||
>
|
||||
{guestNavDesktop.map((item) => (
|
||||
<Link
|
||||
key={item.href + item.label}
|
||||
href={item.href}
|
||||
className="transition-colors hover:text-[var(--color-accent)]"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Mobile hamburger */}
|
||||
<div className="flex shrink-0 items-center lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full text-[var(--color-text)] transition hover:bg-[var(--color-surface-muted)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-primary)]"
|
||||
aria-expanded={mobileOpen}
|
||||
aria-controls="mobile-nav-drawer"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<HamburgerIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="mobile-nav-drawer">
|
||||
<MobileNavDrawer open={mobileOpen} onClose={closeMobile} session={session} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ import { ShitayeLogoLoader } from "@/components/ShitayeLogoLoader";
|
|||
|
||||
type Props = { children: React.ReactNode; redirectTo?: string };
|
||||
|
||||
export function RequireAuth({ children, redirectTo = "/login" }: Props) {
|
||||
export function RequireAuth({ children, redirectTo = "/guest/login" }: Props) {
|
||||
const { session, isHydrated } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
|
|
@ -35,7 +35,10 @@ export function RequireAuth({ children, redirectTo = "/login" }: Props) {
|
|||
return (
|
||||
<div className="mx-auto max-w-lg px-4 py-20 text-center">
|
||||
<p className="text-sm text-[var(--color-muted)]">Redirecting to sign-in…</p>
|
||||
<Link href="/login" className="mt-4 inline-block text-sm font-semibold text-[var(--color-accent)]">
|
||||
<Link
|
||||
href="/guest/login"
|
||||
className="mt-4 inline-block text-sm font-semibold text-[var(--color-accent)]"
|
||||
>
|
||||
Continue manually
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
|||
49
src/lib/data/guestBookings.ts
Normal file
49
src/lib/data/guestBookings.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* Mock allowlist for confirmation / hold references (PAY-*, SHY-*) + last name check.
|
||||
* Aligns with mock IDs from `src/lib/mocks/api.ts` style.
|
||||
*/
|
||||
export type GuestBookingRecord = {
|
||||
ref: string;
|
||||
/** Normalized uppercase last name for demo validation */
|
||||
lastNameKey: string;
|
||||
guestName: string;
|
||||
roomLabel: string;
|
||||
checkOut: string;
|
||||
};
|
||||
|
||||
export const guestBookingAllowlist: GuestBookingRecord[] = [
|
||||
{
|
||||
ref: "PAY-MOCK-CONFIRMED",
|
||||
lastNameKey: "DEMO",
|
||||
guestName: "Demo Guest",
|
||||
roomLabel: "Junior Studio · 1204",
|
||||
checkOut: "2026-04-12",
|
||||
},
|
||||
{
|
||||
ref: "SHY-MOCK-HOLD",
|
||||
lastNameKey: "HOLD",
|
||||
guestName: "Hold Guest",
|
||||
roomLabel: "Standard King · 602",
|
||||
checkOut: "2026-04-10",
|
||||
},
|
||||
];
|
||||
|
||||
function normalize(s: string): string {
|
||||
return s.trim().toUpperCase().replace(/[^A-Z]/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Match booking reference + last name against allowlist.
|
||||
*/
|
||||
export function lookupGuestBooking(
|
||||
refRaw: string,
|
||||
lastNameRaw: string,
|
||||
): GuestBookingRecord | null {
|
||||
const ref = refRaw.trim().toUpperCase();
|
||||
const last = normalize(lastNameRaw);
|
||||
if (!ref || !last) return null;
|
||||
return (
|
||||
guestBookingAllowlist.find((r) => r.ref.toUpperCase() === ref && r.lastNameKey === last) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
93
src/lib/data/marketing-room-pages.ts
Normal file
93
src/lib/data/marketing-room-pages.ts
Normal 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);
|
||||
}
|
||||
77
src/lib/guest/repository.ts
Normal file
77
src/lib/guest/repository.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* localStorage persistence for guest session and orders.
|
||||
* Replace with D1 / REST / Supabase behind the same interface in Phase 2.
|
||||
*/
|
||||
import type { GuestSession, OrderRecord } from "./types";
|
||||
|
||||
export const STORAGE_SESSION_KEY = "shitaye_session_v1";
|
||||
export const STORAGE_ORDERS_KEY = "shitaye_orders_v1";
|
||||
|
||||
export function loadSession(): GuestSession | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_SESSION_KEY);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as GuestSession;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSession(session: GuestSession | null): void {
|
||||
if (typeof window === "undefined") return;
|
||||
if (session) {
|
||||
localStorage.setItem(STORAGE_SESSION_KEY, JSON.stringify(session));
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_SESSION_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function loadOrders(): OrderRecord[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_ORDERS_KEY);
|
||||
if (!raw) return seedOrders();
|
||||
const parsed = JSON.parse(raw) as OrderRecord[];
|
||||
return Array.isArray(parsed) ? parsed : seedOrders();
|
||||
} catch {
|
||||
return seedOrders();
|
||||
}
|
||||
}
|
||||
|
||||
export function saveOrders(orders: OrderRecord[]): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(STORAGE_ORDERS_KEY, JSON.stringify(orders));
|
||||
}
|
||||
|
||||
function seedOrders(): OrderRecord[] {
|
||||
return [
|
||||
{
|
||||
id: "seed-rs-1",
|
||||
category: "room-service",
|
||||
title: "Room service · American breakfast ×2",
|
||||
detail: "Delivered 07:15 · Room charge",
|
||||
totalUsd: 36,
|
||||
placedAt: new Date(Date.now() - 86400000 * 2).toISOString(),
|
||||
status: "completed",
|
||||
},
|
||||
{
|
||||
id: "seed-l-1",
|
||||
category: "laundry",
|
||||
title: "Laundry · Express + 3 shirts",
|
||||
detail: "Returned same evening",
|
||||
totalUsd: 27,
|
||||
placedAt: new Date(Date.now() - 86400000).toISOString(),
|
||||
status: "completed",
|
||||
},
|
||||
{
|
||||
id: "seed-sp-1",
|
||||
category: "spa",
|
||||
title: "Spa · Signature Swedish 60 min",
|
||||
detail: "Apr 4 · 15:00",
|
||||
totalUsd: 85,
|
||||
placedAt: new Date(Date.now() - 86400000 * 3).toISOString(),
|
||||
status: "confirmed",
|
||||
},
|
||||
];
|
||||
}
|
||||
34
src/lib/guest/types.ts
Normal file
34
src/lib/guest/types.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/** Guest portal order and session types (Phase 1: client-only; swap repository for API later). */
|
||||
|
||||
export type OrderCategory = "room-service" | "laundry" | "gym" | "spa";
|
||||
|
||||
export type OrderRecord = {
|
||||
id: string;
|
||||
category: OrderCategory;
|
||||
title: string;
|
||||
detail: string;
|
||||
totalUsd: number;
|
||||
placedAt: string;
|
||||
status: "pending" | "confirmed" | "completed";
|
||||
};
|
||||
|
||||
/** Full account — email / OTP / password / social */
|
||||
export type MemberSession = {
|
||||
kind: "member";
|
||||
email: string;
|
||||
displayName: string;
|
||||
points: number;
|
||||
tier: "Gold" | "Silver";
|
||||
authMethod: "otp" | "password" | "google" | "apple" | "facebook";
|
||||
};
|
||||
|
||||
/** Limited scope — booking reference only */
|
||||
export type BookingRefSession = {
|
||||
kind: "bookingRef";
|
||||
bookingRef: string;
|
||||
guestName: string;
|
||||
roomLabel: string;
|
||||
checkOut: string;
|
||||
};
|
||||
|
||||
export type GuestSession = MemberSession | BookingRefSession;
|
||||
Loading…
Reference in New Issue
Block a user