From d5c7d56c11aa03d7eaf698fffa54eda70ef90f54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Ckirukib=E2=80=9D?= <“kirubeljkl679@gmail.com”> Date: Mon, 6 Apr 2026 21:06:02 +0300 Subject: [PATCH] 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 --- src/app/guest/gym/page.tsx | 10 + src/app/guest/laundry/LaundryClient.tsx | 170 ++++++++++ src/app/guest/laundry/page.tsx | 10 + src/app/guest/page.tsx | 116 +++++++ .../guest/room-service/RoomServiceClient.tsx | 209 ++++++++++++ src/app/guest/room-service/page.tsx | 10 + src/app/guest/spa/page.tsx | 9 + src/app/login/LoginPageClient.tsx | 298 +++++++++++++++++ src/app/login/page.tsx | 22 ++ src/app/page.tsx | 32 +- src/app/profile/ProfilePageClient.tsx | 247 ++++++++++++++ src/app/profile/page.tsx | 10 + src/app/providers.tsx | 5 +- src/app/services/ServicesPageClient.tsx | 46 ++- src/app/services/page.tsx | 14 +- src/components/Footer.tsx | 15 + src/components/Header.tsx | 5 +- src/components/HeaderAccount.tsx | 64 ++++ src/components/RequireAuth.tsx | 46 +++ src/context/AuthContext.tsx | 314 ++++++++++++++++++ src/lib/mocks/guestData.ts | 88 +++++ src/lib/mocks/laundryCatalog.ts | 45 +++ src/lib/mocks/roomServiceMenu.ts | 68 ++++ 23 files changed, 1835 insertions(+), 18 deletions(-) create mode 100644 src/app/guest/gym/page.tsx create mode 100644 src/app/guest/laundry/LaundryClient.tsx create mode 100644 src/app/guest/laundry/page.tsx create mode 100644 src/app/guest/page.tsx create mode 100644 src/app/guest/room-service/RoomServiceClient.tsx create mode 100644 src/app/guest/room-service/page.tsx create mode 100644 src/app/guest/spa/page.tsx create mode 100644 src/app/login/LoginPageClient.tsx create mode 100644 src/app/login/page.tsx create mode 100644 src/app/profile/ProfilePageClient.tsx create mode 100644 src/app/profile/page.tsx create mode 100644 src/components/HeaderAccount.tsx create mode 100644 src/components/RequireAuth.tsx create mode 100644 src/context/AuthContext.tsx create mode 100644 src/lib/mocks/guestData.ts create mode 100644 src/lib/mocks/laundryCatalog.ts create mode 100644 src/lib/mocks/roomServiceMenu.ts diff --git a/src/app/guest/gym/page.tsx b/src/app/guest/gym/page.tsx new file mode 100644 index 0000000..99f34d2 --- /dev/null +++ b/src/app/guest/gym/page.tsx @@ -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"); +} diff --git a/src/app/guest/laundry/LaundryClient.tsx b/src/app/guest/laundry/LaundryClient.tsx new file mode 100644 index 0000000..22dd9e2 --- /dev/null +++ b/src/app/guest/laundry/LaundryClient.tsx @@ -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 ( + + + + ); +} + +function LaundryInner() { + const { addOrder } = useAuth(); + const [qty, setQty] = useState>({}); + 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 ( +
+
+ + +
+
+

+ Laundry service +

+

+ Select pieces and optional express surcharge. Mock request — pickup at reception. +

+
+ + View profile → + +
+ + {sent ? ( +
+ Request logged (demo). Our team will confirm timing by phone. +
+ ) : null} + +
+
+ {laundryItems.map((row) => ( +
+
+

{row.name}

+

+ {row.description} · ${row.priceUsd}/{row.unit} +

+
+
+ + {qty[row.id] ?? 0} + +
+
+ ))} + +
+ + +
+
+
+ ); +} diff --git a/src/app/guest/laundry/page.tsx b/src/app/guest/laundry/page.tsx new file mode 100644 index 0000000..e312fe6 --- /dev/null +++ b/src/app/guest/laundry/page.tsx @@ -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 ; +} diff --git a/src/app/guest/page.tsx b/src/app/guest/page.tsx new file mode 100644 index 0000000..f6ef731 --- /dev/null +++ b/src/app/guest/page.tsx @@ -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 ( +
+
+
+ +

+ During your stay +

+

+ Order to your room, schedule laundry, and book gym & spa — all in one place. Sign in with + email or{" "} + booking reference to track + orders on your profile. +

+
+ + Sign in + + + My stay profile + +
+
+
+ +
+
+ {tiles.map((t) => ( + + + {t.icon} + +

+ {t.subtitle} +

+

+ {t.title} +

+

+ {t.desc} +

+ + Open → + + + ))} +
+ +

+ Questions?{" "} + + {siteConfig.email} + {" "} + ·{" "} + + {siteConfig.primaryPhone} + +

+
+
+ ); +} diff --git a/src/app/guest/room-service/RoomServiceClient.tsx b/src/app/guest/room-service/RoomServiceClient.tsx new file mode 100644 index 0000000..adfe87c --- /dev/null +++ b/src/app/guest/room-service/RoomServiceClient.tsx @@ -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 ( + + + + ); +} + +function RoomServiceInner() { + const { addOrder } = useAuth(); + const [cat, setCat] = useState("breakfast"); + const [qty, setQty] = useState>({}); + 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 ( +
+
+ + +
+
+

+ Digital menu +

+

+ Mock ordering — your tray appears on your profile under orders. Service charges may + apply. +

+
+ + View profile → + +
+ + {sent ? ( +
+ Order sent to the kitchen queue (demo). Add another round or check your profile. +
+ ) : null} + +
+ {roomServiceCategories.map((c) => ( + + ))} +
+ +
+
+ {items.map((item) => ( +
+
+ +
+

+ {item.name} +

+

{item.description}

+

+ ${item.priceUsd} +

+
+ + {qty[item.id] ?? 0} + +
+
+ ))} +
+ + +
+
+
+ ); +} diff --git a/src/app/guest/room-service/page.tsx b/src/app/guest/room-service/page.tsx new file mode 100644 index 0000000..85ea4ba --- /dev/null +++ b/src/app/guest/room-service/page.tsx @@ -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 ; +} diff --git a/src/app/guest/spa/page.tsx b/src/app/guest/spa/page.tsx new file mode 100644 index 0000000..019daeb --- /dev/null +++ b/src/app/guest/spa/page.tsx @@ -0,0 +1,9 @@ +import { redirect } from "next/navigation"; + +export const metadata = { + title: "Spa", +}; + +export default function GuestSpaRedirectPage() { + redirect("/services?kind=spa"); +} diff --git a/src/app/login/LoginPageClient.tsx b/src/app/login/LoginPageClient.tsx new file mode 100644 index 0000000..8244f35 --- /dev/null +++ b/src/app/login/LoginPageClient.tsx @@ -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("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(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 ( +
+
+ +

+ Guest access +

+

+ Sign in with email (OTP or password), social accounts, or your reservation reference to + order room service, laundry, and manage your stay profile. +

+ +
+ {tabs.map((t) => ( + + ))} +
+ +
+ {tab === "otp" && ( +
+ {otpStep === 1 ? ( +
+ + +
+ ) : ( +
+

+ Code sent to {email} +

+ +

+ Demo: enter 123456 +

+
+ + +
+
+ )} +
+ )} + + {tab === "password" && ( +
+ + +

+ Demo password: shitaye or demo123 +

+ +
+ )} + + {tab === "social" && ( +
+

+ Mock sign-in — no external redirect in this demo. +

+ + + +
+ )} + + {tab === "booking" && ( +
+ +

+ Try SHITAYE-2026-DEMO or GUEST-1234 — no email + required. You can place orders and view a limited stay profile. +

+ +
+ )} + + {message ? ( +

+ {message} +

+ ) : null} +
+ +

+ + Guest hub — room service & laundry + +

+
+
+ ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..15cffba --- /dev/null +++ b/src/app/login/page.tsx @@ -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 ( + + + + } + > + + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 809dd36..d74ec7b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -123,16 +123,16 @@ export default function HomePage() { - - View spa & gym services - +
+ + Guest hub + + + Spa & gym + +
@@ -370,7 +378,7 @@ export default function HomePage() { /> ))} - +
diff --git a/src/app/profile/ProfilePageClient.tsx b/src/app/profile/ProfilePageClient.tsx new file mode 100644 index 0000000..f45ced1 --- /dev/null +++ b/src/app/profile/ProfilePageClient.tsx @@ -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 ( +
  • +
    +
    +

    {o.title}

    +

    {o.detail}

    +

    {formatWhen(o.placedAt)}

    +
    +
    + + {o.status} + +

    ${o.totalUsd.toFixed(0)}

    +
    +
    +
  • + ); +} + +export function ProfilePageClient() { + return ( + + + + ); +} + +function ProfileContent() { + const { session, orders, logout } = useAuth(); + const [orderFilter, setOrderFilter] = useState("all"); + + const filteredOrders = useMemo(() => { + if (orderFilter === "all") return orders; + return orders.filter((o) => o.category === orderFilter); + }, [orders, orderFilter]); + + if (!session) { + return null; + } + + return ( +
    +
    + + +
    +
    +

    + {session.kind === "member" + ? `Hello, ${session.displayName}` + : `Welcome, ${session.guestName}`} +

    +

    + {session.kind === "member" ? ( + <> + {session.email} + {" · "} + Signed in via {session.authMethod} + + ) : ( + <> + Booking {session.bookingRef} + {" · "} + {session.roomLabel} · checkout {session.checkOut} + + )} +

    +
    +
    + + Guest hub + + +
    +
    + +
    + +
    +

    Booked appointments

    +
      + {seedAppointments.map((a) => ( +
    • +

      + {a.status} +

      +

      {a.title}

      +

      {a.when}

      +

      {a.where}

      +
    • + ))} +
    +
    + +
    +

    Orders

    +

    + Room service, laundry, gym, and spa — including demo history and new orders from this + device. +

    +
    + {orderTabs.map((t) => ( + + ))} +
    + {filteredOrders.length === 0 ? ( +

    + No orders in this category yet. +

    + ) : ( +
      + {filteredOrders.map((o) => ( + + ))} +
    + )} +
    + +
    +

    Rewards earned

    +
      + {seedRewardsHistory.map((r) => ( +
    • +
      +

      {r.label}

      +

      {r.earnedAt}

      +
      + +{r.points} pts +
    • + ))} +
    +
    +
    +
    + ); +} diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx new file mode 100644 index 0000000..bf7bf59 --- /dev/null +++ b/src/app/profile/page.tsx @@ -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 ; +} diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 92391de..5375e61 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -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 ( - {children} + + {children} + ); } diff --git a/src/app/services/ServicesPageClient.tsx b/src/app/services/ServicesPageClient.tsx index fb573fc..de33e1a 100644 --- a/src/app/services/ServicesPageClient.tsx +++ b/src/app/services/ServicesPageClient.tsx @@ -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("all"); const [selected, setSelected] = useState>(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 (
    @@ -251,11 +276,26 @@ export function ServicesPageClient() {
    diff --git a/src/app/services/page.tsx b/src/app/services/page.tsx index 3033cc0..fe38fdd 100644 --- a/src/app/services/page.tsx +++ b/src/app/services/page.tsx @@ -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 ; + return ( + + + + } + > + + + ); } diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index f1cf9d5..d5955b1 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -145,6 +145,21 @@ export function Footer() { Rooms +
  • + + Guest hub + +
  • +
  • + + Sign in + +
  • +
  • + + My stay + +
  • Spa & gym services diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 8bd3970..a942ee0 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -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() { ))} -
    +
    + + ); + } + + 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 ( +
    + + {points !== "—" ? `${points} pts` : "Stay"} + + + {label} + + + Guest hub + +
    + ); + } + + return ( +
    + + Sign in + + + Guest hub + +
    + ); +} diff --git a/src/components/RequireAuth.tsx b/src/components/RequireAuth.tsx new file mode 100644 index 0000000..45a9917 --- /dev/null +++ b/src/components/RequireAuth.tsx @@ -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 ( +
    + +
    + ); + } + + if (!session) { + return ( +
    +

    Redirecting to sign-in…

    + + Continue manually + +
    + ); + } + + return <>{children}; +} diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx new file mode 100644 index 0000000..3111fe0 --- /dev/null +++ b/src/context/AuthContext.tsx @@ -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 & { status?: OrderRecord["status"] }) => void; + awardPoints: (points: number) => void; +}; + +const AuthContext = createContext(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(null); + const [orders, setOrders] = useState([]); + 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 = { + 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 & { + 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( + () => ({ + session, + orders, + isHydrated, + requestOtp, + verifyOtp, + loginPassword, + loginSocial, + loginBookingRef, + logout, + addOrder, + awardPoints, + }), + [ + session, + orders, + isHydrated, + requestOtp, + verifyOtp, + loginPassword, + loginSocial, + loginBookingRef, + logout, + addOrder, + awardPoints, + ], + ); + + return {children}; +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used within AuthProvider"); + return ctx; +} diff --git a/src/lib/mocks/guestData.ts b/src/lib/mocks/guestData.ts new file mode 100644 index 0000000..7b52f3e --- /dev/null +++ b/src/lib/mocks/guestData.ts @@ -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", + }, +]; diff --git a/src/lib/mocks/laundryCatalog.ts b/src/lib/mocks/laundryCatalog.ts new file mode 100644 index 0000000..ae5829a --- /dev/null +++ b/src/lib/mocks/laundryCatalog.ts @@ -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", + }, +]; diff --git a/src/lib/mocks/roomServiceMenu.ts b/src/lib/mocks/roomServiceMenu.ts new file mode 100644 index 0000000..f1f3deb --- /dev/null +++ b/src/lib/mocks/roomServiceMenu.ts @@ -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, + }, +];