- 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
315 lines
8.5 KiB
TypeScript
315 lines
8.5 KiB
TypeScript
"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;
|
||
}
|