mirror of
https://github.com/mblanke/Gov_Travel_App.git
synced 2026-03-01 14:10:22 -05:00
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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -2,6 +2,10 @@
|
||||
node_modules/
|
||||
package-lock.json
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
.vite/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
|
||||
184
PORTAL_README.md
Normal file
184
PORTAL_README.md
Normal 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
16
client/index.html
Normal 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
19
client/src/App.tsx
Normal 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;
|
||||
236
client/src/components/DataTable.tsx
Normal file
236
client/src/components/DataTable.tsx
Normal 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
56
client/src/index.css
Normal 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
42
client/src/lib/utils.ts
Normal 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
10
client/src/main.tsx
Normal 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>
|
||||
);
|
||||
157
client/src/pages/DashboardPage.tsx
Normal file
157
client/src/pages/DashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
192
client/src/pages/LandingPage.tsx
Normal file
192
client/src/pages/LandingPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
package.json
27
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"
|
||||
}
|
||||
}
|
||||
|
||||
5
postcss.config.js
Normal file
5
postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
55
server.js
55
server.js
@@ -80,13 +80,22 @@ app.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// Serve static files from the current directory
|
||||
// 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 ============
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
81
tailwind.config.js
Normal 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
25
tsconfig.json
Normal 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
10
tsconfig.node.json
Normal 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
26
vite.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user