+>(({ className, ...props }, ref) => (
+ | [role=checkbox]]:translate-y-[2px]",
+ className
+ )}
+ {...props}
+ />
+));
+TableCell.displayName = "TableCell";
+
+export { Table, TableHeader, TableBody, TableHead, TableRow, TableCell };
diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..3cf5561
--- /dev/null
+++ b/src/components/ui/tabs.tsx
@@ -0,0 +1,53 @@
+import * as TabsPrimitive from "@radix-ui/react-tabs";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Tabs = TabsPrimitive.Root;
+
+const TabsList = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+TabsList.displayName = TabsPrimitive.List.displayName;
+
+const TabsTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
+
+const TabsContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+TabsContent.displayName = TabsPrimitive.Content.displayName;
+
+export { Tabs, TabsList, TabsTrigger, TabsContent };
diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..d63cac8
--- /dev/null
+++ b/src/components/ui/textarea.tsx
@@ -0,0 +1,22 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Textarea = React.forwardRef<
+ HTMLTextAreaElement,
+ React.ComponentProps<"textarea">
+>(({ className, ...props }, ref) => {
+ return (
+
+ );
+});
+Textarea.displayName = "Textarea";
+
+export { Textarea };
diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx
new file mode 100644
index 0000000..cb8460e
--- /dev/null
+++ b/src/context/AuthContext.tsx
@@ -0,0 +1,65 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react";
+
+import type { AdminRole } from "@/lib/types";
+
+interface AuthState {
+ role: AdminRole | null;
+ name: string;
+ property: string;
+}
+
+interface AuthContextValue extends AuthState {
+ setRole: (r: AdminRole) => void;
+ setProperty: (p: string) => void;
+ logout: () => void;
+ canManageCodes: boolean;
+ canRefund: boolean;
+ canEditBookings: boolean;
+}
+
+const AuthContext = createContext(null);
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const [role, setRoleState] = useState(null);
+ const [name] = useState("Sophia Mitchell");
+ const [property, setProperty] = useState("Shitaye Suite Hotel");
+
+ const setRole = useCallback((r: AdminRole) => setRoleState(r), []);
+
+ const logout = useCallback(() => setRoleState(null), []);
+
+ const value = useMemo(
+ () => ({
+ role,
+ name,
+ property,
+ setRole,
+ setProperty,
+ logout,
+ canManageCodes: role === "finance" || role === "superadmin",
+ canRefund: role === "finance" || role === "superadmin",
+ canEditBookings:
+ role === "front_desk" ||
+ role === "finance" ||
+ role === "superadmin",
+ }),
+ [role, name, property, setRole, setProperty, logout]
+ );
+
+ return (
+ {children}
+ );
+}
+
+export function useAuth() {
+ const ctx = useContext(AuthContext);
+ if (!ctx) throw new Error("useAuth outside AuthProvider");
+ return ctx;
+}
diff --git a/src/lib/api.ts b/src/lib/api.ts
new file mode 100644
index 0000000..497a372
--- /dev/null
+++ b/src/lib/api.ts
@@ -0,0 +1,33 @@
+export async function apiGet(path: string): Promise {
+ const res = await fetch(`/api${path}`);
+ if (!res.ok) throw new Error(await res.text());
+ return res.json() as Promise;
+}
+
+export async function apiPost(path: string, body: unknown): Promise {
+ const res = await fetch(`/api${path}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const t = await res.text();
+ throw new Error(t);
+ }
+ return res.json() as Promise;
+}
+
+export async function apiPatch(path: string, body: unknown): Promise {
+ const res = await fetch(`/api${path}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) throw new Error(await res.text());
+ return res.json() as Promise;
+}
+
+export async function apiDelete(path: string): Promise {
+ const res = await fetch(`/api${path}`, { method: "DELETE" });
+ if (!res.ok) throw new Error(await res.text());
+}
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
new file mode 100644
index 0000000..b8962a7
--- /dev/null
+++ b/src/lib/constants.ts
@@ -0,0 +1,34 @@
+import type { RoomTypeCatalog } from "@/lib/types";
+
+export const TAX_RATE = 0.15;
+
+export const ROOM_CATALOGUE: RoomTypeCatalog[] = [
+ {
+ slug: "penthouse",
+ name: "The 4 Bedroom Penthouse",
+ nightlyRate: 450,
+ maxGuests: 8,
+ },
+ {
+ slug: "standard",
+ name: "Standard Rooms",
+ nightlyRate: 120,
+ maxGuests: 2,
+ },
+ {
+ slug: "connecting-suite",
+ name: "Connecting Suite",
+ nightlyRate: 280,
+ maxGuests: 6,
+ },
+ {
+ slug: "junior-studio",
+ name: "Junior Studios",
+ nightlyRate: 95,
+ maxGuests: 2,
+ },
+];
+
+export function roomTypeLabel(slug: string): string {
+ return ROOM_CATALOGUE.find((r) => r.slug === slug)?.name ?? slug;
+}
diff --git a/src/lib/format.ts b/src/lib/format.ts
new file mode 100644
index 0000000..2a2e642
--- /dev/null
+++ b/src/lib/format.ts
@@ -0,0 +1,27 @@
+import { format, parseISO } from "date-fns";
+
+/** Display dates in property context; production: Africa/Addis_Ababa */
+export function formatDate(isoDate: string, pattern = "MMM d, yyyy") {
+ try {
+ return format(parseISO(isoDate), pattern);
+ } catch {
+ return isoDate;
+ }
+}
+
+export function formatMoney(amount: number, currency = "USD") {
+ return new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency,
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
+ }).format(amount);
+}
+
+export function formatDateTime(iso: string) {
+ try {
+ return format(parseISO(iso), "MMM d, yyyy HH:mm");
+ } catch {
+ return iso;
+ }
+}
diff --git a/src/lib/room-utils.ts b/src/lib/room-utils.ts
new file mode 100644
index 0000000..58c4c2a
--- /dev/null
+++ b/src/lib/room-utils.ts
@@ -0,0 +1,12 @@
+import { roomTypeLabel } from "@/lib/constants";
+
+export function inferRoomTypeSlug(roomId: string): string {
+ if (roomId.includes("penthouse")) return "penthouse";
+ if (roomId.includes("suite")) return "connecting-suite";
+ if (roomId.includes("studio")) return "junior-studio";
+ return "standard";
+}
+
+export function roomDisplayName(roomId: string) {
+ return roomTypeLabel(inferRoomTypeSlug(roomId));
+}
diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts
new file mode 100644
index 0000000..e61542f
--- /dev/null
+++ b/src/lib/types/index.ts
@@ -0,0 +1,198 @@
+export type AdminRole =
+ | "viewer"
+ | "front_desk"
+ | "finance"
+ | "superadmin";
+
+export type BookingStatus =
+ | "draft"
+ | "held"
+ | "payment_pending"
+ | "confirmed"
+ | "cancelled"
+ | "expired";
+
+export type PaymentStatus = "paid" | "pending" | "failed" | "refunded";
+
+export type TransactionType =
+ | "payment"
+ | "refund"
+ | "adjustment"
+ | "charge";
+
+export type RoomInventoryStatus =
+ | "available"
+ | "occupied"
+ | "maintenance"
+ | "out_of_order";
+
+export type RoomBlockReason =
+ | "maintenance"
+ | "owner_hold"
+ | "closed"
+ | "other";
+
+export type DiscountType = "percent" | "fixed_amount";
+
+export interface GuestDetails {
+ firstName: string;
+ lastName: string;
+ email: string;
+ phone: string;
+ flightBookingNumber: string;
+ arrivalTime: string;
+}
+
+export interface PricingLine {
+ nightlySubtotal: number;
+ couponCode?: string;
+ discountCodeId?: string;
+ discountPercent: number;
+ discountAmount: number;
+ taxRate: number;
+ taxAmount: number;
+ total: number;
+ totalCents: number;
+ currency: string;
+}
+
+export interface Booking {
+ id: string;
+ guest: GuestDetails;
+ checkIn: string;
+ checkOut: string;
+ guests: number;
+ roomId: string;
+ nights: number;
+ pricing: PricingLine;
+ status: BookingStatus;
+ holdReference?: string;
+ payLaterHold: boolean;
+ confirmationId?: string;
+ paidAt?: string;
+ referralCode?: string;
+ referralCodeId?: string;
+ createdAt: string;
+ updatedAt: string;
+ internalNotes?: string[];
+}
+
+export interface Payment {
+ id: string;
+ bookingId: string;
+ provider: string;
+ amount: number;
+ currency: string;
+ status: PaymentStatus;
+ last4?: string;
+ createdAt: string;
+}
+
+export interface Transaction {
+ id: string;
+ type: TransactionType;
+ amount: number;
+ currency: string;
+ status: PaymentStatus | "completed" | "pending";
+ bookingId?: string;
+ paymentRef?: string;
+ description: string;
+ createdAt: string;
+}
+
+export interface RoomTypeCatalog {
+ slug: string;
+ name: string;
+ nightlyRate: number;
+ maxGuests: number;
+}
+
+export interface Room {
+ id: string;
+ name: string;
+ roomTypeSlug: string;
+ floor?: string;
+ maxGuests: number;
+ baseRate: number;
+ status: RoomInventoryStatus;
+ notes?: string;
+}
+
+export interface RoomBlock {
+ id: string;
+ roomId: string;
+ startDate: string;
+ endDate: string;
+ reason: RoomBlockReason;
+ title: string;
+ createdAt: string;
+}
+
+export interface SiteVisit {
+ id: string;
+ occurredAt: string;
+ path: string;
+ referrer?: string;
+ utmSource?: string;
+ utmMedium?: string;
+ utmCampaign?: string;
+ sessionId?: string;
+ device?: string;
+}
+
+export interface DiscountCode {
+ id: string;
+ code: string;
+ description?: string;
+ discountType: DiscountType;
+ value: number;
+ currency?: string;
+ validFrom: string;
+ validTo: string;
+ maxRedemptions: number | null;
+ redemptionCount: number;
+ isActive: boolean;
+ createdAt: string;
+}
+
+export interface ReferralCode {
+ id: string;
+ code: string;
+ label: string;
+ attributedTo?: string;
+ validFrom: string;
+ validTo: string;
+ maxRedemptions: number | null;
+ redemptionCount: number;
+ isActive: boolean;
+ createdAt: string;
+}
+
+export interface CustomerRow {
+ email: string;
+ name: string;
+ bookingCount: number;
+ lastStay?: string;
+}
+
+export interface TimelineSegment {
+ bookingId: string;
+ guestName: string;
+ roomId: string;
+ start: string;
+ end: string;
+ status: BookingStatus;
+ paymentLabel: string;
+ source: string;
+}
+
+export interface DashboardPayload {
+ bookingSeries: { date: string; total: number; online: number; cancelled: number }[];
+ visitsSeries: { date: string; views: number; sessions: number }[];
+ heatmap: { roomId: string; state: "vacant" | "not_ready" | "occupied" | "unavailable" }[];
+ revenueExtras: { label: string; current: number; target: number }[];
+ rating: { score: number; label: string; imageUrl?: string };
+ recentBookings: Booking[];
+ calendarEvents: { id: string; title: string; date: string; accent: "sky" | "pink" | "violet" }[];
+ codeStats: { discountRedemptions: number; referralRedemptions: number };
+}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..365058c
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..c74a885
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,30 @@
+import "@fontsource/inter/latin-400.css";
+import "@fontsource/inter/latin-500.css";
+import "@fontsource/inter/latin-600.css";
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { BrowserRouter } from "react-router-dom";
+
+import App from "@/App";
+import { AuthProvider } from "@/context/AuthContext";
+import "@/styles/globals.css";
+
+async function enableMocking() {
+ if (import.meta.env.MODE !== "development") return;
+ const { worker } = await import("@/mocks/browser");
+ await worker.start({
+ onUnhandledRequest: "bypass",
+ });
+}
+
+void enableMocking().then(() => {
+ createRoot(document.getElementById("root")!).render(
+
+
+
+
+
+
+
+ );
+});
diff --git a/src/mocks/browser.ts b/src/mocks/browser.ts
new file mode 100644
index 0000000..dfae56e
--- /dev/null
+++ b/src/mocks/browser.ts
@@ -0,0 +1,5 @@
+import { setupWorker } from "msw/browser";
+
+import { handlers } from "@/mocks/handlers";
+
+export const worker = setupWorker(...handlers);
diff --git a/src/mocks/db.ts b/src/mocks/db.ts
new file mode 100644
index 0000000..1b07251
--- /dev/null
+++ b/src/mocks/db.ts
@@ -0,0 +1,120 @@
+import type {
+ Booking,
+ BookingStatus,
+ DiscountCode,
+ Payment,
+ ReferralCode,
+ Room,
+ RoomBlock,
+ SiteVisit,
+ Transaction,
+} from "@/lib/types";
+import {
+ seedBlocks,
+ seedBookings,
+ seedDiscountCodes,
+ seedPayments,
+ seedReferralCodes,
+ seedRooms,
+ seedSiteVisits,
+ seedTransactions,
+} from "@/mocks/seed";
+
+function deepClone(x: T): T {
+ return JSON.parse(JSON.stringify(x)) as T;
+}
+
+export interface MockDb {
+ bookings: Booking[];
+ rooms: Room[];
+ blocks: RoomBlock[];
+ payments: Payment[];
+ transactions: Transaction[];
+ discountCodes: DiscountCode[];
+ referralCodes: ReferralCode[];
+ siteVisits: SiteVisit[];
+}
+
+let db: MockDb = createFreshDb();
+
+function createFreshDb(): MockDb {
+ return {
+ bookings: deepClone(seedBookings),
+ rooms: deepClone(seedRooms),
+ blocks: deepClone(seedBlocks),
+ payments: deepClone(seedPayments),
+ transactions: deepClone(seedTransactions),
+ discountCodes: deepClone(seedDiscountCodes),
+ referralCodes: deepClone(seedReferralCodes),
+ siteVisits: deepClone(seedSiteVisits),
+ };
+}
+
+export function resetDb() {
+ db = createFreshDb();
+}
+
+export function getDb(): MockDb {
+ return db;
+}
+
+/** Inclusive start, exclusive end for nights (hotel convention) */
+export function parseYmd(s: string) {
+ const [y, m, d] = s.split("-").map(Number);
+ return new Date(y, m - 1, d);
+}
+
+export function rangesOverlap(
+ aStart: string,
+ aEnd: string,
+ bStart: string,
+ bEnd: string
+) {
+ const as = parseYmd(aStart).getTime();
+ const ae = parseYmd(aEnd).getTime();
+ const bs = parseYmd(bStart).getTime();
+ const be = parseYmd(bEnd).getTime();
+ return as < be && bs < ae;
+}
+
+export function bookingConflicts(
+ roomId: string,
+ checkIn: string,
+ checkOut: string,
+ excludeId?: string
+): boolean {
+ const active: BookingStatus[] = [
+ "held",
+ "payment_pending",
+ "confirmed",
+ ];
+ return db.bookings.some(
+ (b) =>
+ b.roomId === roomId &&
+ active.includes(b.status) &&
+ b.id !== excludeId &&
+ rangesOverlap(checkIn, checkOut, b.checkIn, b.checkOut)
+ );
+}
+
+export function blockConflicts(
+ roomId: string,
+ start: string,
+ end: string,
+ excludeId?: string
+) {
+ return db.blocks.some(
+ (blk) =>
+ blk.roomId === roomId &&
+ blk.id !== excludeId &&
+ rangesOverlap(start, end, blk.startDate, blk.endDate)
+ );
+}
+
+export function generateId(prefix: string) {
+ return `${prefix}-${Math.random().toString(36).slice(2, 10)}`;
+}
+
+export function normalizeCode(code: string) {
+ return code.trim().toUpperCase();
+}
diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts
new file mode 100644
index 0000000..7625adb
--- /dev/null
+++ b/src/mocks/handlers.ts
@@ -0,0 +1,566 @@
+import { addDays, format, parseISO, startOfMonth, endOfMonth, eachDayOfInterval } from "date-fns";
+import { http, HttpResponse } from "msw";
+
+import { TAX_RATE } from "@/lib/constants";
+import type { Booking, CustomerRow, DashboardPayload } from "@/lib/types";
+import {
+ bookingConflicts,
+ blockConflicts,
+ generateId,
+ getDb,
+ normalizeCode,
+ parseYmd,
+ rangesOverlap,
+} from "@/mocks/db";
+
+const API = "/api";
+
+export const handlers = [
+ http.get(`${API}/bookings`, ({ request }) => {
+ const url = new URL(request.url);
+ const status = url.searchParams.get("status");
+ const roomId = url.searchParams.get("roomId");
+ const email = url.searchParams.get("email");
+ const q = url.searchParams.get("q")?.toLowerCase();
+ const discountCode = url.searchParams.get("discountCode");
+ const referralCode = url.searchParams.get("referralCode");
+ let list = [...getDb().bookings];
+ if (status) list = list.filter((b) => b.status === status);
+ if (roomId) list = list.filter((b) => b.roomId === roomId);
+ if (email) list = list.filter((b) => b.guest.email === email);
+ if (discountCode)
+ list = list.filter(
+ (b) =>
+ b.pricing.couponCode?.toUpperCase() === discountCode.toUpperCase()
+ );
+ if (referralCode)
+ list = list.filter(
+ (b) => b.referralCode?.toUpperCase() === referralCode.toUpperCase()
+ );
+ if (q) {
+ list = list.filter(
+ (b) =>
+ `${b.guest.firstName} ${b.guest.lastName}`
+ .toLowerCase()
+ .includes(q) ||
+ b.guest.email.toLowerCase().includes(q) ||
+ b.holdReference?.toLowerCase().includes(q) ||
+ b.confirmationId?.toLowerCase().includes(q) ||
+ b.id.toLowerCase().includes(q)
+ );
+ }
+ return HttpResponse.json({ data: list });
+ }),
+
+ http.get(`${API}/bookings/:id`, ({ params }) => {
+ const b = getDb().bookings.find((x) => x.id === params.id);
+ if (!b) return HttpResponse.json({ error: "Not found" }, { status: 404 });
+ return HttpResponse.json(b);
+ }),
+
+ http.patch(`${API}/bookings/:id`, async ({ params, request }) => {
+ const body = (await request.json()) as Partial;
+ const db = getDb();
+ const idx = db.bookings.findIndex((x) => x.id === params.id);
+ if (idx === -1)
+ return HttpResponse.json({ error: "Not found" }, { status: 404 });
+ const cur = db.bookings[idx];
+ const next = { ...cur, ...body, updatedAt: new Date().toISOString() };
+ if (body.internalNotes) {
+ next.internalNotes = [
+ ...(cur.internalNotes ?? []),
+ ...body.internalNotes,
+ ];
+ }
+ db.bookings[idx] = next;
+ return HttpResponse.json(next);
+ }),
+
+ http.post(`${API}/bookings`, async ({ request }) => {
+ const body = (await request.json()) as Partial & {
+ guest: Booking["guest"];
+ checkIn: string;
+ checkOut: string;
+ roomId: string;
+ guests: number;
+ };
+ const db = getDb();
+ if (
+ bookingConflicts(body.roomId!, body.checkIn!, body.checkOut!)
+ ) {
+ return HttpResponse.json(
+ { error: "Room unavailable for these dates" },
+ { status: 409 }
+ );
+ }
+ const ci = parseYmd(body.checkIn!);
+ const co = parseYmd(body.checkOut!);
+ const nights = Math.max(
+ 1,
+ Math.round((co.getTime() - ci.getTime()) / 86400000)
+ );
+ const room = db.rooms.find((r) => r.id === body.roomId);
+ const nightly = room?.baseRate ?? 120;
+ const sub = nightly * nights;
+ let discountPct = 0;
+ let coupon: string | undefined;
+ if (body.pricing?.couponCode) {
+ const dc = db.discountCodes.find(
+ (d) =>
+ normalizeCode(d.code) === normalizeCode(body.pricing!.couponCode!) &&
+ d.isActive
+ );
+ if (
+ dc &&
+ dc.discountType === "percent" &&
+ (!dc.maxRedemptions || dc.redemptionCount < dc.maxRedemptions)
+ ) {
+ discountPct = dc.value;
+ coupon = dc.code;
+ dc.redemptionCount += 1;
+ }
+ }
+ const discountAmount = sub * (discountPct / 100);
+ const after = sub - discountAmount;
+ const taxAmount = after * TAX_RATE;
+ const total = after + taxAmount;
+ const id = generateId("b");
+ const booking: Booking = {
+ id,
+ guest: body.guest,
+ checkIn: body.checkIn!,
+ checkOut: body.checkOut!,
+ guests: body.guests ?? 2,
+ roomId: body.roomId!,
+ nights,
+ status: body.status ?? "confirmed",
+ payLaterHold: body.payLaterHold ?? false,
+ holdReference: `SHY-${id.slice(-6).toUpperCase()}`,
+ pricing: {
+ nightlySubtotal: sub,
+ couponCode: coupon,
+ discountPercent: discountPct,
+ discountAmount,
+ taxRate: TAX_RATE,
+ taxAmount,
+ total,
+ totalCents: Math.round(total * 100),
+ currency: "USD",
+ },
+ referralCode: body.referralCode,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ };
+ if (body.referralCode) {
+ const rc = db.referralCodes.find(
+ (r) => normalizeCode(r.code) === normalizeCode(body.referralCode!)
+ );
+ if (rc && rc.isActive) rc.redemptionCount += 1;
+ }
+ db.bookings.push(booking);
+ return HttpResponse.json(booking, { status: 201 });
+ }),
+
+ http.get(`${API}/payments`, () =>
+ HttpResponse.json({ data: getDb().payments })
+ ),
+
+ http.get(`${API}/transactions`, ({ request }) => {
+ const url = new URL(request.url);
+ const bookingId = url.searchParams.get("bookingId");
+ let t = [...getDb().transactions];
+ if (bookingId) t = t.filter((x) => x.bookingId === bookingId);
+ t.sort(
+ (a, b) =>
+ new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
+ );
+ return HttpResponse.json({ data: t });
+ }),
+
+ http.get(`${API}/rooms`, () =>
+ HttpResponse.json({ data: getDb().rooms })
+ ),
+
+ http.post(`${API}/rooms`, async ({ request }) => {
+ const body = (await request.json()) as Omit<
+ import("@/lib/types").Room,
+ "id"
+ >;
+ const db = getDb();
+ const room = { ...body, id: generateId("r") };
+ db.rooms.push(room);
+ return HttpResponse.json(room, { status: 201 });
+ }),
+
+ http.patch(`${API}/rooms/:id`, async ({ params, request }) => {
+ const body = (await request.json()) as Partial;
+ const db = getDb();
+ const idx = db.rooms.findIndex((r) => r.id === params.id);
+ if (idx === -1)
+ return HttpResponse.json({ error: "Not found" }, { status: 404 });
+ db.rooms[idx] = { ...db.rooms[idx], ...body };
+ return HttpResponse.json(db.rooms[idx]);
+ }),
+
+ http.get(`${API}/rooms/:id/blocks`, ({ params }) => {
+ const list = getDb().blocks.filter((b) => b.roomId === params.id);
+ return HttpResponse.json({ data: list });
+ }),
+
+ http.post(`${API}/rooms/:id/blocks`, async ({ params, request }) => {
+ const body = (await request.json()) as Omit<
+ import("@/lib/types").RoomBlock,
+ "id" | "roomId" | "createdAt"
+ >;
+ const db = getDb();
+ if (blockConflicts(params.id as string, body.startDate, body.endDate)) {
+ return HttpResponse.json(
+ { error: "Overlapping block exists" },
+ { status: 409 }
+ );
+ }
+ const blk: import("@/lib/types").RoomBlock = {
+ ...body,
+ id: generateId("blk"),
+ roomId: params.id as string,
+ createdAt: new Date().toISOString(),
+ };
+ db.blocks.push(blk);
+ return HttpResponse.json(blk, { status: 201 });
+ }),
+
+ http.delete(`${API}/rooms/:roomId/blocks/:blockId`, ({ params }) => {
+ const db = getDb();
+ const idx = db.blocks.findIndex(
+ (b) => b.id === params.blockId && b.roomId === params.roomId
+ );
+ if (idx === -1)
+ return HttpResponse.json({ error: "Not found" }, { status: 404 });
+ db.blocks.splice(idx, 1);
+ return HttpResponse.json({ ok: true });
+ }),
+
+ http.get(`${API}/dashboard/summary`, () => {
+ const db = getDb();
+ const today = format(new Date(), "yyyy-MM-dd");
+ const arrivals = db.bookings.filter(
+ (b) => b.checkIn === today && ["confirmed", "held"].includes(b.status)
+ ).length;
+ const departures = db.bookings.filter(
+ (b) => b.checkOut === today && b.status === "confirmed"
+ ).length;
+ const unpaidHolds = db.bookings.filter(
+ (b) => b.status === "held" && b.payLaterHold
+ ).length;
+ const revenue = db.bookings
+ .filter((b) => b.status === "confirmed")
+ .reduce((s, b) => s + b.pricing.total, 0);
+ return HttpResponse.json({
+ arrivals,
+ departures,
+ unpaidHolds,
+ revenueMonth: revenue,
+ });
+ }),
+
+ http.get(`${API}/dashboard`, () => {
+ const db = getDb();
+ const payload: DashboardPayload = {
+ bookingSeries: Array.from({ length: 14 }, (_, i) => {
+ const d = addDays(new Date(), -13 + i);
+ return {
+ date: format(d, "MMM d"),
+ total: 20 + (i % 5) * 3,
+ online: 12 + (i % 4) * 2,
+ cancelled: i % 6 === 0 ? 2 : 0,
+ };
+ }),
+ visitsSeries: Array.from({ length: 14 }, (_, i) => {
+ const d = addDays(new Date(), -13 + i);
+ const key = format(d, "yyyy-MM-dd");
+ const dayVisits = db.siteVisits.filter((v) =>
+ v.occurredAt.startsWith(key)
+ );
+ const sessions = new Set(dayVisits.map((v) => v.sessionId)).size;
+ return {
+ date: format(d, "MMM d"),
+ views: dayVisits.length * 8,
+ sessions: sessions || dayVisits.length,
+ };
+ }),
+ heatmap: db.rooms.map((r) => ({
+ roomId: r.id,
+ state:
+ r.status === "maintenance"
+ ? ("not_ready" as const)
+ : r.status === "occupied"
+ ? ("occupied" as const)
+ : r.status === "out_of_order"
+ ? ("unavailable" as const)
+ : ("vacant" as const),
+ })),
+ revenueExtras: [
+ { label: "Restaurant", current: 12400, target: 18000 },
+ { label: "Bar", current: 8200, target: 12000 },
+ { label: "Spa", current: 5600, target: 9000 },
+ ],
+ rating: {
+ score: 4.8,
+ label: "Impressive",
+ imageUrl:
+ "https://images.unsplash.com/photo-1571896349842-33c89424de2d?w=400&q=80",
+ },
+ recentBookings: [...db.bookings]
+ .sort(
+ (a, b) =>
+ new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
+ )
+ .slice(0, 5),
+ calendarEvents: [
+ {
+ id: "e1",
+ title: "VIP arrival — Suite 201",
+ date: format(new Date(), "yyyy-MM-dd"),
+ accent: "sky",
+ },
+ {
+ id: "e2",
+ title: "Staff training",
+ date: format(addDays(new Date(), 2), "yyyy-MM-dd"),
+ accent: "pink",
+ },
+ ],
+ codeStats: {
+ discountRedemptions: db.discountCodes.reduce(
+ (s, d) => s + d.redemptionCount,
+ 0
+ ),
+ referralRedemptions: db.referralCodes.reduce(
+ (s, r) => s + r.redemptionCount,
+ 0
+ ),
+ },
+ };
+ return HttpResponse.json(payload);
+ }),
+
+ http.get(`${API}/reservations/timeline`, ({ request }) => {
+ const url = new URL(request.url);
+ const month = url.searchParams.get("month") ?? format(new Date(), "yyyy-MM");
+ const start = startOfMonth(parseISO(`${month}-01`));
+ const end = endOfMonth(start);
+ const days = eachDayOfInterval({ start, end }).map((d) =>
+ format(d, "yyyy-MM-dd")
+ );
+ const db = getDb();
+ const segments = db.bookings
+ .filter((b) =>
+ rangesOverlap(b.checkIn, b.checkOut, format(start, "yyyy-MM-dd"), format(end, "yyyy-MM-dd"))
+ )
+ .map((b) => ({
+ bookingId: b.id,
+ guestName: `${b.guest.firstName} ${b.guest.lastName}`,
+ roomId: b.roomId,
+ start: b.checkIn,
+ end: b.checkOut,
+ status: b.status,
+ paymentLabel:
+ b.status === "confirmed"
+ ? "Paid"
+ : b.status === "payment_pending"
+ ? "Part-paid"
+ : "Unpaid",
+ source: ["Direct", "Booking.com", "Direct"][b.id.length % 3],
+ }));
+ return HttpResponse.json({ days, rooms: db.rooms, segments });
+ }),
+
+ http.get(`${API}/export/bookings.csv`, () => {
+ const db = getDb();
+ const header =
+ "id,guest,email,checkIn,checkOut,room,status,total\n";
+ const rows = db.bookings
+ .map(
+ (b) =>
+ `${b.id},"${b.guest.firstName} ${b.guest.lastName}",${b.guest.email},${b.checkIn},${b.checkOut},${b.roomId},${b.status},${b.pricing.total}`
+ )
+ .join("\n");
+ return new HttpResponse(header + rows, {
+ headers: {
+ "Content-Type": "text/csv",
+ "Content-Disposition": 'attachment; filename="bookings.csv"',
+ },
+ });
+ }),
+
+ http.get(`${API}/customers`, () => {
+ const map = new Map();
+ for (const b of getDb().bookings) {
+ const k = b.guest.email.toLowerCase();
+ const name = `${b.guest.firstName} ${b.guest.lastName}`;
+ const prev = map.get(k);
+ if (!prev) {
+ map.set(k, {
+ email: b.guest.email,
+ name,
+ bookingCount: 1,
+ lastStay: b.checkOut,
+ });
+ } else {
+ prev.bookingCount += 1;
+ if (!prev.lastStay || b.checkOut > prev.lastStay)
+ prev.lastStay = b.checkOut;
+ }
+ }
+ return HttpResponse.json({ data: [...map.values()] });
+ }),
+
+ http.get(`${API}/analytics/visits/recent`, () => {
+ const visits = [...getDb().siteVisits]
+ .sort(
+ (a, b) =>
+ new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime()
+ )
+ .slice(0, 40);
+ return HttpResponse.json({ data: visits });
+ }),
+
+ http.get(`${API}/analytics/visits`, ({ request }) => {
+ const url = new URL(request.url);
+ const from = url.searchParams.get("from");
+ const to = url.searchParams.get("to");
+ let visits = [...getDb().siteVisits];
+ if (from) visits = visits.filter((v) => v.occurredAt >= from);
+ if (to) visits = visits.filter((v) => v.occurredAt <= to);
+ const byDay = new Map }>();
+ for (const v of visits) {
+ const day = v.occurredAt.slice(0, 10);
+ if (!byDay.has(day))
+ byDay.set(day, { views: 0, sessions: new Set() });
+ const g = byDay.get(day)!;
+ g.views += 1;
+ if (v.sessionId) g.sessions.add(v.sessionId);
+ }
+ const series = [...byDay.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([date, g]) => ({
+ date,
+ views: g.views,
+ sessions: g.sessions.size || g.views,
+ }));
+ return HttpResponse.json({
+ series,
+ totalViews: visits.length,
+ });
+ }),
+
+ http.post(`${API}/analytics/visits`, async ({ request }) => {
+ const body = (await request.json()) as Partial<
+ import("@/lib/types").SiteVisit
+ >;
+ const db = getDb();
+ const v: import("@/lib/types").SiteVisit = {
+ id: generateId("v"),
+ occurredAt: body.occurredAt ?? new Date().toISOString(),
+ path: body.path ?? "/",
+ referrer: body.referrer,
+ utmSource: body.utmSource,
+ utmMedium: body.utmMedium,
+ utmCampaign: body.utmCampaign,
+ sessionId: body.sessionId ?? `s-${Date.now()}`,
+ device: body.device,
+ };
+ db.siteVisits.push(v);
+ return HttpResponse.json(v, { status: 201 });
+ }),
+
+ http.get(`${API}/discount-codes`, () =>
+ HttpResponse.json({ data: getDb().discountCodes })
+ ),
+
+ http.post(`${API}/discount-codes`, async ({ request }) => {
+ const body = (await request.json()) as Partial & {
+ generate?: boolean;
+ };
+ const db = getDb();
+ const code =
+ body.code && !body.generate
+ ? normalizeCode(body.code)
+ : `SAVE${Math.random().toString(36).slice(2, 6).toUpperCase()}`;
+ if (db.discountCodes.some((d) => normalizeCode(d.code) === code)) {
+ return HttpResponse.json({ error: "Code exists" }, { status: 409 });
+ }
+ const dc: import("@/lib/types").DiscountCode = {
+ id: generateId("dc"),
+ code,
+ description: body.description,
+ discountType: body.discountType ?? "percent",
+ value: body.value ?? 10,
+ currency: body.currency,
+ validFrom: body.validFrom ?? format(new Date(), "yyyy-MM-dd"),
+ validTo: body.validTo ?? format(addDays(new Date(), 365), "yyyy-MM-dd"),
+ maxRedemptions: body.maxRedemptions ?? null,
+ redemptionCount: 0,
+ isActive: body.isActive ?? true,
+ createdAt: new Date().toISOString(),
+ };
+ db.discountCodes.push(dc);
+ return HttpResponse.json(dc, { status: 201 });
+ }),
+
+ http.patch(`${API}/discount-codes/:id`, async ({ params, request }) => {
+ const body = (await request.json()) as Partial<
+ import("@/lib/types").DiscountCode
+ >;
+ const db = getDb();
+ const idx = db.discountCodes.findIndex((d) => d.id === params.id);
+ if (idx === -1)
+ return HttpResponse.json({ error: "Not found" }, { status: 404 });
+ db.discountCodes[idx] = { ...db.discountCodes[idx], ...body };
+ return HttpResponse.json(db.discountCodes[idx]);
+ }),
+
+ http.get(`${API}/referral-codes`, () =>
+ HttpResponse.json({ data: getDb().referralCodes })
+ ),
+
+ http.post(`${API}/referral-codes`, async ({ request }) => {
+ const body = (await request.json()) as Partial<
+ import("@/lib/types").ReferralCode
+ > & { generate?: boolean };
+ const db = getDb();
+ const code =
+ body.code && !body.generate
+ ? normalizeCode(body.code)
+ : `REF${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
+ if (db.referralCodes.some((r) => normalizeCode(r.code) === code)) {
+ return HttpResponse.json({ error: "Code exists" }, { status: 409 });
+ }
+ const rc: import("@/lib/types").ReferralCode = {
+ id: generateId("rf"),
+ code,
+ label: body.label ?? "Campaign",
+ attributedTo: body.attributedTo,
+ validFrom: body.validFrom ?? format(new Date(), "yyyy-MM-dd"),
+ validTo: body.validTo ?? format(addDays(new Date(), 365), "yyyy-MM-dd"),
+ maxRedemptions: body.maxRedemptions ?? null,
+ redemptionCount: 0,
+ isActive: body.isActive ?? true,
+ createdAt: new Date().toISOString(),
+ };
+ db.referralCodes.push(rc);
+ return HttpResponse.json(rc, { status: 201 });
+ }),
+
+ http.patch(`${API}/referral-codes/:id`, async ({ params, request }) => {
+ const body = (await request.json()) as Partial<
+ import("@/lib/types").ReferralCode
+ >;
+ const db = getDb();
+ const idx = db.referralCodes.findIndex((r) => r.id === params.id);
+ if (idx === -1)
+ return HttpResponse.json({ error: "Not found" }, { status: 404 });
+ db.referralCodes[idx] = { ...db.referralCodes[idx], ...body };
+ return HttpResponse.json(db.referralCodes[idx]);
+ }),
+];
diff --git a/src/mocks/seed.ts b/src/mocks/seed.ts
new file mode 100644
index 0000000..e7a7a22
--- /dev/null
+++ b/src/mocks/seed.ts
@@ -0,0 +1,363 @@
+import { TAX_RATE } from "@/lib/constants";
+import type {
+ Booking,
+ DiscountCode,
+ Payment,
+ ReferralCode,
+ Room,
+ RoomBlock,
+ SiteVisit,
+ Transaction,
+} from "@/lib/types";
+
+const now = new Date();
+const iso = (d: Date) => d.toISOString();
+
+function pricing(
+ nightly: number,
+ nights: number,
+ coupon?: string,
+ pct = 0
+): Booking["pricing"] {
+ const sub = nightly * nights;
+ const discountAmount = sub * (pct / 100);
+ const after = sub - discountAmount;
+ const taxAmount = after * TAX_RATE;
+ const total = after + taxAmount;
+ return {
+ nightlySubtotal: sub,
+ couponCode: coupon,
+ discountPercent: pct,
+ discountAmount,
+ taxRate: TAX_RATE,
+ taxAmount,
+ total,
+ totalCents: Math.round(total * 100),
+ currency: "USD",
+ };
+}
+
+export const seedBookings: Booking[] = [
+ {
+ id: "b1",
+ guest: {
+ firstName: "Amina",
+ lastName: "Tesfaye",
+ email: "amina@example.com",
+ phone: "+251911000001",
+ flightBookingNumber: "ETH708",
+ arrivalTime: "14:30",
+ },
+ checkIn: "2026-03-22",
+ checkOut: "2026-03-25",
+ guests: 2,
+ roomId: "r-standard-101",
+ nights: 3,
+ pricing: pricing(120, 3, "SHITAYE10", 10),
+ status: "confirmed",
+ holdReference: "SHY-H001",
+ payLaterHold: false,
+ confirmationId: "PAY-001",
+ paidAt: iso(new Date(now.getTime() - 86400000)),
+ referralCode: "PARTNER2024",
+ createdAt: iso(new Date(now.getTime() - 172800000)),
+ updatedAt: iso(now),
+ },
+ {
+ id: "b2",
+ guest: {
+ firstName: "James",
+ lastName: "Brown",
+ email: "james@example.com",
+ phone: "+447700900123",
+ flightBookingNumber: "BA123",
+ arrivalTime: "09:00",
+ },
+ checkIn: "2026-03-23",
+ checkOut: "2026-03-28",
+ guests: 4,
+ roomId: "r-suite-201",
+ nights: 5,
+ pricing: pricing(280, 5, undefined, 0),
+ status: "held",
+ holdReference: "SHY-H002",
+ payLaterHold: true,
+ createdAt: iso(new Date(now.getTime() - 3600000)),
+ updatedAt: iso(now),
+ },
+ {
+ id: "b3",
+ guest: {
+ firstName: "Sofia",
+ lastName: "Mitchell",
+ email: "sofia@example.com",
+ phone: "+12025550199",
+ flightBookingNumber: "DL44",
+ arrivalTime: "16:00",
+ },
+ checkIn: "2026-03-20",
+ checkOut: "2026-03-22",
+ guests: 2,
+ roomId: "r-studio-305",
+ nights: 2,
+ pricing: pricing(95, 2, "WELCOME5", 5),
+ status: "confirmed",
+ confirmationId: "PAY-003",
+ paidAt: iso(new Date(now.getTime() - 259200000)),
+ payLaterHold: false,
+ createdAt: iso(new Date(now.getTime() - 400000000)),
+ updatedAt: iso(now),
+ },
+ {
+ id: "b4",
+ guest: {
+ firstName: "Yonas",
+ lastName: "Kebede",
+ email: "yonas@example.com",
+ phone: "+251922000002",
+ flightBookingNumber: "ET302",
+ arrivalTime: "11:15",
+ },
+ checkIn: "2026-03-25",
+ checkOut: "2026-03-30",
+ guests: 6,
+ roomId: "r-penthouse-1",
+ nights: 5,
+ pricing: pricing(450, 5),
+ status: "payment_pending",
+ holdReference: "SHY-H004",
+ payLaterHold: false,
+ createdAt: iso(now),
+ updatedAt: iso(now),
+ },
+ {
+ id: "b5",
+ guest: {
+ firstName: "Elena",
+ lastName: "Rossi",
+ email: "elena@example.it",
+ phone: "+39333111222",
+ flightBookingNumber: "AZ784",
+ arrivalTime: "20:00",
+ },
+ checkIn: "2026-03-18",
+ checkOut: "2026-03-19",
+ guests: 2,
+ roomId: "r-standard-102",
+ nights: 1,
+ pricing: pricing(120, 1),
+ status: "cancelled",
+ holdReference: "SHY-H005",
+ payLaterHold: false,
+ createdAt: iso(new Date(now.getTime() - 500000000)),
+ updatedAt: iso(now),
+ },
+];
+
+export const seedRooms: Room[] = [
+ {
+ id: "r-standard-101",
+ name: "Room 101",
+ roomTypeSlug: "standard",
+ floor: "1",
+ maxGuests: 2,
+ baseRate: 120,
+ status: "occupied",
+ },
+ {
+ id: "r-standard-102",
+ name: "Room 102",
+ roomTypeSlug: "standard",
+ floor: "1",
+ maxGuests: 2,
+ baseRate: 120,
+ status: "available",
+ },
+ {
+ id: "r-suite-201",
+ name: "Suite 201",
+ roomTypeSlug: "connecting-suite",
+ floor: "2",
+ maxGuests: 6,
+ baseRate: 280,
+ status: "occupied",
+ },
+ {
+ id: "r-studio-305",
+ name: "Studio 305",
+ roomTypeSlug: "junior-studio",
+ floor: "3",
+ maxGuests: 2,
+ baseRate: 95,
+ status: "available",
+ },
+ {
+ id: "r-penthouse-1",
+ name: "Penthouse",
+ roomTypeSlug: "penthouse",
+ floor: "PH",
+ maxGuests: 8,
+ baseRate: 450,
+ status: "occupied",
+ },
+ {
+ id: "r-standard-103",
+ name: "Room 103",
+ roomTypeSlug: "standard",
+ floor: "1",
+ maxGuests: 2,
+ baseRate: 120,
+ status: "maintenance",
+ },
+];
+
+export const seedBlocks: RoomBlock[] = [
+ {
+ id: "blk1",
+ roomId: "r-standard-103",
+ startDate: "2026-03-21",
+ endDate: "2026-03-24",
+ reason: "maintenance",
+ title: "HVAC service",
+ createdAt: iso(now),
+ },
+];
+
+export const seedPayments: Payment[] = [
+ {
+ id: "pay1",
+ bookingId: "b1",
+ provider: "mock",
+ amount: seedBookings[0].pricing.total,
+ currency: "USD",
+ status: "paid",
+ last4: "4242",
+ createdAt: seedBookings[0].paidAt!,
+ },
+ {
+ id: "pay3",
+ bookingId: "b3",
+ provider: "mock",
+ amount: seedBookings[2].pricing.total,
+ currency: "USD",
+ status: "paid",
+ last4: "1881",
+ createdAt: seedBookings[2].paidAt!,
+ },
+];
+
+export const seedTransactions: Transaction[] = [
+ {
+ id: "t1",
+ type: "payment",
+ amount: seedBookings[0].pricing.total,
+ currency: "USD",
+ status: "completed",
+ bookingId: "b1",
+ paymentRef: "PAY-001",
+ description: "Card capture",
+ createdAt: seedBookings[0].paidAt!,
+ },
+ {
+ id: "t2",
+ type: "refund",
+ amount: 80,
+ currency: "USD",
+ status: "completed",
+ bookingId: "b5",
+ description: "Partial refund — cancellation policy",
+ createdAt: iso(new Date(now.getTime() - 86400000)),
+ },
+];
+
+export const seedDiscountCodes: DiscountCode[] = [
+ {
+ id: "dc1",
+ code: "SHITAYE10",
+ description: "10% off public campaign",
+ discountType: "percent",
+ value: 10,
+ validFrom: "2026-01-01",
+ validTo: "2026-12-31",
+ maxRedemptions: 500,
+ redemptionCount: 42,
+ isActive: true,
+ createdAt: iso(new Date(now.getTime() - 86400000 * 60)),
+ },
+ {
+ id: "dc2",
+ code: "WELCOME5",
+ discountType: "percent",
+ value: 5,
+ validFrom: "2026-01-01",
+ validTo: "2026-06-30",
+ maxRedemptions: null,
+ redemptionCount: 18,
+ isActive: true,
+ createdAt: iso(new Date(now.getTime() - 86400000 * 30)),
+ },
+ {
+ id: "dc3",
+ code: "OLDPROMO",
+ discountType: "fixed_amount",
+ value: 2500,
+ currency: "USD",
+ validFrom: "2025-01-01",
+ validTo: "2025-12-31",
+ maxRedemptions: 100,
+ redemptionCount: 100,
+ isActive: false,
+ createdAt: iso(new Date(now.getTime() - 86400000 * 400)),
+ },
+];
+
+export const seedReferralCodes: ReferralCode[] = [
+ {
+ id: "rf1",
+ code: "PARTNER2024",
+ label: "Travel partner Q1",
+ attributedTo: "partner-ethio-tours",
+ validFrom: "2026-01-01",
+ validTo: "2026-12-31",
+ maxRedemptions: null,
+ redemptionCount: 12,
+ isActive: true,
+ createdAt: iso(new Date(now.getTime() - 86400000 * 90)),
+ },
+ {
+ id: "rf2",
+ code: "VIPGUEST",
+ label: "VIP referrals",
+ validFrom: "2026-03-01",
+ validTo: "2026-12-31",
+ maxRedemptions: 50,
+ redemptionCount: 3,
+ isActive: true,
+ createdAt: iso(new Date(now.getTime() - 86400000 * 5)),
+ },
+];
+
+function seedVisits(): SiteVisit[] {
+ const visits: SiteVisit[] = [];
+ let id = 0;
+ for (let d = 29; d >= 0; d--) {
+ const day = new Date(now);
+ day.setDate(day.getDate() - d);
+ for (let i = 0; i < 3; i++) {
+ visits.push({
+ id: `v${++id}`,
+ occurredAt: new Date(
+ day.getTime() + i * 3600000 + 1000 * id
+ ).toISOString(),
+ path: ["/", "/booking", "/rooms", "/contact"][i % 4],
+ referrer: i % 2 ? "https://google.com" : undefined,
+ utmSource: i === 0 ? "newsletter" : undefined,
+ sessionId: `s-${d}-${i}`,
+ device: ["mobile", "desktop"][id % 2],
+ });
+ }
+ }
+ return visits;
+}
+
+export const seedSiteVisits = seedVisits();
diff --git a/src/pages/BookingDetailPage.tsx b/src/pages/BookingDetailPage.tsx
new file mode 100644
index 0000000..0431e5c
--- /dev/null
+++ b/src/pages/BookingDetailPage.tsx
@@ -0,0 +1,136 @@
+import { useEffect, useState } from "react";
+import { Link, useParams } from "react-router-dom";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Textarea } from "@/components/ui/textarea";
+import { useAuth } from "@/context/AuthContext";
+import { apiGet, apiPatch } from "@/lib/api";
+import type { Booking } from "@/lib/types";
+import { formatDate, formatDateTime, formatMoney } from "@/lib/format";
+import { roomDisplayName } from "@/lib/room-utils";
+
+export function BookingDetailPage() {
+ const { id } = useParams<{ id: string }>();
+ const [b, setB] = useState(null);
+ const [note, setNote] = useState("");
+ const { canEditBookings } = useAuth();
+
+ useEffect(() => {
+ if (!id) return;
+ apiGet(`/bookings/${id}`).then(setB).catch(console.error);
+ }, [id]);
+
+ if (!b) return Loading… ;
+
+ async function addNote() {
+ if (!b || !note.trim() || !canEditBookings) return;
+ const next = await apiPatch(`/bookings/${b.id}`, {
+ internalNotes: [note.trim()],
+ });
+ setB(next);
+ setNote("");
+ }
+
+ return (
+
+
+
+
+
+ {b.guest.firstName} {b.guest.lastName}
+
+ {b.id}
+
+ {b.status}
+
+
+
+
+
+ Guest
+
+
+
+ Email:{" "}
+ {b.guest.email}
+
+
+ Phone:{" "}
+ {b.guest.phone}
+
+
+ PNR:{" "}
+ {b.guest.flightBookingNumber}
+
+
+ Arrival:{" "}
+ {b.guest.arrivalTime}
+
+
+
+
+
+
+ Stay
+
+
+
+ {formatDate(b.checkIn)} → {formatDate(b.checkOut)} ({b.nights}{" "}
+ nights)
+
+ Guests: {b.guests}
+ Room: {roomDisplayName(b.roomId)}
+ {b.holdReference && Hold: {b.holdReference} }
+ {b.confirmationId && Payment ref: {b.confirmationId} }
+ {b.paidAt && Paid: {formatDateTime(b.paidAt)} }
+
+
+
+
+
+ Pricing
+
+
+ Nightly subtotal: {formatMoney(b.pricing.nightlySubtotal)}
+ Coupon: {b.pricing.couponCode ?? "—"}
+ Discount: {b.pricing.discountPercent}%
+ Tax: {formatMoney(b.pricing.taxAmount)}
+
+ Total: {formatMoney(b.pricing.total)}
+
+ Referral: {b.referralCode ?? "—"}
+
+
+
+
+
+ Internal notes
+
+
+
+ {(b.internalNotes ?? []).map((n, i) => (
+ - {n}
+ ))}
+
+ {canEditBookings && (
+ <>
+
+
+
+
+ );
+}
diff --git a/src/pages/BookingsPage.tsx b/src/pages/BookingsPage.tsx
new file mode 100644
index 0000000..2eca03d
--- /dev/null
+++ b/src/pages/BookingsPage.tsx
@@ -0,0 +1,203 @@
+import { useEffect, useState } from "react";
+import { Link, useSearchParams } from "react-router-dom";
+
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+} from "@/components/ui/breadcrumb";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { apiGet } from "@/lib/api";
+import type { Booking } from "@/lib/types";
+import { formatDate, formatMoney } from "@/lib/format";
+import { roomDisplayName } from "@/lib/room-utils";
+
+export function BookingsPage() {
+ const [searchParams] = useSearchParams();
+ const ref = searchParams.get("referral") ?? "";
+ const [list, setList] = useState([]);
+ const [status, setStatus] = useState("all");
+ const [q, setQ] = useState("");
+
+ useEffect(() => {
+ const params = new URLSearchParams();
+ if (status !== "all") params.set("status", status);
+ if (q) params.set("q", q);
+ if (ref) params.set("referralCode", ref);
+ const t = setTimeout(() => {
+ apiGet<{ data: Booking[] }>(`/bookings?${params}`)
+ .then((r) => setList(r.data))
+ .catch(console.error);
+ }, 200);
+ return () => clearTimeout(t);
+ }, [status, q, ref]);
+
+ return (
+
+
+
+
+ Home
+
+
+
+ Bookings
+
+
+
+
+
+ Bookings
+
+ Search, filter, export
+
+
+
+
+
+
+ {["Total", "Confirmed", "Held", "Pending"].map((label, i) => (
+
+
+
+ {label}
+
+
+
+
+ {i === 0
+ ? list.length
+ : list.filter((b) =>
+ label === "Confirmed"
+ ? b.status === "confirmed"
+ : label === "Held"
+ ? b.status === "held"
+ : b.status === "payment_pending"
+ ).length}
+
+
+
+ ))}
+
+
+
+
+
+ setQ(e.target.value)}
+ className="max-w-md"
+ />
+
+
+
+
+
+
+ Guest
+ Room
+ Dates
+ Status
+ Total
+
+
+
+
+ {list.map((b) => (
+
+
+
+ {b.guest.firstName} {b.guest.lastName}
+
+
+ {b.guest.email}
+
+
+
+ {roomDisplayName(b.roomId)}
+
+
+ {formatDate(b.checkIn)} → {formatDate(b.checkOut)}
+
+
+ {b.status}
+
+
+ {formatMoney(b.pricing.total)}
+
+
+
+
+
+ ))}
+
+
+
+
+ {list.map((b) => (
+
+
+ {b.guest.firstName} {b.guest.lastName}
+
+
+ {formatDate(b.checkIn)} · {b.status}
+
+
+ {formatMoney(b.pricing.total)}
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/pages/CalendarPage.tsx b/src/pages/CalendarPage.tsx
new file mode 100644
index 0000000..0f9ad5c
--- /dev/null
+++ b/src/pages/CalendarPage.tsx
@@ -0,0 +1,199 @@
+import { useCallback, useEffect, useState } from "react";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { apiDelete, apiGet, apiPost } from "@/lib/api";
+import type { Room, RoomBlock } from "@/lib/types";
+import { formatDate } from "@/lib/format";
+
+export function CalendarPage() {
+ const [rooms, setRooms] = useState([]);
+ const [roomId, setRoomId] = useState("");
+ const [blocks, setBlocks] = useState([]);
+ const [start, setStart] = useState("");
+ const [end, setEnd] = useState("");
+ const [title, setTitle] = useState("");
+ const [reason, setReason] = useState("maintenance");
+
+ const loadRooms = useCallback(() => {
+ apiGet<{ data: Room[] }>("/rooms").then((r) => {
+ setRooms(r.data);
+ setRoomId((cur) => cur || (r.data[0]?.id ?? ""));
+ });
+ }, []);
+
+ const loadBlocks = useCallback(() => {
+ if (!roomId) return;
+ apiGet<{ data: RoomBlock[] }>(`/rooms/${roomId}/blocks`).then((r) =>
+ setBlocks(r.data)
+ );
+ }, [roomId]);
+
+ useEffect(() => {
+ loadRooms();
+ }, [loadRooms]);
+
+ useEffect(() => {
+ loadBlocks();
+ }, [loadBlocks]);
+
+ async function addBlock(e: React.FormEvent) {
+ e.preventDefault();
+ await apiPost(`/rooms/${roomId}/blocks`, {
+ startDate: start,
+ endDate: end,
+ title,
+ reason,
+ });
+ setTitle("");
+ loadBlocks();
+ }
+
+ return (
+
+ Calendar & blocks
+
+ Block rooms for maintenance or holds (mock).
+
+
+
+
+ Room
+
+
+
+
+
+
+
+
+ New block
+
+
+
+
+
+
+
+
+ Blocks for selected room
+
+
+
+
+
+ Title
+ Dates
+ Reason
+
+
+
+
+ {blocks.map((b) => (
+
+ {b.title}
+
+ {formatDate(b.startDate)} → {formatDate(b.endDate)}
+
+ {b.reason}
+
+
+
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/pages/CustomersPage.tsx b/src/pages/CustomersPage.tsx
new file mode 100644
index 0000000..2a04a64
--- /dev/null
+++ b/src/pages/CustomersPage.tsx
@@ -0,0 +1,75 @@
+import { useEffect, useState } from "react";
+
+import { Card, CardContent } from "@/components/ui/card";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { apiGet } from "@/lib/api";
+import type { CustomerRow } from "@/lib/types";
+import { formatDate } from "@/lib/format";
+
+export function CustomersPage() {
+ const [rows, setRows] = useState([]);
+
+ useEffect(() => {
+ apiGet<{ data: CustomerRow[] }>("/customers").then((r) =>
+ setRows(r.data)
+ );
+ }, []);
+
+ return (
+
+ Customers
+
+ Aggregated from bookings (read-only mock).
+
+
+
+
+
+
+
+ Name
+ Email
+ Bookings
+ Last stay
+
+
+
+ {rows.map((c) => (
+
+ {c.name}
+
+ {c.email}
+
+ {c.bookingCount}
+
+ {c.lastStay ? formatDate(c.lastStay) : "—"}
+
+
+ ))}
+
+
+
+
+ {rows.map((c) => (
+
+ {c.name}
+ {c.email}
+
+ {c.bookingCount} bookings
+ {c.lastStay && ` · last ${formatDate(c.lastStay)}`}
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx
new file mode 100644
index 0000000..5976ad6
--- /dev/null
+++ b/src/pages/DashboardPage.tsx
@@ -0,0 +1,297 @@
+import { useEffect, useState } from "react";
+import {
+ Area,
+ AreaChart,
+ CartesianGrid,
+ Legend,
+ Line,
+ LineChart,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from "recharts";
+
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Progress } from "@/components/ui/progress";
+import { apiGet } from "@/lib/api";
+import type { Booking, DashboardPayload } from "@/lib/types";
+import { formatDate, formatMoney } from "@/lib/format";
+import { roomDisplayName } from "@/lib/room-utils";
+
+const tooltipStyle = {
+ backgroundColor: "var(--navy)",
+ border: "none",
+ borderRadius: "8px",
+ color: "#fff",
+};
+
+export function DashboardPage() {
+ const [data, setData] = useState(null);
+ const [err, setErr] = useState(null);
+
+ useEffect(() => {
+ apiGet("/dashboard")
+ .then(setData)
+ .catch((e) => setErr(String(e)));
+ }, []);
+
+ if (err) return {err} ;
+ if (!data) return Loading… ;
+
+ return (
+
+
+ Dashboard
+
+ Bookings, visits, and revenue snapshot
+
+
+
+
+
+
+ Booking trends
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Site visits
+
+
+
+
+
+
+
+
+
+
+ Room status
+
+
+
+ {data.heatmap.map((h) => (
+
+ {h.roomId.slice(-2)}
+
+ ))}
+
+
+
+ {" "}
+ Vacant
+
+
+ {" "}
+ Occupied
+
+
+ Not ready
+
+
+
+
+
+
+
+ Guest rating
+
+
+ {data.rating.score}
+ {data.rating.label}
+ {data.rating.imageUrl && (
+
+ )}
+
+
+ Discount uses
+ {data.codeStats.discountRedemptions}
+
+
+ Referrals
+ {data.codeStats.referralRedemptions}
+
+
+
+
+
+
+
+
+
+ Extra revenue
+
+
+ {data.revenueExtras.map((r) => (
+
+
+ {r.label}
+
+ {formatMoney(r.current)} / {formatMoney(r.target)}
+
+
+
+
+ ))}
+
+
+
+
+
+ Upcoming
+
+
+ {data.calendarEvents.map((e) => (
+
+ {e.title}
+
+ {formatDate(e.date)}
+
+
+ ))}
+
+
+
+
+
+
+ Recent bookings
+
+
+
+
+
+ Guest
+ Stay
+ Room type
+ Status
+ Total
+
+
+
+ {data.recentBookings.map((b: Booking) => (
+
+
+ {b.guest.firstName} {b.guest.lastName}
+
+
+ {formatDate(b.checkIn)} → {formatDate(b.checkOut)}
+
+
+ {roomDisplayName(b.roomId)}
+
+
+ {b.status}
+
+
+ {formatMoney(b.pricing.total)}
+
+
+ ))}
+
+
+
+
+ {data.recentBookings.map((b: Booking) => (
+
+
+ {b.guest.firstName} {b.guest.lastName}
+
+
+ {formatDate(b.checkIn)} — {b.status}
+
+
+ {formatMoney(b.pricing.total)}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/pages/DiscountCodesPage.tsx b/src/pages/DiscountCodesPage.tsx
new file mode 100644
index 0000000..849cf7d
--- /dev/null
+++ b/src/pages/DiscountCodesPage.tsx
@@ -0,0 +1,201 @@
+import { Copy } from "lucide-react";
+import { useCallback, useEffect, useState } from "react";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { useAuth } from "@/context/AuthContext";
+import { apiGet, apiPatch, apiPost } from "@/lib/api";
+import type { DiscountCode } from "@/lib/types";
+
+function copy(s: string) {
+ void navigator.clipboard.writeText(s);
+}
+
+export function DiscountCodesPage() {
+ const { canManageCodes } = useAuth();
+ const [rows, setRows] = useState([]);
+ const [open, setOpen] = useState(false);
+ const [custom, setCustom] = useState("");
+ const [generate, setGenerate] = useState(true);
+ const [value, setValue] = useState("10");
+ const [dtype, setDtype] = useState<"percent" | "fixed_amount">("percent");
+
+ const load = useCallback(() => {
+ apiGet<{ data: DiscountCode[] }>("/discount-codes").then((r) =>
+ setRows(r.data)
+ );
+ }, []);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ async function create(e: React.FormEvent) {
+ e.preventDefault();
+ await apiPost("/discount-codes", {
+ generate,
+ code: generate ? undefined : custom,
+ discountType: dtype,
+ value: Number(value),
+ });
+ setOpen(false);
+ load();
+ }
+
+ async function toggle(dc: DiscountCode) {
+ if (!canManageCodes) return;
+ await apiPatch(`/discount-codes/${dc.id}`, { isActive: !dc.isActive });
+ load();
+ }
+
+ return (
+
+
+ Discount codes
+ {canManageCodes && (
+
+ )}
+
+
+
+
+
+
+
+ Code
+ Type
+ Value
+ Redemptions
+ Active
+
+
+
+
+ {rows.map((d) => (
+
+ {d.code}
+ {d.discountType}
+
+ {d.discountType === "percent"
+ ? `${d.value}%`
+ : d.value}
+
+
+ {d.redemptionCount}
+ {d.maxRedemptions != null && ` / ${d.maxRedemptions}`}
+
+
+
+ {d.isActive ? "Active" : "Off"}
+
+
+
+
+ {canManageCodes && (
+
+ )}
+
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..b610e95
--- /dev/null
+++ b/src/pages/LoginPage.tsx
@@ -0,0 +1,54 @@
+import { useNavigate } from "react-router-dom";
+
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { useAuth } from "@/context/AuthContext";
+import type { AdminRole } from "@/lib/types";
+
+const roles: { id: AdminRole; label: string }[] = [
+ { id: "viewer", label: "Viewer (read-only)" },
+ { id: "front_desk", label: "Front desk" },
+ { id: "finance", label: "Finance" },
+ { id: "superadmin", label: "Super admin" },
+];
+
+export function LoginPage() {
+ const { setRole } = useAuth();
+ const navigate = useNavigate();
+
+ function pick(role: AdminRole) {
+ setRole(role);
+ navigate("/dashboard", { replace: true });
+ }
+
+ return (
+
+
+
+ Yaltopia Hotels Admin
+
+ Mock sign-in — choose a role to explore RBAC (no password).
+
+
+
+ {roles.map((r) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/pages/NewBookingPage.tsx b/src/pages/NewBookingPage.tsx
new file mode 100644
index 0000000..28bb840
--- /dev/null
+++ b/src/pages/NewBookingPage.tsx
@@ -0,0 +1,203 @@
+import { useEffect, useState } from "react";
+import { useNavigate } from "react-router-dom";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { apiGet, apiPost } from "@/lib/api";
+import type { Booking, Room } from "@/lib/types";
+
+export function NewBookingPage() {
+ const nav = useNavigate();
+ const [rooms, setRooms] = useState([]);
+ const [roomId, setRoomId] = useState("");
+ const [checkIn, setCheckIn] = useState("");
+ const [checkOut, setCheckOut] = useState("");
+ const [guests, setGuests] = useState("2");
+ const [firstName, setFirstName] = useState("");
+ const [lastName, setLastName] = useState("");
+ const [email, setEmail] = useState("");
+ const [phone, setPhone] = useState("");
+ const [pnr, setPnr] = useState("");
+ const [arrival, setArrival] = useState("14:00");
+ const [coupon, setCoupon] = useState("");
+ const [referral, setReferral] = useState("");
+ const [err, setErr] = useState(null);
+
+ useEffect(() => {
+ apiGet<{ data: Room[] }>("/rooms")
+ .then((r) => {
+ setRooms(r.data);
+ if (r.data[0]) setRoomId(r.data[0].id);
+ })
+ .catch(console.error);
+ }, []);
+
+ async function submit(e: React.FormEvent) {
+ e.preventDefault();
+ setErr(null);
+ try {
+ const body: Partial & Record = {
+ guest: {
+ firstName,
+ lastName,
+ email,
+ phone,
+ flightBookingNumber: pnr,
+ arrivalTime: arrival,
+ },
+ checkIn,
+ checkOut,
+ roomId,
+ guests: Number(guests),
+ status: "confirmed",
+ payLaterHold: false,
+ };
+ if (coupon) body.pricing = { couponCode: coupon } as Booking["pricing"];
+ if (referral) body.referralCode = referral;
+ const created = await apiPost("/bookings", body);
+ nav(`/bookings/${created.id}`);
+ } catch (e: unknown) {
+ setErr(e instanceof Error ? e.message : "Failed");
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/src/pages/PaymentsPage.tsx b/src/pages/PaymentsPage.tsx
new file mode 100644
index 0000000..295dd7b
--- /dev/null
+++ b/src/pages/PaymentsPage.tsx
@@ -0,0 +1,62 @@
+import { useEffect, useState } from "react";
+
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { apiGet } from "@/lib/api";
+import type { Payment } from "@/lib/types";
+import { formatDateTime, formatMoney } from "@/lib/format";
+
+export function PaymentsPage() {
+ const [rows, setRows] = useState([]);
+
+ useEffect(() => {
+ apiGet<{ data: Payment[] }>("/payments").then((r) => setRows(r.data));
+ }, []);
+
+ return (
+
+ Payments
+
+
+ Payment records
+
+
+
+
+
+ Booking
+ Status
+ When
+ Amount
+
+
+
+ {rows.map((p) => (
+
+ {p.bookingId}
+
+ {p.status}
+
+
+ {formatDateTime(p.createdAt)}
+
+
+ {formatMoney(p.amount)} {p.last4 && `· ${p.last4}`}
+
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/pages/ReferralCodesPage.tsx b/src/pages/ReferralCodesPage.tsx
new file mode 100644
index 0000000..63f69c7
--- /dev/null
+++ b/src/pages/ReferralCodesPage.tsx
@@ -0,0 +1,175 @@
+import { Copy } from "lucide-react";
+import { useCallback, useEffect, useState } from "react";
+import { Link } from "react-router-dom";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { useAuth } from "@/context/AuthContext";
+import { apiGet, apiPatch, apiPost } from "@/lib/api";
+import type { ReferralCode } from "@/lib/types";
+
+function copy(s: string) {
+ void navigator.clipboard.writeText(s);
+}
+
+export function ReferralCodesPage() {
+ const { canManageCodes } = useAuth();
+ const [rows, setRows] = useState([]);
+ const [open, setOpen] = useState(false);
+ const [label, setLabel] = useState("");
+ const [custom, setCustom] = useState("");
+ const [generate, setGenerate] = useState(true);
+
+ const load = useCallback(() => {
+ apiGet<{ data: ReferralCode[] }>("/referral-codes").then((r) =>
+ setRows(r.data)
+ );
+ }, []);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ async function create(e: React.FormEvent) {
+ e.preventDefault();
+ await apiPost("/referral-codes", {
+ generate,
+ code: generate ? undefined : custom,
+ label,
+ });
+ setOpen(false);
+ setLabel("");
+ load();
+ }
+
+ async function toggle(rc: ReferralCode) {
+ if (!canManageCodes) return;
+ await apiPatch(`/referral-codes/${rc.id}`, { isActive: !rc.isActive });
+ load();
+ }
+
+ return (
+
+
+ Referral codes
+ {canManageCodes && (
+
+ )}
+
+
+
+
+
+
+
+ Code
+ Label
+ Redemptions
+ Active
+
+
+
+
+ {rows.map((r) => (
+
+ {r.code}
+ {r.label}
+
+ {r.redemptionCount}
+ {r.maxRedemptions != null && ` / ${r.maxRedemptions}`}
+
+
+
+ {r.isActive ? "Active" : "Off"}
+
+
+
+
+
+ {canManageCodes && (
+
+ )}
+
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/pages/ReservationsPage.tsx b/src/pages/ReservationsPage.tsx
new file mode 100644
index 0000000..37544ff
--- /dev/null
+++ b/src/pages/ReservationsPage.tsx
@@ -0,0 +1,139 @@
+import { format } from "date-fns";
+import { useEffect, useState } from "react";
+
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { apiGet } from "@/lib/api";
+import type { Room } from "@/lib/types";
+
+interface TimelineResp {
+ days: string[];
+ rooms: Room[];
+ segments: {
+ bookingId: string;
+ guestName: string;
+ roomId: string;
+ start: string;
+ end: string;
+ status: string;
+ paymentLabel: string;
+ source: string;
+ }[];
+}
+
+export function ReservationsPage() {
+ const [month, setMonth] = useState(format(new Date(), "yyyy-MM"));
+ const [data, setData] = useState(null);
+
+ useEffect(() => {
+ apiGet(`/reservations/timeline?month=${month}`)
+ .then(setData)
+ .catch(console.error);
+ }, [month]);
+
+ if (!data)
+ return Loading timeline… ;
+
+ const dayWidth = 56;
+ const roomCol = 120;
+
+ return (
+
+
+
+ Reservations
+
+ Gantt-style view (mock data)
+
+
+ setMonth(e.target.value)}
+ className="rounded-xl border border-input bg-background px-3 py-2 text-sm"
+ />
+
+
+
+ Occupied
+ Check-in / out
+ Reserved
+
+
+
+
+ Timeline
+
+
+
+
+
+ Room
+
+ {data.days.map((d) => (
+
+ {d.slice(8)}
+
+ ))}
+
+ {data.rooms.map((room) => (
+
+
+ {room.name}
+
+
+ {data.segments
+ .filter((s) => s.roomId === room.id)
+ .map((s) => {
+ const startIdx = data.days.findIndex(
+ (d) => d >= s.start
+ );
+ const endIdx = data.days.findIndex((d) => d >= s.end);
+ const si =
+ startIdx >= 0 ? startIdx : 0;
+ const ei =
+ endIdx >= 0 ? endIdx : data.days.length;
+ const span = Math.max(1, ei - si);
+ return (
+
+
+ {s.guestName}
+
+
+ );
+ })}
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/pages/RoomsPage.tsx b/src/pages/RoomsPage.tsx
new file mode 100644
index 0000000..7463c55
--- /dev/null
+++ b/src/pages/RoomsPage.tsx
@@ -0,0 +1,173 @@
+import { useEffect, useState } from "react";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { apiGet, apiPost } from "@/lib/api";
+import { ROOM_CATALOGUE } from "@/lib/constants";
+import type { Room } from "@/lib/types";
+import { formatMoney } from "@/lib/format";
+
+export function RoomsPage() {
+ const [rooms, setRooms] = useState([]);
+ const [open, setOpen] = useState(false);
+ const [name, setName] = useState("");
+ const [slug, setSlug] = useState(ROOM_CATALOGUE[0].slug);
+ const [maxGuests, setMaxGuests] = useState("2");
+ const [baseRate, setBaseRate] = useState("120");
+
+ function load() {
+ apiGet<{ data: Room[] }>("/rooms").then((r) => setRooms(r.data));
+ }
+
+ useEffect(() => {
+ load();
+ }, []);
+
+ async function addRoom(e: React.FormEvent) {
+ e.preventDefault();
+ await apiPost("/rooms", {
+ name,
+ roomTypeSlug: slug,
+ maxGuests: Number(maxGuests),
+ baseRate: Number(baseRate),
+ status: "available",
+ floor: "",
+ });
+ setOpen(false);
+ setName("");
+ load();
+ }
+
+ return (
+
+
+ Rooms
+
+
+
+
+
+ Inventory
+
+
+
+
+
+ Name
+ Type
+ Guests
+ Rate
+ Status
+
+
+
+ {rooms.map((r) => (
+
+ {r.name}
+
+ {r.roomTypeSlug}
+
+ {r.maxGuests}
+ {formatMoney(r.baseRate)}
+
+ {r.status}
+
+
+ ))}
+
+
+
+
+ {rooms.map((r) => (
+
+ {r.name}
+
+ {r.roomTypeSlug} · {formatMoney(r.baseRate)}
+
+ {r.status}
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx
new file mode 100644
index 0000000..5ddd3bf
--- /dev/null
+++ b/src/pages/SettingsPage.tsx
@@ -0,0 +1,19 @@
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { TAX_RATE } from "@/lib/constants";
+
+export function SettingsPage() {
+ return (
+
+ Settings
+
+
+ Property (mock)
+
+
+ Tax rate used in MSW pricing: {(TAX_RATE * 100).toFixed(0)}%
+ Connect real backend, auth, and PSP in a future phase.
+
+
+
+ );
+}
diff --git a/src/pages/TransactionsPage.tsx b/src/pages/TransactionsPage.tsx
new file mode 100644
index 0000000..2174ac7
--- /dev/null
+++ b/src/pages/TransactionsPage.tsx
@@ -0,0 +1,81 @@
+import { useEffect, useState } from "react";
+
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { apiGet } from "@/lib/api";
+import type { Transaction } from "@/lib/types";
+import { formatDateTime, formatMoney } from "@/lib/format";
+
+export function TransactionsPage() {
+ const [rows, setRows] = useState([]);
+
+ useEffect(() => {
+ apiGet<{ data: Transaction[] }>("/transactions").then((r) =>
+ setRows(r.data)
+ );
+ }, []);
+
+ return (
+
+ Transactions
+
+
+ Ledger
+
+
+
+
+
+ Date
+ Type
+ Booking
+ Description
+ Status
+ Amount
+
+
+
+ {rows.map((t) => (
+
+
+ {formatDateTime(t.createdAt)}
+
+ {t.type}
+ {t.bookingId ?? "—"}
+
+ {t.description}
+
+
+ {t.status}
+
+
+ {formatMoney(t.amount)}
+
+
+ ))}
+
+
+
+
+ {rows.map((t) => (
+
+ {t.type}
+
+ {formatDateTime(t.createdAt)}
+
+ {formatMoney(t.amount)}
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/pages/VisitsPage.tsx b/src/pages/VisitsPage.tsx
new file mode 100644
index 0000000..9c7f59f
--- /dev/null
+++ b/src/pages/VisitsPage.tsx
@@ -0,0 +1,128 @@
+import { useCallback, useEffect, useState } from "react";
+import {
+ Bar,
+ BarChart,
+ CartesianGrid,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from "recharts";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { apiGet, apiPost } from "@/lib/api";
+import type { SiteVisit } from "@/lib/types";
+import { formatDateTime } from "@/lib/format";
+
+export function VisitsPage() {
+ const [series, setSeries] = useState<
+ { date: string; views: number; sessions: number }[]
+ >([]);
+ const [recent, setRecent] = useState([]);
+
+ const refresh = useCallback(async () => {
+ const v = await apiGet<{
+ series: { date: string; views: number; sessions: number }[];
+ }>("/analytics/visits");
+ setSeries(v.series.slice(-21));
+ const r = await apiGet<{ data: SiteVisit[] }>("/analytics/visits/recent");
+ setRecent(r.data);
+ }, []);
+
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ async function simulateHit() {
+ await apiPost("/analytics/visits", {
+ path: "/booking",
+ device: "desktop",
+ });
+ await refresh();
+ }
+
+ return (
+
+
+ Site visits
+
+
+
+
+
+ Views by day
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Recent events
+
+
+
+
+
+ When
+ Path
+ Device
+ Referrer
+
+
+
+ {recent.slice(0, 15).map((v) => (
+
+
+ {formatDateTime(v.occurredAt)}
+
+ {v.path}
+ {v.device ?? "—"}
+
+ {v.referrer ?? "—"}
+
+
+ ))}
+
+
+
+
+ {recent.slice(0, 10).map((v) => (
+
+ {v.path}
+
+ {formatDateTime(v.occurredAt)}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/styles/globals.css b/src/styles/globals.css
new file mode 100644
index 0000000..b6cd577
--- /dev/null
+++ b/src/styles/globals.css
@@ -0,0 +1,84 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-navy: var(--navy);
+}
+
+:root {
+ --radius: 0.875rem;
+ --background: oklch(0.985 0.004 250);
+ --foreground: oklch(0.22 0.04 260);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.22 0.04 260);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.22 0.04 260);
+ --primary: oklch(0.55 0.2 260);
+ --primary-foreground: oklch(0.99 0 0);
+ --secondary: oklch(0.96 0.02 250);
+ --secondary-foreground: oklch(0.28 0.05 260);
+ --muted: oklch(0.96 0.01 250);
+ --muted-foreground: oklch(0.48 0.02 260);
+ --accent: oklch(0.93 0.04 250);
+ --accent-foreground: oklch(0.32 0.08 260);
+ --destructive: oklch(0.55 0.2 25);
+ --border: oklch(0.91 0.01 250);
+ --input: oklch(0.91 0.01 250);
+ --ring: oklch(0.55 0.2 260);
+ --navy: oklch(0.28 0.08 260);
+ --chart-1: oklch(0.28 0.08 260);
+ --chart-2: oklch(0.55 0.2 260);
+ --chart-3: oklch(0.65 0.15 250);
+ --chart-4: oklch(0.75 0.1 250);
+ --chart-5: oklch(0.85 0.06 250);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground antialiased;
+ font-family: "Inter", system-ui, sans-serif;
+ padding-bottom: env(safe-area-inset-bottom, 0px);
+ }
+}
+
+.pb-nav {
+ padding-bottom: calc(4.5rem + env(safe-area-inset-bottom, 0px));
+}
+
+@media (min-width: 1024px) {
+ .pb-nav {
+ padding-bottom: 0;
+ }
+}
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/tsconfig.app.json b/tsconfig.app.json
new file mode 100644
index 0000000..66eca84
--- /dev/null
+++ b/tsconfig.app.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..d32ff68
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "files": [],
+ "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
+}
diff --git a/tsconfig.node.json b/tsconfig.node.json
new file mode 100644
index 0000000..823b88a
--- /dev/null
+++ b/tsconfig.node.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2023",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "strict": true,
+ "noEmit": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..7c7bdb0
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,13 @@
+import path from "node:path";
+import tailwindcss from "@tailwindcss/vite";
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+});
|