mirror of
https://github.com/marcogll/AnchorOS.git
synced 2026-03-15 16:24:30 +00:00
feat: Implementar sistema de kiosko, enrollment e integración Telegram
## Sistema de Kiosko ✅ - Nuevo rol 'kiosk' en enum user_role - Tabla kiosks con autenticación por API key (64 caracteres) - Funciones SQL: generate_kiosk_api_key(), is_kiosk(), get_available_resources_with_priority() - API Routes: authenticate, bookings (GET/POST), confirm, resources/available, walkin - Componentes UI: BookingConfirmation, WalkInFlow, ResourceAssignment - Página kiosko: /kiosk/[locationId]/page.tsx ## Sistema de Enrollment ✅ - API routes para administración: /api/admin/users, /api/admin/kiosks, /api/admin/locations - Frontend enrollment: /admin/enrollment con autenticación por ADMIN_KEY - Creación de staff (admin, manager, staff, artist) con Supabase Auth - Creación de kiosks con generación automática de API key - Componentes UI: card, button, input, label, select, tabs ## Actualización de Recursos ✅ - Reemplazo de recursos con códigos estándarizados - Estructura por location: 3 mkup, 1 lshs, 4 pedi, 4 mani - Migración de limpieza: elimina duplicados - Total: 12 recursos por location ## Integración Telegram y Scoring ✅ - Campos agregados a staff: telegram_id, email, gmail, google_account, telegram_chat_id - Sistema de scoring: performance_score, total_bookings_completed, total_guarantees_count - Tablas: telegram_notifications, telegram_groups, telegram_bots - Funciones: update_staff_performance_score(), get_top_performers(), get_performance_summary() - Triggers automáticos: notificaciones al crear/confirmar/completar booking - Cálculo de score: base 50 +10 por booking +5 por garantía +1 por $100 ## Actualización de Tipos ✅ - UserRole: agregado 'kiosk' - CustomerTier: agregado 'black', 'VIP' - Nuevas interfaces: Kiosk ## Documentación ✅ - KIOSK_SYSTEM.md: Documentación completa del sistema - KIOSK_IMPLEMENTATION.md: Guía rápida - ENROLLMENT_SYSTEM.md: Sistema de enrollment - RESOURCES_UPDATE.md: Actualización de recursos - PROJECT_UPDATE_JAN_2026.md: Resumen de proyecto ## Componentes UI (7) - button.tsx, card.tsx, input.tsx, label.tsx, select.tsx, tabs.tsx ## Migraciones SQL (4) - 20260116000000_add_kiosk_system.sql - 20260116010000_update_resources.sql - 20260116020000_cleanup_and_fix_resources.sql - 20260116030000_telegram_integration.sql ## Métricas - ~7,500 líneas de código - 32 archivos creados/modificados - 7 componentes UI - 10 API routes - 4 migraciones SQL
This commit is contained in:
221
components/kiosk/BookingConfirmation.tsx
Normal file
221
components/kiosk/BookingConfirmation.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
interface BookingConfirmationProps {
|
||||
apiKey: string
|
||||
onConfirm: (booking: any) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function BookingConfirmation({ apiKey, onConfirm, onCancel }: BookingConfirmationProps) {
|
||||
const [shortId, setShortId] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [booking, setBooking] = useState<any>(null)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!shortId || shortId.length !== 6) {
|
||||
setError('Ingresa el código de 6 caracteres de tu cita')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setBooking(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/kiosk/bookings?short_id=${shortId}`, {
|
||||
headers: {
|
||||
'x-kiosk-api-key': apiKey
|
||||
}
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'No se encontró la cita')
|
||||
}
|
||||
|
||||
if (!data.bookings || data.bookings.length === 0) {
|
||||
setError('No se encontró ninguna cita con ese código')
|
||||
return
|
||||
}
|
||||
|
||||
const foundBooking = data.bookings[0]
|
||||
|
||||
if (foundBooking.status !== 'pending') {
|
||||
setError(`La cita ya está ${foundBooking.status === 'confirmed' ? 'confirmada' : foundBooking.status}`)
|
||||
setBooking(foundBooking)
|
||||
return
|
||||
}
|
||||
|
||||
setBooking(foundBooking)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al buscar la cita')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!booking) return
|
||||
|
||||
setConfirming(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/kiosk/bookings/${shortId}/confirm`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-kiosk-api-key': apiKey
|
||||
}
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Error al confirmar la cita')
|
||||
}
|
||||
|
||||
onConfirm(data.booking)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al confirmar la cita')
|
||||
} finally {
|
||||
setConfirming(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDateTime = (dateTime: string, timezone: string) => {
|
||||
const date = new Date(dateTime)
|
||||
return new Intl.DateTimeFormat('es-MX', {
|
||||
dateStyle: 'full',
|
||||
timeStyle: 'short',
|
||||
timeZone: timezone || 'America/Monterrey'
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Confirmar Cita</CardTitle>
|
||||
<CardDescription>
|
||||
Ingresa el código de tu cita para confirmar tu llegada
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!booking ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="shortId" className="text-sm font-medium">
|
||||
Código de Cita (6 caracteres)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="shortId"
|
||||
placeholder="Ej: ABC123"
|
||||
value={shortId}
|
||||
onChange={(e) => setShortId(e.target.value.toUpperCase())}
|
||||
maxLength={6}
|
||||
className="text-center text-2xl tracking-widest uppercase"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button onClick={handleSearch} disabled={loading}>
|
||||
{loading ? 'Buscando...' : 'Buscar'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 text-red-700 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button variant="outline" onClick={onCancel} className="w-full">
|
||||
Cancelar
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<h3 className="font-semibold text-lg mb-3">Detalles de la Cita</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Código:</span>
|
||||
<span className="font-mono font-bold">{booking.short_id}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Servicio:</span>
|
||||
<span>{booking.service?.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Duración:</span>
|
||||
<span>{booking.service?.duration_minutes} minutos</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Artista:</span>
|
||||
<span>{booking.staff?.display_name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Espacio:</span>
|
||||
<span>{booking.resource?.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Fecha:</span>
|
||||
<span>{formatDateTime(booking.start_time_utc, 'America/Monterrey')}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Estado:</span>
|
||||
<span className={`font-semibold ${
|
||||
booking.status === 'confirmed' ? 'text-green-600' :
|
||||
booking.status === 'pending' ? 'text-yellow-600' : 'text-gray-600'
|
||||
}`}>
|
||||
{booking.status === 'confirmed' ? 'Confirmada' :
|
||||
booking.status === 'pending' ? 'Pendiente' :
|
||||
booking.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{booking.status === 'pending' && (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={confirming}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
{confirming ? 'Confirmando...' : 'Confirmar Llegada'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 text-red-700 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setBooking(null)
|
||||
setShortId('')
|
||||
setError(null)
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Buscar otra cita
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
156
components/kiosk/ResourceAssignment.tsx
Normal file
156
components/kiosk/ResourceAssignment.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Clock, MapPin } from 'lucide-react'
|
||||
|
||||
interface ResourceAssignmentProps {
|
||||
resources: Array<{
|
||||
resource_id: string
|
||||
resource_name: string
|
||||
resource_type: string
|
||||
capacity: number
|
||||
priority: number
|
||||
}>
|
||||
start_time: string
|
||||
end_time: string
|
||||
}
|
||||
|
||||
export function ResourceAssignment({ resources, start_time, end_time }: ResourceAssignmentProps) {
|
||||
const formatDateTime = (dateTime: string) => {
|
||||
const date = new Date(dateTime)
|
||||
return new Intl.DateTimeFormat('es-MX', {
|
||||
dateStyle: 'full',
|
||||
timeStyle: 'short',
|
||||
timeZone: 'America/Monterrey'
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
const getPriorityColor = (priority: number) => {
|
||||
switch (priority) {
|
||||
case 1:
|
||||
return 'bg-green-100 text-green-700 border-green-300'
|
||||
case 2:
|
||||
return 'bg-blue-100 text-blue-700 border-blue-300'
|
||||
case 3:
|
||||
return 'bg-gray-100 text-gray-700 border-gray-300'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700 border-gray-300'
|
||||
}
|
||||
}
|
||||
|
||||
const getPriorityLabel = (priority: number) => {
|
||||
switch (priority) {
|
||||
case 1:
|
||||
return 'Alta'
|
||||
case 2:
|
||||
return 'Media'
|
||||
case 3:
|
||||
return 'Baja'
|
||||
default:
|
||||
return 'Normal'
|
||||
}
|
||||
}
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case 'station':
|
||||
return 'Estación'
|
||||
case 'room':
|
||||
return 'Sala'
|
||||
case 'equipment':
|
||||
return 'Equipo'
|
||||
default:
|
||||
return type
|
||||
}
|
||||
}
|
||||
|
||||
const getRecommendedResource = () => {
|
||||
return resources.length > 0 ? resources[0] : null
|
||||
}
|
||||
|
||||
const recommended = getRecommendedResource()
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Espacios Disponibles</CardTitle>
|
||||
<CardDescription>
|
||||
{formatDateTime(start_time)} - {new Date(end_time).toLocaleTimeString('es-MX', { timeZone: 'America/Monterrey' })}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{resources.length === 0 ? (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-md text-center">
|
||||
<p className="text-red-700">No hay espacios disponibles para este horario</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{recommended && (
|
||||
<div className="p-4 bg-green-50 border-2 border-green-300 rounded-md">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<Badge className="mb-2 bg-green-600">
|
||||
Recomendado
|
||||
</Badge>
|
||||
<h3 className="font-semibold text-lg">{recommended.resource_name}</h3>
|
||||
</div>
|
||||
<Badge className={getPriorityColor(recommended.priority)}>
|
||||
{getPriorityLabel(recommended.priority)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span>{getTypeLabel(recommended.resource_type)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>Capacidad: {recommended.capacity} persona(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resources.length > 1 && (
|
||||
<>
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
Otros espacios disponibles:
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{resources.slice(1).map((resource, index) => (
|
||||
<div
|
||||
key={resource.resource_id}
|
||||
className="p-3 bg-gray-50 border border-gray-200 rounded-md flex justify-between items-center"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{resource.resource_name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{getTypeLabel(resource.resource_type)} • Capacidad: {resource.capacity}
|
||||
</p>
|
||||
</div>
|
||||
<Badge className={getPriorityColor(resource.priority)}>
|
||||
{getPriorityLabel(resource.priority)}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-md text-sm">
|
||||
<p className="font-medium text-blue-900 mb-1">
|
||||
Prioridad de asignación:
|
||||
</p>
|
||||
<ul className="space-y-1 text-blue-800">
|
||||
<li>1. Estaciones (prioridad alta)</li>
|
||||
<li>2. Salas (prioridad media)</li>
|
||||
<li>3. Equipo (prioridad baja)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
438
components/kiosk/WalkInFlow.tsx
Normal file
438
components/kiosk/WalkInFlow.tsx
Normal file
@@ -0,0 +1,438 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { ResourceAssignment } from './ResourceAssignment'
|
||||
import { Clock, User, Mail, Phone, CheckCircle } from 'lucide-react'
|
||||
|
||||
interface WalkInFlowProps {
|
||||
apiKey: string
|
||||
onComplete: (booking: any) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function WalkInFlow({ apiKey, onComplete, onCancel }: WalkInFlowProps) {
|
||||
const [step, setStep] = useState<'services' | 'customer' | 'confirm' | 'success'>('services')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [services, setServices] = useState<any[]>([])
|
||||
const [selectedService, setSelectedService] = useState<any>(null)
|
||||
const [customerData, setCustomerData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: ''
|
||||
})
|
||||
const [availableResources, setAvailableResources] = useState<any[]>(null)
|
||||
const [createdBooking, setCreatedBooking] = useState<any>(null)
|
||||
|
||||
const loadServices = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/services', {
|
||||
headers: {
|
||||
'x-kiosk-api-key': apiKey
|
||||
}
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Error al cargar servicios')
|
||||
}
|
||||
|
||||
setServices(data.services || [])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar servicios')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const checkAvailability = async (service: any) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const now = new Date()
|
||||
const endTime = new Date(now)
|
||||
endTime.setMinutes(endTime.getMinutes() + service.duration_minutes)
|
||||
|
||||
const response = await fetch(
|
||||
`/api/kiosk/resources/available?start_time=${now.toISOString()}&end_time=${endTime.toISOString()}&service_id=${service.id}`,
|
||||
{
|
||||
headers: {
|
||||
'x-kiosk-api-key': apiKey
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Error al verificar disponibilidad')
|
||||
}
|
||||
|
||||
if (data.resources.length === 0) {
|
||||
setError('No hay espacios disponibles ahora mismo')
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedService(service)
|
||||
setAvailableResources(data.resources)
|
||||
setStep('customer')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al verificar disponibilidad')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCustomerSubmit = async () => {
|
||||
if (!customerData.name || !customerData.email) {
|
||||
setError('Nombre y email son requeridos')
|
||||
return
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(customerData.email)) {
|
||||
setError('Email inválido')
|
||||
return
|
||||
}
|
||||
|
||||
setStep('confirm')
|
||||
}
|
||||
|
||||
const handleConfirmBooking = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/kiosk/walkin', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-kiosk-api-key': apiKey,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
customer_email: customerData.email,
|
||||
customer_phone: customerData.phone,
|
||||
customer_name: customerData.name,
|
||||
service_id: selectedService.id,
|
||||
notes: 'Walk-in desde kiosko'
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Error al crear reserva')
|
||||
}
|
||||
|
||||
setCreatedBooking(data.booking)
|
||||
setStep('success')
|
||||
onComplete(data.booking)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al crear reserva')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN'
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
const formatDateTime = (dateTime: string) => {
|
||||
const date = new Date(dateTime)
|
||||
return new Intl.DateTimeFormat('es-MX', {
|
||||
dateStyle: 'full',
|
||||
timeStyle: 'short',
|
||||
timeZone: 'America/Monterrey'
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
if (step === 'services') {
|
||||
return (
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Reserva Inmediata (Walk-in)</CardTitle>
|
||||
<CardDescription>
|
||||
Selecciona el servicio que deseas recibir ahora
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{services.length === 0 && !loading && (
|
||||
<Button onClick={loadServices} className="w-full">
|
||||
Cargar Servicios
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Cargando servicios...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{services.length > 0 && (
|
||||
<div className="grid gap-3">
|
||||
{services.map((service) => (
|
||||
<button
|
||||
key={service.id}
|
||||
onClick={() => checkAvailability(service)}
|
||||
disabled={loading}
|
||||
className="p-4 border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors text-left disabled:opacity-50"
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">{service.name}</h3>
|
||||
{service.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{service.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-bold text-lg">{formatCurrency(service.base_price)}</p>
|
||||
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{service.duration_minutes} min
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 text-red-700 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button variant="outline" onClick={onCancel} className="w-full">
|
||||
Cancelar
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === 'customer') {
|
||||
return (
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Tus Datos</CardTitle>
|
||||
<CardDescription>
|
||||
Ingresa tu información para crear la reserva
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{selectedService && (
|
||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<p className="font-semibold">{selectedService.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatCurrency(selectedService.base_price)} • {selectedService.duration_minutes} min
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="name" className="text-sm font-medium flex items-center gap-2">
|
||||
<User className="w-4 h-4" />
|
||||
Nombre completo
|
||||
</label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Ej: María García"
|
||||
value={customerData.name}
|
||||
onChange={(e) => setCustomerData({ ...customerData, name: e.target.value })}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-sm font-medium flex items-center gap-2">
|
||||
<Mail className="w-4 h-4" />
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Ej: maria@email.com"
|
||||
value={customerData.email}
|
||||
onChange={(e) => setCustomerData({ ...customerData, email: e.target.value })}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="phone" className="text-sm font-medium flex items-center gap-2">
|
||||
<Phone className="w-4 h-4" />
|
||||
Teléfono (opcional)
|
||||
</label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
placeholder="Ej: 8112345678"
|
||||
value={customerData.phone}
|
||||
onChange={(e) => setCustomerData({ ...customerData, phone: e.target.value })}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 text-red-700 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setStep('services')}
|
||||
className="flex-1"
|
||||
disabled={loading}
|
||||
>
|
||||
Atrás
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCustomerSubmit}
|
||||
className="flex-1"
|
||||
disabled={loading}
|
||||
>
|
||||
Continuar
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === 'confirm') {
|
||||
return (
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Confirmar Reserva</CardTitle>
|
||||
<CardDescription>
|
||||
Revisa los detalles antes de confirmar
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="p-4 bg-gray-50 border border-gray-200 rounded-md">
|
||||
<h3 className="font-semibold mb-2">Servicio</h3>
|
||||
<p className="text-lg">{selectedService.name}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{formatCurrency(selectedService.base_price)} • {selectedService.duration_minutes} minutos
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gray-50 border border-gray-200 rounded-md">
|
||||
<h3 className="font-semibold mb-2">Cliente</h3>
|
||||
<p className="font-medium">{customerData.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{customerData.email}</p>
|
||||
{customerData.phone && (
|
||||
<p className="text-sm text-muted-foreground">{customerData.phone}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{availableResources && (
|
||||
<ResourceAssignment
|
||||
resources={availableResources}
|
||||
start_time={new Date().toISOString()}
|
||||
end_time={new Date(Date.now() + selectedService.duration_minutes * 60000).toISOString()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 text-red-700 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setStep('customer')}
|
||||
className="flex-1"
|
||||
disabled={loading}
|
||||
>
|
||||
Atrás
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmBooking}
|
||||
className="flex-1"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Creando reserva...' : 'Confirmar Reserva'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === 'success' && createdBooking) {
|
||||
return (
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-green-600">
|
||||
<CheckCircle className="w-6 h-6" />
|
||||
¡Reserva Creada con Éxito!
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Tu código de reserva es: <span className="font-mono font-bold">{createdBooking.short_id}</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded-md">
|
||||
<h3 className="font-semibold mb-3">Detalles de la Reserva</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Código:</span>
|
||||
<span className="font-mono font-bold">{createdBooking.short_id}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Servicio:</span>
|
||||
<span>{createdBooking.service?.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Artista:</span>
|
||||
<span>{createdBooking.staff_name || createdBooking.staff?.display_name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Espacio:</span>
|
||||
<span>{createdBooking.resource_name || createdBooking.resource?.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Hora:</span>
|
||||
<span>{formatDateTime(createdBooking.start_time_utc)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Estado:</span>
|
||||
<span className="text-green-600 font-semibold">Confirmada</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={onCancel} className="w-full">
|
||||
Volver al Inicio
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
55
components/ui/button.tsx
Normal file
55
components/ui/button.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
79
components/ui/card.tsx
Normal file
79
components/ui/card.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
24
components/ui/input.tsx
Normal file
24
components/ui/input.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
23
components/ui/label.tsx
Normal file
23
components/ui/label.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
157
components/ui/select.tsx
Normal file
157
components/ui/select.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
53
components/ui/tabs.tsx
Normal file
53
components/ui/tabs.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
Reference in New Issue
Block a user