Initial commit from remix

This commit is contained in:
gpt-engineer-app[bot]
2025-09-25 16:01:00 +00:00
commit 5ddc52658d
149 changed files with 32798 additions and 0 deletions

View File

@@ -0,0 +1,141 @@
import React, { useEffect, useState } from 'react';
import type { ApexOptions } from 'apexcharts';
// Chart module is imported dynamically inside the component to avoid SSR/hot-reload issues.
interface ApexChartProps {
type?: 'area' | 'line' | 'bar' | 'pie' | 'donut';
data?: number[];
labels?: string[];
title?: string;
height?: number;
color?: string;
}
const ApexChart: React.FC<ApexChartProps> = ({
type = 'area',
data = [],
labels = [],
title = 'Chart',
height = 350,
color = '#F84525',
}) => {
const isClient = typeof window !== 'undefined';
const [Chart, setChart] = useState<any>(null);
useEffect(() => {
if (isClient) {
import('react-apexcharts')
.then((m) => setChart(m.default))
.catch((e) => console.error('Failed to load react-apexcharts', e));
}
}, [isClient]);
const safeData = Array.isArray(data) ? data.map((n) => (Number.isFinite(Number(n)) ? Number(n) : 0)) : [];
const isDarkMode = typeof document !== 'undefined' && document.documentElement.classList.contains('dark');
// Show loading/no data state if chart not ready or no data
if (!isClient || !Chart || safeData.length === 0) {
return (
<div className="card-enhanced">
<div className="card-body p-4">
<h5 className="card-title mb-3">{title || 'Chart'}</h5>
<div className="text-sm text-muted">
{!Chart ? 'Cargando gráfico...' : 'No hay datos disponibles.'}
</div>
</div>
</div>
);
}
// Ensure options are valid and chart configuration is safe
const options: ApexOptions = {
chart: {
type: type || 'area',
height: height || 350,
zoom: { enabled: false },
toolbar: { show: false },
foreColor: isDarkMode ? '#91989e' : '#433c3a',
animations: { enabled: false } // Disable animations for stability
},
colors: [color || '#F84525'],
dataLabels: { enabled: false },
stroke: {
curve: 'smooth',
width: 3,
},
fill: type === 'area'
? {
type: 'gradient',
gradient: {
shadeIntensity: 1,
type: 'vertical',
opacityFrom: 0.4,
opacityTo: 0,
stops: [0, 70, 97],
gradientToColors: ['#f7b733'],
},
}
: undefined,
markers: {
size: type === 'area' ? 3 : 0,
strokeWidth: 0,
hover: { sizeOffset: 2 },
colors: type === 'area' ? ['#FFFFFF'] : [color || '#F84525'],
},
xaxis: {
categories: labels && labels.length > 0 ? labels : ['Sin datos'],
axisBorder: { show: false },
axisTicks: { show: false },
labels: { style: { colors: isDarkMode ? '#91989e' : '#aaa' } },
},
yaxis: {
labels: { style: { colors: isDarkMode ? '#91989e' : '#aaa' } },
min: 0
},
grid: { borderColor: isDarkMode ? '#26292d' : '#eff2f7' },
legend: {
horizontalAlign: 'left',
labels: { colors: isDarkMode ? '#ffffff' : '#433c3a' },
},
title: {
text: title || 'Chart',
style: { color: isDarkMode ? '#ffffff' : '#433c3a' }
},
theme: { mode: isDarkMode ? 'dark' : 'light' },
noData: {
text: 'No hay datos disponibles',
align: 'center',
verticalAlign: 'middle',
style: {
color: isDarkMode ? '#ffffff' : '#433c3a',
fontSize: '14px'
}
}
};
// Ensure series data is valid
const series = [{
name: title || 'Data',
data: safeData && safeData.length > 0 ? safeData : [0]
}];
return (
<div className="card-enhanced">
<div className="card-body p-4">
<h5 className="card-title mb-3">{title || 'Chart'}</h5>
{Chart && (
<Chart
options={options}
series={series}
type={type || 'area'}
height={height || 350}
key={`chart-${title}-${safeData.length}`}
/>
)}
</div>
</div>
);
};
export default ApexChart;

View File

@@ -0,0 +1,318 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Calendar } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { CalendarIcon } from 'lucide-react';
import { format } from 'date-fns';
import { cn } from '@/lib/utils';
// CreateReservationDto validation schema
const bookingSchema = z.object({
establishmentId: z.string().min(1, 'Establishment ID is required'),
userId: z.string().min(1, 'User ID is required'),
type: z.enum(['hotel', 'restaurant', 'tour', 'activity'], {
required_error: 'Type is required',
}),
referenceId: z.string().optional(),
checkInDate: z.string().min(1, 'Check-in date is required'),
checkOutDate: z.string().optional(),
checkInTime: z.string().optional(),
guestsCount: z.number().min(1, 'At least 1 guest is required'),
specialRequests: z.string().optional(),
totalAmount: z.number().min(0, 'Total amount must be positive'),
});
type BookingFormData = z.infer<typeof bookingSchema>;
interface Booking {
id: string;
establishmentId: string;
userId: string;
type: 'hotel' | 'restaurant' | 'tour' | 'activity';
referenceId?: string;
checkInDate: string;
checkOutDate?: string;
checkInTime?: string;
guestsCount: number;
specialRequests?: string;
totalAmount: number;
}
interface BookingFormProps {
booking?: Booking | null;
onSubmit: (data: BookingFormData) => void;
onCancel: () => void;
}
export function BookingForm({ booking, onSubmit, onCancel }: BookingFormProps) {
const form = useForm<BookingFormData>({
resolver: zodResolver(bookingSchema),
defaultValues: {
establishmentId: booking?.establishmentId || '',
userId: booking?.userId || '',
type: booking?.type || 'hotel',
referenceId: booking?.referenceId || '',
checkInDate: booking?.checkInDate || '',
checkOutDate: booking?.checkOutDate || '',
checkInTime: booking?.checkInTime || '',
guestsCount: booking?.guestsCount || 1,
specialRequests: booking?.specialRequests || '',
totalAmount: booking?.totalAmount || 0,
},
});
const handleSubmit = (data: BookingFormData) => {
onSubmit(data);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="establishmentId"
render={({ field }) => (
<FormItem>
<FormLabel>Establishment ID *</FormLabel>
<FormControl>
<Input placeholder="Enter establishment ID" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="userId"
render={({ field }) => (
<FormItem>
<FormLabel>User ID *</FormLabel>
<FormControl>
<Input placeholder="Enter user ID" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Type *</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select booking type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="hotel">Hotel</SelectItem>
<SelectItem value="restaurant">Restaurant</SelectItem>
<SelectItem value="tour">Tour</SelectItem>
<SelectItem value="activity">Activity</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="referenceId"
render={({ field }) => (
<FormItem>
<FormLabel>Reference ID</FormLabel>
<FormControl>
<Input placeholder="Enter reference ID" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="checkInDate"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Check-in Date *</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"pl-3 text-left font-normal",
!field.value && "text-muted-foreground"
)}
>
{field.value ? (
format(new Date(field.value), "PPP")
) : (
<span>Pick a date</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value ? new Date(field.value) : undefined}
onSelect={(date) => field.onChange(date ? format(date, 'yyyy-MM-dd') : '')}
disabled={(date) => date < new Date()}
initialFocus
className="pointer-events-auto"
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="checkOutDate"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Check-out Date</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"pl-3 text-left font-normal",
!field.value && "text-muted-foreground"
)}
>
{field.value ? (
format(new Date(field.value), "PPP")
) : (
<span>Pick a date</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value ? new Date(field.value) : undefined}
onSelect={(date) => field.onChange(date ? format(date, 'yyyy-MM-dd') : '')}
disabled={(date) => date < new Date()}
initialFocus
className="pointer-events-auto"
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="checkInTime"
render={({ field }) => (
<FormItem>
<FormLabel>Check-in Time</FormLabel>
<FormControl>
<Input type="time" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="guestsCount"
render={({ field }) => (
<FormItem>
<FormLabel>Number of Guests *</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Enter number of guests"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="totalAmount"
render={({ field }) => (
<FormItem>
<FormLabel>Total Amount *</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
placeholder="Enter total amount"
{...field}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="specialRequests"
render={({ field }) => (
<FormItem>
<FormLabel>Special Requests</FormLabel>
<FormControl>
<Textarea
placeholder="Enter any special requests or notes"
className="resize-none"
rows={3}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end space-x-4 pt-4">
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" className="bg-primary hover:bg-primary/90">
{booking ? 'Update Booking' : 'Create Booking'}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,120 @@
import { useEffect, useRef } from 'react';
import CountUp from 'react-countup';
interface CounterCardProps {
icon: React.ReactNode;
title: string;
value: number;
suffix?: string;
trend?: number;
color?: string;
}
const CounterCard: React.FC<CounterCardProps> = ({
icon,
title,
value,
suffix = '',
trend,
color = '#F84525'
}) => {
const cardRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!('IntersectionObserver' in window)) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
(entry.target as Element).classList.add('animate-fade-in-up');
}
});
},
{ threshold: 0.1 }
);
try {
if (cardRef.current && cardRef.current instanceof Element) {
observer.observe(cardRef.current);
}
} catch (e) {
console.warn('IntersectionObserver.observe failed:', e);
}
return () => observer.disconnect();
}, []);
return (
<div
ref={cardRef}
className="card-enhanced widget-card position-relative overflow-hidden"
style={{ minHeight: '140px' }}
>
<div className="card-body p-4">
<div className="d-flex align-items-center justify-content-between">
<div className="flex-grow-1">
<div className="d-flex align-items-center mb-2">
<div
className="rounded-circle p-2 me-3"
style={{ backgroundColor: `${color}20`, color: color }}
>
{icon}
</div>
<h6 className="text-muted mb-0 small">{title}</h6>
</div>
<div className="d-flex align-items-center">
<h3 className="mb-0 fw-bold me-2" style={{ color: color }}>
<CountUp
start={0}
end={value}
duration={2.5}
separator=","
suffix={suffix}
/>
</h3>
{trend && (
<span
className={`badge ${trend > 0 ? 'bg-success' : 'bg-danger'} d-flex align-items-center`}
style={{ fontSize: '10px' }}
>
<i className={`bi bi-arrow-${trend > 0 ? 'up' : 'down'} me-1`}></i>
{Math.abs(trend)}%
</span>
)}
</div>
</div>
<div
className="rounded-circle d-flex align-items-center justify-content-center"
style={{
width: '60px',
height: '60px',
backgroundColor: `${color}10`,
color: color
}}
>
{icon}
</div>
</div>
</div>
{/* Animated background element */}
<div
className="position-absolute"
style={{
bottom: '-20px',
right: '-20px',
width: '80px',
height: '80px',
borderRadius: '50%',
backgroundColor: `${color}08`,
opacity: 0.3
}}
></div>
</div>
);
};
export default CounterCard;

View File

@@ -0,0 +1,273 @@
import { useState, useMemo } from 'react';
import { ChevronLeft, ChevronRight, Search, Filter, Download } from 'lucide-react';
interface Column {
key: string;
label: string;
sortable?: boolean;
render?: (value: any, row: any) => React.ReactNode;
}
interface EnhancedDataTableProps {
data: any[];
columns: Column[];
itemsPerPage?: number;
searchable?: boolean;
exportable?: boolean;
className?: string;
}
const EnhancedDataTable: React.FC<EnhancedDataTableProps> = ({
data,
columns,
itemsPerPage = 10,
searchable = true,
exportable = true,
className = ''
}) => {
const [currentPage, setCurrentPage] = useState(1);
const [searchTerm, setSearchTerm] = useState('');
const [sortConfig, setSortConfig] = useState<{
key: string;
direction: 'asc' | 'desc';
} | null>(null);
// Filter and search data
const filteredData = useMemo(() => {
return data.filter(item =>
columns.some(column =>
String(item[column.key] || '')
.toLowerCase()
.includes(searchTerm.toLowerCase())
)
);
}, [data, searchTerm, columns]);
// Sort data
const sortedData = useMemo(() => {
if (!sortConfig) return filteredData;
return [...filteredData].sort((a, b) => {
const aValue = a[sortConfig.key];
const bValue = b[sortConfig.key];
if (aValue < bValue) {
return sortConfig.direction === 'asc' ? -1 : 1;
}
if (aValue > bValue) {
return sortConfig.direction === 'asc' ? 1 : -1;
}
return 0;
});
}, [filteredData, sortConfig]);
// Paginate data
const paginatedData = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage;
return sortedData.slice(startIndex, startIndex + itemsPerPage);
}, [sortedData, currentPage, itemsPerPage]);
const totalPages = Math.ceil(sortedData.length / itemsPerPage);
const handleSort = (columnKey: string) => {
const column = columns.find(col => col.key === columnKey);
if (!column?.sortable) return;
setSortConfig(current => ({
key: columnKey,
direction:
current?.key === columnKey && current.direction === 'asc'
? 'desc'
: 'asc'
}));
};
const exportToCSV = () => {
const headers = columns.map(col => col.label).join(',');
const rows = sortedData.map(row =>
columns.map(col => `"${row[col.key] || ''}"`).join(',')
).join('\n');
const csvContent = `${headers}\n${rows}`;
const blob = new Blob([csvContent], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'table-data.csv';
a.click();
window.URL.revokeObjectURL(url);
};
const goToPage = (page: number) => {
setCurrentPage(Math.max(1, Math.min(page, totalPages)));
};
const renderPaginationButtons = () => {
const buttons = [];
const maxVisible = 5;
const start = Math.max(1, currentPage - Math.floor(maxVisible / 2));
const end = Math.min(totalPages, start + maxVisible - 1);
for (let i = start; i <= end; i++) {
buttons.push(
<button
key={i}
onClick={() => goToPage(i)}
className={`px-3 py-2 text-sm rounded-lg ${
i === currentPage
? 'bg-primary text-white'
: 'bg-white text-gray-700 hover:bg-gray-50 border border-gray-300'
}`}
>
{i}
</button>
);
}
return buttons;
};
return (
<div className={`card-enhanced ${className}`}>
{/* Table Header */}
<div className="card-header border-bottom-0 pb-0" style={{ position: 'relative' }}>
<div className="d-flex justify-content-between align-items-center" style={{ paddingLeft: '16px' }}>
<h5 className="card-title mb-0">Data Table</h5>
<div className="d-flex gap-2">
{searchable && (
<div className="position-relative">
<Search className="position-absolute start-0 top-50 translate-middle-y ms-3" size={16} />
<input
type="text"
placeholder="Search..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="form-control-enhanced ps-5"
style={{ width: '200px' }}
/>
</div>
)}
{exportable && (
<button
onClick={exportToCSV}
className="btn btn-outline-primary btn-sm d-flex align-items-center gap-2"
data-tooltip="Export to CSV"
>
<Download size={16} />
Export
</button>
)}
</div>
</div>
</div>
{/* Table */}
<div className="card-body p-0">
<div className="table-responsive">
<table className="table table-hover mb-0">
<thead>
<tr style={{ backgroundColor: '#f8e8e5' }}>
{columns.map((column) => (
<th
key={column.key}
className={`text-uppercase ${column.sortable ? 'cursor-pointer user-select-none' : ''}`}
style={{
fontSize: '11px',
fontWeight: 600,
letterSpacing: '1px',
padding: '0.8rem 1rem'
}}
onClick={() => handleSort(column.key)}
>
<div className="d-flex align-items-center gap-2">
{column.label}
{column.sortable && (
<div className="d-flex flex-column">
<i
className={`bi bi-caret-up-fill ${
sortConfig?.key === column.key && sortConfig.direction === 'asc'
? 'text-primary'
: 'text-muted'
}`}
style={{ fontSize: '8px', lineHeight: '1' }}
></i>
<i
className={`bi bi-caret-down-fill ${
sortConfig?.key === column.key && sortConfig.direction === 'desc'
? 'text-primary'
: 'text-muted'
}`}
style={{ fontSize: '8px', lineHeight: '1' }}
></i>
</div>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{paginatedData.map((row, index) => (
<tr key={index} className="border-bottom">
{columns.map((column) => (
<td key={column.key} style={{ padding: '0.8rem 1rem' }}>
{column.render
? column.render(row[column.key], row)
: row[column.key]
}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{paginatedData.length === 0 && (
<div className="text-center py-5 text-muted">
<p>No data found</p>
</div>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="card-footer bg-transparent border-top">
<div className="d-flex justify-content-between align-items-center">
<div className="text-muted small">
Showing {((currentPage - 1) * itemsPerPage) + 1} to{' '}
{Math.min(currentPage * itemsPerPage, sortedData.length)} of{' '}
{sortedData.length} entries
</div>
<div className="d-flex gap-1">
<button
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage === 1}
className="btn btn-outline-secondary btn-sm d-flex align-items-center gap-1"
>
<ChevronLeft size={16} />
Previous
</button>
{renderPaginationButtons()}
<button
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage === totalPages}
className="btn btn-outline-secondary btn-sm d-flex align-items-center gap-1"
>
Next
<ChevronRight size={16} />
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default EnhancedDataTable;

View File

@@ -0,0 +1,163 @@
import { useCallback, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { Upload, X, Image, File } from 'lucide-react';
interface EnhancedFileUploadProps {
onFilesChange: (files: File[]) => void;
maxFiles?: number;
maxSize?: number; // in bytes
accept?: { [key: string]: string[] };
className?: string;
}
const EnhancedFileUpload: React.FC<EnhancedFileUploadProps> = ({
onFilesChange,
maxFiles = 5,
maxSize = 10 * 1024 * 1024, // 10MB
accept = {
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp'],
'application/pdf': ['.pdf'],
'text/*': ['.txt', '.doc', '.docx']
},
className = ''
}) => {
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<{ [key: string]: number }>({});
const onDrop = useCallback(async (acceptedFiles: File[]) => {
setUploading(true);
const newFiles = [...uploadedFiles, ...acceptedFiles].slice(0, maxFiles);
setUploadedFiles(newFiles);
onFilesChange(newFiles);
// Simulate upload progress
for (const file of acceptedFiles) {
for (let progress = 0; progress <= 100; progress += 10) {
setUploadProgress(prev => ({ ...prev, [file.name]: progress }));
await new Promise(resolve => setTimeout(resolve, 100));
}
}
setUploading(false);
setUploadProgress({});
}, [uploadedFiles, maxFiles, onFilesChange]);
const removeFile = (fileToRemove: File) => {
const newFiles = uploadedFiles.filter(file => file !== fileToRemove);
setUploadedFiles(newFiles);
onFilesChange(newFiles);
};
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({
onDrop,
accept,
maxSize,
maxFiles: maxFiles - uploadedFiles.length,
disabled: uploading
});
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const getFileIcon = (file: File) => {
if (file.type.startsWith('image/')) {
return <Image className="w-5 h-5" />;
}
return <File className="w-5 h-5" />;
};
return (
<div className={`file-upload-enhanced ${className}`}>
{/* Dropzone */}
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-xl p-6 text-center transition-all cursor-pointer ${
isDragActive
? 'border-primary bg-primary/5'
: 'border-gray-300 hover:border-primary hover:bg-primary/5'
} ${uploading ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<input {...getInputProps()} />
<div className="space-y-4">
<div className="mx-auto w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center">
<Upload className="w-8 h-8 text-primary" />
</div>
<div>
<h6 className="font-semibold text-gray-800 mb-2">
{isDragActive ? 'Drop files here' : 'Drag & drop files here'}
</h6>
<p className="text-gray-600 text-sm mb-3">
or <span className="text-primary font-medium">browse files</span>
</p>
<p className="text-xs text-gray-500">
Max {maxFiles} files, up to {formatFileSize(maxSize)} each
</p>
</div>
</div>
</div>
{/* File Rejections */}
{fileRejections.length > 0 && (
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg">
<h6 className="text-red-800 font-medium mb-1">Upload Errors:</h6>
{fileRejections.map(({ file, errors }) => (
<div key={file.name} className="text-sm text-red-600">
{file.name}: {errors.map(e => e.message).join(', ')}
</div>
))}
</div>
)}
{/* Uploaded Files List */}
{uploadedFiles.length > 0 && (
<div className="mt-4 space-y-2">
<h6 className="font-medium text-gray-800">Uploaded Files ({uploadedFiles.length})</h6>
{uploadedFiles.map((file, index) => (
<div key={`${file.name}-${index}`} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center space-x-3">
<div className="text-primary">
{getFileIcon(file)}
</div>
<div>
<p className="font-medium text-gray-800 text-sm">{file.name}</p>
<p className="text-xs text-gray-500">{formatFileSize(file.size)}</p>
</div>
</div>
<div className="flex items-center space-x-2">
{uploadProgress[file.name] !== undefined && (
<div className="w-20 bg-gray-200 rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all"
style={{ width: `${uploadProgress[file.name]}%` }}
></div>
</div>
)}
<button
onClick={() => removeFile(file)}
className="p-1 text-red-500 hover:text-red-700 hover:bg-red-50 rounded"
disabled={uploading}
>
<X className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
);
};
export default EnhancedFileUpload;

View File

@@ -0,0 +1,125 @@
import { useState, useCallback } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { useDropzone } from 'react-dropzone';
import { Upload, X, Image as ImageIcon } from 'lucide-react';
interface ReviewPhotoUploadProps {
onUpload: (images: string[]) => void;
onClose: () => void;
}
export function ReviewPhotoUpload({ onUpload, onClose }: ReviewPhotoUploadProps) {
const [selectedImages, setSelectedImages] = useState<string[]>([]);
const [isDragging, setIsDragging] = useState(false);
const onDrop = useCallback((acceptedFiles: File[]) => {
const imagePromises = acceptedFiles.map(file => {
return new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(file);
});
});
Promise.all(imagePromises).then(images => {
setSelectedImages(prev => [...prev, ...images]);
});
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp']
},
multiple: true,
maxFiles: 5,
onDragEnter: () => setIsDragging(true),
onDragLeave: () => setIsDragging(false),
});
const removeImage = (index: number) => {
setSelectedImages(prev => prev.filter((_, i) => i !== index));
};
const handleUpload = () => {
onUpload(selectedImages);
setSelectedImages([]);
};
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Add Photos to Reply</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Upload Area */}
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
isDragActive || isDragging
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-primary hover:bg-primary/5'
}`}
>
<input {...getInputProps()} />
<div className="flex flex-col items-center gap-2">
<Upload className="h-8 w-8 text-muted-foreground" />
<div>
<p className="text-sm font-medium">
{isDragActive ? 'Drop photos here' : 'Click to upload photos'}
</p>
<p className="text-xs text-muted-foreground">
Drag & drop or click to select (Max 5 photos)
</p>
</div>
</div>
</div>
{/* Selected Images Preview */}
{selectedImages.length > 0 && (
<div className="space-y-3">
<h4 className="text-sm font-medium">Selected Photos ({selectedImages.length})</h4>
<div className="grid grid-cols-3 gap-2">
{selectedImages.map((image, index) => (
<div key={index} className="relative group">
<img
src={image}
alt={`Preview ${index + 1}`}
className="w-full h-20 rounded-lg object-cover"
/>
<Button
variant="destructive"
size="sm"
className="absolute -top-2 -right-2 h-6 w-6 p-0 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => removeImage(index)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
)}
{/* Actions */}
<div className="flex items-center justify-between pt-4">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
onClick={handleUpload}
disabled={selectedImages.length === 0}
className="bg-primary hover:bg-primary/90"
>
<ImageIcon className="h-4 w-4 mr-1" />
Add {selectedImages.length} Photo{selectedImages.length !== 1 ? 's' : ''}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,162 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Camera, Send, X, Star } from 'lucide-react';
import { ReviewPhotoUpload } from './ReviewPhotoUpload';
interface Review {
id: string;
userName: string;
userAvatar: string;
rating: number;
comment: string;
createdAt: string;
}
interface ReviewReplyDialogProps {
review: Review;
onReply: (reviewId: string, content: string, images?: string[]) => void;
onClose: () => void;
}
export function ReviewReplyDialog({ review, onReply, onClose }: ReviewReplyDialogProps) {
const [replyContent, setReplyContent] = useState('');
const [images, setImages] = useState<string[]>([]);
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
const handleSubmit = () => {
if (replyContent.trim()) {
onReply(review.id, replyContent, images);
setReplyContent('');
setImages([]);
onClose();
}
};
const handleImageUpload = (newImages: string[]) => {
setImages([...images, ...newImages]);
setShowPhotoUpload(false);
};
const removeImage = (index: number) => {
setImages(images.filter((_, i) => i !== index));
};
const renderStars = (rating: number) => {
return Array.from({ length: 5 }, (_, i) => (
<Star
key={i}
size={14}
className={i < rating ? "text-yellow-400 fill-yellow-400" : "text-muted-foreground"}
/>
));
};
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Reply to Review</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Original Review */}
<div className="bg-muted p-4 rounded-lg">
<div className="flex gap-3">
<Avatar className="h-10 w-10">
<AvatarImage src={review.userAvatar} alt={review.userName} />
<AvatarFallback>{review.userName.charAt(0)}</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<div>
<h4 className="font-medium text-sm">{review.userName}</h4>
<p className="text-xs text-muted-foreground">{review.createdAt}</p>
</div>
<div className="flex items-center gap-1">
{renderStars(review.rating)}
<span className="text-xs ml-1">{review.rating}/5</span>
</div>
</div>
<p className="text-sm text-muted-foreground">{review.comment}</p>
</div>
</div>
</div>
{/* Reply Form */}
<div className="space-y-3">
<Textarea
placeholder="Write your reply..."
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
rows={4}
className="resize-none"
/>
{/* Image Previews */}
{images.length > 0 && (
<div className="flex gap-2 flex-wrap">
{images.map((image, index) => (
<div key={index} className="relative">
<img
src={image}
alt={`Upload ${index + 1}`}
className="w-20 h-20 rounded-lg object-cover"
/>
<Button
variant="destructive"
size="sm"
className="absolute -top-2 -right-2 h-6 w-6 p-0 rounded-full"
onClick={() => removeImage(index)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{/* Actions */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowPhotoUpload(true)}
className="h-8"
>
<Camera className="h-4 w-4 mr-1" />
Add Photos
</Button>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!replyContent.trim()}
className="bg-primary hover:bg-primary/90"
>
<Send className="h-4 w-4 mr-1" />
Send Reply
</Button>
</div>
</div>
</div>
</div>
{/* Photo Upload Dialog */}
{showPhotoUpload && (
<ReviewPhotoUpload
onUpload={handleImageUpload}
onClose={() => setShowPhotoUpload(false)}
/>
)}
</DialogContent>
</Dialog>
);
}