+
+ );
+ }
+
+ 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,
+ },
+];