diff --git a/package-lock.json b/package-lock.json
index 9db6f7e..c5784d2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -32,7 +32,8 @@
"react-router-dom": "^7.4.0",
"recharts": "^2.15.1",
"tailwind-merge": "^3.0.2",
- "tw-animate-css": "^1.2.4"
+ "tw-animate-css": "^1.2.4",
+ "zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
@@ -3051,9 +3052,6 @@
"arm"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3068,9 +3066,6 @@
"arm"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3085,9 +3080,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3102,9 +3094,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3119,9 +3108,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3136,9 +3122,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3153,9 +3136,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3170,9 +3150,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3187,9 +3164,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3204,9 +3178,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3221,9 +3192,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3238,9 +3206,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3255,9 +3220,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3490,9 +3452,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3510,9 +3469,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3530,9 +3486,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3550,9 +3503,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5488,9 +5438,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5512,9 +5459,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5536,9 +5480,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5560,9 +5501,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6942,6 +6880,34 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
+ },
+ "node_modules/zustand": {
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
+ "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/package.json b/package.json
index 3561635..4ee1389 100644
--- a/package.json
+++ b/package.json
@@ -34,7 +34,8 @@
"react-router-dom": "^7.4.0",
"recharts": "^2.15.1",
"tailwind-merge": "^3.0.2",
- "tw-animate-css": "^1.2.4"
+ "tw-animate-css": "^1.2.4",
+ "zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
diff --git a/src/App.tsx b/src/App.tsx
index fc2247e..ab27b73 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,7 +1,7 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { AppLayout } from "@/components/layout/AppLayout";
-import { useAuth } from "@/context/AuthContext";
+import { useAuthStore } from "@/store/authStore";
import { BookingDetailPage } from "@/pages/BookingDetailPage";
import { BookingsPage } from "@/pages/BookingsPage";
import { CalendarPage } from "@/pages/CalendarPage";
@@ -17,10 +17,19 @@ import { RoomsPage } from "@/pages/RoomsPage";
import { SettingsPage } from "@/pages/SettingsPage";
import { TransactionsPage } from "@/pages/TransactionsPage";
import { VisitsPage } from "@/pages/VisitsPage";
+import { ManageUsersPage } from "@/pages/ManageUsersPage";
function ProtectedLayout() {
- const { role } = useAuth();
- if (!role) return ;
+ const accessToken = useAuthStore((s) => s.accessToken);
+ const bootstrapped = useAuthStore((s) => s.bootstrapped);
+ if (!bootstrapped) {
+ return (
+
+ Loading…
+
+ );
+ }
+ if (!accessToken) return ;
return ;
}
@@ -43,6 +52,7 @@ export default function App() {
} />
} />
} />
+ } />
} />
} />
diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts
index e61542f..0815144 100644
--- a/src/lib/types/index.ts
+++ b/src/lib/types/index.ts
@@ -2,7 +2,7 @@ export type AdminRole =
| "viewer"
| "front_desk"
| "finance"
- | "superadmin";
+ | "ADMIN";
export type BookingStatus =
| "draft"
@@ -58,6 +58,8 @@ export interface PricingLine {
export interface Booking {
id: string;
+ /** When loaded from the hotel API, used instead of inferring label from room id */
+ roomDisplayLabel?: string;
guest: GuestDetails;
checkIn: string;
checkOut: string;
@@ -157,13 +159,8 @@ export interface DiscountCode {
export interface ReferralCode {
id: string;
+ meta: string;
code: string;
- label: string;
- attributedTo?: string;
- validFrom: string;
- validTo: string;
- maxRedemptions: number | null;
- redemptionCount: number;
isActive: boolean;
createdAt: string;
}
@@ -192,6 +189,33 @@ export interface DashboardPayload {
heatmap: { roomId: string; state: "vacant" | "not_ready" | "occupied" | "unavailable" }[];
revenueExtras: { label: string; current: number; target: number }[];
rating: { score: number; label: string; imageUrl?: string };
+};
+
+export enum HotelStaffRole {
+ FRONT_DESK = "FRONT_DESK",
+ FINANCE = "FINANCE",
+}
+
+export interface RegisterHotelStaffDto {
+ name: string;
+ email: string;
+ phone?: string;
+ password: string;
+ hotelRole: HotelStaffRole;
+}
+
+export interface StaffAccess {
+ id: string;
+ user: {
+ name: string;
+ email: string;
+ phone?: string;
+ role?: HotelStaffRole;
+ };
+ createdAt: string;
+}
+
+export interface DashboardPayload {
recentBookings: Booking[];
calendarEvents: { id: string; title: string; date: string; accent: "sky" | "pink" | "violet" }[];
codeStats: { discountRedemptions: number; referralRedemptions: number };
diff --git a/src/main.tsx b/src/main.tsx
index c74a885..34eaa97 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -9,15 +9,16 @@ 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",
- });
-}
+// async function enableMocking() {
+// if (import.meta.env.MODE !== "development") return;
+// if (import.meta.env.VITE_MSW === "false") return;
+// const { worker } = await import("@/mocks/browser");
+// await worker.start({
+// onUnhandledRequest: "bypass",
+// });
+// }
-void enableMocking().then(() => {
+// void enableMocking().then(() => {
createRoot(document.getElementById("root")!).render(
@@ -27,4 +28,4 @@ void enableMocking().then(() => {
);
-});
+// });
diff --git a/src/mocks/browser.ts b/src/mocks/browser.ts
deleted file mode 100644
index dfae56e..0000000
--- a/src/mocks/browser.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-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
deleted file mode 100644
index 1b07251..0000000
--- a/src/mocks/db.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-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
deleted file mode 100644
index 7625adb..0000000
--- a/src/mocks/handlers.ts
+++ /dev/null
@@ -1,566 +0,0 @@
-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
deleted file mode 100644
index e7a7a22..0000000
--- a/src/mocks/seed.ts
+++ /dev/null
@@ -1,363 +0,0 @@
-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/vite-env.d.ts b/src/vite-env.d.ts
index 11f02fe..9c6f750 100644
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -1 +1,11 @@
///
+
+interface ImportMetaEnv {
+ readonly VITE_API_BASE_URL?: string;
+ readonly VITE_MSW?: string;
+ readonly VITE_PROXY_TARGET?: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/vite.config.ts b/vite.config.ts
index 7c7bdb0..05cb987 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -5,6 +5,14 @@ import { defineConfig } from "vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
+ server: {
+ proxy: {
+ "/api": {
+ target: process.env.VITE_PROXY_TARGET ?? "http://localhost:3000",
+ changeOrigin: true,
+ },
+ },
+ },
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),