initial integration setup
This commit is contained in:
parent
b8aecf31e3
commit
4f11503167
94
package-lock.json
generated
94
package-lock.json
generated
|
|
@ -32,7 +32,8 @@
|
||||||
"react-router-dom": "^7.4.0",
|
"react-router-dom": "^7.4.0",
|
||||||
"recharts": "^2.15.1",
|
"recharts": "^2.15.1",
|
||||||
"tailwind-merge": "^3.0.2",
|
"tailwind-merge": "^3.0.2",
|
||||||
"tw-animate-css": "^1.2.4"
|
"tw-animate-css": "^1.2.4",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.21.0",
|
"@eslint/js": "^9.21.0",
|
||||||
|
|
@ -3051,9 +3052,6 @@
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3068,9 +3066,6 @@
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3085,9 +3080,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3102,9 +3094,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3119,9 +3108,6 @@
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3136,9 +3122,6 @@
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3153,9 +3136,6 @@
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3170,9 +3150,6 @@
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3187,9 +3164,6 @@
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3204,9 +3178,6 @@
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3221,9 +3192,6 @@
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3238,9 +3206,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3255,9 +3220,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3490,9 +3452,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3510,9 +3469,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3530,9 +3486,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3550,9 +3503,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5488,9 +5438,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5512,9 +5459,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5536,9 +5480,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5560,9 +5501,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -6942,6 +6880,34 @@
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,8 @@
|
||||||
"react-router-dom": "^7.4.0",
|
"react-router-dom": "^7.4.0",
|
||||||
"recharts": "^2.15.1",
|
"recharts": "^2.15.1",
|
||||||
"tailwind-merge": "^3.0.2",
|
"tailwind-merge": "^3.0.2",
|
||||||
"tw-animate-css": "^1.2.4"
|
"tw-animate-css": "^1.2.4",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.21.0",
|
"@eslint/js": "^9.21.0",
|
||||||
|
|
|
||||||
16
src/App.tsx
16
src/App.tsx
|
|
@ -1,7 +1,7 @@
|
||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
|
||||||
import { AppLayout } from "@/components/layout/AppLayout";
|
import { AppLayout } from "@/components/layout/AppLayout";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
import { BookingDetailPage } from "@/pages/BookingDetailPage";
|
import { BookingDetailPage } from "@/pages/BookingDetailPage";
|
||||||
import { BookingsPage } from "@/pages/BookingsPage";
|
import { BookingsPage } from "@/pages/BookingsPage";
|
||||||
import { CalendarPage } from "@/pages/CalendarPage";
|
import { CalendarPage } from "@/pages/CalendarPage";
|
||||||
|
|
@ -17,10 +17,19 @@ import { RoomsPage } from "@/pages/RoomsPage";
|
||||||
import { SettingsPage } from "@/pages/SettingsPage";
|
import { SettingsPage } from "@/pages/SettingsPage";
|
||||||
import { TransactionsPage } from "@/pages/TransactionsPage";
|
import { TransactionsPage } from "@/pages/TransactionsPage";
|
||||||
import { VisitsPage } from "@/pages/VisitsPage";
|
import { VisitsPage } from "@/pages/VisitsPage";
|
||||||
|
import { ManageUsersPage } from "@/pages/ManageUsersPage";
|
||||||
|
|
||||||
function ProtectedLayout() {
|
function ProtectedLayout() {
|
||||||
const { role } = useAuth();
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
if (!role) return <Navigate to="/login" replace />;
|
const bootstrapped = useAuthStore((s) => s.bootstrapped);
|
||||||
|
if (!bootstrapped) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center text-muted-foreground">
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!accessToken) return <Navigate to="/login" replace />;
|
||||||
return <AppLayout />;
|
return <AppLayout />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,6 +52,7 @@ export default function App() {
|
||||||
<Route path="/marketing/visits" element={<VisitsPage />} />
|
<Route path="/marketing/visits" element={<VisitsPage />} />
|
||||||
<Route path="/marketing/discount-codes" element={<DiscountCodesPage />} />
|
<Route path="/marketing/discount-codes" element={<DiscountCodesPage />} />
|
||||||
<Route path="/marketing/referral-codes" element={<ReferralCodesPage />} />
|
<Route path="/marketing/referral-codes" element={<ReferralCodesPage />} />
|
||||||
|
<Route path="/settings/users" element={<ManageUsersPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ export type AdminRole =
|
||||||
| "viewer"
|
| "viewer"
|
||||||
| "front_desk"
|
| "front_desk"
|
||||||
| "finance"
|
| "finance"
|
||||||
| "superadmin";
|
| "ADMIN";
|
||||||
|
|
||||||
export type BookingStatus =
|
export type BookingStatus =
|
||||||
| "draft"
|
| "draft"
|
||||||
|
|
@ -58,6 +58,8 @@ export interface PricingLine {
|
||||||
|
|
||||||
export interface Booking {
|
export interface Booking {
|
||||||
id: string;
|
id: string;
|
||||||
|
/** When loaded from the hotel API, used instead of inferring label from room id */
|
||||||
|
roomDisplayLabel?: string;
|
||||||
guest: GuestDetails;
|
guest: GuestDetails;
|
||||||
checkIn: string;
|
checkIn: string;
|
||||||
checkOut: string;
|
checkOut: string;
|
||||||
|
|
@ -157,13 +159,8 @@ export interface DiscountCode {
|
||||||
|
|
||||||
export interface ReferralCode {
|
export interface ReferralCode {
|
||||||
id: string;
|
id: string;
|
||||||
|
meta: string;
|
||||||
code: string;
|
code: string;
|
||||||
label: string;
|
|
||||||
attributedTo?: string;
|
|
||||||
validFrom: string;
|
|
||||||
validTo: string;
|
|
||||||
maxRedemptions: number | null;
|
|
||||||
redemptionCount: number;
|
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
@ -192,6 +189,33 @@ export interface DashboardPayload {
|
||||||
heatmap: { roomId: string; state: "vacant" | "not_ready" | "occupied" | "unavailable" }[];
|
heatmap: { roomId: string; state: "vacant" | "not_ready" | "occupied" | "unavailable" }[];
|
||||||
revenueExtras: { label: string; current: number; target: number }[];
|
revenueExtras: { label: string; current: number; target: number }[];
|
||||||
rating: { score: number; label: string; imageUrl?: string };
|
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[];
|
recentBookings: Booking[];
|
||||||
calendarEvents: { id: string; title: string; date: string; accent: "sky" | "pink" | "violet" }[];
|
calendarEvents: { id: string; title: string; date: string; accent: "sky" | "pink" | "violet" }[];
|
||||||
codeStats: { discountRedemptions: number; referralRedemptions: number };
|
codeStats: { discountRedemptions: number; referralRedemptions: number };
|
||||||
|
|
|
||||||
19
src/main.tsx
19
src/main.tsx
|
|
@ -9,15 +9,16 @@ import App from "@/App";
|
||||||
import { AuthProvider } from "@/context/AuthContext";
|
import { AuthProvider } from "@/context/AuthContext";
|
||||||
import "@/styles/globals.css";
|
import "@/styles/globals.css";
|
||||||
|
|
||||||
async function enableMocking() {
|
// async function enableMocking() {
|
||||||
if (import.meta.env.MODE !== "development") return;
|
// if (import.meta.env.MODE !== "development") return;
|
||||||
const { worker } = await import("@/mocks/browser");
|
// if (import.meta.env.VITE_MSW === "false") return;
|
||||||
await worker.start({
|
// const { worker } = await import("@/mocks/browser");
|
||||||
onUnhandledRequest: "bypass",
|
// await worker.start({
|
||||||
});
|
// onUnhandledRequest: "bypass",
|
||||||
}
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
void enableMocking().then(() => {
|
// void enableMocking().then(() => {
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
|
@ -27,4 +28,4 @@ void enableMocking().then(() => {
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
});
|
// });
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import { setupWorker } from "msw/browser";
|
|
||||||
|
|
||||||
import { handlers } from "@/mocks/handlers";
|
|
||||||
|
|
||||||
export const worker = setupWorker(...handlers);
|
|
||||||
120
src/mocks/db.ts
120
src/mocks/db.ts
|
|
@ -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<T>(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();
|
|
||||||
}
|
|
||||||
|
|
@ -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<Booking>;
|
|
||||||
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<Booking> & {
|
|
||||||
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<import("@/lib/types").Room>;
|
|
||||||
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<string, CustomerRow>();
|
|
||||||
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<string, { views: number; sessions: Set<string> }>();
|
|
||||||
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<import("@/lib/types").DiscountCode> & {
|
|
||||||
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]);
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
@ -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();
|
|
||||||
10
src/vite-env.d.ts
vendored
10
src/vite-env.d.ts
vendored
|
|
@ -1 +1,11 @@
|
||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_BASE_URL?: string;
|
||||||
|
readonly VITE_MSW?: string;
|
||||||
|
readonly VITE_PROXY_TARGET?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,14 @@ import { defineConfig } from "vite";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: process.env.VITE_PROXY_TARGET ?? "http://localhost:3000",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user