Shitaye-FrontEnd/src/context/AuthContext.tsx
“kirukib” d5c7d56c11 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
2026-04-06 21:06:02 +03:00

315 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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;
}