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:
parent
93f93eb087
commit
d5c7d56c11
10
src/app/guest/gym/page.tsx
Normal file
10
src/app/guest/gym/page.tsx
Normal 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");
|
||||
}
|
||||
170
src/app/guest/laundry/LaundryClient.tsx
Normal file
170
src/app/guest/laundry/LaundryClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
src/app/guest/laundry/page.tsx
Normal file
10
src/app/guest/laundry/page.tsx
Normal 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
116
src/app/guest/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
209
src/app/guest/room-service/RoomServiceClient.tsx
Normal file
209
src/app/guest/room-service/RoomServiceClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
src/app/guest/room-service/page.tsx
Normal file
10
src/app/guest/room-service/page.tsx
Normal 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 />;
|
||||
}
|
||||
9
src/app/guest/spa/page.tsx
Normal file
9
src/app/guest/spa/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata = {
|
||||
title: "Spa",
|
||||
};
|
||||
|
||||
export default function GuestSpaRedirectPage() {
|
||||
redirect("/services?kind=spa");
|
||||
}
|
||||
298
src/app/login/LoginPageClient.tsx
Normal file
298
src/app/login/LoginPageClient.tsx
Normal 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
22
src/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -123,16 +123,16 @@ export default function HomePage() {
|
|||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<a
|
||||
href={siteConfig.googleMapsDirectionsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-mustard inline-flex px-6 py-3 text-sm"
|
||||
>
|
||||
Get directions
|
||||
</a>
|
||||
<a
|
||||
</a>
|
||||
<a
|
||||
href={siteConfig.googleMapsPlaceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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)] hover:text-[var(--color-accent)]"
|
||||
>
|
||||
Open in Google Maps
|
||||
|
|
@ -185,13 +185,21 @@ export default function HomePage() {
|
|||
Treatments & fitness sessions
|
||||
</h2>
|
||||
<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
|
||||
dedicated services page.
|
||||
Browse our spa menu and gym add-ons — or use the guest hub for room service, laundry, and
|
||||
profile access.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/services" className="btn-mustard shrink-0 px-8 py-3.5 text-sm">
|
||||
View spa & gym services
|
||||
</Link>
|
||||
<div className="flex shrink-0 flex-col gap-3 sm:flex-row">
|
||||
<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 href="/services" className="btn-mustard px-8 py-3.5 text-sm">
|
||||
Spa & gym
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -370,7 +378,7 @@ export default function HomePage() {
|
|||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto max-w-7xl px-4 py-20 text-center md:px-8">
|
||||
|
|
|
|||
247
src/app/profile/ProfilePageClient.tsx
Normal file
247
src/app/profile/ProfilePageClient.tsx
Normal 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
10
src/app/profile/page.tsx
Normal 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 />;
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { AuthProvider } from "@/context/AuthContext";
|
||||
import { BookingProvider } from "@/context/BookingContext";
|
||||
import { CurrencyProvider } from "@/context/CurrencyContext";
|
||||
import type { ReactNode } from "react";
|
||||
|
|
@ -7,7 +8,9 @@ import type { ReactNode } from "react";
|
|||
export function Providers({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<CurrencyProvider>
|
||||
<BookingProvider>{children}</BookingProvider>
|
||||
<AuthProvider>
|
||||
<BookingProvider>{children}</BookingProvider>
|
||||
</AuthProvider>
|
||||
</CurrencyProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
import Image from "next/image";
|
||||
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 {
|
||||
spaGymFilters,
|
||||
spaGymServices,
|
||||
|
|
@ -156,9 +158,18 @@ function SelectionPanel({
|
|||
}
|
||||
|
||||
export function ServicesPageClient() {
|
||||
const searchParams = useSearchParams();
|
||||
const { session, addOrder } = useAuth();
|
||||
const [filter, setFilter] = useState<SpaGymFilterId>("all");
|
||||
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(() => {
|
||||
if (filter === "all") return spaGymServices;
|
||||
return spaGymServices.filter((s) => s.kind === filter);
|
||||
|
|
@ -190,6 +201,20 @@ export function ServicesPageClient() {
|
|||
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 (
|
||||
<div className="bg-[var(--color-bg)]">
|
||||
<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">
|
||||
<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
|
||||
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>
|
||||
</aside>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { ShitayeLogoLoader } from "@/components/ShitayeLogoLoader";
|
||||
import { ServicesPageClient } from "./ServicesPageClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -8,5 +10,15 @@ export const metadata: Metadata = {
|
|||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,6 +145,21 @@ export function Footer() {
|
|||
Rooms
|
||||
</Link>
|
||||
</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>
|
||||
<Link href="/services" className="text-stone-200 hover:text-white">
|
||||
Spa & gym services
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { HeaderAccount } from "@/components/HeaderAccount";
|
||||
import { CurrencySwitcher } from "@/components/CurrencySwitcher";
|
||||
import { ReviewsMenu } from "@/components/ReviewsMenu";
|
||||
import { siteConfig } from "@/lib/mocks/site";
|
||||
|
||||
const nav = [
|
||||
{ href: "/#rooms", label: "Rooms" },
|
||||
{ href: "/guest", label: "Guest hub" },
|
||||
{ href: "/services", label: "Services" },
|
||||
{ href: "/#wellness", label: "Gym & Spa" },
|
||||
{ href: "/#dining", label: "Dining & venues" },
|
||||
|
|
@ -68,7 +70,8 @@ export function Header() {
|
|||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<div className="flex shrink-0 items-center gap-2 md:gap-3">
|
||||
<HeaderAccount />
|
||||
<Link
|
||||
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"
|
||||
|
|
|
|||
64
src/components/HeaderAccount.tsx
Normal file
64
src/components/HeaderAccount.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/components/RequireAuth.tsx
Normal file
46
src/components/RequireAuth.tsx
Normal 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
314
src/context/AuthContext.tsx
Normal 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;
|
||||
}
|
||||
88
src/lib/mocks/guestData.ts
Normal file
88
src/lib/mocks/guestData.ts
Normal 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",
|
||||
},
|
||||
];
|
||||
45
src/lib/mocks/laundryCatalog.ts
Normal file
45
src/lib/mocks/laundryCatalog.ts
Normal 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",
|
||||
},
|
||||
];
|
||||
68
src/lib/mocks/roomServiceMenu.ts
Normal file
68
src/lib/mocks/roomServiceMenu.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
Loading…
Reference in New Issue
Block a user