diff --git a/.gitignore b/.gitignore
index e1c6eb2..f2e7af5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,10 @@
node_modules/
package-lock.json
+# Build output
+dist/
+.vite/
+
# Environment variables
.env
diff --git a/PORTAL_README.md b/PORTAL_README.md
new file mode 100644
index 0000000..d3e1ae8
--- /dev/null
+++ b/PORTAL_README.md
@@ -0,0 +1,184 @@
+# Premium Data Portal - Government Travel Rates
+
+A modern, luxury data portal showcasing government travel rates with premium UX design.
+
+## ✨ Features
+
+### 🎨 Premium Design
+- **Glassmorphism Effects**: Translucent panels with backdrop blur
+- **Gradient Accents**: Deep navy + gold gradient theme
+- **Smooth Animations**: Framer Motion micro-interactions
+- **Typography**: Inter + Playfair Display pairing
+- **Dark Mode**: Premium dark theme with accent lighting
+
+### 📊 Data Portal
+- **Interactive Tables**: Search, sort, filter, and export per-diem and accommodation rates
+- **Animated Counters**: Real-time statistics with smooth counting animations
+- **CSV Export**: One-click data export for analysis
+- **Pagination**: Smooth navigation through large datasets
+- **Responsive Design**: Works beautifully on all devices
+
+### 🛫 Travel Integration
+- **OpenFlights Data**: Free, open-source flight data (6,072+ airports)
+- **Rate Coverage**: 233 countries, 9,114 per-diem entries, 1,356 accommodation rates
+- **Real-time Search**: Fast database queries with caching
+
+## 🚀 Quick Start
+
+### Development
+```bash
+# Install dependencies
+npm install
+
+# Start backend server
+npm start
+
+# Start frontend dev server (in another terminal)
+npm run dev:client
+```
+
+Backend runs on `http://localhost:5001`
+Frontend runs on `http://localhost:3000`
+
+### Production Build
+```bash
+# Build React frontend
+npm run build:client
+
+# Start production server (serves built React app)
+NODE_ENV=production npm start
+```
+
+## 🏗️ Architecture
+
+### Frontend (`/client`)
+- **Framework**: React 18 with TypeScript
+- **Build Tool**: Vite 7
+- **Styling**: Tailwind CSS 4 with custom theme
+- **Animations**: Framer Motion
+- **Icons**: Lucide React
+- **UI Components**: Custom-built with Radix UI primitives
+
+### Backend
+- **Server**: Node.js + Express
+- **Database**: SQLite3 with Better-SQLite3
+- **APIs**:
+ - `/api/rates/per-diem` - All per-diem rates
+ - `/api/rates/accommodations` - Canadian accommodation rates
+ - `/api/stats` - Portal statistics
+ - `/api/flights/search` - OpenFlights integration
+
+### Key Files
+```
+├── client/
+│ ├── src/
+│ │ ├── App.tsx # Main application
+│ │ ├── pages/
+│ │ │ ├── LandingPage.tsx # Hero + animated counters
+│ │ │ └── DashboardPage.tsx # Data tables portal
+│ │ ├── components/
+│ │ │ └── DataTable.tsx # Interactive table component
+│ │ └── lib/
+│ │ └── utils.ts # Utilities (format, export)
+│ └── index.html # Entry point
+├── server.js # Express backend
+├── openFlightsService.js # Free flight data integration
+└── services/
+ └── databaseService.js # Database layer
+```
+
+## 🎨 Design System
+
+### Colors
+- **Background**: Deep navy (#0B1120)
+- **Accent**: Gold gradient (#FACC15 → #EAB308)
+- **Glass**: rgba(255, 255, 255, 0.1) with 10px blur
+- **Text**: White (#FAFAFA) with gray-300 secondary
+
+### Typography
+- **Headings**: Playfair Display (Display font)
+- **Body**: Inter (Sans-serif)
+- **Weights**: 300-900 range
+
+### Spacing
+- **Container**: max-width 1200px, px-4 mobile
+- **Sections**: py-20 desktop, py-12 mobile
+- **Cards**: p-8 with rounded-2xl
+
+## 📦 npm Scripts
+
+| Script | Description |
+|--------|-------------|
+| `npm start` | Start production server |
+| `npm run dev` | Start backend with nodemon |
+| `npm run dev:client` | Start Vite dev server |
+| `npm run build:client` | Build React app for production |
+| `npm run preview` | Preview production build |
+| `npm test` | Run Jest tests |
+
+## 🔧 Configuration
+
+### Environment Variables (`.env`)
+```env
+PORT=5001
+NODE_ENV=production
+AMADEUS_API_KEY=your_key_here # Optional
+AMADEUS_API_SECRET=your_secret # Optional
+```
+
+### Vite Configuration
+- Root: `./client`
+- Output: `../dist/client`
+- Proxy: `/api` → `http://localhost:5001`
+
+## 📊 Data Sources
+
+1. **NJC Travel Directive** - Canadian government per-diem rates
+2. **OpenFlights** - Free, open-source airport/airline/route data
+3. **Scraped Data** - Government accommodation rates (scraped via Python)
+
+## 🚀 Deployment
+
+### Docker
+```bash
+# Build image with React app
+docker build -t govt-travel-app:latest .
+
+# Run container
+docker run -d -p 5001:5001 govt-travel-app:latest
+```
+
+### Manual
+```bash
+# Build frontend
+npm run build:client
+
+# Set production environment
+export NODE_ENV=production
+
+# Start server
+npm start
+```
+
+The server automatically serves the built React app when `NODE_ENV=production` and `/dist/client` exists.
+
+## 🎯 Future Enhancements
+
+- [ ] Interactive map view with country selection
+- [ ] Data visualizations with Recharts (rate comparisons)
+- [ ] Advanced filters (date ranges, rate thresholds)
+- [ ] User authentication and saved searches
+- [ ] Real-time Amadeus flight pricing integration
+- [ ] Mobile app (React Native)
+
+## 📝 License
+
+ISC
+
+## 🤝 Contributing
+
+Built with premium attention to detail. Contributions welcome!
+
+---
+
+**Made with ✨ by the Government Travel Team**
diff --git a/client/index.html b/client/index.html
new file mode 100644
index 0000000..8a3933b
--- /dev/null
+++ b/client/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+ Government Travel Data Portal
+
+
+
+
+
+
+
+
+
diff --git a/client/src/App.tsx b/client/src/App.tsx
new file mode 100644
index 0000000..8f135b4
--- /dev/null
+++ b/client/src/App.tsx
@@ -0,0 +1,19 @@
+import { useState, useEffect } from "react";
+import LandingPage from "./pages/LandingPage";
+import DashboardPage from "./pages/DashboardPage";
+
+function App() {
+ const [showDashboard, setShowDashboard] = useState(false);
+
+ return (
+
+ {!showDashboard ? (
+ setShowDashboard(true)} />
+ ) : (
+ setShowDashboard(false)} />
+ )}
+
+ );
+}
+
+export default App;
diff --git a/client/src/components/DataTable.tsx b/client/src/components/DataTable.tsx
new file mode 100644
index 0000000..e608a94
--- /dev/null
+++ b/client/src/components/DataTable.tsx
@@ -0,0 +1,236 @@
+import { useState, useMemo } from "react";
+import { motion } from "framer-motion";
+import { Search, Download, Filter, X } from "lucide-react";
+import { exportToCSV, formatCurrency, cn } from "@/lib/utils";
+
+interface Column {
+ key: string;
+ label: string;
+ sortable?: boolean;
+ format?: (value: any) => string;
+}
+
+interface DataTableProps {
+ data: any[];
+ columns: Column[];
+ title: string;
+ searchKeys: string[];
+}
+
+export default function DataTable({
+ data,
+ columns,
+ title,
+ searchKeys,
+}: DataTableProps) {
+ const [searchTerm, setSearchTerm] = useState("");
+ const [sortColumn, setSortColumn] = useState(null);
+ const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
+ const [currentPage, setCurrentPage] = useState(1);
+ const itemsPerPage = 20;
+
+ const filteredData = useMemo(() => {
+ if (!searchTerm) return data;
+
+ return data.filter((item) =>
+ searchKeys.some((key) =>
+ String(item[key]).toLowerCase().includes(searchTerm.toLowerCase())
+ )
+ );
+ }, [data, searchTerm, searchKeys]);
+
+ const sortedData = useMemo(() => {
+ if (!sortColumn) return filteredData;
+
+ return [...filteredData].sort((a, b) => {
+ const aVal = a[sortColumn];
+ const bVal = b[sortColumn];
+
+ if (typeof aVal === "number" && typeof bVal === "number") {
+ return sortDirection === "asc" ? aVal - bVal : bVal - aVal;
+ }
+
+ const aStr = String(aVal).toLowerCase();
+ const bStr = String(bVal).toLowerCase();
+ return sortDirection === "asc"
+ ? aStr.localeCompare(bStr)
+ : bStr.localeCompare(aStr);
+ });
+ }, [filteredData, sortColumn, sortDirection]);
+
+ const paginatedData = useMemo(() => {
+ const startIndex = (currentPage - 1) * itemsPerPage;
+ return sortedData.slice(startIndex, startIndex + itemsPerPage);
+ }, [sortedData, currentPage]);
+
+ const totalPages = Math.ceil(sortedData.length / itemsPerPage);
+
+ const handleSort = (column: string) => {
+ if (sortColumn === column) {
+ setSortDirection((prev) => (prev === "asc" ? "desc" : "asc"));
+ } else {
+ setSortColumn(column);
+ setSortDirection("asc");
+ }
+ };
+
+ const handleExport = () => {
+ exportToCSV(
+ sortedData,
+ `${title.toLowerCase().replace(/\s+/g, "-")}-export`
+ );
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+ {title}
+
+
{sortedData.length} entries found
+
+
+
+
+
+ Export CSV
+
+
+
+
+ {/* Search */}
+
+
+ {
+ setSearchTerm(e.target.value);
+ setCurrentPage(1);
+ }}
+ className="w-full pl-10 pr-10 py-3 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-yellow-400/50 transition-all"
+ />
+ {searchTerm && (
+ setSearchTerm("")}
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white"
+ >
+
+
+ )}
+
+
+ {/* Table */}
+
+
+
+
+ {columns.map((column) => (
+
+ column.sortable !== false && handleSort(column.key)
+ }
+ className={cn(
+ "px-4 py-3 text-left text-sm font-semibold text-gray-400 uppercase tracking-wider",
+ column.sortable !== false &&
+ "cursor-pointer hover:text-white transition-colors"
+ )}
+ >
+
+ {column.label}
+ {column.sortable !== false && sortColumn === column.key && (
+
+ {sortDirection === "asc" ? "↑" : "↓"}
+
+ )}
+
+
+ ))}
+
+
+
+ {paginatedData.map((row, idx) => (
+
+ {columns.map((column) => (
+
+ {column.format
+ ? column.format(row[column.key])
+ : row[column.key]}
+
+ ))}
+
+ ))}
+
+
+
+
+ {/* Pagination */}
+ {totalPages > 1 && (
+
+
setCurrentPage((p) => Math.max(1, p - 1))}
+ disabled={currentPage === 1}
+ className="px-4 py-2 bg-white/5 hover:bg-white/10 text-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
+ >
+ Previous
+
+
+
+ {Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
+ let pageNum;
+ if (totalPages <= 5) {
+ pageNum = i + 1;
+ } else if (currentPage <= 3) {
+ pageNum = i + 1;
+ } else if (currentPage >= totalPages - 2) {
+ pageNum = totalPages - 4 + i;
+ } else {
+ pageNum = currentPage - 2 + i;
+ }
+
+ return (
+ setCurrentPage(pageNum)}
+ className={cn(
+ "w-10 h-10 rounded-lg transition-all",
+ currentPage === pageNum
+ ? "bg-yellow-400 text-gray-900 font-semibold"
+ : "bg-white/5 hover:bg-white/10 text-gray-300"
+ )}
+ >
+ {pageNum}
+
+ );
+ })}
+
+
+
setCurrentPage((p) => Math.min(totalPages, p + 1))}
+ disabled={currentPage === totalPages}
+ className="px-4 py-2 bg-white/5 hover:bg-white/10 text-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
+ >
+ Next
+
+
+ )}
+
+ );
+}
diff --git a/client/src/index.css b/client/src/index.css
new file mode 100644
index 0000000..81cf73d
--- /dev/null
+++ b/client/src/index.css
@@ -0,0 +1,56 @@
+@import "tailwindcss";
+
+@theme {
+ --color-background: 222.2 84% 4.9%;
+ --color-foreground: 210 40% 98%;
+ --color-card: 222.2 84% 6%;
+ --color-card-foreground: 210 40% 98%;
+ --color-primary: 45 100% 51%;
+ --color-primary-foreground: 222.2 47.4% 11.2%;
+ --color-secondary: 217.2 32.6% 17.5%;
+ --color-secondary-foreground: 210 40% 98%;
+ --radius-lg: 0.5rem;
+ --radius-md: calc(0.5rem - 2px);
+ --radius-sm: calc(0.5rem - 4px);
+}
+
+/* Glassmorphism effect */
+.glass {
+ @apply bg-white/10 backdrop-blur-md border border-white/20;
+}
+
+.glass-dark {
+ @apply bg-gray-900/40 backdrop-blur-md border border-gray-700/50;
+}
+
+/* Gold gradient text */
+.text-gold-gradient {
+ @apply bg-gradient-to-r from-yellow-400 via-yellow-500 to-yellow-600 bg-clip-text text-transparent;
+}
+
+/* Animated underline */
+.animated-underline {
+ @apply relative after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-0 after:bg-primary after:transition-all after:duration-300 hover:after:w-full;
+}
+
+body {
+ font-family: 'Inter', sans-serif;
+}
+
+/* Custom scrollbar */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ @apply bg-gray-100 dark:bg-gray-800;
+}
+
+::-webkit-scrollbar-thumb {
+ @apply bg-gray-400 dark:bg-gray-600 rounded-full;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ @apply bg-gray-500 dark:bg-gray-500;
+}
diff --git a/client/src/lib/utils.ts b/client/src/lib/utils.ts
new file mode 100644
index 0000000..679bcf5
--- /dev/null
+++ b/client/src/lib/utils.ts
@@ -0,0 +1,42 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+
+export function formatCurrency(
+ amount: number,
+ currency: string = "CAD"
+): string {
+ return new Intl.NumberFormat("en-CA", {
+ style: "currency",
+ currency,
+ }).format(amount);
+}
+
+export function formatDate(date: string | Date): string {
+ return new Intl.DateTimeFormat("en-CA", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ }).format(new Date(date));
+}
+
+export function exportToCSV(data: any[], filename: string) {
+ const headers = Object.keys(data[0]);
+ const csv = [
+ headers.join(","),
+ ...data.map((row) =>
+ headers.map((header) => JSON.stringify(row[header] ?? "")).join(",")
+ ),
+ ].join("\n");
+
+ const blob = new Blob([csv], { type: "text/csv" });
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `${filename}.csv`;
+ a.click();
+ window.URL.revokeObjectURL(url);
+}
diff --git a/client/src/main.tsx b/client/src/main.tsx
new file mode 100644
index 0000000..966f17a
--- /dev/null
+++ b/client/src/main.tsx
@@ -0,0 +1,10 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import App from "./App.tsx";
+import "./index.css";
+
+ReactDOM.createRoot(document.getElementById("root")!).render(
+
+
+
+);
diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx
new file mode 100644
index 0000000..694431a
--- /dev/null
+++ b/client/src/pages/DashboardPage.tsx
@@ -0,0 +1,157 @@
+import { useState, useEffect } from "react";
+import { motion } from "framer-motion";
+import { Home, Table2, Map, BarChart3, Plane } from "lucide-react";
+import DataTable from "../components/DataTable";
+import { formatCurrency } from "@/lib/utils";
+
+interface DashboardPageProps {
+ onBack: () => void;
+}
+
+export default function DashboardPage({ onBack }: DashboardPageProps) {
+ const [activeTab, setActiveTab] = useState("per-diem");
+ const [perDiemData, setPerDiemData] = useState([]);
+ const [accommodationData, setAccommodationData] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ // Fetch data from API
+ Promise.all([
+ fetch("/api/rates/per-diem").then((r) => r.json()),
+ fetch("/api/rates/accommodations").then((r) => r.json()),
+ ])
+ .then(([perDiem, accommodations]) => {
+ setPerDiemData(perDiem.data || []);
+ setAccommodationData(accommodations.data || []);
+ setLoading(false);
+ })
+ .catch((error) => {
+ console.error("Error fetching data:", error);
+ setLoading(false);
+ });
+ }, []);
+
+ const perDiemColumns = [
+ { key: "country", label: "Country", sortable: true },
+ { key: "city", label: "City", sortable: true },
+ {
+ key: "breakfast",
+ label: "Breakfast",
+ sortable: true,
+ format: (v: number) => formatCurrency(v),
+ },
+ {
+ key: "lunch",
+ label: "Lunch",
+ sortable: true,
+ format: (v: number) => formatCurrency(v),
+ },
+ {
+ key: "dinner",
+ label: "Dinner",
+ sortable: true,
+ format: (v: number) => formatCurrency(v),
+ },
+ {
+ key: "incidentals",
+ label: "Incidentals",
+ sortable: true,
+ format: (v: number) => formatCurrency(v),
+ },
+ ];
+
+ const accommodationColumns = [
+ { key: "city", label: "City", sortable: true },
+ { key: "province", label: "Province", sortable: true },
+ {
+ key: "rate",
+ label: "Rate",
+ sortable: true,
+ format: (v: number) => formatCurrency(v),
+ },
+ { key: "currency", label: "Currency", sortable: false },
+ ];
+
+ return (
+
+ {/* Navigation */}
+
+
+
+
+
+ Travel Data Portal
+
+
+
+
setActiveTab("per-diem")}
+ className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-all ${
+ activeTab === "per-diem"
+ ? "bg-yellow-400 text-gray-900"
+ : "text-gray-300 hover:bg-white/5"
+ }`}
+ >
+
+ Per Diem
+
+
setActiveTab("accommodations")}
+ className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-all ${
+ activeTab === "accommodations"
+ ? "bg-yellow-400 text-gray-900"
+ : "text-gray-300 hover:bg-white/5"
+ }`}
+ >
+
+ Accommodations
+
+
+
+
+
+
+ {/* Main Content */}
+
+ {loading ? (
+
+ ) : (
+
+ {activeTab === "per-diem" && (
+
+ )}
+ {activeTab === "accommodations" && (
+
+ )}
+
+ )}
+
+
+ {/* Background effects */}
+
+
+
+
+ );
+}
diff --git a/client/src/pages/LandingPage.tsx b/client/src/pages/LandingPage.tsx
new file mode 100644
index 0000000..d89ebc2
--- /dev/null
+++ b/client/src/pages/LandingPage.tsx
@@ -0,0 +1,192 @@
+import { motion } from "framer-motion";
+import { useState, useEffect } from "react";
+import {
+ Plane,
+ Building2,
+ DollarSign,
+ MapPin,
+ ArrowRight,
+ Sparkles,
+} from "lucide-react";
+
+interface AnimatedCounterProps {
+ end: number;
+ duration?: number;
+ suffix?: string;
+ prefix?: string;
+}
+
+function AnimatedCounter({
+ end,
+ duration = 2000,
+ suffix = "",
+ prefix = "",
+}: AnimatedCounterProps) {
+ const [count, setCount] = useState(0);
+
+ useEffect(() => {
+ let startTime: number | null = null;
+ const animate = (timestamp: number) => {
+ if (!startTime) startTime = timestamp;
+ const progress = Math.min((timestamp - startTime) / duration, 1);
+ setCount(Math.floor(progress * end));
+ if (progress < 1) {
+ requestAnimationFrame(animate);
+ }
+ };
+ requestAnimationFrame(animate);
+ }, [end, duration]);
+
+ return (
+
+ {prefix}
+ {count.toLocaleString()}
+ {suffix}
+
+ );
+}
+
+export default function LandingPage({
+ onGetStarted,
+}: {
+ onGetStarted: () => void;
+}) {
+ return (
+
+ {/* Background effects */}
+
+
+ {/* Hero Section */}
+
+
+
+
+
+
+ Premium Travel Data Portal
+
+
+
+
+ Government Travel
+ Made Transparent
+
+
+
+ Access comprehensive travel rate data, per-diem allowances, and
+ accommodation costs for government employees across Canada and
+ internationally.
+
+
+
+ Explore Data Portal
+
+
+
+
+ {/* Animated Stats */}
+
+
+
+
+
Countries Covered
+
+
+
+
+
+
Accommodation Rates
+
+
+
+
+
+
+
+
Airport Locations
+
+
+
+ {/* Features Grid */}
+
+
+
+
+
+
+
+
+ Interactive Maps
+
+
+ Explore travel rates geographically with our interactive map
+ interface. Filter by country, city, or region.
+
+
+
+
+
+
+
+
+
+
+
+ Live Data Tables
+
+
+ Search, sort, and export comprehensive travel rate data with
+ advanced filtering capabilities.
+
+
+
+
+
+
+
+
+
+ Flight Integration
+
+
+ Get real-time flight data integrated with travel allowances
+ for complete trip cost estimation.
+
+
+
+
+
+
+
+ {/* Decorative gradient orbs */}
+
+
+
+ );
+}
diff --git a/package.json b/package.json
index d70fabf..d19ecf7 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,9 @@
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
+ "dev:client": "vite",
+ "build:client": "vite build",
+ "preview": "vite preview",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
@@ -19,25 +22,47 @@
"author": "",
"license": "ISC",
"dependencies": {
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@radix-ui/react-select": "^2.2.6",
+ "@radix-ui/react-tabs": "^1.1.13",
"amadeus": "^11.0.0",
"axios": "^1.13.1",
"better-sqlite3": "^11.0.0",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
+ "framer-motion": "^12.26.2",
"helmet": "^7.1.0",
"joi": "^17.11.0",
+ "lucide-react": "^0.562.0",
"node-cache": "^5.1.2",
+ "react": "^19.2.3",
+ "react-dom": "^19.2.3",
+ "recharts": "^3.6.0",
"sqlite3": "^5.1.7",
+ "tailwind-merge": "^3.4.0",
"winston": "^3.11.0",
"winston-daily-rotate-file": "^4.7.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
+ "@tailwindcss/postcss": "^4.1.18",
+ "@types/node": "^25.0.8",
+ "@types/react": "^19.2.8",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^5.1.2",
+ "autoprefixer": "^10.4.23",
"jest": "^29.7.0",
"nodemon": "^3.0.2",
- "supertest": "^6.3.3"
+ "postcss": "^8.5.6",
+ "supertest": "^6.3.3",
+ "tailwindcss": "^4.1.18",
+ "typescript": "^5.9.3",
+ "vite": "^7.3.1"
}
}
diff --git a/postcss.config.js b/postcss.config.js
new file mode 100644
index 0000000..a7f73a2
--- /dev/null
+++ b/postcss.config.js
@@ -0,0 +1,5 @@
+export default {
+ plugins: {
+ '@tailwindcss/postcss': {},
+ },
+}
diff --git a/server.js b/server.js
index 94896ad..8229a3b 100644
--- a/server.js
+++ b/server.js
@@ -80,13 +80,22 @@ app.use((req, res, next) => {
next();
});
-// Serve static files from the current directory
-app.use(
- express.static(__dirname, {
- maxAge: "1d",
+// Serve React app (production build) or legacy static files
+if (process.env.NODE_ENV === 'production' && require('fs').existsSync(path.join(__dirname, 'dist', 'client'))) {
+ // Serve React production build
+ app.use(express.static(path.join(__dirname, 'dist', 'client'), {
+ maxAge: '1d',
etag: true,
- })
-);
+ }));
+} else {
+ // Serve legacy static files from the current directory
+ app.use(
+ express.static(__dirname, {
+ maxAge: "1d",
+ etag: true,
+ })
+ );
+}
// Disable caching for HTML and JS files
app.use((req, res, next) => {
@@ -204,6 +213,50 @@ app.get(
}
})();
+// ============ DATA PORTAL ENDPOINTS ============
+
+/**
+ * Get all per-diem rates
+ * GET /api/rates/per-diem
+ */
+app.get("/api/rates/per-diem", async (req, res) => {
+ try {
+ const data = await dbService.getAllPerDiemRates();
+ res.json({ success: true, data, count: data.length });
+ } catch (error) {
+ logger.error("Error fetching per-diem rates:", error);
+ res.status(500).json({ error: "Failed to fetch per-diem rates" });
+ }
+});
+
+/**
+ * Get all accommodation rates
+ * GET /api/rates/accommodations
+ */
+app.get("/api/rates/accommodations", async (req, res) => {
+ try {
+ const data = await dbService.getAllAccommodations();
+ res.json({ success: true, data, count: data.length });
+ } catch (error) {
+ logger.error("Error fetching accommodation rates:", error);
+ res.status(500).json({ error: "Failed to fetch accommodation rates" });
+ }
+});
+
+/**
+ * Get portal statistics
+ * GET /api/stats
+ */
+app.get("/api/stats", async (req, res) => {
+ try {
+ const stats = await dbService.getStats();
+ res.json({ success: true, stats });
+ } catch (error) {
+ logger.error("Error fetching stats:", error);
+ res.status(500).json({ error: "Failed to fetch statistics" });
+ }
+});
+
// ============ DATABASE SEARCH ENDPOINTS ============
/**
diff --git a/services/databaseService.js b/services/databaseService.js
index 84ccb44..3f4246c 100644
--- a/services/databaseService.js
+++ b/services/databaseService.js
@@ -266,6 +266,69 @@ class DatabaseService {
});
}
+ /**
+ * Get all per-diem rates
+ */
+ async getAllPerDiemRates() {
+ const query = `
+ SELECT country, city_name as city, breakfast, lunch, dinner,
+ incidentals, currency
+ FROM travel_rates
+ WHERE country IS NOT NULL
+ AND country != 'Canada'
+ ORDER BY country, city_name
+ `;
+
+ return new Promise((resolve, reject) => {
+ this.db.all(query, [], (err, rows) => {
+ if (err) reject(err);
+ else resolve(rows);
+ });
+ });
+ }
+
+ /**
+ * Get all accommodation rates
+ */
+ async getAllAccommodations() {
+ const query = `
+ SELECT city_name as city, province, accommodation_rate as rate, currency
+ FROM travel_rates
+ WHERE country = 'Canada'
+ AND accommodation_rate IS NOT NULL
+ ORDER BY province, city_name
+ `;
+
+ return new Promise((resolve, reject) => {
+ this.db.all(query, [], (err, rows) => {
+ if (err) reject(err);
+ else resolve(rows);
+ });
+ });
+ }
+
+ /**
+ * Get statistics
+ */
+ async getStats() {
+ const queries = {
+ countries: `SELECT COUNT(DISTINCT country) as count FROM travel_rates WHERE country != 'Canada'`,
+ accommodations: `SELECT COUNT(*) as count FROM travel_rates WHERE country = 'Canada' AND accommodation_rate IS NOT NULL`,
+ perDiem: `SELECT COUNT(*) as count FROM travel_rates WHERE country != 'Canada'`,
+ };
+
+ const results = {};
+ for (const [key, query] of Object.entries(queries)) {
+ results[key] = await new Promise((resolve, reject) => {
+ this.db.get(query, [], (err, row) => {
+ if (err) reject(err);
+ else resolve(row.count);
+ });
+ });
+ }
+ return results;
+ }
+
close() {
if (this.db) {
this.db.close();
diff --git a/tailwind.config.js b/tailwind.config.js
new file mode 100644
index 0000000..33de89a
--- /dev/null
+++ b/tailwind.config.js
@@ -0,0 +1,81 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ darkMode: ["class"],
+ content: ["./client/index.html", "./client/src/**/*.{js,ts,jsx,tsx}"],
+ theme: {
+ extend: {
+ colors: {
+ border: "hsl(var(--border))",
+ input: "hsl(var(--input))",
+ ring: "hsl(var(--ring))",
+ background: "hsl(var(--background))",
+ foreground: "hsl(var(--foreground))",
+ primary: {
+ DEFAULT: "hsl(var(--primary))",
+ foreground: "hsl(var(--primary-foreground))",
+ },
+ secondary: {
+ DEFAULT: "hsl(var(--secondary))",
+ foreground: "hsl(var(--secondary-foreground))",
+ },
+ destructive: {
+ DEFAULT: "hsl(var(--destructive))",
+ foreground: "hsl(var(--destructive-foreground))",
+ },
+ muted: {
+ DEFAULT: "hsl(var(--muted))",
+ foreground: "hsl(var(--muted-foreground))",
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent))",
+ foreground: "hsl(var(--accent-foreground))",
+ },
+ popover: {
+ DEFAULT: "hsl(var(--popover))",
+ foreground: "hsl(var(--popover-foreground))",
+ },
+ card: {
+ DEFAULT: "hsl(var(--card))",
+ foreground: "hsl(var(--card-foreground))",
+ },
+ },
+ borderRadius: {
+ lg: "var(--radius)",
+ md: "calc(var(--radius) - 2px)",
+ sm: "calc(var(--radius) - 4px)",
+ },
+ fontFamily: {
+ sans: ["Inter", "sans-serif"],
+ display: ["Playfair Display", "serif"],
+ },
+ keyframes: {
+ "fade-in": {
+ "0%": { opacity: "0", transform: "translateY(10px)" },
+ "100%": { opacity: "1", transform: "translateY(0)" },
+ },
+ "slide-in": {
+ "0%": { transform: "translateX(-100%)" },
+ "100%": { transform: "translateX(0)" },
+ },
+ glow: {
+ "0%, 100%": { boxShadow: "0 0 20px rgba(212, 175, 55, 0.3)" },
+ "50%": { boxShadow: "0 0 30px rgba(212, 175, 55, 0.6)" },
+ },
+ },
+ animation: {
+ "fade-in": "fade-in 0.5s ease-out",
+ "slide-in": "slide-in 0.3s ease-out",
+ glow: "glow 2s ease-in-out infinite",
+ },
+ backgroundImage: {
+ "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
+ glass:
+ "linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05))",
+ },
+ backdrop: {
+ "blur-glass": "blur(10px)",
+ },
+ },
+ },
+ plugins: [],
+};
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..77174c2
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./client/src/*"]
+ }
+ },
+ "include": ["client/src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/tsconfig.node.json b/tsconfig.node.json
new file mode 100644
index 0000000..42872c5
--- /dev/null
+++ b/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..cb0f801
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,26 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import path from "path";
+
+export default defineConfig({
+ plugins: [react()],
+ root: "./client",
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./client/src"),
+ },
+ },
+ server: {
+ port: 3000,
+ proxy: {
+ "/api": {
+ target: "http://localhost:5001",
+ changeOrigin: true,
+ },
+ },
+ },
+ build: {
+ outDir: "../dist/client",
+ emptyOutDir: true,
+ },
+});