From b092d42cee8ff602c81b769a6820ef76c5e615e0 Mon Sep 17 00:00:00 2001 From: Zyrhone Castillo Date: Fri, 10 Oct 2025 14:17:12 +0800 Subject: [PATCH 1/2] Add files via upload --- App.tsx | 75 + Attributions.md | 3 + components/Dashboard.tsx | 146 ++ components/Login.tsx | 210 ++ components/Payments.tsx | 201 ++ components/PropertyDataContext.tsx | 276 +++ components/RoomReservations.tsx | 660 ++++++ components/figma/ImageWithFallback.tsx | 27 + components/ui/accordion.tsx | 66 + components/ui/alert-dialog.tsx | 157 ++ components/ui/alert.tsx | 66 + components/ui/aspect-ratio.tsx | 11 + components/ui/avatar.tsx | 53 + components/ui/badge.tsx | 46 + components/ui/breadcrumb.tsx | 109 + components/ui/button.tsx | 58 + components/ui/calendar.tsx | 75 + components/ui/card.tsx | 92 + components/ui/carousel.tsx | 241 ++ components/ui/chart.tsx | 353 +++ components/ui/checkbox.tsx | 32 + components/ui/collapsible.tsx | 33 + components/ui/command.tsx | 177 ++ components/ui/context-menu.tsx | 252 ++ components/ui/dialog.tsx | 135 ++ components/ui/drawer.tsx | 132 ++ components/ui/dropdown-menu.tsx | 257 ++ components/ui/form.tsx | 168 ++ components/ui/hover-card.tsx | 44 + components/ui/input-otp.tsx | 77 + components/ui/input.tsx | 21 + components/ui/label.tsx | 24 + components/ui/menubar.tsx | 276 +++ components/ui/navigation-menu.tsx | 168 ++ components/ui/pagination.tsx | 127 + components/ui/popover.tsx | 48 + components/ui/progress.tsx | 31 + components/ui/radio-group.tsx | 45 + components/ui/resizable.tsx | 56 + components/ui/scroll-area.tsx | 58 + components/ui/select.tsx | 189 ++ components/ui/separator.tsx | 28 + components/ui/sheet.tsx | 139 ++ components/ui/sidebar.tsx | 726 ++++++ components/ui/skeleton.tsx | 13 + components/ui/slider.tsx | 63 + components/ui/sonner.tsx | 25 + components/ui/switch.tsx | 31 + components/ui/table.tsx | 116 + components/ui/tabs.tsx | 66 + components/ui/textarea.tsx | 18 + components/ui/toggle-group.tsx | 73 + components/ui/toggle.tsx | 47 + components/ui/tooltip.tsx | 61 + components/ui/use-mobile.ts | 21 + components/ui/utils.ts | 6 + guidelines/Guidelines.md | 61 + index.css | 2956 ++++++++++++++++++++++++ main.tsx | 7 + 59 files changed, 9732 insertions(+) create mode 100644 App.tsx create mode 100644 Attributions.md create mode 100644 components/Dashboard.tsx create mode 100644 components/Login.tsx create mode 100644 components/Payments.tsx create mode 100644 components/PropertyDataContext.tsx create mode 100644 components/RoomReservations.tsx create mode 100644 components/figma/ImageWithFallback.tsx create mode 100644 components/ui/accordion.tsx create mode 100644 components/ui/alert-dialog.tsx create mode 100644 components/ui/alert.tsx create mode 100644 components/ui/aspect-ratio.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/breadcrumb.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/calendar.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/carousel.tsx create mode 100644 components/ui/chart.tsx create mode 100644 components/ui/checkbox.tsx create mode 100644 components/ui/collapsible.tsx create mode 100644 components/ui/command.tsx create mode 100644 components/ui/context-menu.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/drawer.tsx create mode 100644 components/ui/dropdown-menu.tsx create mode 100644 components/ui/form.tsx create mode 100644 components/ui/hover-card.tsx create mode 100644 components/ui/input-otp.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/menubar.tsx create mode 100644 components/ui/navigation-menu.tsx create mode 100644 components/ui/pagination.tsx create mode 100644 components/ui/popover.tsx create mode 100644 components/ui/progress.tsx create mode 100644 components/ui/radio-group.tsx create mode 100644 components/ui/resizable.tsx create mode 100644 components/ui/scroll-area.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/sheet.tsx create mode 100644 components/ui/sidebar.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/slider.tsx create mode 100644 components/ui/sonner.tsx create mode 100644 components/ui/switch.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 components/ui/toggle-group.tsx create mode 100644 components/ui/toggle.tsx create mode 100644 components/ui/tooltip.tsx create mode 100644 components/ui/use-mobile.ts create mode 100644 components/ui/utils.ts create mode 100644 guidelines/Guidelines.md create mode 100644 index.css create mode 100644 main.tsx 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 + +
+
+ + +
+
+ + setEmail(e.target.value)} + disabled={isLocked} + required + /> +
+ +
+ +
+ setPassword(e.target.value)} + disabled={isLocked} + required + className="pr-10" + /> + +
+
+ + {error && ( + + + + {isLocked ? ( + + Account locked. Please wait {remainingTime} second{remainingTime !== 1 ? 's' : ''} before trying again. + + ) : ( + error + )} + + + )} + + + +
+

Demo Credentials:

+

+ Email: admin@ja.com +

+

+ Password: password123 +

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

+
+ + + + + + + Record Payment + +
+
+ + +
+
+ + setFormData({ ...formData, amount: e.target.value })} + required + /> +
+
+ + setFormData({ ...formData, date: e.target.value })} + required + /> +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ + {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

+
+
+
+
+ Vacant +
+
+
+ Reserved +
+
+
+ Occupied +
+
+
+ +
+ {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 + + +
+
+

Personal Information

+
+
+ + handleGuestProfileChange('firstName', e.target.value)} + placeholder="John" + required + /> +
+
+ + handleGuestProfileChange('lastName', e.target.value)} + placeholder="Doe" + required + /> +
+
+ +
+
+ + handleGuestProfileChange('email', e.target.value)} + placeholder="john.doe@example.com" + required + /> +
+
+ + handleGuestProfileChange('phone', e.target.value)} + placeholder="+1 (555) 123-4567" + required + /> +
+
+
+ +
+

Address

+
+ + handleGuestProfileChange('address', e.target.value)} + placeholder="123 Main Street" + required + /> +
+ +
+
+ + handleGuestProfileChange('city', e.target.value)} + placeholder="New York" + required + /> +
+
+ + handleGuestProfileChange('state', e.target.value)} + placeholder="NY" + required + /> +
+
+ + handleGuestProfileChange('zipCode', e.target.value)} + placeholder="10001" + required + /> +
+
+
+ +
+

Identification

+
+
+ + handleGuestProfileChange('idType', e.target.value)} + placeholder="Driver's License, Passport, etc." + required + /> +
+
+ + handleGuestProfileChange('idNumber', e.target.value)} + placeholder="ID123456789" + required + /> +
+
+
+
+
+
+
+ +
+ + + 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 ? ( +
+
+ Error loading image +
+
+ ) : ( + {alt} + ) +} 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