Build premium data portal with React + Tailwind CSS

Frontend Features:
- Landing page with glassmorphism, animated counters, hero section
- Interactive data tables with search, sort, filter, CSV export
- Premium dark theme (navy + gold accents)
- Framer Motion animations and micro-interactions
- Responsive design with Inter + Playfair Display typography
- DataTable component with pagination and live search

Backend Updates:
- New API endpoints: /api/rates/per-diem, /api/rates/accommodations, /api/stats
- Database service methods for bulk data retrieval
- Production mode serves built React app from /dist/client
- Fallback to legacy HTML for development

Tech Stack:
- React 18 + TypeScript
- Vite 7 build tool
- Tailwind CSS 4 with @tailwindcss/postcss
- Framer Motion for animations
- Lucide React icons
- SQLite3 backend

Build Output:
- 351KB optimized JavaScript bundle
- 29KB CSS bundle
- Fully tree-shaken and minified
This commit is contained in:
2026-01-13 11:05:54 -05:00
parent 66b72d5f74
commit 4d915aa3ea
18 changed files with 1211 additions and 7 deletions

4
.gitignore vendored
View File

@@ -2,6 +2,10 @@
node_modules/
package-lock.json
# Build output
dist/
.vite/
# Environment variables
.env

184
PORTAL_README.md Normal file
View File

@@ -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**

16
client/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Government Travel Data Portal</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Playfair+Display:wght@400;700;900&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

19
client/src/App.tsx Normal file
View File

@@ -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 (
<div className="min-h-screen">
{!showDashboard ? (
<LandingPage onGetStarted={() => setShowDashboard(true)} />
) : (
<DashboardPage onBack={() => setShowDashboard(false)} />
)}
</div>
);
}
export default App;

View File

@@ -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<string | null>(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 (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="glass-dark rounded-2xl p-6"
>
{/* Header */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-6">
<div>
<h2 className="text-3xl font-display font-bold text-white mb-2">
{title}
</h2>
<p className="text-gray-400">{sortedData.length} entries found</p>
</div>
<div className="flex gap-3">
<button
onClick={handleExport}
className="inline-flex items-center gap-2 px-4 py-2 bg-yellow-400/20 hover:bg-yellow-400/30 text-yellow-400 rounded-lg transition-all duration-200"
>
<Download className="w-4 h-4" />
Export CSV
</button>
</div>
</div>
{/* Search */}
<div className="relative mb-6">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Search..."
value={searchTerm}
onChange={(e) => {
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 && (
<button
onClick={() => setSearchTerm("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white"
>
<X className="w-5 h-5" />
</button>
)}
</div>
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-white/10">
{columns.map((column) => (
<th
key={column.key}
onClick={() =>
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"
)}
>
<div className="flex items-center gap-2">
{column.label}
{column.sortable !== false && sortColumn === column.key && (
<span className="text-yellow-400">
{sortDirection === "asc" ? "↑" : "↓"}
</span>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{paginatedData.map((row, idx) => (
<motion.tr
key={idx}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: idx * 0.02 }}
className="border-b border-white/5 hover:bg-white/5 transition-colors"
>
{columns.map((column) => (
<td key={column.key} className="px-4 py-4 text-gray-300">
{column.format
? column.format(row[column.key])
: row[column.key]}
</td>
))}
</motion.tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center items-center gap-2 mt-6">
<button
onClick={() => 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
</button>
<div className="flex gap-2">
{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 (
<button
key={pageNum}
onClick={() => 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}
</button>
);
})}
</div>
<button
onClick={() => 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
</button>
</div>
)}
</motion.div>
);
}

56
client/src/index.css Normal file
View File

@@ -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;
}

42
client/src/lib/utils.ts Normal file
View File

@@ -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);
}

10
client/src/main.tsx Normal file
View File

@@ -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(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -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<any[]>([]);
const [accommodationData, setAccommodationData] = useState<any[]>([]);
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 (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900">
{/* Navigation */}
<nav className="glass-dark border-b border-white/10 sticky top-0 z-50 backdrop-blur-md">
<div className="container mx-auto px-4">
<div className="flex items-center justify-between h-16">
<button
onClick={onBack}
className="flex items-center gap-2 text-white hover:text-yellow-400 transition-colors"
>
<Home className="w-5 h-5" />
<span className="font-semibold">Travel Data Portal</span>
</button>
<div className="flex gap-4">
<button
onClick={() => 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"
}`}
>
<Table2 className="w-4 h-4" />
Per Diem
</button>
<button
onClick={() => 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"
}`}
>
<Table2 className="w-4 h-4" />
Accommodations
</button>
</div>
</div>
</div>
</nav>
{/* Main Content */}
<div className="container mx-auto px-4 py-8">
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-16 w-16 border-t-2 border-b-2 border-yellow-400"></div>
</div>
) : (
<motion.div
key={activeTab}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
>
{activeTab === "per-diem" && (
<DataTable
data={perDiemData}
columns={perDiemColumns}
title="Per Diem Rates"
searchKeys={["country", "city"]}
/>
)}
{activeTab === "accommodations" && (
<DataTable
data={accommodationData}
columns={accommodationColumns}
title="Accommodation Rates"
searchKeys={["city", "province"]}
/>
)}
</motion.div>
)}
</div>
{/* Background effects */}
<div className="fixed inset-0 bg-[url('/grid.svg')] bg-center [mask-image:linear-gradient(180deg,white,rgba(255,255,255,0))] pointer-events-none"></div>
<div className="fixed top-1/4 -left-48 w-96 h-96 bg-yellow-400/20 rounded-full blur-3xl pointer-events-none"></div>
<div className="fixed bottom-1/4 -right-48 w-96 h-96 bg-blue-400/20 rounded-full blur-3xl pointer-events-none"></div>
</div>
);
}

View File

@@ -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 (
<span className="text-gold-gradient text-5xl md:text-7xl font-bold">
{prefix}
{count.toLocaleString()}
{suffix}
</span>
);
}
export default function LandingPage({
onGetStarted,
}: {
onGetStarted: () => void;
}) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900">
{/* Background effects */}
<div className="absolute inset-0 bg-[url('/grid.svg')] bg-center [mask-image:linear-gradient(180deg,white,rgba(255,255,255,0))]"></div>
{/* Hero Section */}
<div className="relative z-10">
<div className="container mx-auto px-4 py-20">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }}
className="text-center"
>
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full glass-dark mb-8">
<Sparkles className="w-4 h-4 text-yellow-400" />
<span className="text-sm text-gray-300">
Premium Travel Data Portal
</span>
</div>
<h1 className="font-display text-5xl md:text-7xl lg:text-8xl font-bold text-white mb-6">
Government Travel
<span className="block text-gold-gradient">Made Transparent</span>
</h1>
<p className="text-xl md:text-2xl text-gray-300 mb-12 max-w-3xl mx-auto">
Access comprehensive travel rate data, per-diem allowances, and
accommodation costs for government employees across Canada and
internationally.
</p>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={onGetStarted}
className="inline-flex items-center gap-2 px-8 py-4 bg-gradient-to-r from-yellow-400 to-yellow-600 text-gray-900 rounded-full font-semibold text-lg shadow-lg hover:shadow-yellow-500/50 transition-all duration-300 animate-glow"
>
Explore Data Portal
<ArrowRight className="w-5 h-5" />
</motion.button>
</motion.div>
{/* Animated Stats */}
<motion.div
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.3 }}
className="grid grid-cols-1 md:grid-cols-4 gap-6 mt-24"
>
<div className="glass-dark p-8 rounded-2xl text-center">
<Plane className="w-12 h-12 text-yellow-400 mx-auto mb-4" />
<AnimatedCounter end={233} suffix="+" />
<p className="text-gray-400 mt-2">Countries Covered</p>
</div>
<div className="glass-dark p-8 rounded-2xl text-center">
<Building2 className="w-12 h-12 text-yellow-400 mx-auto mb-4" />
<AnimatedCounter end={1356} suffix="+" />
<p className="text-gray-400 mt-2">Accommodation Rates</p>
</div>
<div className="glass-dark p-8 rounded-2xl text-center">
<DollarSign className="w-12 h-12 text-yellow-400 mx-auto mb-4" />
<AnimatedCounter end={9114} suffix="+" />
<p className="text-gray-400 mt-2">Per-Diem Entries</p>
</div>
<div className="glass-dark p-8 rounded-2xl text-center">
<MapPin className="w-12 h-12 text-yellow-400 mx-auto mb-4" />
<AnimatedCounter end={6072} suffix="+" />
<p className="text-gray-400 mt-2">Airport Locations</p>
</div>
</motion.div>
{/* Features Grid */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.8, delay: 0.6 }}
className="grid grid-cols-1 md:grid-cols-3 gap-8 mt-24"
>
<div className="group relative overflow-hidden rounded-2xl glass-dark p-8 hover:bg-white/15 transition-all duration-300">
<div className="absolute inset-0 bg-gradient-to-br from-yellow-400/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
<div className="relative">
<div className="w-16 h-16 rounded-full bg-yellow-400/20 flex items-center justify-center mb-4">
<MapPin className="w-8 h-8 text-yellow-400" />
</div>
<h3 className="text-2xl font-display font-bold text-white mb-3">
Interactive Maps
</h3>
<p className="text-gray-400">
Explore travel rates geographically with our interactive map
interface. Filter by country, city, or region.
</p>
</div>
</div>
<div className="group relative overflow-hidden rounded-2xl glass-dark p-8 hover:bg-white/15 transition-all duration-300">
<div className="absolute inset-0 bg-gradient-to-br from-yellow-400/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
<div className="relative">
<div className="w-16 h-16 rounded-full bg-yellow-400/20 flex items-center justify-center mb-4">
<DollarSign className="w-8 h-8 text-yellow-400" />
</div>
<h3 className="text-2xl font-display font-bold text-white mb-3">
Live Data Tables
</h3>
<p className="text-gray-400">
Search, sort, and export comprehensive travel rate data with
advanced filtering capabilities.
</p>
</div>
</div>
<div className="group relative overflow-hidden rounded-2xl glass-dark p-8 hover:bg-white/15 transition-all duration-300">
<div className="absolute inset-0 bg-gradient-to-br from-yellow-400/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
<div className="relative">
<div className="w-16 h-16 rounded-full bg-yellow-400/20 flex items-center justify-center mb-4">
<Plane className="w-8 h-8 text-yellow-400" />
</div>
<h3 className="text-2xl font-display font-bold text-white mb-3">
Flight Integration
</h3>
<p className="text-gray-400">
Get real-time flight data integrated with travel allowances
for complete trip cost estimation.
</p>
</div>
</div>
</motion.div>
</div>
</div>
{/* Decorative gradient orbs */}
<div className="absolute top-1/4 -left-48 w-96 h-96 bg-yellow-400/30 rounded-full blur-3xl"></div>
<div className="absolute bottom-1/4 -right-48 w-96 h-96 bg-blue-400/30 rounded-full blur-3xl"></div>
</div>
);
}

View File

@@ -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"
}
}

5
postcss.config.js Normal file
View File

@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}

View File

@@ -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 ============
/**

View File

@@ -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();

81
tailwind.config.js Normal file
View File

@@ -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: [],
};

25
tsconfig.json Normal file
View File

@@ -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" }]
}

10
tsconfig.node.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

26
vite.config.ts Normal file
View File

@@ -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,
},
});