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

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