feat(guest): hub, digital menu & laundry, auth (OTP/password/social/booking ref), profile

- AuthProvider: mock email OTP (123456), password (shitaye/demo123), social, booking refs
- Profile: points, shuttle, appointments, tabbed orders, rewards; orders persist in localStorage
- Guest hub /guest with room service, laundry, gym/spa deep links to /services?kind=
- RequireAuth + HeaderAccount; nav/footer links; spa save to profile from services
- Homepage CTA strip: Guest hub + Spa & gym

Made-with: Cursor
This commit is contained in:
“kirukib” 2026-04-06 21:06:02 +03:00
parent 93f93eb087
commit d5c7d56c11
23 changed files with 1835 additions and 18 deletions

View File

@ -0,0 +1,10 @@
import { redirect } from "next/navigation";
export const metadata = {
title: "Gym",
};
/** Deep-link to spa & gym page with gym filter. */
export default function GuestGymRedirectPage() {
redirect("/services?kind=gym");
}

View File

@ -0,0 +1,170 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { RequireAuth } from "@/components/RequireAuth";
import { useAuth } from "@/context/AuthContext";
import { laundryItems } from "@/lib/mocks/laundryCatalog";
export function LaundryClient() {
return (
<RequireAuth redirectTo="/login">
<LaundryInner />
</RequireAuth>
);
}
function LaundryInner() {
const { addOrder } = useAuth();
const [qty, setQty] = useState<Record<string, number>>({});
const [express, setExpress] = useState(false);
const [sent, setSent] = useState(false);
function bump(id: string, delta: number) {
setQty((prev) => {
const next = { ...prev };
const n = Math.max(0, (next[id] ?? 0) + delta);
if (n === 0) delete next[id];
else next[id] = n;
return next;
});
}
const lines = useMemo(() => {
const out: { id: string; name: string; count: number; unitUsd: number }[] = [];
for (const row of laundryItems) {
const q = qty[row.id];
if (q && q > 0) {
out.push({ id: row.id, name: row.name, count: q, unitUsd: row.priceUsd });
}
}
return out;
}, [qty]);
const subtotal = useMemo(() => {
let s = lines.reduce((a, l) => a + l.unitUsd * l.count, 0);
if (express) s += 15;
return s;
}, [lines, express]);
function submit() {
if (lines.length === 0 && !express) return;
const detail = [
...lines.map((l) => `${l.name} ×${l.count}`),
express ? "Express same-day (+$15)" : null,
]
.filter(Boolean)
.join("; ");
addOrder({
category: "laundry",
title: "Laundry · " + (lines.length ? `${lines.length} item type(s)` : "Express only"),
detail,
totalUsd: Math.round(subtotal * 100) / 100,
status: "pending",
});
setQty({});
setExpress(false);
setSent(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)]">Laundry</span>
</nav>
<div className="mt-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
<h1 className="font-heading text-3xl font-semibold text-[var(--color-text)] md:text-4xl">
Laundry service
</h1>
<p className="mt-2 max-w-xl text-sm text-[var(--color-muted)]">
Select pieces and optional express surcharge. Mock request pickup at reception.
</p>
</div>
<Link href="/profile" className="text-sm font-semibold text-[var(--color-accent)] hover:underline">
View profile
</Link>
</div>
{sent ? (
<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 logged (demo). Our team will confirm timing by phone.
</div>
) : null}
<div className="mt-10 grid gap-10 lg:grid-cols-[1fr_380px]">
<div className="space-y-2">
{laundryItems.map((row) => (
<div
key={row.id}
className="flex flex-col gap-3 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between"
>
<div>
<p className="font-semibold text-[var(--color-text)]">{row.name}</p>
<p className="text-sm text-[var(--color-muted)]">
{row.description} · ${row.priceUsd}/{row.unit}
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => bump(row.id, -1)}
className="h-9 w-9 rounded-full border border-[var(--color-border)] text-lg font-semibold"
>
</button>
<span className="w-8 text-center font-semibold">{qty[row.id] ?? 0}</span>
<button
type="button"
onClick={() => bump(row.id, 1)}
className="h-9 w-9 rounded-full border border-[var(--color-border)] text-lg font-semibold"
>
+
</button>
</div>
</div>
))}
<label className="mt-4 flex cursor-pointer items-center gap-3 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-muted)] p-4">
<input
type="checkbox"
checked={express}
onChange={(e) => setExpress(e.target.checked)}
className="h-4 w-4 rounded border-[var(--color-border)]"
/>
<span className="text-sm text-[var(--color-text)]">
Express same-day (+$15 per order)
</span>
</label>
</div>
<aside className="lg:sticky lg:top-28">
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-primary)]">
Summary
</p>
<p className="mt-4 font-heading text-2xl font-semibold">${subtotal.toFixed(2)}</p>
<button
type="button"
onClick={submit}
disabled={lines.length === 0 && !express}
className="btn-mustard mt-6 w-full justify-center py-3 text-sm disabled:cursor-not-allowed disabled:opacity-50"
>
Submit laundry request
</button>
</div>
</aside>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,10 @@
import { LaundryClient } from "./LaundryClient";
export const metadata = {
title: "Laundry",
description: "Laundry and pressing service — Shitaye Suite Hotel.",
};
export default function LaundryPage() {
return <LaundryClient />;
}

116
src/app/guest/page.tsx Normal file
View File

@ -0,0 +1,116 @@
import type { Metadata } from "next";
import Link from "next/link";
import { siteConfig } from "@/lib/mocks/site";
export const metadata: Metadata = {
title: "Guest hub",
description: "Digital room service, laundry, gym, and spa — order during your stay at Shitaye.",
};
const tiles = [
{
href: "/guest/room-service",
title: "Digital menu",
subtitle: "Room service",
desc: "Breakfast through late evening — add to tray and send to the kitchen (demo).",
icon: "🍽",
},
{
href: "/guest/laundry",
title: "Laundry",
subtitle: "Pressing & express",
desc: "Shirts, suits, and same-day express — priced per item.",
icon: "👔",
},
{
href: "/guest/gym",
title: "Gym",
subtitle: "Sessions & passes",
desc: "Day passes, PT, and classes — opens spa & gym menu filtered to fitness.",
icon: "🏋",
},
{
href: "/guest/spa",
title: "Spa",
subtitle: "Treatments",
desc: "Massages and rituals — opens spa & gym menu filtered to spa.",
icon: "🌿",
},
];
export default function GuestHubPage() {
return (
<div className="bg-[var(--color-bg)]">
<section className="border-b border-[var(--color-border)] bg-pattern-brand-gold py-14 md:py-20">
<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)]">Guest hub</span>
</nav>
<h1 className="mt-4 max-w-3xl font-heading text-3xl font-semibold tracking-tight text-[var(--color-text)] md:text-5xl">
During your stay
</h1>
<p className="mt-5 max-w-2xl text-sm leading-relaxed text-[var(--color-muted)] md:text-base">
Order to your room, schedule laundry, and book gym & spa all in one place. Sign in with
email or{" "}
<span className="font-medium text-[var(--color-text)]">booking reference</span> to track
orders on your profile.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<Link href="/login" className="btn-mustard px-6 py-3 text-sm">
Sign in
</Link>
<Link
href="/profile"
className="inline-flex items-center rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-6 py-3 text-sm font-semibold text-[var(--color-text)] transition hover:border-[var(--color-accent)]"
>
My stay profile
</Link>
</div>
</div>
</section>
<section className="mx-auto max-w-7xl px-4 py-14 md:px-8 md:py-20">
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{tiles.map((t) => (
<Link
key={t.href}
href={t.href}
className="card-lift group flex flex-col rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm md:p-7"
>
<span className="text-3xl" aria-hidden>
{t.icon}
</span>
<p className="mt-4 text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-primary)]">
{t.subtitle}
</p>
<h2 className="mt-2 font-heading text-xl font-semibold text-[var(--color-text)]">
{t.title}
</h2>
<p className="mt-2 flex-1 text-sm leading-relaxed text-[var(--color-muted)]">
{t.desc}
</p>
<span className="mt-6 text-sm font-semibold text-[var(--color-accent)] group-hover:underline">
Open
</span>
</Link>
))}
</div>
<p className="mt-12 text-center text-sm text-[var(--color-muted)]">
Questions?{" "}
<a href={`mailto:${siteConfig.email}`} className="font-semibold text-[var(--color-accent)]">
{siteConfig.email}
</a>{" "}
·{" "}
<a href={`tel:${siteConfig.primaryPhone.replace(/\s/g, "")}`} className="font-semibold">
{siteConfig.primaryPhone}
</a>
</p>
</section>
</div>
);
}

View File

@ -0,0 +1,209 @@
"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 {
roomServiceCategories,
roomServiceItems,
type MenuCategory,
type MenuItem,
} from "@/lib/mocks/roomServiceMenu";
export function RoomServiceClient() {
return (
<RequireAuth redirectTo="/login">
<RoomServiceInner />
</RequireAuth>
);
}
function RoomServiceInner() {
const { addOrder } = useAuth();
const [cat, setCat] = useState<MenuCategory>("breakfast");
const [qty, setQty] = useState<Record<string, number>>({});
const [sent, setSent] = useState(false);
const items = useMemo(
() => roomServiceItems.filter((i) => i.category === cat),
[cat],
);
function bump(id: string, delta: number) {
setQty((prev) => {
const next = { ...prev };
const n = Math.max(0, (next[id] ?? 0) + delta);
if (n === 0) delete next[id];
else next[id] = n;
return next;
});
}
const cartLines = useMemo(() => {
const lines: { item: MenuItem; count: number }[] = [];
for (const id of Object.keys(qty)) {
const item = roomServiceItems.find((i) => i.id === id);
const count = qty[id];
if (item && count > 0) lines.push({ item, count });
}
return lines;
}, [qty]);
const subtotal = useMemo(
() => cartLines.reduce((s, l) => s + l.item.priceUsd * l.count, 0),
[cartLines],
);
function submit() {
if (cartLines.length === 0) return;
const detail = cartLines.map((l) => `${l.item.name} ×${l.count}`).join("; ");
addOrder({
category: "room-service",
title: `Room service · ${cartLines.length} line(s)`,
detail,
totalUsd: Math.round(subtotal * 100) / 100,
status: "pending",
});
setQty({});
setSent(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)]">Room service</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">
Digital menu
</h1>
<p className="mt-2 max-w-xl text-sm text-[var(--color-muted)]">
Mock ordering your tray appears on your profile under orders. Service charges may
apply.
</p>
</div>
<Link href="/profile" className="text-sm font-semibold text-[var(--color-accent)] hover:underline">
View profile
</Link>
</div>
{sent ? (
<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)]">
Order sent to the kitchen queue (demo). Add another round or check your profile.
</div>
) : null}
<div className="mt-8 flex flex-wrap gap-2">
{roomServiceCategories.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setCat(c.id)}
className={`rounded-full border px-4 py-2 text-xs font-semibold transition md:text-sm ${
cat === c.id
? "border-[var(--color-primary)] bg-[var(--color-primary)] text-[var(--color-on-primary)]"
: "border-[var(--color-border)] bg-[var(--color-surface)] hover:bg-[var(--color-surface-muted)]"
}`}
>
{c.label}
</button>
))}
</div>
<div className="mt-10 grid gap-10 lg:grid-cols-[1fr_360px]">
<div className="grid gap-4 sm:grid-cols-2">
{items.map((item) => (
<article
key={item.id}
className="flex flex-col rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-4 shadow-sm"
>
<div className="relative aspect-[4/3] overflow-hidden rounded-xl bg-[var(--color-surface-muted)]">
<Image
src="https://images.unsplash.com/photo-1414235077428-338989a2e8c0?w=600&q=80"
alt=""
fill
className="object-cover opacity-90"
sizes="(max-width:1024px) 50vw, 25vw"
/>
</div>
<h2 className="mt-3 font-heading text-lg font-semibold text-[var(--color-text)]">
{item.name}
</h2>
<p className="mt-1 text-sm text-[var(--color-muted)]">{item.description}</p>
<p className="mt-2 text-sm font-semibold text-[var(--color-primary)]">
${item.priceUsd}
</p>
<div className="mt-3 flex items-center gap-2">
<button
type="button"
onClick={() => bump(item.id, -1)}
className="h-9 w-9 rounded-full border border-[var(--color-border)] text-lg font-semibold"
aria-label="Decrease"
>
</button>
<span className="w-8 text-center font-semibold">{qty[item.id] ?? 0}</span>
<button
type="button"
onClick={() => bump(item.id, 1)}
className="h-9 w-9 rounded-full border border-[var(--color-border)] text-lg font-semibold"
aria-label="Increase"
>
+
</button>
</div>
</article>
))}
</div>
<aside className="lg:sticky lg:top-28">
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-primary)]">
Your tray
</p>
{cartLines.length === 0 ? (
<p className="mt-4 text-sm text-[var(--color-muted)]">No items yet.</p>
) : (
<ul className="mt-4 space-y-2 text-sm">
{cartLines.map((l) => (
<li key={l.item.id} className="flex justify-between gap-2">
<span className="text-[var(--color-text)]">
{l.item.name} ×{l.count}
</span>
<span className="font-medium">${(l.item.priceUsd * l.count).toFixed(0)}</span>
</li>
))}
</ul>
)}
<div className="mt-4 flex flex-wrap items-center justify-between gap-2 border-t border-[var(--color-border)] pt-4">
<span className="text-sm text-[var(--color-muted)]">Subtotal</span>
<span className="font-heading text-xl font-semibold">${subtotal.toFixed(2)}</span>
</div>
<button
type="button"
onClick={submit}
disabled={cartLines.length === 0}
className="btn-mustard mt-6 w-full justify-center py-3 text-sm disabled:cursor-not-allowed disabled:opacity-50"
>
Send to kitchen
</button>
</div>
</aside>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,10 @@
import { RoomServiceClient } from "./RoomServiceClient";
export const metadata = {
title: "Room service",
description: "Digital room service menu — Shitaye Suite Hotel.",
};
export default function RoomServicePage() {
return <RoomServiceClient />;
}

View File

@ -0,0 +1,9 @@
import { redirect } from "next/navigation";
export const metadata = {
title: "Spa",
};
export default function GuestSpaRedirectPage() {
redirect("/services?kind=spa");
}

View File

@ -0,0 +1,298 @@
"use client";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { useAuth } from "@/context/AuthContext";
type Tab = "otp" | "password" | "social" | "booking";
export function LoginPageClient() {
const router = useRouter();
const searchParams = useSearchParams();
const nextPath = searchParams.get("next") || "/profile";
const {
requestOtp,
verifyOtp,
loginPassword,
loginSocial,
loginBookingRef,
} = useAuth();
const [tab, setTab] = useState<Tab>("otp");
const [email, setEmail] = useState("");
const [otp, setOtp] = useState("");
const [otpStep, setOtpStep] = useState<1 | 2>(1);
const [password, setPassword] = useState("");
const [bookingRef, setBookingRef] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSendOtp(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setMessage(null);
const r = await requestOtp(email);
setLoading(false);
setMessage(r.message);
if (r.ok) setOtpStep(2);
}
async function handleVerifyOtp(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setMessage(null);
const r = await verifyOtp(email, otp);
setLoading(false);
setMessage(r.message);
if (r.ok) router.push(nextPath);
}
async function handlePassword(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setMessage(null);
const r = await loginPassword(email, password);
setLoading(false);
setMessage(r.message);
if (r.ok) router.push(nextPath);
}
function handleSocial(provider: "google" | "apple" | "facebook") {
loginSocial(provider);
router.push(nextPath);
}
function handleBookingRef(e: React.FormEvent) {
e.preventDefault();
setMessage(null);
const r = loginBookingRef(bookingRef);
setMessage(r.message);
if (r.ok) router.push(nextPath);
}
const tabs: { id: Tab; label: string }[] = [
{ id: "otp", label: "Email & OTP" },
{ id: "password", label: "Password" },
{ id: "social", label: "Social" },
{ id: "booking", label: "Booking ID" },
];
return (
<div className="bg-[var(--color-bg)] py-16 md:py-24">
<div className="mx-auto max-w-lg 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)]">Sign in</span>
</nav>
<h1 className="mt-4 font-heading text-3xl font-semibold text-[var(--color-text)] md:text-4xl">
Guest access
</h1>
<p className="mt-3 text-sm leading-relaxed text-[var(--color-muted)]">
Sign in with email (OTP or password), social accounts, or your reservation reference to
order room service, laundry, and manage your stay profile.
</p>
<div className="mt-8 flex flex-wrap gap-2 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-1.5 shadow-sm">
{tabs.map((t) => (
<button
key={t.id}
type="button"
onClick={() => {
setTab(t.id);
setMessage(null);
}}
className={`flex-1 rounded-xl px-3 py-2.5 text-center text-xs font-semibold transition sm:text-sm ${
tab === t.id
? "bg-[var(--color-primary)] text-[var(--color-on-primary)] shadow"
: "text-[var(--color-muted)] hover:bg-[var(--color-surface-muted)]"
}`}
>
{t.label}
</button>
))}
</div>
<div className="mt-8 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm md:p-8">
{tab === "otp" && (
<div>
{otpStep === 1 ? (
<form onSubmit={handleSendOtp} className="space-y-4">
<label className="block text-sm font-medium text-[var(--color-text)]">
Email
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
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="you@example.com"
/>
</label>
<button
type="submit"
disabled={loading}
className="btn-mustard w-full justify-center py-3 text-sm disabled:opacity-60"
>
{loading ? "Sending…" : "Send verification code"}
</button>
</form>
) : (
<form onSubmit={handleVerifyOtp} className="space-y-4">
<p className="text-sm text-[var(--color-muted)]">
Code sent to <strong className="text-[var(--color-text)]">{email}</strong>
</p>
<label className="block text-sm font-medium text-[var(--color-text)]">
One-time code
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
required
value={otp}
onChange={(e) => setOtp(e.target.value)}
className="mt-1.5 w-full rounded-xl border border-[var(--color-border)] px-4 py-3 font-mono text-lg tracking-widest focus:border-[var(--color-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20"
placeholder="123456"
maxLength={8}
/>
</label>
<p className="text-xs text-[var(--color-muted)]">
Demo: enter <strong>123456</strong>
</p>
<div className="flex gap-2">
<button
type="button"
onClick={() => {
setOtpStep(1);
setOtp("");
}}
className="flex-1 rounded-full border border-[var(--color-border)] py-3 text-sm font-semibold"
>
Back
</button>
<button
type="submit"
disabled={loading}
className="btn-mustard flex-[2] justify-center py-3 text-sm disabled:opacity-60"
>
{loading ? "Verifying…" : "Verify & sign in"}
</button>
</div>
</form>
)}
</div>
)}
{tab === "password" && (
<form onSubmit={handlePassword} className="space-y-4">
<label className="block text-sm font-medium text-[var(--color-text)]">
Email
<input
type="email"
required
value={email}
onChange={(e) => setEmail(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"
/>
</label>
<label className="block text-sm font-medium text-[var(--color-text)]">
Password
<input
type="password"
required
value={password}
onChange={(e) => setPassword(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"
autoComplete="current-password"
/>
</label>
<p className="text-xs text-[var(--color-muted)]">
Demo password: <strong>shitaye</strong> or <strong>demo123</strong>
</p>
<button
type="submit"
disabled={loading}
className="btn-mustard w-full justify-center py-3 text-sm disabled:opacity-60"
>
{loading ? "Signing in…" : "Sign in"}
</button>
</form>
)}
{tab === "social" && (
<div className="space-y-3">
<p className="text-sm text-[var(--color-muted)]">
Mock sign-in no external redirect in this demo.
</p>
<button
type="button"
onClick={() => handleSocial("google")}
className="flex w-full items-center justify-center gap-2 rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] py-3 text-sm font-semibold transition hover:bg-[var(--color-surface-muted)]"
>
<span className="text-base" aria-hidden>
G
</span>
Continue with Google
</button>
<button
type="button"
onClick={() => handleSocial("apple")}
className="flex w-full items-center justify-center gap-2 rounded-full border border-[var(--color-border)] bg-[var(--color-text)] py-3 text-sm font-semibold text-white transition hover:opacity-90"
>
Continue with Apple
</button>
<button
type="button"
onClick={() => handleSocial("facebook")}
className="flex w-full items-center justify-center gap-2 rounded-full border border-[var(--color-border)] bg-[#1877f2] py-3 text-sm font-semibold text-white transition hover:opacity-95"
>
Continue with Facebook
</button>
</div>
)}
{tab === "booking" && (
<form onSubmit={handleBookingRef} className="space-y-4">
<label className="block text-sm font-medium text-[var(--color-text)]">
Booking / confirmation 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"
/>
</label>
<p className="text-xs text-[var(--color-muted)]">
Try <strong>SHITAYE-2026-DEMO</strong> or <strong>GUEST-1234</strong> no email
required. You can place orders and view a limited stay profile.
</p>
<button type="submit" className="btn-mustard w-full justify-center py-3 text-sm">
Continue with booking ID
</button>
</form>
)}
{message ? (
<p
className={`mt-4 text-sm ${message.includes("not") || message.includes("Invalid") || message.includes("Incorrect") ? "text-red-700" : "text-[var(--color-primary)]"}`}
>
{message}
</p>
) : null}
</div>
<p className="mt-8 text-center text-sm text-[var(--color-muted)]">
<Link href="/guest" className="font-semibold text-[var(--color-accent)] hover:underline">
Guest hub room service & laundry
</Link>
</p>
</div>
</div>
);
}

22
src/app/login/page.tsx Normal file
View File

@ -0,0 +1,22 @@
import { Suspense } from "react";
import { ShitayeLogoLoader } from "@/components/ShitayeLogoLoader";
import { LoginPageClient } from "./LoginPageClient";
export const metadata = {
title: "Sign in",
description: "Sign in to Shitaye Suite Hotel guest portal — OTP, password, social, or booking ID.",
};
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>
}
>
<LoginPageClient />
</Suspense>
);
}

View File

@ -185,13 +185,21 @@ export default function HomePage() {
Treatments & fitness sessions Treatments & fitness sessions
</h2> </h2>
<p className="mt-3 text-sm leading-relaxed text-[var(--color-muted)]"> <p className="mt-3 text-sm leading-relaxed text-[var(--color-muted)]">
Browse our spa menu and gym add-ons build a mock selection and send a request from the Browse our spa menu and gym add-ons or use the guest hub for room service, laundry, and
dedicated services page. profile access.
</p> </p>
</div> </div>
<Link href="/services" className="btn-mustard shrink-0 px-8 py-3.5 text-sm"> <div className="flex shrink-0 flex-col gap-3 sm:flex-row">
View spa & gym services <Link
href="/guest"
className="inline-flex items-center justify-center rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-6 py-3.5 text-sm font-semibold text-[var(--color-text)] transition hover:border-[var(--color-accent)] hover:text-[var(--color-accent)]"
>
Guest hub
</Link> </Link>
<Link href="/services" className="btn-mustard px-8 py-3.5 text-sm">
Spa & gym
</Link>
</div>
</div> </div>
</section> </section>

View File

@ -0,0 +1,247 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { RequireAuth } from "@/components/RequireAuth";
import { useAuth } from "@/context/AuthContext";
import type { OrderCategory, OrderRecord } from "@/context/AuthContext";
import {
seedAppointments,
seedRewardsHistory,
seedShuttle,
} from "@/lib/mocks/guestData";
import { siteConfig } from "@/lib/mocks/site";
const orderTabs: { id: OrderCategory | "all"; label: string }[] = [
{ id: "all", label: "All" },
{ id: "room-service", label: "Room service" },
{ id: "laundry", label: "Laundry" },
{ id: "gym", label: "Gym" },
{ id: "spa", label: "Spa" },
];
function formatWhen(iso: string) {
try {
return new Date(iso).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
} catch {
return iso;
}
}
function OrderRow({ o }: { o: OrderRecord }) {
return (
<li className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-4 py-3 text-sm">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<p className="font-semibold text-[var(--color-text)]">{o.title}</p>
<p className="mt-0.5 text-xs text-[var(--color-muted)]">{o.detail}</p>
<p className="mt-1 text-xs text-[var(--color-muted)]">{formatWhen(o.placedAt)}</p>
</div>
<div className="text-right">
<span className="text-[10px] font-bold uppercase tracking-wider text-[var(--color-primary)]">
{o.status}
</span>
<p className="font-semibold text-[var(--color-text)]">${o.totalUsd.toFixed(0)}</p>
</div>
</div>
</li>
);
}
export function ProfilePageClient() {
return (
<RequireAuth>
<ProfileContent />
</RequireAuth>
);
}
function ProfileContent() {
const { session, orders, logout } = useAuth();
const [orderFilter, setOrderFilter] = useState<OrderCategory | "all">("all");
const filteredOrders = useMemo(() => {
if (orderFilter === "all") return orders;
return orders.filter((o) => o.category === orderFilter);
}, [orders, orderFilter]);
if (!session) {
return null;
}
return (
<div className="bg-[var(--color-bg)] pb-20 pt-8 md:pt-12">
<div className="mx-auto max-w-7xl px-4 md:px-8">
<nav className="text-xs font-medium text-[var(--color-muted)]">
<Link href="/" className="hover:text-[var(--color-accent)]">
Home
</Link>
<span className="mx-2 opacity-50">/</span>
<span className="text-[var(--color-text)]">My stay</span>
</nav>
<div className="mt-6 flex flex-col gap-4 border-b border-[var(--color-border)] pb-8 md:flex-row md:items-end md:justify-between">
<div>
<h1 className="font-heading text-3xl font-semibold text-[var(--color-text)] md:text-4xl">
{session.kind === "member"
? `Hello, ${session.displayName}`
: `Welcome, ${session.guestName}`}
</h1>
<p className="mt-2 text-sm text-[var(--color-muted)]">
{session.kind === "member" ? (
<>
<span className="font-medium text-[var(--color-text)]">{session.email}</span>
{" · "}
Signed in via {session.authMethod}
</>
) : (
<>
Booking <span className="font-mono font-semibold">{session.bookingRef}</span>
{" · "}
{session.roomLabel} · checkout {session.checkOut}
</>
)}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Link href="/guest" className="btn-mustard px-5 py-2.5 text-sm">
Guest hub
</Link>
<button
type="button"
onClick={() => logout()}
className="rounded-full border border-[var(--color-border)] px-5 py-2.5 text-sm font-semibold text-[var(--color-muted)] transition hover:bg-[var(--color-surface-muted)]"
>
Sign out
</button>
</div>
</div>
<div className="mt-10 grid gap-6 lg:grid-cols-3">
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-primary)]">
Rewards points
</p>
{session.kind === "member" ? (
<>
<p className="mt-3 font-heading text-4xl font-semibold text-[var(--color-text)]">
{session.points.toLocaleString()}
</p>
<p className="mt-1 text-sm text-[var(--color-muted)]">
{session.tier} tier · earn on stays & dining
</p>
</>
) : (
<p className="mt-3 text-sm leading-relaxed text-[var(--color-muted)]">
Full loyalty points unlock when you sign in with email. Booking-ID access covers
orders and stay tools.
</p>
)}
</div>
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm lg:col-span-2">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-primary)]">
Airport shuttle
</p>
<p className="mt-2 font-heading text-xl text-[var(--color-text)]">
Lobby pickup · {seedShuttle.lobbyPickupTime}
</p>
<p className="mt-2 text-sm text-[var(--color-muted)]">
{new Date(seedShuttle.departureDate).toLocaleDateString(undefined, {
weekday: "long",
month: "long",
day: "numeric",
})}{" "}
· {seedShuttle.airport}
</p>
<p className="mt-1 text-sm font-medium text-[var(--color-text)]">
{seedShuttle.flightLabel}
</p>
<p className="mt-3 text-xs text-[var(--color-muted)]">{seedShuttle.notes}</p>
<a
href={`mailto:${siteConfig.email}?subject=Shuttle%20change`}
className="mt-4 inline-block text-sm font-semibold text-[var(--color-accent)] hover:underline"
>
Request a change
</a>
</div>
</div>
<section className="mt-12">
<h2 className="font-heading text-2xl text-[var(--color-text)]">Booked appointments</h2>
<ul className="mt-4 grid gap-3 md:grid-cols-2">
{seedAppointments.map((a) => (
<li
key={a.id}
className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-sm"
>
<p className="text-xs font-semibold uppercase tracking-wider text-[var(--color-primary)]">
{a.status}
</p>
<p className="mt-2 font-semibold text-[var(--color-text)]">{a.title}</p>
<p className="mt-1 text-sm text-[var(--color-muted)]">{a.when}</p>
<p className="mt-1 text-sm text-[var(--color-muted)]">{a.where}</p>
</li>
))}
</ul>
</section>
<section className="mt-12">
<h2 className="font-heading text-2xl text-[var(--color-text)]">Orders</h2>
<p className="mt-1 text-sm text-[var(--color-muted)]">
Room service, laundry, gym, and spa including demo history and new orders from this
device.
</p>
<div className="mt-4 flex flex-wrap gap-2">
{orderTabs.map((t) => (
<button
key={t.id}
type="button"
onClick={() => setOrderFilter(t.id)}
className={`rounded-full border px-4 py-2 text-xs font-semibold transition md:text-sm ${
orderFilter === t.id
? "border-[var(--color-primary)] bg-[var(--color-primary)] text-[var(--color-on-primary)]"
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)] hover:bg-[var(--color-surface-muted)]"
}`}
>
{t.label}
</button>
))}
</div>
{filteredOrders.length === 0 ? (
<p className="mt-6 rounded-xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-muted)] px-4 py-10 text-center text-sm text-[var(--color-muted)]">
No orders in this category yet.
</p>
) : (
<ul className="mt-6 space-y-3">
{filteredOrders.map((o) => (
<OrderRow key={o.id} o={o} />
))}
</ul>
)}
</section>
<section className="mt-12">
<h2 className="font-heading text-2xl text-[var(--color-text)]">Rewards earned</h2>
<ul className="mt-4 space-y-2">
{seedRewardsHistory.map((r) => (
<li
key={r.id}
className="flex items-center justify-between rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-sm"
>
<div>
<p className="font-medium text-[var(--color-text)]">{r.label}</p>
<p className="text-xs text-[var(--color-muted)]">{r.earnedAt}</p>
</div>
<span className="badge-mustard">+{r.points} pts</span>
</li>
))}
</ul>
</section>
</div>
</div>
);
}

10
src/app/profile/page.tsx Normal file
View File

@ -0,0 +1,10 @@
import { ProfilePageClient } from "./ProfilePageClient";
export const metadata = {
title: "My stay",
description: "Profile, rewards, appointments, shuttle, and orders at Shitaye Suite Hotel.",
};
export default function ProfilePage() {
return <ProfilePageClient />;
}

View File

@ -1,5 +1,6 @@
"use client"; "use client";
import { AuthProvider } from "@/context/AuthContext";
import { BookingProvider } from "@/context/BookingContext"; import { BookingProvider } from "@/context/BookingContext";
import { CurrencyProvider } from "@/context/CurrencyContext"; import { CurrencyProvider } from "@/context/CurrencyContext";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
@ -7,7 +8,9 @@ import type { ReactNode } from "react";
export function Providers({ children }: { children: ReactNode }) { export function Providers({ children }: { children: ReactNode }) {
return ( return (
<CurrencyProvider> <CurrencyProvider>
<AuthProvider>
<BookingProvider>{children}</BookingProvider> <BookingProvider>{children}</BookingProvider>
</AuthProvider>
</CurrencyProvider> </CurrencyProvider>
); );
} }

View File

@ -2,7 +2,9 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useMemo, useState } from "react"; import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/context/AuthContext";
import { import {
spaGymFilters, spaGymFilters,
spaGymServices, spaGymServices,
@ -156,9 +158,18 @@ function SelectionPanel({
} }
export function ServicesPageClient() { export function ServicesPageClient() {
const searchParams = useSearchParams();
const { session, addOrder } = useAuth();
const [filter, setFilter] = useState<SpaGymFilterId>("all"); const [filter, setFilter] = useState<SpaGymFilterId>("all");
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
useEffect(() => {
const k = searchParams.get("kind");
if (k === "spa" || k === "gym") {
setFilter(k);
}
}, [searchParams]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (filter === "all") return spaGymServices; if (filter === "all") return spaGymServices;
return spaGymServices.filter((s) => s.kind === filter); return spaGymServices.filter((s) => s.kind === filter);
@ -190,6 +201,20 @@ export function ServicesPageClient() {
setSelected(new Set()); setSelected(new Set());
} }
function saveSelectionToProfile() {
if (!session || selectedItems.length === 0) return;
for (const s of selectedItems) {
addOrder({
category: s.kind === "spa" ? "spa" : "gym",
title: `${s.kind === "spa" ? "Spa" : "Gym"} · ${s.title}`,
detail: `${s.duration} · $${s.priceUsd} (${s.priceNote})`,
totalUsd: s.priceUsd,
status: "pending",
});
}
clear();
}
return ( return (
<div className="bg-[var(--color-bg)]"> <div className="bg-[var(--color-bg)]">
<section className="border-b border-[var(--color-border)] bg-pattern-brand-gold py-12 md:py-16"> <section className="border-b border-[var(--color-border)] bg-pattern-brand-gold py-12 md:py-16">
@ -251,11 +276,26 @@ export function ServicesPageClient() {
<aside className="lg:sticky lg:top-28"> <aside className="lg:sticky lg:top-28">
<SelectionPanel items={selectedItems} onRemove={remove} onClear={clear} /> <SelectionPanel items={selectedItems} onRemove={remove} onClear={clear} />
{session && selectedItems.length > 0 ? (
<button
type="button"
onClick={saveSelectionToProfile}
className="mt-4 w-full rounded-full border-2 border-[var(--color-primary)] bg-[var(--color-surface)] py-3 text-sm font-semibold text-[var(--color-primary)] transition hover:bg-[var(--color-primary)] hover:text-[var(--color-on-primary)]"
>
Save selection to my stay
</button>
) : null}
<Link
href="/guest"
className="mt-3 block text-center text-sm font-medium text-[var(--color-muted)] hover:text-[var(--color-accent)]"
>
Guest hub
</Link>
<Link <Link
href="/#wellness" href="/#wellness"
className="mt-4 block text-center text-sm font-semibold text-[var(--color-primary)] underline-offset-4 hover:underline" className="mt-2 block text-center text-sm font-semibold text-[var(--color-primary)] underline-offset-4 hover:underline"
> >
Back to hotel wellness overview Hotel wellness overview
</Link> </Link>
</aside> </aside>
</div> </div>

View File

@ -1,4 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Suspense } from "react";
import { ShitayeLogoLoader } from "@/components/ShitayeLogoLoader";
import { ServicesPageClient } from "./ServicesPageClient"; import { ServicesPageClient } from "./ServicesPageClient";
export const metadata: Metadata = { export const metadata: Metadata = {
@ -8,5 +10,15 @@ export const metadata: Metadata = {
}; };
export default function ServicesPage() { export default function ServicesPage() {
return <ServicesPageClient />; return (
<Suspense
fallback={
<div className="flex min-h-[50vh] items-center justify-center bg-[var(--color-bg)]">
<ShitayeLogoLoader label="Loading spa & gym…" />
</div>
}
>
<ServicesPageClient />
</Suspense>
);
} }

View File

@ -145,6 +145,21 @@ export function Footer() {
Rooms Rooms
</Link> </Link>
</li> </li>
<li>
<Link href="/guest" className="text-stone-200 hover:text-white">
Guest hub
</Link>
</li>
<li>
<Link href="/login" className="text-stone-200 hover:text-white">
Sign in
</Link>
</li>
<li>
<Link href="/profile" className="text-stone-200 hover:text-white">
My stay
</Link>
</li>
<li> <li>
<Link href="/services" className="text-stone-200 hover:text-white"> <Link href="/services" className="text-stone-200 hover:text-white">
Spa & gym services Spa & gym services

View File

@ -1,11 +1,13 @@
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 { CurrencySwitcher } from "@/components/CurrencySwitcher"; import { CurrencySwitcher } from "@/components/CurrencySwitcher";
import { ReviewsMenu } from "@/components/ReviewsMenu"; import { ReviewsMenu } from "@/components/ReviewsMenu";
import { siteConfig } from "@/lib/mocks/site"; import { siteConfig } from "@/lib/mocks/site";
const nav = [ const nav = [
{ href: "/#rooms", label: "Rooms" }, { href: "/#rooms", label: "Rooms" },
{ href: "/guest", label: "Guest hub" },
{ href: "/services", label: "Services" }, { href: "/services", label: "Services" },
{ href: "/#wellness", label: "Gym & Spa" }, { href: "/#wellness", label: "Gym & Spa" },
{ href: "/#dining", label: "Dining & venues" }, { href: "/#dining", label: "Dining & venues" },
@ -68,7 +70,8 @@ export function Header() {
</Link> </Link>
))} ))}
</nav> </nav>
<div className="flex shrink-0 items-center"> <div className="flex shrink-0 items-center gap-2 md:gap-3">
<HeaderAccount />
<Link <Link
href="/booking" href="/booking"
className="btn-mustard px-4 py-2.5 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-primary)] md:px-5" className="btn-mustard px-4 py-2.5 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-primary)] md:px-5"

View File

@ -0,0 +1,64 @@
"use client";
import Link from "next/link";
import { useAuth } from "@/context/AuthContext";
export function HeaderAccount() {
const { session, isHydrated } = useAuth();
if (!isHydrated) {
return (
<span className="hidden h-9 w-20 animate-pulse rounded-full bg-[var(--color-surface-muted)] sm:block" />
);
}
if (session) {
const points =
session.kind === "member" ? session.points : "—";
const label =
session.kind === "member"
? session.displayName.split(" ")[0] ?? "Guest"
: session.guestName.split(" ")[0] ?? "Guest";
return (
<div className="flex items-center gap-2 sm:gap-3">
<Link
href="/profile"
className="hidden max-w-[140px] truncate rounded-full border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3 py-1.5 text-xs font-semibold text-[var(--color-primary)] sm:inline-block"
title="Loyalty points"
>
{points !== "—" ? `${points} pts` : "Stay"}
</Link>
<Link
href="/profile"
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>
);
}
return (
<div className="flex items-center gap-2">
<Link
href="/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
</Link>
<Link
href="/guest"
className="hidden text-xs font-medium text-[var(--color-muted)] hover:text-[var(--color-accent)] md:inline"
>
Guest hub
</Link>
</div>
);
}

View File

@ -0,0 +1,46 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useAuth } from "@/context/AuthContext";
import { ShitayeLogoLoader } from "@/components/ShitayeLogoLoader";
type Props = { children: React.ReactNode; redirectTo?: string };
export function RequireAuth({ children, redirectTo = "/login" }: Props) {
const { session, isHydrated } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isHydrated) return;
if (!session) {
const next =
typeof window !== "undefined"
? `${redirectTo}?next=${encodeURIComponent(window.location.pathname)}`
: redirectTo;
router.replace(next);
}
}, [isHydrated, session, router, redirectTo]);
if (!isHydrated) {
return (
<div className="flex min-h-[50vh] items-center justify-center">
<ShitayeLogoLoader label="Checking your session…" />
</div>
);
}
if (!session) {
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)]">
Continue manually
</Link>
</div>
);
}
return <>{children}</>;
}

314
src/context/AuthContext.tsx Normal file
View File

@ -0,0 +1,314 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { DEMO_BOOKING_REFS } from "@/lib/mocks/guestData";
const STORAGE_SESSION = "shitaye_session_v1";
const STORAGE_ORDERS = "shitaye_orders_v1";
export type OrderCategory = "room-service" | "laundry" | "gym" | "spa";
export type OrderRecord = {
id: string;
category: OrderCategory;
title: string;
detail: string;
totalUsd: number;
placedAt: string;
status: "pending" | "confirmed" | "completed";
};
export type MemberSession = {
kind: "member";
email: string;
displayName: string;
points: number;
tier: "Gold" | "Silver";
/** How they signed in — for display only */
authMethod: "otp" | "password" | "google" | "apple" | "facebook";
};
export type BookingRefSession = {
kind: "bookingRef";
bookingRef: string;
guestName: string;
roomLabel: string;
checkOut: string;
};
export type GuestSession = MemberSession | BookingRefSession;
type AuthContextValue = {
session: GuestSession | null;
orders: OrderRecord[];
isHydrated: boolean;
/** Demo OTP is always 123456 */
requestOtp: (email: string) => Promise<{ ok: boolean; message: string }>;
verifyOtp: (email: string, code: string) => Promise<{ ok: boolean; message: string }>;
loginPassword: (email: string, password: string) => Promise<{ ok: boolean; message: string }>;
loginSocial: (provider: "google" | "apple" | "facebook") => void;
loginBookingRef: (ref: string) => { ok: boolean; message: string };
logout: () => void;
addOrder: (o: Omit<OrderRecord, "id" | "placedAt" | "status"> & { status?: OrderRecord["status"] }) => void;
awardPoints: (points: number) => void;
};
const AuthContext = createContext<AuthContextValue | null>(null);
function loadOrders(): OrderRecord[] {
if (typeof window === "undefined") return [];
try {
const raw = localStorage.getItem(STORAGE_ORDERS);
if (!raw) return seedOrders();
const parsed = JSON.parse(raw) as OrderRecord[];
return Array.isArray(parsed) ? parsed : seedOrders();
} catch {
return seedOrders();
}
}
function seedOrders(): OrderRecord[] {
return [
{
id: "seed-rs-1",
category: "room-service",
title: "Room service · American breakfast ×2",
detail: "Delivered 07:15 · Room charge",
totalUsd: 36,
placedAt: new Date(Date.now() - 86400000 * 2).toISOString(),
status: "completed",
},
{
id: "seed-l-1",
category: "laundry",
title: "Laundry · Express + 3 shirts",
detail: "Returned same evening",
totalUsd: 27,
placedAt: new Date(Date.now() - 86400000).toISOString(),
status: "completed",
},
{
id: "seed-sp-1",
category: "spa",
title: "Spa · Signature Swedish 60 min",
detail: "Apr 4 · 15:00",
totalUsd: 85,
placedAt: new Date(Date.now() - 86400000 * 3).toISOString(),
status: "confirmed",
},
];
}
function loadSession(): GuestSession | null {
if (typeof window === "undefined") return null;
try {
const raw = localStorage.getItem(STORAGE_SESSION);
if (!raw) return null;
return JSON.parse(raw) as GuestSession;
} catch {
return null;
}
}
function persistSession(s: GuestSession | null) {
if (typeof window === "undefined") return;
if (s) localStorage.setItem(STORAGE_SESSION, JSON.stringify(s));
else localStorage.removeItem(STORAGE_SESSION);
}
function persistOrders(orders: OrderRecord[]) {
if (typeof window === "undefined") return;
localStorage.setItem(STORAGE_ORDERS, JSON.stringify(orders));
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<GuestSession | null>(null);
const [orders, setOrders] = useState<OrderRecord[]>([]);
const [isHydrated, setIsHydrated] = useState(false);
useEffect(() => {
setSession(loadSession());
setOrders(loadOrders());
setIsHydrated(true);
}, []);
const requestOtp = useCallback(async (email: string) => {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return { ok: false, message: "Enter a valid email address." };
}
return { ok: true, message: "Demo code sent. Use OTP 123456 to continue." };
}, []);
const verifyOtp = useCallback(async (email: string, code: string) => {
const trimmed = code.replace(/\s/g, "");
if (trimmed !== "123456") {
return { ok: false, message: "Invalid code. Demo OTP is 123456." };
}
const local = email.split("@")[0] ?? "Guest";
const name = local.charAt(0).toUpperCase() + local.slice(1);
const next: MemberSession = {
kind: "member",
email: email.toLowerCase(),
displayName: name,
points: 2400,
tier: "Gold",
authMethod: "otp",
};
setSession(next);
persistSession(next);
return { ok: true, message: "Signed in." };
}, []);
const loginPassword = useCallback(async (email: string, password: string) => {
if (!email || !password) {
return { ok: false, message: "Email and password required." };
}
if (password !== "shitaye" && password !== "demo123") {
return {
ok: false,
message: "Incorrect password. Try demo password: shitaye",
};
}
const local = email.split("@")[0] ?? "Guest";
const name = local.charAt(0).toUpperCase() + local.slice(1);
const next: MemberSession = {
kind: "member",
email: email.toLowerCase(),
displayName: name,
points: 2400,
tier: "Gold",
authMethod: "password",
};
setSession(next);
persistSession(next);
return { ok: true, message: "Signed in." };
}, []);
const loginSocial = useCallback((provider: "google" | "apple" | "facebook") => {
const names: Record<typeof provider, string> = {
google: "Google Guest",
apple: "Apple Guest",
facebook: "Facebook Guest",
};
const next: MemberSession = {
kind: "member",
email: `guest.${provider}@shitaye.demo`,
displayName: names[provider],
points: 2100,
tier: "Silver",
authMethod: provider,
};
setSession(next);
persistSession(next);
}, []);
const loginBookingRef = useCallback((ref: string) => {
const key = ref.trim().toUpperCase();
const row = DEMO_BOOKING_REFS[key];
if (!row) {
return {
ok: false,
message: "Reference not found. Try SHITAYE-2026-DEMO or GUEST-1234.",
};
}
const next: BookingRefSession = {
kind: "bookingRef",
bookingRef: key,
guestName: row.guestName,
roomLabel: row.room,
checkOut: row.checkOut,
};
setSession(next);
persistSession(next);
return { ok: true, message: "Linked to your stay." };
}, []);
const logout = useCallback(() => {
setSession(null);
persistSession(null);
}, []);
const addOrder = useCallback(
(
o: Omit<OrderRecord, "id" | "placedAt" | "status"> & {
status?: OrderRecord["status"];
},
) => {
const rec: OrderRecord = {
...o,
id: `ord-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
placedAt: new Date().toISOString(),
status: o.status ?? "pending",
};
setOrders((prev) => {
const next = [rec, ...prev];
persistOrders(next);
return next;
});
if (session?.kind === "member") {
const bonus = Math.min(150, Math.round(o.totalUsd * 2));
setSession((s) => {
if (!s || s.kind !== "member") return s;
const u = { ...s, points: s.points + bonus };
persistSession(u);
return u;
});
}
},
[session],
);
const awardPoints = useCallback((points: number) => {
setSession((s) => {
if (!s || s.kind !== "member") return s;
const u = { ...s, points: s.points + points };
persistSession(u);
return u;
});
}, []);
const value = useMemo<AuthContextValue>(
() => ({
session,
orders,
isHydrated,
requestOtp,
verifyOtp,
loginPassword,
loginSocial,
loginBookingRef,
logout,
addOrder,
awardPoints,
}),
[
session,
orders,
isHydrated,
requestOtp,
verifyOtp,
loginPassword,
loginSocial,
loginBookingRef,
logout,
addOrder,
awardPoints,
],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}

View File

@ -0,0 +1,88 @@
/** Demo booking references — any guest can use these in mock mode. */
export const DEMO_BOOKING_REFS: Record<
string,
{ guestName: string; room: string; checkOut: string }
> = {
"SHITAYE-2026-DEMO": {
guestName: "Demo Guest",
room: "Junior Studio · 1204",
checkOut: "2026-04-12",
},
"GUEST-1234": {
guestName: "Abebe T.",
room: "Standard King · 805",
checkOut: "2026-04-09",
},
};
export type MockAppointment = {
id: string;
title: string;
when: string;
where: string;
status: "confirmed" | "pending";
};
export type MockShuttle = {
/** ISO date */
departureDate: string;
/** e.g. "04:15" */
lobbyPickupTime: string;
/** e.g. "Bole International (ADD)" */
airport: string;
flightLabel: string;
notes: string;
};
export type MockReward = {
id: string;
label: string;
points: number;
earnedAt: string;
};
export const seedAppointments: MockAppointment[] = [
{
id: "a1",
title: "Deep tissue massage",
when: "Today · 16:30",
where: "Spa · Treatment suite B",
status: "confirmed",
},
{
id: "a2",
title: "Small-group HIIT",
when: "Tomorrow · 07:00",
where: "Fitness centre · Studio",
status: "confirmed",
},
];
export const seedShuttle: MockShuttle = {
departureDate: "2026-04-11",
lobbyPickupTime: "04:15",
airport: "Bole International (ADD)",
flightLabel: "ET 302 · Addis → Frankfurt",
notes: "Please be in the lobby 10 minutes early. Shuttle is complimentary for this stay.",
};
export const seedRewardsHistory: MockReward[] = [
{
id: "r1",
label: "Welcome bonus — direct booking",
points: 500,
earnedAt: "2026-04-01",
},
{
id: "r2",
label: "Room service order",
points: 40,
earnedAt: "2026-04-03",
},
{
id: "r3",
label: "Spa visit",
points: 120,
earnedAt: "2026-04-04",
},
];

View File

@ -0,0 +1,45 @@
export type LaundryItem = {
id: string;
name: string;
description: string;
priceUsd: number;
unit: string;
};
export const laundryItems: LaundryItem[] = [
{
id: "l-1",
name: "Shirt / blouse",
description: "Pressed",
priceUsd: 4,
unit: "each",
},
{
id: "l-2",
name: "Trousers / skirt",
description: "Pressed",
priceUsd: 5,
unit: "each",
},
{
id: "l-3",
name: "Suit (2 pc)",
description: "Clean & press",
priceUsd: 18,
unit: "set",
},
{
id: "l-4",
name: "Dress",
description: "Delicate cycle",
priceUsd: 12,
unit: "each",
},
{
id: "l-5",
name: "Express (same day)",
description: "Surcharge on top of item prices",
priceUsd: 15,
unit: "per order",
},
];

View File

@ -0,0 +1,68 @@
export type MenuCategory = "breakfast" | "mains" | "desserts" | "beverages";
export const roomServiceCategories: { id: MenuCategory; label: string }[] = [
{ id: "breakfast", label: "Breakfast" },
{ id: "mains", label: "Mains & light bites" },
{ id: "desserts", label: "Desserts" },
{ id: "beverages", label: "Beverages" },
];
export type MenuItem = {
id: string;
category: MenuCategory;
name: string;
description: string;
priceUsd: number;
};
export const roomServiceItems: MenuItem[] = [
{
id: "bf-1",
category: "breakfast",
name: "Full American breakfast",
description: "Eggs any style, beef bacon, chicken sausage, beans, toast, juice, coffee.",
priceUsd: 18,
},
{
id: "bf-2",
category: "breakfast",
name: "Ethiopian breakfast platter",
description: "Injera, spiced lentils, fresh cheese, honey, seasonal fruit.",
priceUsd: 14,
},
{
id: "mn-1",
category: "mains",
name: "Grilled salmon",
description: "Herb butter, seasonal vegetables, lemon.",
priceUsd: 28,
},
{
id: "mn-2",
category: "mains",
name: "Beef tibs",
description: "Traditional sauté with peppers, injera or rice.",
priceUsd: 22,
},
{
id: "ds-1",
category: "desserts",
name: "Chocolate fondant",
description: "Warm centre, vanilla ice cream.",
priceUsd: 12,
},
{
id: "bv-1",
category: "beverages",
name: "Fresh juice",
description: "Orange, mango, or mixed.",
priceUsd: 6,
},
{
id: "bv-2",
category: "beverages",
name: "Ethiopian coffee ceremony (2)",
description: "Traditional preparation — allow 20 min.",
priceUsd: 15,
},
];