From 4f1150316752e30c105b523101bd36b396470e19 Mon Sep 17 00:00:00 2001 From: brooktewabe Date: Wed, 1 Apr 2026 11:25:55 +0300 Subject: [PATCH] initial integration setup --- package-lock.json | 94 +++---- package.json | 3 +- src/App.tsx | 16 +- src/lib/types/index.ts | 38 ++- src/main.tsx | 19 +- src/mocks/browser.ts | 5 - src/mocks/db.ts | 120 --------- src/mocks/handlers.ts | 566 ----------------------------------------- src/mocks/seed.ts | 363 -------------------------- src/vite-env.d.ts | 10 + vite.config.ts | 8 + 11 files changed, 104 insertions(+), 1138 deletions(-) delete mode 100644 src/mocks/browser.ts delete mode 100644 src/mocks/db.ts delete mode 100644 src/mocks/handlers.ts delete mode 100644 src/mocks/seed.ts 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"),