diff --git a/App.tsx b/App.tsx
new file mode 100644
index 00000000..404fdc1c
--- /dev/null
+++ b/App.tsx
@@ -0,0 +1,75 @@
+import { useState } from 'react';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from './components/ui/tabs';
+import { Dashboard } from './components/Dashboard';
+import { Payments } from './components/Payments';
+import { RoomReservations } from './components/RoomReservations';
+import { PropertyDataProvider } from './components/PropertyDataContext';
+import { Login } from './components/Login';
+import { Building2, LogOut } from 'lucide-react';
+import { Button } from './components/ui/button';
+
+export default function App() {
+ const [activeTab, setActiveTab] = useState('dashboard');
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
+
+ const handleLogin = () => {
+ setIsAuthenticated(true);
+ };
+
+ const handleLogout = () => {
+ setIsAuthenticated(false);
+ setActiveTab('dashboard');
+ };
+
+ if (!isAuthenticated) {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
Joyce Apartelle
+
Property Management System
+
+
+
+
+
+
+
+
+
+
+
+ Dashboard
+ Reservations
+ Payments
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/Attributions.md b/Attributions.md
new file mode 100644
index 00000000..9b7cd4e1
--- /dev/null
+++ b/Attributions.md
@@ -0,0 +1,3 @@
+This Figma Make file includes components from [shadcn/ui](https://ui.shadcn.com/) used under [MIT license](https://github.com/shadcn-ui/ui/blob/main/LICENSE.md).
+
+This Figma Make file includes photos from [Unsplash](https://unsplash.com) used under [license](https://unsplash.com/license).
\ No newline at end of file
diff --git a/components/Dashboard.tsx b/components/Dashboard.tsx
new file mode 100644
index 00000000..f369e0e0
--- /dev/null
+++ b/components/Dashboard.tsx
@@ -0,0 +1,146 @@
+import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
+import { Building2, Users, FileText, DollarSign, Wrench, TrendingUp } from 'lucide-react';
+import { usePropertyData } from './PropertyDataContext';
+
+export function Dashboard() {
+ const { properties, tenants, leases, payments, maintenanceRequests } = usePropertyData();
+
+ const totalProperties = properties.length;
+ const totalTenants = tenants.length;
+ const activeLeases = leases.filter(l => l.status === 'active').length;
+ const totalRevenue = payments
+ .filter(p => p.status === 'paid')
+ .reduce((sum, p) => sum + p.amount, 0);
+ const pendingMaintenance = maintenanceRequests.filter(m => m.status === 'pending').length;
+ const occupancyRate = properties.length > 0
+ ? ((activeLeases / properties.length) * 100).toFixed(1)
+ : 0;
+
+ const stats = [
+ {
+ title: 'Total Properties',
+ value: totalProperties,
+ icon: Building2,
+ color: 'text-blue-600',
+ bgColor: 'bg-blue-50',
+ },
+ {
+ title: 'Total Revenue',
+ value: `₱${totalRevenue.toLocaleString()}`,
+ icon: DollarSign,
+ color: 'text-emerald-600',
+ bgColor: 'bg-emerald-50',
+ },
+ {
+ title: 'Occupancy Rate',
+ value: `${occupancyRate}%`,
+ icon: TrendingUp,
+ color: 'text-indigo-600',
+ bgColor: 'bg-indigo-50',
+ },
+ ];
+
+ const recentPayments = payments.slice(0, 5);
+ const recentMaintenance = maintenanceRequests.slice(0, 5);
+
+ return (
+
+
+
Dashboard Overview
+
Key metrics and recent activity
+
+
+
+ {stats.map((stat) => {
+ const Icon = stat.icon;
+ return (
+
+
+ {stat.title}
+
+
+
+
+
+ {stat.value}
+
+
+ );
+ })}
+
+
+
+
+
+ Recent Payments
+
+
+
+ {recentPayments.length === 0 ? (
+
No payments recorded
+ ) : (
+ recentPayments.map((payment) => {
+ const tenant = tenants.find(t => t.id === payment.tenantId);
+ return (
+
+
+
{tenant?.name || 'Unknown Tenant'}
+
{payment.date}
+
+
+
₱{payment.amount.toLocaleString()}
+
+ {payment.status}
+
+
+
+ );
+ })
+ )}
+
+
+
+
+
+ Recent Maintenance Requests
+
+
+
+ {recentMaintenance.length === 0 ? (
+
No maintenance requests
+ ) : (
+ recentMaintenance.map((request) => {
+ const property = properties.find(p => p.id === request.propertyId);
+ return (
+
+
+
{request.title}
+
+ {request.status}
+
+
+
{property?.address || 'Unknown Property'}
+
{request.date}
+
+ );
+ })
+ )}
+
+
+
+
+
+ );
+}
diff --git a/components/Login.tsx b/components/Login.tsx
new file mode 100644
index 00000000..acd20839
--- /dev/null
+++ b/components/Login.tsx
@@ -0,0 +1,210 @@
+import { useState, useEffect } from 'react';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
+import { Input } from './ui/input';
+import { Label } from './ui/label';
+import { Button } from './ui/button';
+import { Alert, AlertDescription } from './ui/alert';
+import { Building2, Eye, EyeOff, AlertCircle } from 'lucide-react';
+
+const LOCKOUT_DURATION = 30000; // 30 seconds in milliseconds
+const MAX_ATTEMPTS = 3;
+
+// Demo credentials
+const DEMO_EMAIL = 'admin@ja.com';
+const DEMO_PASSWORD = 'password123';
+
+interface LoginProps {
+ onLogin: () => void;
+}
+
+export function Login({ onLogin }: LoginProps) {
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [showPassword, setShowPassword] = useState(false);
+ const [attempts, setAttempts] = useState(0);
+ const [isLocked, setIsLocked] = useState(false);
+ const [lockoutEndTime, setLockoutEndTime] = useState(null);
+ const [remainingTime, setRemainingTime] = useState(0);
+ const [error, setError] = useState('');
+
+ // Check for existing lockout in localStorage
+ useEffect(() => {
+ const storedLockoutEnd = localStorage.getItem('lockoutEndTime');
+ const storedAttempts = localStorage.getItem('loginAttempts');
+
+ if (storedLockoutEnd) {
+ const endTime = parseInt(storedLockoutEnd);
+ const now = Date.now();
+
+ if (now < endTime) {
+ setIsLocked(true);
+ setLockoutEndTime(endTime);
+ setRemainingTime(Math.ceil((endTime - now) / 1000));
+ } else {
+ // Lockout expired
+ localStorage.removeItem('lockoutEndTime');
+ localStorage.removeItem('loginAttempts');
+ }
+ }
+
+ if (storedAttempts) {
+ setAttempts(parseInt(storedAttempts));
+ }
+ }, []);
+
+ // Countdown timer for lockout
+ useEffect(() => {
+ if (isLocked && lockoutEndTime) {
+ const interval = setInterval(() => {
+ const now = Date.now();
+ const remaining = Math.ceil((lockoutEndTime - now) / 1000);
+
+ if (remaining <= 0) {
+ setIsLocked(false);
+ setLockoutEndTime(null);
+ setAttempts(0);
+ setError('');
+ localStorage.removeItem('lockoutEndTime');
+ localStorage.removeItem('loginAttempts');
+ clearInterval(interval);
+ } else {
+ setRemainingTime(remaining);
+ }
+ }, 1000);
+
+ return () => clearInterval(interval);
+ }
+ }, [isLocked, lockoutEndTime]);
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (isLocked) {
+ return;
+ }
+
+ setError('');
+
+ // Check credentials
+ if (email === DEMO_EMAIL && password === DEMO_PASSWORD) {
+ // Successful login
+ localStorage.removeItem('lockoutEndTime');
+ localStorage.removeItem('loginAttempts');
+ onLogin();
+ } else {
+ // Failed login
+ const newAttempts = attempts + 1;
+ setAttempts(newAttempts);
+ localStorage.setItem('loginAttempts', newAttempts.toString());
+
+ if (newAttempts >= MAX_ATTEMPTS) {
+ // Lock the account
+ const endTime = Date.now() + LOCKOUT_DURATION;
+ setIsLocked(true);
+ setLockoutEndTime(endTime);
+ setRemainingTime(Math.ceil(LOCKOUT_DURATION / 1000));
+ localStorage.setItem('lockoutEndTime', endTime.toString());
+ setError(`Too many failed attempts. Account locked for ${LOCKOUT_DURATION / 1000} seconds.`);
+ } else {
+ setError(`Incorrect email or password. ${MAX_ATTEMPTS - newAttempts} attempt(s) remaining.`);
+ }
+
+ // Clear password field
+ setPassword('');
+ }
+ };
+
+ return (
+
+
+
+
+
+ Welcome to Joyce Apartelle
+
+ Sign in to access your property management dashboard
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/Payments.tsx b/components/Payments.tsx
new file mode 100644
index 00000000..47d87fdc
--- /dev/null
+++ b/components/Payments.tsx
@@ -0,0 +1,201 @@
+import { useState } from 'react';
+import { Card, CardContent } from './ui/card';
+import { Button } from './ui/button';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
+import { Input } from './ui/input';
+import { Label } from './ui/label';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
+import { Plus, DollarSign } from 'lucide-react';
+import { usePropertyData } from './PropertyDataContext';
+import { Badge } from './ui/badge';
+
+export function Payments() {
+ const { payments, tenants, addPayment } = usePropertyData();
+ const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
+ const [formData, setFormData] = useState({
+ tenantId: '',
+ amount: '',
+ date: new Date().toISOString().split('T')[0],
+ method: 'bank-transfer',
+ status: 'paid',
+ });
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+
+ addPayment({
+ ...formData,
+ amount: parseFloat(formData.amount),
+ });
+
+ setIsAddDialogOpen(false);
+ setFormData({
+ tenantId: '',
+ amount: '',
+ date: new Date().toISOString().split('T')[0],
+ method: 'bank-transfer',
+ status: 'paid',
+ });
+ };
+
+ const getTenantName = (tenantId: string) => {
+ const tenant = tenants.find(t => t.id === tenantId);
+ return tenant?.name || 'Unknown Tenant';
+ };
+
+ return (
+
+
+
+
Payments
+
Track rent payments and transactions
+
+
+
+
+ {tenants.length === 0 ? (
+
+
+
+ No tenants available
+ Add tenants to start recording payments
+
+
+ ) : (
+
+
+ {payments.length === 0 ? (
+
+
No payments recorded
+
Start tracking rent payments
+
+ ) : (
+
+
+
+
+ Tenant
+ Amount
+ Date
+ Method
+ Status
+
+
+
+ {payments.map((payment) => (
+
+ {getTenantName(payment.tenantId)}
+ ₱{payment.amount.toLocaleString()}
+ {payment.date}
+ {payment.method.replace('-', ' ')}
+
+
+ {payment.status}
+
+
+
+ ))}
+
+
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/components/PropertyDataContext.tsx b/components/PropertyDataContext.tsx
new file mode 100644
index 00000000..83b9f157
--- /dev/null
+++ b/components/PropertyDataContext.tsx
@@ -0,0 +1,276 @@
+import { createContext, useContext, useState, ReactNode } from 'react';
+
+interface Property {
+ id: string;
+ address: string;
+ type: string;
+ bedrooms: number;
+ bathrooms: number;
+ rent: number;
+ status: string;
+}
+
+interface Tenant {
+ id: string;
+ name: string;
+ email: string;
+ phone: string;
+}
+
+interface Lease {
+ id: string;
+ propertyId: string;
+ tenantId: string;
+ startDate: string;
+ endDate: string;
+ monthlyRent: number;
+ deposit: number;
+ status: string;
+}
+
+interface Payment {
+ id: string;
+ tenantId: string;
+ amount: number;
+ date: string;
+ method: string;
+ status: string;
+}
+
+interface MaintenanceRequest {
+ id: string;
+ propertyId: string;
+ title: string;
+ description: string;
+ priority: string;
+ status: string;
+ date: string;
+}
+
+interface PropertyDataContextType {
+ properties: Property[];
+ tenants: Tenant[];
+ leases: Lease[];
+ payments: Payment[];
+ maintenanceRequests: MaintenanceRequest[];
+ addProperty: (property: Omit) => void;
+ updateProperty: (id: string, property: Partial) => void;
+ addTenant: (tenant: Omit) => void;
+ updateTenant: (id: string, tenant: Partial) => void;
+ addLease: (lease: Omit) => void;
+ addPayment: (payment: Omit) => void;
+ addMaintenanceRequest: (request: Omit) => void;
+ updateMaintenanceRequest: (id: string, request: Partial) => void;
+}
+
+const PropertyDataContext = createContext(undefined);
+
+export function PropertyDataProvider({ children }: { children: ReactNode }) {
+ const [properties, setProperties] = useState([
+ {
+ id: '1',
+ address: '123 Main St, Apt 4B',
+ type: 'Apartment',
+ bedrooms: 2,
+ bathrooms: 1,
+ rent: 1500,
+ status: 'occupied',
+ },
+ {
+ id: '2',
+ address: '456 Oak Avenue',
+ type: 'House',
+ bedrooms: 3,
+ bathrooms: 2.5,
+ rent: 2200,
+ status: 'available',
+ },
+ {
+ id: '3',
+ address: '789 Pine Street, Unit 12',
+ type: 'Condo',
+ bedrooms: 1,
+ bathrooms: 1,
+ rent: 1200,
+ status: 'occupied',
+ },
+ ]);
+
+ const [tenants, setTenants] = useState([
+ {
+ id: '1',
+ name: 'John Smith',
+ email: 'john.smith@email.com',
+ phone: '(555) 123-4567',
+ },
+ {
+ id: '2',
+ name: 'Sarah Johnson',
+ email: 'sarah.j@email.com',
+ phone: '(555) 234-5678',
+ },
+ ]);
+
+ const [leases, setLeases] = useState([
+ {
+ id: '1',
+ propertyId: '1',
+ tenantId: '1',
+ startDate: '2024-01-01',
+ endDate: '2024-12-31',
+ monthlyRent: 1500,
+ deposit: 3000,
+ status: 'active',
+ },
+ {
+ id: '2',
+ propertyId: '3',
+ tenantId: '2',
+ startDate: '2024-06-01',
+ endDate: '2025-05-31',
+ monthlyRent: 1200,
+ deposit: 2400,
+ status: 'active',
+ },
+ ]);
+
+ const [payments, setPayments] = useState([
+ {
+ id: '1',
+ tenantId: '1',
+ amount: 1500,
+ date: '2024-10-01',
+ method: 'bank-transfer',
+ status: 'paid',
+ },
+ {
+ id: '2',
+ tenantId: '2',
+ amount: 1200,
+ date: '2024-10-01',
+ method: 'check',
+ status: 'paid',
+ },
+ {
+ id: '3',
+ tenantId: '1',
+ amount: 1500,
+ date: '2024-09-01',
+ method: 'bank-transfer',
+ status: 'paid',
+ },
+ {
+ id: '4',
+ tenantId: '2',
+ amount: 1200,
+ date: '2024-09-03',
+ method: 'bank-transfer',
+ status: 'paid',
+ },
+ {
+ id: '5',
+ tenantId: '1',
+ amount: 1500,
+ date: '2024-11-01',
+ method: 'bank-transfer',
+ status: 'pending',
+ },
+ ]);
+
+ const [maintenanceRequests, setMaintenanceRequests] = useState([
+ {
+ id: '1',
+ propertyId: '1',
+ title: 'Leaking faucet in kitchen',
+ description: 'Kitchen sink faucet is dripping constantly',
+ priority: 'medium',
+ status: 'pending',
+ date: '2024-10-05',
+ },
+ {
+ id: '2',
+ propertyId: '3',
+ title: 'Heating not working',
+ description: 'Central heating system not turning on',
+ priority: 'urgent',
+ status: 'in-progress',
+ date: '2024-10-07',
+ },
+ {
+ id: '3',
+ propertyId: '1',
+ title: 'Light fixture replacement',
+ description: 'Bedroom light fixture needs replacement',
+ priority: 'low',
+ status: 'completed',
+ date: '2024-09-28',
+ },
+ ]);
+
+ const addProperty = (property: Omit) => {
+ const newProperty = { ...property, id: Date.now().toString() };
+ setProperties([...properties, newProperty]);
+ };
+
+ const updateProperty = (id: string, updates: Partial) => {
+ setProperties(properties.map(p => p.id === id ? { ...p, ...updates } : p));
+ };
+
+ const addTenant = (tenant: Omit) => {
+ const newTenant = { ...tenant, id: Date.now().toString() };
+ setTenants([...tenants, newTenant]);
+ };
+
+ const updateTenant = (id: string, updates: Partial) => {
+ setTenants(tenants.map(t => t.id === id ? { ...t, ...updates } : t));
+ };
+
+ const addLease = (lease: Omit) => {
+ const newLease = { ...lease, id: Date.now().toString() };
+ setLeases([...leases, newLease]);
+ };
+
+ const addPayment = (payment: Omit) => {
+ const newPayment = { ...payment, id: Date.now().toString() };
+ setPayments([newPayment, ...payments]);
+ };
+
+ const addMaintenanceRequest = (request: Omit) => {
+ const newRequest = { ...request, id: Date.now().toString() };
+ setMaintenanceRequests([newRequest, ...maintenanceRequests]);
+ };
+
+ const updateMaintenanceRequest = (id: string, updates: Partial) => {
+ setMaintenanceRequests(maintenanceRequests.map(r => r.id === id ? { ...r, ...updates } : r));
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function usePropertyData() {
+ const context = useContext(PropertyDataContext);
+ if (context === undefined) {
+ throw new Error('usePropertyData must be used within a PropertyDataProvider');
+ }
+ return context;
+}
diff --git a/components/RoomReservations.tsx b/components/RoomReservations.tsx
new file mode 100644
index 00000000..2fbf7e1c
--- /dev/null
+++ b/components/RoomReservations.tsx
@@ -0,0 +1,660 @@
+import { useState } from 'react';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
+import { Button } from './ui/button';
+import { Input } from './ui/input';
+import { Label } from './ui/label';
+import { Checkbox } from './ui/checkbox';
+import { Calendar } from './ui/calendar';
+import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
+import { Badge } from './ui/badge';
+import {
+ DoorOpen,
+ Calendar as CalendarIcon,
+ User,
+ ArrowLeft,
+ Wifi,
+ Tv,
+ Coffee,
+ Wind,
+ UtensilsCrossed,
+ Waves
+} from 'lucide-react';
+import { format } from 'date-fns';
+
+type RoomStatus = 'vacant' | 'reserved' | 'occupied';
+
+interface Room {
+ id: string;
+ number: string;
+ type: string;
+ status: RoomStatus;
+ price: number;
+ capacity: number;
+}
+
+interface Inclusion {
+ id: string;
+ name: string;
+ icon: React.ReactNode;
+ price: number;
+}
+
+interface GuestProfile {
+ firstName: string;
+ lastName: string;
+ email: string;
+ phone: string;
+ address: string;
+ city: string;
+ state: string;
+ zipCode: string;
+ idType: string;
+ idNumber: string;
+}
+
+type ViewState = 'rooms' | 'details' | 'profile';
+
+const SAMPLE_ROOMS: Room[] = [
+ { id: '1', number: '101', type: 'Standard Single', status: 'vacant', price: 2000, capacity: 1 },
+ { id: '2', number: '102', type: 'Standard Double', status: 'vacant', price: 3000, capacity: 2 },
+ { id: '3', number: '103', type: 'Deluxe Single', status: 'reserved', price: 2500, capacity: 1 },
+ { id: '4', number: '104', type: 'Deluxe Double', status: 'occupied', price: 3500, capacity: 2 },
+ { id: '5', number: '201', type: 'Standard Single', status: 'vacant', price: 2000, capacity: 1 },
+ { id: '6', number: '202', type: 'Suite', status: 'vacant', price: 5000, capacity: 4 },
+ { id: '7', number: '203', type: 'Standard Double', status: 'reserved', price: 3000, capacity: 2 },
+ { id: '8', number: '204', type: 'Deluxe Double', status: 'occupied', price: 3500, capacity: 2 },
+ { id: '9', number: '301', type: 'Standard Single', status: 'vacant', price: 2000, capacity: 1 },
+ { id: '10', number: '302', type: 'Suite', status: 'reserved', price: 5000, capacity: 4 },
+ { id: '11', number: '303', type: 'Deluxe Single', status: 'vacant', price: 2500, capacity: 1 },
+ { id: '12', number: '304', type: 'Standard Double', status: 'vacant', price: 3000, capacity: 2 },
+];
+
+const AVAILABLE_INCLUSIONS: Inclusion[] = [
+ { id: 'wifi', name: 'High-Speed WiFi', icon: , price: 250 },
+ { id: 'tv', name: 'Premium TV Channels', icon: , price: 350 },
+ { id: 'breakfast', name: 'Breakfast', icon: , price: 500 },
+ { id: 'ac', name: 'Air Conditioning', icon: , price: 300 },
+ { id: 'minibar', name: 'Mini Bar', icon: , price: 600 },
+ { id: 'pool', name: 'Pool Access', icon: , price: 400 },
+];
+
+export function RoomReservations() {
+ const [currentView, setCurrentView] = useState('rooms');
+ const [selectedRoom, setSelectedRoom] = useState(null);
+ const [checkInDate, setCheckInDate] = useState();
+ const [checkOutDate, setCheckOutDate] = useState();
+ const [selectedInclusions, setSelectedInclusions] = useState([]);
+ const [guestProfile, setGuestProfile] = useState({
+ firstName: '',
+ lastName: '',
+ email: '',
+ phone: '',
+ address: '',
+ city: '',
+ state: '',
+ zipCode: '',
+ idType: '',
+ idNumber: '',
+ });
+
+ const getStatusColor = (status: RoomStatus) => {
+ switch (status) {
+ case 'vacant':
+ return 'bg-green-50 border-green-200 hover:border-green-300 hover:shadow-md';
+ case 'reserved':
+ return 'bg-yellow-50 border-yellow-200 hover:border-yellow-300 hover:shadow-md';
+ case 'occupied':
+ return 'bg-rose-50 border-rose-200 cursor-not-allowed opacity-75';
+ default:
+ return 'bg-gray-50 border-gray-200';
+ }
+ };
+
+ const getStatusBadgeColor = (status: RoomStatus) => {
+ switch (status) {
+ case 'vacant':
+ return 'bg-green-200 text-green-800';
+ case 'reserved':
+ return 'bg-yellow-200 text-yellow-800';
+ case 'occupied':
+ return 'bg-rose-200 text-rose-800';
+ default:
+ return 'bg-gray-200 text-gray-800';
+ }
+ };
+
+ const handleRoomSelect = (room: Room) => {
+ if (room.status === 'occupied') return;
+ setSelectedRoom(room);
+ setCurrentView('details');
+ };
+
+ const handleInclusionToggle = (inclusionId: string) => {
+ setSelectedInclusions(prev =>
+ prev.includes(inclusionId)
+ ? prev.filter(id => id !== inclusionId)
+ : [...prev, inclusionId]
+ );
+ };
+
+ const handleCancel = () => {
+ if (currentView === 'details') {
+ setCurrentView('rooms');
+ setSelectedRoom(null);
+ setCheckInDate(undefined);
+ setCheckOutDate(undefined);
+ setSelectedInclusions([]);
+ } else if (currentView === 'profile') {
+ setCurrentView('details');
+ }
+ };
+
+ const handleAvail = () => {
+ if (currentView === 'details') {
+ setCurrentView('profile');
+ } else if (currentView === 'profile') {
+ // Handle final reservation submission
+ alert('Reservation completed successfully!');
+ // Reset to rooms view
+ setCurrentView('rooms');
+ setSelectedRoom(null);
+ setCheckInDate(undefined);
+ setCheckOutDate(undefined);
+ setSelectedInclusions([]);
+ setGuestProfile({
+ firstName: '',
+ lastName: '',
+ email: '',
+ phone: '',
+ address: '',
+ city: '',
+ state: '',
+ zipCode: '',
+ idType: '',
+ idNumber: '',
+ });
+ }
+ };
+
+ const calculateTotal = () => {
+ if (!selectedRoom) return 0;
+ const inclusionsTotal = selectedInclusions.reduce((sum, id) => {
+ const inclusion = AVAILABLE_INCLUSIONS.find(inc => inc.id === id);
+ return sum + (inclusion?.price || 0);
+ }, 0);
+ return selectedRoom.price + inclusionsTotal;
+ };
+
+ const handleGuestProfileChange = (field: keyof GuestProfile, value: string) => {
+ setGuestProfile(prev => ({ ...prev, [field]: value }));
+ };
+
+ // Rooms List View
+ if (currentView === 'rooms') {
+ return (
+
+
+
+
Room Reservations
+
Select a room to make a reservation
+
+
+
+
+
+ {SAMPLE_ROOMS.map(room => (
+
handleRoomSelect(room)}
+ >
+
+
+
+ Room {room.number}
+ {room.type}
+
+
+ {room.status}
+
+
+
+
+
+
+
+ Up to {room.capacity}
+
+
₱{room.price}/night
+
+
+
+ ))}
+
+
+ );
+ }
+
+ // Room Details View
+ if (currentView === 'details' && selectedRoom) {
+ return (
+
+
+
+
+
+
+
+
+
+ Room {selectedRoom.number}
+ {selectedRoom.type}
+
+
+ {selectedRoom.status}
+
+
+
+
+
+
+
+
+ Capacity
+
+
Up to {selectedRoom.capacity} guest(s)
+
+
+ Base Price
+ ₱{selectedRoom.price}/night
+
+
+
+
+
+
+
+ Select Check-in and Check-out Dates
+
+
+
+
+
+
+
+
+
+
+ date < new Date()}
+ />
+
+
+
+
+
+
+
+
+
+
+
+ date < (checkInDate || new Date())}
+ />
+
+
+
+
+
+
+
+
+
+ Room Inclusions
+ Select additional amenities for your stay
+
+
+
+ {AVAILABLE_INCLUSIONS.map(inclusion => (
+
+
+ handleInclusionToggle(inclusion.id)}
+ />
+
+
+
+₱{inclusion.price}/night
+
+ ))}
+
+
+
+
+
+
+
+
+ Booking Summary
+
+
+
+
+ Room
+ {selectedRoom.number}
+
+
+ Type
+ {selectedRoom.type}
+
+
+ Base Price
+ ₱{selectedRoom.price}/night
+
+ {selectedInclusions.length > 0 && (
+ <>
+
+
Inclusions:
+ {selectedInclusions.map(id => {
+ const inclusion = AVAILABLE_INCLUSIONS.find(inc => inc.id === id);
+ return (
+
+ {inclusion?.name}
+ +₱{inclusion?.price}/night
+
+ );
+ })}
+
+ >
+ )}
+
+
+
+ Total per night
+ ₱{calculateTotal()}
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ // Guest Profile View
+ if (currentView === 'profile' && selectedRoom) {
+ return (
+
+
+
+
+
+
+
+ Guest Profile
+ Please provide your information to complete the reservation
+
+
+
+
+
+
+
+
+
+
+ Reservation Summary
+
+
+
+
+ Room
+ {selectedRoom.number} - {selectedRoom.type}
+
+
+ Check-in
+ {checkInDate ? format(checkInDate, 'PP') : 'Not set'}
+
+
+ Check-out
+ {checkOutDate ? format(checkOutDate, 'PP') : 'Not set'}
+
+
+ Duration
+
+ {checkInDate && checkOutDate
+ ? `${Math.ceil((checkOutDate.getTime() - checkInDate.getTime()) / (1000 * 60 * 60 * 24))} night(s)`
+ : 'N/A'}
+
+
+ {selectedInclusions.length > 0 && (
+ <>
+
+
Inclusions:
+ {selectedInclusions.map(id => {
+ const inclusion = AVAILABLE_INCLUSIONS.find(inc => inc.id === id);
+ return (
+
+ {inclusion?.icon}
+ {inclusion?.name}
+
+ );
+ })}
+
+ >
+ )}
+
+
+
+ Rate per night
+ ₱{calculateTotal()}
+
+
+ Total Amount
+
+ ₱{checkInDate && checkOutDate
+ ? calculateTotal() * Math.ceil((checkOutDate.getTime() - checkInDate.getTime()) / (1000 * 60 * 60 * 24))
+ : calculateTotal()}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return null;
+}
diff --git a/components/figma/ImageWithFallback.tsx b/components/figma/ImageWithFallback.tsx
new file mode 100644
index 00000000..0e26139b
--- /dev/null
+++ b/components/figma/ImageWithFallback.tsx
@@ -0,0 +1,27 @@
+import React, { useState } from 'react'
+
+const ERROR_IMG_SRC =
+ 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODgiIGhlaWdodD0iODgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBvcGFjaXR5PSIuMyIgZmlsbD0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIzLjciPjxyZWN0IHg9IjE2IiB5PSIxNiIgd2lkdGg9IjU2IiBoZWlnaHQ9IjU2IiByeD0iNiIvPjxwYXRoIGQ9Im0xNiA1OCAxNi0xOCAzMiAzMiIvPjxjaXJjbGUgY3g9IjUzIiBjeT0iMzUiIHI9IjciLz48L3N2Zz4KCg=='
+
+export function ImageWithFallback(props: React.ImgHTMLAttributes) {
+ const [didError, setDidError] = useState(false)
+
+ const handleError = () => {
+ setDidError(true)
+ }
+
+ const { src, alt, style, className, ...rest } = props
+
+ return didError ? (
+
+
+

+
+
+ ) : (
+
+ )
+}
diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx
new file mode 100644
index 00000000..aa2c37b2
--- /dev/null
+++ b/components/ui/accordion.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import * as React from "react";
+import * as AccordionPrimitive from "@radix-ui/react-accordion@1.2.3";
+import { ChevronDownIcon } from "lucide-react@0.487.0";
+
+import { cn } from "./utils";
+
+function Accordion({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function AccordionItem({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AccordionTrigger({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ svg]:rotate-180",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+ );
+}
+
+function AccordionContent({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx
new file mode 100644
index 00000000..68f3605c
--- /dev/null
+++ b/components/ui/alert-dialog.tsx
@@ -0,0 +1,157 @@
+"use client";
+
+import * as React from "react";
+import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog@1.1.6";
+
+import { cn } from "./utils";
+import { buttonVariants } from "./button";
+
+function AlertDialog({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function AlertDialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ );
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogAction({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogCancel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+};
diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx
new file mode 100644
index 00000000..856b94db
--- /dev/null
+++ b/components/ui/alert.tsx
@@ -0,0 +1,66 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority@0.7.1";
+
+import { cn } from "./utils";
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
+ {
+ variants: {
+ variant: {
+ default: "bg-card text-card-foreground",
+ destructive:
+ "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+function Alert({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ );
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export { Alert, AlertTitle, AlertDescription };
diff --git a/components/ui/aspect-ratio.tsx b/components/ui/aspect-ratio.tsx
new file mode 100644
index 00000000..2a2f462e
--- /dev/null
+++ b/components/ui/aspect-ratio.tsx
@@ -0,0 +1,11 @@
+"use client";
+
+import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio@1.1.2";
+
+function AspectRatio({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+export { AspectRatio };
diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx
new file mode 100644
index 00000000..589b1665
--- /dev/null
+++ b/components/ui/avatar.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import * as React from "react";
+import * as AvatarPrimitive from "@radix-ui/react-avatar@1.1.3";
+
+import { cn } from "./utils";
+
+function Avatar({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AvatarImage({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Avatar, AvatarImage, AvatarFallback };
diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx
new file mode 100644
index 00000000..3f8eff87
--- /dev/null
+++ b/components/ui/badge.tsx
@@ -0,0 +1,46 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot@1.1.2";
+import { cva, type VariantProps } from "class-variance-authority@0.7.1";
+
+import { cn } from "./utils";
+
+const badgeVariants = cva(
+ "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ destructive:
+ "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+function Badge({
+ className,
+ variant,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"span"> &
+ VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "span";
+
+ return (
+
+ );
+}
+
+export { Badge, badgeVariants };
diff --git a/components/ui/breadcrumb.tsx b/components/ui/breadcrumb.tsx
new file mode 100644
index 00000000..d2adf987
--- /dev/null
+++ b/components/ui/breadcrumb.tsx
@@ -0,0 +1,109 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot@1.1.2";
+import { ChevronRight, MoreHorizontal } from "lucide-react@0.487.0";
+
+import { cn } from "./utils";
+
+function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
+ return ;
+}
+
+function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
+ return (
+
+ );
+}
+
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+
+ );
+}
+
+function BreadcrumbLink({
+ asChild,
+ className,
+ ...props
+}: React.ComponentProps<"a"> & {
+ asChild?: boolean;
+}) {
+ const Comp = asChild ? Slot : "a";
+
+ return (
+
+ );
+}
+
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+function BreadcrumbSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"li">) {
+ return (
+ svg]:size-3.5", className)}
+ {...props}
+ >
+ {children ?? }
+
+ );
+}
+
+function BreadcrumbEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+
+ More
+
+ );
+}
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+};
diff --git a/components/ui/button.tsx b/components/ui/button.tsx
new file mode 100644
index 00000000..308ad2bd
--- /dev/null
+++ b/components/ui/button.tsx
@@ -0,0 +1,58 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot@1.1.2";
+import { cva, type VariantProps } from "class-variance-authority@0.7.1";
+
+import { cn } from "./utils";
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "border bg-background text-foreground hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost:
+ "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+ icon: "size-9 rounded-md",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> &
+ VariantProps & {
+ asChild?: boolean;
+ }) {
+ const Comp = asChild ? Slot : "button";
+
+ return (
+
+ );
+}
+
+export { Button, buttonVariants };
diff --git a/components/ui/calendar.tsx b/components/ui/calendar.tsx
new file mode 100644
index 00000000..b30236fa
--- /dev/null
+++ b/components/ui/calendar.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import * as React from "react";
+import { ChevronLeft, ChevronRight } from "lucide-react@0.487.0";
+import { DayPicker } from "react-day-picker@8.10.1";
+
+import { cn } from "./utils";
+import { buttonVariants } from "./button";
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ ...props
+}: React.ComponentProps) {
+ return (
+ .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
+ : "[&:has([aria-selected])]:rounded-md",
+ ),
+ day: cn(
+ buttonVariants({ variant: "ghost" }),
+ "size-8 p-0 font-normal aria-selected:opacity-100",
+ ),
+ day_range_start:
+ "day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground",
+ day_range_end:
+ "day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground",
+ day_selected:
+ "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
+ day_today: "bg-accent text-accent-foreground",
+ day_outside:
+ "day-outside text-muted-foreground aria-selected:text-muted-foreground",
+ day_disabled: "text-muted-foreground opacity-50",
+ day_range_middle:
+ "aria-selected:bg-accent aria-selected:text-accent-foreground",
+ day_hidden: "invisible",
+ ...classNames,
+ }}
+ components={{
+ IconLeft: ({ className, ...props }) => (
+
+ ),
+ IconRight: ({ className, ...props }) => (
+
+ ),
+ }}
+ {...props}
+ />
+ );
+}
+
+export { Calendar };
diff --git a/components/ui/card.tsx b/components/ui/card.tsx
new file mode 100644
index 00000000..5f9d58a5
--- /dev/null
+++ b/components/ui/card.tsx
@@ -0,0 +1,92 @@
+import * as React from "react";
+
+import { cn } from "./utils";
+
+function Card({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+};
diff --git a/components/ui/carousel.tsx b/components/ui/carousel.tsx
new file mode 100644
index 00000000..2752b36c
--- /dev/null
+++ b/components/ui/carousel.tsx
@@ -0,0 +1,241 @@
+"use client";
+
+import * as React from "react";
+import useEmblaCarousel, {
+ type UseEmblaCarouselType,
+} from "embla-carousel-react@8.6.0";
+import { ArrowLeft, ArrowRight } from "lucide-react@0.487.0";
+
+import { cn } from "./utils";
+import { Button } from "./button";
+
+type CarouselApi = UseEmblaCarouselType[1];
+type UseCarouselParameters = Parameters;
+type CarouselOptions = UseCarouselParameters[0];
+type CarouselPlugin = UseCarouselParameters[1];
+
+type CarouselProps = {
+ opts?: CarouselOptions;
+ plugins?: CarouselPlugin;
+ orientation?: "horizontal" | "vertical";
+ setApi?: (api: CarouselApi) => void;
+};
+
+type CarouselContextProps = {
+ carouselRef: ReturnType[0];
+ api: ReturnType[1];
+ scrollPrev: () => void;
+ scrollNext: () => void;
+ canScrollPrev: boolean;
+ canScrollNext: boolean;
+} & CarouselProps;
+
+const CarouselContext = React.createContext(null);
+
+function useCarousel() {
+ const context = React.useContext(CarouselContext);
+
+ if (!context) {
+ throw new Error("useCarousel must be used within a ");
+ }
+
+ return context;
+}
+
+function Carousel({
+ orientation = "horizontal",
+ opts,
+ setApi,
+ plugins,
+ className,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & CarouselProps) {
+ const [carouselRef, api] = useEmblaCarousel(
+ {
+ ...opts,
+ axis: orientation === "horizontal" ? "x" : "y",
+ },
+ plugins,
+ );
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false);
+ const [canScrollNext, setCanScrollNext] = React.useState(false);
+
+ const onSelect = React.useCallback((api: CarouselApi) => {
+ if (!api) return;
+ setCanScrollPrev(api.canScrollPrev());
+ setCanScrollNext(api.canScrollNext());
+ }, []);
+
+ const scrollPrev = React.useCallback(() => {
+ api?.scrollPrev();
+ }, [api]);
+
+ const scrollNext = React.useCallback(() => {
+ api?.scrollNext();
+ }, [api]);
+
+ const handleKeyDown = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === "ArrowLeft") {
+ event.preventDefault();
+ scrollPrev();
+ } else if (event.key === "ArrowRight") {
+ event.preventDefault();
+ scrollNext();
+ }
+ },
+ [scrollPrev, scrollNext],
+ );
+
+ React.useEffect(() => {
+ if (!api || !setApi) return;
+ setApi(api);
+ }, [api, setApi]);
+
+ React.useEffect(() => {
+ if (!api) return;
+ onSelect(api);
+ api.on("reInit", onSelect);
+ api.on("select", onSelect);
+
+ return () => {
+ api?.off("select", onSelect);
+ };
+ }, [api, onSelect]);
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
+ const { carouselRef, orientation } = useCarousel();
+
+ return (
+
+ );
+}
+
+function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
+ const { orientation } = useCarousel();
+
+ return (
+
+ );
+}
+
+function CarouselPrevious({
+ className,
+ variant = "outline",
+ size = "icon",
+ ...props
+}: React.ComponentProps) {
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel();
+
+ return (
+
+ );
+}
+
+function CarouselNext({
+ className,
+ variant = "outline",
+ size = "icon",
+ ...props
+}: React.ComponentProps) {
+ const { orientation, scrollNext, canScrollNext } = useCarousel();
+
+ return (
+
+ );
+}
+
+export {
+ type CarouselApi,
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselPrevious,
+ CarouselNext,
+};
diff --git a/components/ui/chart.tsx b/components/ui/chart.tsx
new file mode 100644
index 00000000..d558e197
--- /dev/null
+++ b/components/ui/chart.tsx
@@ -0,0 +1,353 @@
+"use client";
+
+import * as React from "react";
+import * as RechartsPrimitive from "recharts@2.15.2";
+
+import { cn } from "./utils";
+
+// Format: { THEME_NAME: CSS_SELECTOR }
+const THEMES = { light: "", dark: ".dark" } as const;
+
+export type ChartConfig = {
+ [k in string]: {
+ label?: React.ReactNode;
+ icon?: React.ComponentType;
+ } & (
+ | { color?: string; theme?: never }
+ | { color?: never; theme: Record }
+ );
+};
+
+type ChartContextProps = {
+ config: ChartConfig;
+};
+
+const ChartContext = React.createContext(null);
+
+function useChart() {
+ const context = React.useContext(ChartContext);
+
+ if (!context) {
+ throw new Error("useChart must be used within a ");
+ }
+
+ return context;
+}
+
+function ChartContainer({
+ id,
+ className,
+ children,
+ config,
+ ...props
+}: React.ComponentProps<"div"> & {
+ config: ChartConfig;
+ children: React.ComponentProps<
+ typeof RechartsPrimitive.ResponsiveContainer
+ >["children"];
+}) {
+ const uniqueId = React.useId();
+ const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
+
+ return (
+
+
+
+
+ {children}
+
+
+
+ );
+}
+
+const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
+ const colorConfig = Object.entries(config).filter(
+ ([, config]) => config.theme || config.color,
+ );
+
+ if (!colorConfig.length) {
+ return null;
+ }
+
+ return (
+