diff --git a/.gitignore b/.gitignore index e1c6eb2..f2e7af5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,10 @@ node_modules/ package-lock.json +# Build output +dist/ +.vite/ + # Environment variables .env diff --git a/PORTAL_README.md b/PORTAL_README.md new file mode 100644 index 0000000..d3e1ae8 --- /dev/null +++ b/PORTAL_README.md @@ -0,0 +1,184 @@ +# Premium Data Portal - Government Travel Rates + +A modern, luxury data portal showcasing government travel rates with premium UX design. + +## ✨ Features + +### 🎨 Premium Design +- **Glassmorphism Effects**: Translucent panels with backdrop blur +- **Gradient Accents**: Deep navy + gold gradient theme +- **Smooth Animations**: Framer Motion micro-interactions +- **Typography**: Inter + Playfair Display pairing +- **Dark Mode**: Premium dark theme with accent lighting + +### 📊 Data Portal +- **Interactive Tables**: Search, sort, filter, and export per-diem and accommodation rates +- **Animated Counters**: Real-time statistics with smooth counting animations +- **CSV Export**: One-click data export for analysis +- **Pagination**: Smooth navigation through large datasets +- **Responsive Design**: Works beautifully on all devices + +### 🛫 Travel Integration +- **OpenFlights Data**: Free, open-source flight data (6,072+ airports) +- **Rate Coverage**: 233 countries, 9,114 per-diem entries, 1,356 accommodation rates +- **Real-time Search**: Fast database queries with caching + +## 🚀 Quick Start + +### Development +```bash +# Install dependencies +npm install + +# Start backend server +npm start + +# Start frontend dev server (in another terminal) +npm run dev:client +``` + +Backend runs on `http://localhost:5001` +Frontend runs on `http://localhost:3000` + +### Production Build +```bash +# Build React frontend +npm run build:client + +# Start production server (serves built React app) +NODE_ENV=production npm start +``` + +## 🏗️ Architecture + +### Frontend (`/client`) +- **Framework**: React 18 with TypeScript +- **Build Tool**: Vite 7 +- **Styling**: Tailwind CSS 4 with custom theme +- **Animations**: Framer Motion +- **Icons**: Lucide React +- **UI Components**: Custom-built with Radix UI primitives + +### Backend +- **Server**: Node.js + Express +- **Database**: SQLite3 with Better-SQLite3 +- **APIs**: + - `/api/rates/per-diem` - All per-diem rates + - `/api/rates/accommodations` - Canadian accommodation rates + - `/api/stats` - Portal statistics + - `/api/flights/search` - OpenFlights integration + +### Key Files +``` +├── client/ +│ ├── src/ +│ │ ├── App.tsx # Main application +│ │ ├── pages/ +│ │ │ ├── LandingPage.tsx # Hero + animated counters +│ │ │ └── DashboardPage.tsx # Data tables portal +│ │ ├── components/ +│ │ │ └── DataTable.tsx # Interactive table component +│ │ └── lib/ +│ │ └── utils.ts # Utilities (format, export) +│ └── index.html # Entry point +├── server.js # Express backend +├── openFlightsService.js # Free flight data integration +└── services/ + └── databaseService.js # Database layer +``` + +## 🎨 Design System + +### Colors +- **Background**: Deep navy (#0B1120) +- **Accent**: Gold gradient (#FACC15 → #EAB308) +- **Glass**: rgba(255, 255, 255, 0.1) with 10px blur +- **Text**: White (#FAFAFA) with gray-300 secondary + +### Typography +- **Headings**: Playfair Display (Display font) +- **Body**: Inter (Sans-serif) +- **Weights**: 300-900 range + +### Spacing +- **Container**: max-width 1200px, px-4 mobile +- **Sections**: py-20 desktop, py-12 mobile +- **Cards**: p-8 with rounded-2xl + +## 📦 npm Scripts + +| Script | Description | +|--------|-------------| +| `npm start` | Start production server | +| `npm run dev` | Start backend with nodemon | +| `npm run dev:client` | Start Vite dev server | +| `npm run build:client` | Build React app for production | +| `npm run preview` | Preview production build | +| `npm test` | Run Jest tests | + +## 🔧 Configuration + +### Environment Variables (`.env`) +```env +PORT=5001 +NODE_ENV=production +AMADEUS_API_KEY=your_key_here # Optional +AMADEUS_API_SECRET=your_secret # Optional +``` + +### Vite Configuration +- Root: `./client` +- Output: `../dist/client` +- Proxy: `/api` → `http://localhost:5001` + +## 📊 Data Sources + +1. **NJC Travel Directive** - Canadian government per-diem rates +2. **OpenFlights** - Free, open-source airport/airline/route data +3. **Scraped Data** - Government accommodation rates (scraped via Python) + +## 🚀 Deployment + +### Docker +```bash +# Build image with React app +docker build -t govt-travel-app:latest . + +# Run container +docker run -d -p 5001:5001 govt-travel-app:latest +``` + +### Manual +```bash +# Build frontend +npm run build:client + +# Set production environment +export NODE_ENV=production + +# Start server +npm start +``` + +The server automatically serves the built React app when `NODE_ENV=production` and `/dist/client` exists. + +## 🎯 Future Enhancements + +- [ ] Interactive map view with country selection +- [ ] Data visualizations with Recharts (rate comparisons) +- [ ] Advanced filters (date ranges, rate thresholds) +- [ ] User authentication and saved searches +- [ ] Real-time Amadeus flight pricing integration +- [ ] Mobile app (React Native) + +## 📝 License + +ISC + +## 🤝 Contributing + +Built with premium attention to detail. Contributions welcome! + +--- + +**Made with ✨ by the Government Travel Team** diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..8a3933b --- /dev/null +++ b/client/index.html @@ -0,0 +1,16 @@ + + + + + + + Government Travel Data Portal + + + + + +
+ + + diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..8f135b4 --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,19 @@ +import { useState, useEffect } from "react"; +import LandingPage from "./pages/LandingPage"; +import DashboardPage from "./pages/DashboardPage"; + +function App() { + const [showDashboard, setShowDashboard] = useState(false); + + return ( +
+ {!showDashboard ? ( + setShowDashboard(true)} /> + ) : ( + setShowDashboard(false)} /> + )} +
+ ); +} + +export default App; diff --git a/client/src/components/DataTable.tsx b/client/src/components/DataTable.tsx new file mode 100644 index 0000000..e608a94 --- /dev/null +++ b/client/src/components/DataTable.tsx @@ -0,0 +1,236 @@ +import { useState, useMemo } from "react"; +import { motion } from "framer-motion"; +import { Search, Download, Filter, X } from "lucide-react"; +import { exportToCSV, formatCurrency, cn } from "@/lib/utils"; + +interface Column { + key: string; + label: string; + sortable?: boolean; + format?: (value: any) => string; +} + +interface DataTableProps { + data: any[]; + columns: Column[]; + title: string; + searchKeys: string[]; +} + +export default function DataTable({ + data, + columns, + title, + searchKeys, +}: DataTableProps) { + const [searchTerm, setSearchTerm] = useState(""); + const [sortColumn, setSortColumn] = useState(null); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 20; + + const filteredData = useMemo(() => { + if (!searchTerm) return data; + + return data.filter((item) => + searchKeys.some((key) => + String(item[key]).toLowerCase().includes(searchTerm.toLowerCase()) + ) + ); + }, [data, searchTerm, searchKeys]); + + const sortedData = useMemo(() => { + if (!sortColumn) return filteredData; + + return [...filteredData].sort((a, b) => { + const aVal = a[sortColumn]; + const bVal = b[sortColumn]; + + if (typeof aVal === "number" && typeof bVal === "number") { + return sortDirection === "asc" ? aVal - bVal : bVal - aVal; + } + + const aStr = String(aVal).toLowerCase(); + const bStr = String(bVal).toLowerCase(); + return sortDirection === "asc" + ? aStr.localeCompare(bStr) + : bStr.localeCompare(aStr); + }); + }, [filteredData, sortColumn, sortDirection]); + + const paginatedData = useMemo(() => { + const startIndex = (currentPage - 1) * itemsPerPage; + return sortedData.slice(startIndex, startIndex + itemsPerPage); + }, [sortedData, currentPage]); + + const totalPages = Math.ceil(sortedData.length / itemsPerPage); + + const handleSort = (column: string) => { + if (sortColumn === column) { + setSortDirection((prev) => (prev === "asc" ? "desc" : "asc")); + } else { + setSortColumn(column); + setSortDirection("asc"); + } + }; + + const handleExport = () => { + exportToCSV( + sortedData, + `${title.toLowerCase().replace(/\s+/g, "-")}-export` + ); + }; + + return ( + + {/* Header */} +
+
+

+ {title} +

+

{sortedData.length} entries found

+
+ +
+ +
+
+ + {/* Search */} +
+ + { + setSearchTerm(e.target.value); + setCurrentPage(1); + }} + className="w-full pl-10 pr-10 py-3 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-yellow-400/50 transition-all" + /> + {searchTerm && ( + + )} +
+ + {/* Table */} +
+ + + + {columns.map((column) => ( + + ))} + + + + {paginatedData.map((row, idx) => ( + + {columns.map((column) => ( + + ))} + + ))} + +
+ column.sortable !== false && handleSort(column.key) + } + className={cn( + "px-4 py-3 text-left text-sm font-semibold text-gray-400 uppercase tracking-wider", + column.sortable !== false && + "cursor-pointer hover:text-white transition-colors" + )} + > +
+ {column.label} + {column.sortable !== false && sortColumn === column.key && ( + + {sortDirection === "asc" ? "↑" : "↓"} + + )} +
+
+ {column.format + ? column.format(row[column.key]) + : row[column.key]} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + +
+ {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 ( + + ); + })} +
+ + +
+ )} +
+ ); +} diff --git a/client/src/index.css b/client/src/index.css new file mode 100644 index 0000000..81cf73d --- /dev/null +++ b/client/src/index.css @@ -0,0 +1,56 @@ +@import "tailwindcss"; + +@theme { + --color-background: 222.2 84% 4.9%; + --color-foreground: 210 40% 98%; + --color-card: 222.2 84% 6%; + --color-card-foreground: 210 40% 98%; + --color-primary: 45 100% 51%; + --color-primary-foreground: 222.2 47.4% 11.2%; + --color-secondary: 217.2 32.6% 17.5%; + --color-secondary-foreground: 210 40% 98%; + --radius-lg: 0.5rem; + --radius-md: calc(0.5rem - 2px); + --radius-sm: calc(0.5rem - 4px); +} + +/* Glassmorphism effect */ +.glass { + @apply bg-white/10 backdrop-blur-md border border-white/20; +} + +.glass-dark { + @apply bg-gray-900/40 backdrop-blur-md border border-gray-700/50; +} + +/* Gold gradient text */ +.text-gold-gradient { + @apply bg-gradient-to-r from-yellow-400 via-yellow-500 to-yellow-600 bg-clip-text text-transparent; +} + +/* Animated underline */ +.animated-underline { + @apply relative after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-0 after:bg-primary after:transition-all after:duration-300 hover:after:w-full; +} + +body { + font-family: 'Inter', sans-serif; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + @apply bg-gray-100 dark:bg-gray-800; +} + +::-webkit-scrollbar-thumb { + @apply bg-gray-400 dark:bg-gray-600 rounded-full; +} + +::-webkit-scrollbar-thumb:hover { + @apply bg-gray-500 dark:bg-gray-500; +} diff --git a/client/src/lib/utils.ts b/client/src/lib/utils.ts new file mode 100644 index 0000000..679bcf5 --- /dev/null +++ b/client/src/lib/utils.ts @@ -0,0 +1,42 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +export function formatCurrency( + amount: number, + currency: string = "CAD" +): string { + return new Intl.NumberFormat("en-CA", { + style: "currency", + currency, + }).format(amount); +} + +export function formatDate(date: string | Date): string { + return new Intl.DateTimeFormat("en-CA", { + year: "numeric", + month: "long", + day: "numeric", + }).format(new Date(date)); +} + +export function exportToCSV(data: any[], filename: string) { + const headers = Object.keys(data[0]); + const csv = [ + headers.join(","), + ...data.map((row) => + headers.map((header) => JSON.stringify(row[header] ?? "")).join(",") + ), + ].join("\n"); + + const blob = new Blob([csv], { type: "text/csv" }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${filename}.csv`; + a.click(); + window.URL.revokeObjectURL(url); +} diff --git a/client/src/main.tsx b/client/src/main.tsx new file mode 100644 index 0000000..966f17a --- /dev/null +++ b/client/src/main.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App.tsx"; +import "./index.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + +); diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..694431a --- /dev/null +++ b/client/src/pages/DashboardPage.tsx @@ -0,0 +1,157 @@ +import { useState, useEffect } from "react"; +import { motion } from "framer-motion"; +import { Home, Table2, Map, BarChart3, Plane } from "lucide-react"; +import DataTable from "../components/DataTable"; +import { formatCurrency } from "@/lib/utils"; + +interface DashboardPageProps { + onBack: () => void; +} + +export default function DashboardPage({ onBack }: DashboardPageProps) { + const [activeTab, setActiveTab] = useState("per-diem"); + const [perDiemData, setPerDiemData] = useState([]); + const [accommodationData, setAccommodationData] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + // Fetch data from API + Promise.all([ + fetch("/api/rates/per-diem").then((r) => r.json()), + fetch("/api/rates/accommodations").then((r) => r.json()), + ]) + .then(([perDiem, accommodations]) => { + setPerDiemData(perDiem.data || []); + setAccommodationData(accommodations.data || []); + setLoading(false); + }) + .catch((error) => { + console.error("Error fetching data:", error); + setLoading(false); + }); + }, []); + + const perDiemColumns = [ + { key: "country", label: "Country", sortable: true }, + { key: "city", label: "City", sortable: true }, + { + key: "breakfast", + label: "Breakfast", + sortable: true, + format: (v: number) => formatCurrency(v), + }, + { + key: "lunch", + label: "Lunch", + sortable: true, + format: (v: number) => formatCurrency(v), + }, + { + key: "dinner", + label: "Dinner", + sortable: true, + format: (v: number) => formatCurrency(v), + }, + { + key: "incidentals", + label: "Incidentals", + sortable: true, + format: (v: number) => formatCurrency(v), + }, + ]; + + const accommodationColumns = [ + { key: "city", label: "City", sortable: true }, + { key: "province", label: "Province", sortable: true }, + { + key: "rate", + label: "Rate", + sortable: true, + format: (v: number) => formatCurrency(v), + }, + { key: "currency", label: "Currency", sortable: false }, + ]; + + return ( +
+ {/* Navigation */} + + + {/* Main Content */} +
+ {loading ? ( +
+
+
+ ) : ( + + {activeTab === "per-diem" && ( + + )} + {activeTab === "accommodations" && ( + + )} + + )} +
+ + {/* Background effects */} +
+
+
+
+ ); +} diff --git a/client/src/pages/LandingPage.tsx b/client/src/pages/LandingPage.tsx new file mode 100644 index 0000000..d89ebc2 --- /dev/null +++ b/client/src/pages/LandingPage.tsx @@ -0,0 +1,192 @@ +import { motion } from "framer-motion"; +import { useState, useEffect } from "react"; +import { + Plane, + Building2, + DollarSign, + MapPin, + ArrowRight, + Sparkles, +} from "lucide-react"; + +interface AnimatedCounterProps { + end: number; + duration?: number; + suffix?: string; + prefix?: string; +} + +function AnimatedCounter({ + end, + duration = 2000, + suffix = "", + prefix = "", +}: AnimatedCounterProps) { + const [count, setCount] = useState(0); + + useEffect(() => { + let startTime: number | null = null; + const animate = (timestamp: number) => { + if (!startTime) startTime = timestamp; + const progress = Math.min((timestamp - startTime) / duration, 1); + setCount(Math.floor(progress * end)); + if (progress < 1) { + requestAnimationFrame(animate); + } + }; + requestAnimationFrame(animate); + }, [end, duration]); + + return ( + + {prefix} + {count.toLocaleString()} + {suffix} + + ); +} + +export default function LandingPage({ + onGetStarted, +}: { + onGetStarted: () => void; +}) { + return ( +
+ {/* Background effects */} +
+ + {/* Hero Section */} +
+
+ +
+ + + Premium Travel Data Portal + +
+ +

+ Government Travel + Made Transparent +

+ +

+ Access comprehensive travel rate data, per-diem allowances, and + accommodation costs for government employees across Canada and + internationally. +

+ + + Explore Data Portal + + +
+ + {/* Animated Stats */} + +
+ + +

Countries Covered

+
+ +
+ + +

Accommodation Rates

+
+ +
+ + +

Per-Diem Entries

+
+ +
+ + +

Airport Locations

+
+
+ + {/* Features Grid */} + +
+
+
+
+ +
+

+ Interactive Maps +

+

+ Explore travel rates geographically with our interactive map + interface. Filter by country, city, or region. +

+
+
+ +
+
+
+
+ +
+

+ Live Data Tables +

+

+ Search, sort, and export comprehensive travel rate data with + advanced filtering capabilities. +

+
+
+ +
+
+
+
+ +
+

+ Flight Integration +

+

+ Get real-time flight data integrated with travel allowances + for complete trip cost estimation. +

+
+
+
+
+
+ + {/* Decorative gradient orbs */} +
+
+
+ ); +} diff --git a/package.json b/package.json index d70fabf..d19ecf7 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,9 @@ "scripts": { "start": "node server.js", "dev": "nodemon server.js", + "dev:client": "vite", + "build:client": "vite build", + "preview": "vite preview", "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage" @@ -19,25 +22,47 @@ "author": "", "license": "ISC", "dependencies": { + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-tabs": "^1.1.13", "amadeus": "^11.0.0", "axios": "^1.13.1", "better-sqlite3": "^11.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "compression": "^1.7.4", "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^4.18.2", "express-rate-limit": "^7.1.5", + "framer-motion": "^12.26.2", "helmet": "^7.1.0", "joi": "^17.11.0", + "lucide-react": "^0.562.0", "node-cache": "^5.1.2", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "recharts": "^3.6.0", "sqlite3": "^5.1.7", + "tailwind-merge": "^3.4.0", "winston": "^3.11.0", "winston-daily-rotate-file": "^4.7.1", "xlsx": "^0.18.5" }, "devDependencies": { + "@tailwindcss/postcss": "^4.1.18", + "@types/node": "^25.0.8", + "@types/react": "^19.2.8", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.2", + "autoprefixer": "^10.4.23", "jest": "^29.7.0", "nodemon": "^3.0.2", - "supertest": "^6.3.3" + "postcss": "^8.5.6", + "supertest": "^6.3.3", + "tailwindcss": "^4.1.18", + "typescript": "^5.9.3", + "vite": "^7.3.1" } } diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..a7f73a2 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +} diff --git a/server.js b/server.js index 94896ad..8229a3b 100644 --- a/server.js +++ b/server.js @@ -80,13 +80,22 @@ app.use((req, res, next) => { next(); }); -// Serve static files from the current directory -app.use( - express.static(__dirname, { - maxAge: "1d", +// Serve React app (production build) or legacy static files +if (process.env.NODE_ENV === 'production' && require('fs').existsSync(path.join(__dirname, 'dist', 'client'))) { + // Serve React production build + app.use(express.static(path.join(__dirname, 'dist', 'client'), { + maxAge: '1d', etag: true, - }) -); + })); +} else { + // Serve legacy static files from the current directory + app.use( + express.static(__dirname, { + maxAge: "1d", + etag: true, + }) + ); +} // Disable caching for HTML and JS files app.use((req, res, next) => { @@ -204,6 +213,50 @@ app.get( } })(); +// ============ DATA PORTAL ENDPOINTS ============ + +/** + * Get all per-diem rates + * GET /api/rates/per-diem + */ +app.get("/api/rates/per-diem", async (req, res) => { + try { + const data = await dbService.getAllPerDiemRates(); + res.json({ success: true, data, count: data.length }); + } catch (error) { + logger.error("Error fetching per-diem rates:", error); + res.status(500).json({ error: "Failed to fetch per-diem rates" }); + } +}); + +/** + * Get all accommodation rates + * GET /api/rates/accommodations + */ +app.get("/api/rates/accommodations", async (req, res) => { + try { + const data = await dbService.getAllAccommodations(); + res.json({ success: true, data, count: data.length }); + } catch (error) { + logger.error("Error fetching accommodation rates:", error); + res.status(500).json({ error: "Failed to fetch accommodation rates" }); + } +}); + +/** + * Get portal statistics + * GET /api/stats + */ +app.get("/api/stats", async (req, res) => { + try { + const stats = await dbService.getStats(); + res.json({ success: true, stats }); + } catch (error) { + logger.error("Error fetching stats:", error); + res.status(500).json({ error: "Failed to fetch statistics" }); + } +}); + // ============ DATABASE SEARCH ENDPOINTS ============ /** diff --git a/services/databaseService.js b/services/databaseService.js index 84ccb44..3f4246c 100644 --- a/services/databaseService.js +++ b/services/databaseService.js @@ -266,6 +266,69 @@ class DatabaseService { }); } + /** + * Get all per-diem rates + */ + async getAllPerDiemRates() { + const query = ` + SELECT country, city_name as city, breakfast, lunch, dinner, + incidentals, currency + FROM travel_rates + WHERE country IS NOT NULL + AND country != 'Canada' + ORDER BY country, city_name + `; + + return new Promise((resolve, reject) => { + this.db.all(query, [], (err, rows) => { + if (err) reject(err); + else resolve(rows); + }); + }); + } + + /** + * Get all accommodation rates + */ + async getAllAccommodations() { + const query = ` + SELECT city_name as city, province, accommodation_rate as rate, currency + FROM travel_rates + WHERE country = 'Canada' + AND accommodation_rate IS NOT NULL + ORDER BY province, city_name + `; + + return new Promise((resolve, reject) => { + this.db.all(query, [], (err, rows) => { + if (err) reject(err); + else resolve(rows); + }); + }); + } + + /** + * Get statistics + */ + async getStats() { + const queries = { + countries: `SELECT COUNT(DISTINCT country) as count FROM travel_rates WHERE country != 'Canada'`, + accommodations: `SELECT COUNT(*) as count FROM travel_rates WHERE country = 'Canada' AND accommodation_rate IS NOT NULL`, + perDiem: `SELECT COUNT(*) as count FROM travel_rates WHERE country != 'Canada'`, + }; + + const results = {}; + for (const [key, query] of Object.entries(queries)) { + results[key] = await new Promise((resolve, reject) => { + this.db.get(query, [], (err, row) => { + if (err) reject(err); + else resolve(row.count); + }); + }); + } + return results; + } + close() { if (this.db) { this.db.close(); diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..33de89a --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,81 @@ +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: ["class"], + content: ["./client/index.html", "./client/src/**/*.{js,ts,jsx,tsx}"], + theme: { + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + fontFamily: { + sans: ["Inter", "sans-serif"], + display: ["Playfair Display", "serif"], + }, + keyframes: { + "fade-in": { + "0%": { opacity: "0", transform: "translateY(10px)" }, + "100%": { opacity: "1", transform: "translateY(0)" }, + }, + "slide-in": { + "0%": { transform: "translateX(-100%)" }, + "100%": { transform: "translateX(0)" }, + }, + glow: { + "0%, 100%": { boxShadow: "0 0 20px rgba(212, 175, 55, 0.3)" }, + "50%": { boxShadow: "0 0 30px rgba(212, 175, 55, 0.6)" }, + }, + }, + animation: { + "fade-in": "fade-in 0.5s ease-out", + "slide-in": "slide-in 0.3s ease-out", + glow: "glow 2s ease-in-out infinite", + }, + backgroundImage: { + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + glass: + "linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05))", + }, + backdrop: { + "blur-glass": "blur(10px)", + }, + }, + }, + plugins: [], +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..77174c2 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["./client/src/*"] + } + }, + "include": ["client/src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..cb0f801 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import path from "path"; + +export default defineConfig({ + plugins: [react()], + root: "./client", + resolve: { + alias: { + "@": path.resolve(__dirname, "./client/src"), + }, + }, + server: { + port: 3000, + proxy: { + "/api": { + target: "http://localhost:5001", + changeOrigin: true, + }, + }, + }, + build: { + outDir: "../dist/client", + emptyOutDir: true, + }, +});