diff --git a/dripzone-app/src/app/admin/encargos/page.tsx b/dripzone-app/src/app/admin/encargos/page.tsx new file mode 100644 index 0000000..f8e6192 --- /dev/null +++ b/dripzone-app/src/app/admin/encargos/page.tsx @@ -0,0 +1,434 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { + Sparkles, + Package, + Clock, + CheckCircle2, + XCircle, + MessageCircle, + Phone, + Filter, + DollarSign, + Tag, + Search, + ExternalLink, + ChevronRight, + AlertCircle, + Eye, +} from 'lucide-react'; +import { CustomProductRequest } from '@/types'; +import { formatCOP } from '@/lib/pricing-engine'; + +export default function AdminCustomRequestsPage() { + const [requests, setRequests] = useState([]); + const [statusFilter, setStatusFilter] = useState('all'); + const [search, setSearch] = useState(''); + const [selectedRequest, setSelectedRequest] = useState(null); + const [isQuoting, setIsQuoting] = useState(false); + const [quotedPriceInput, setQuotedPriceInput] = useState(''); + const [adminNotesInput, setAdminNotesInput] = useState(''); + const [toastMessage, setToastMessage] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const fetchRequests = async () => { + try { + const res = await fetch('/api/custom-requests'); + const data = await res.json(); + if (data.success && Array.isArray(data.requests)) { + setRequests(data.requests); + } + } catch (e) { + console.error(e); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchRequests(); + }, []); + + const handleUpdateStatus = async ( + id: string, + newStatus: CustomProductRequest['status'], + quotedPrice?: number, + notify: boolean = false + ) => { + try { + const res = await fetch('/api/custom-requests', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id, + updates: { + status: newStatus, + quotedPrice: quotedPrice !== undefined ? quotedPrice : undefined, + adminNotes: adminNotesInput || undefined, + }, + notifyCustomer: notify, + }), + }); + const data = await res.json(); + if (data.success) { + setRequests((prev) => prev.map((r) => (r.id === id ? data.request : r))); + setIsQuoting(false); + setToastMessage(`✅ Solicitud actualizada a "${newStatus.toUpperCase()}".`); + if (selectedRequest?.id === id) { + setSelectedRequest(data.request); + } + } + } catch (e) { + console.error(e); + } + }; + + const filtered = requests.filter((r) => { + if (statusFilter !== 'all' && r.status !== statusFilter) return false; + if (search) { + const q = search.toLowerCase(); + const matchName = r.customerName?.toLowerCase().includes(q); + const matchModel = r.modelName?.toLowerCase().includes(q); + const matchBrand = r.brand?.toLowerCase().includes(q); + const matchPhone = r.customerPhone?.includes(q); + if (!matchName && !matchModel && !matchBrand && !matchPhone) return false; + } + return true; + }); + + const getStatusBadge = (status: CustomProductRequest['status']) => { + switch (status) { + case 'pending': + return ( + + Pendiente + + ); + case 'searching': + return ( + + En Búsqueda + + ); + case 'quoted': + return ( + + Cotizado + + ); + case 'accepted': + case 'completed': + return ( + + Apartado / Concretado + + ); + case 'rejected': + return ( + + Sin Disponibilidad + + ); + default: + return null; + } + }; + + return ( +
+ {/* Header */} +
+
+

+ + Encargos y Pedidos Especiales ({requests.length}) +

+

+ Gestión de solicitudes de zapatillas y artículos que los clientes piden por encargo. Cotiza con proveedores y responde directamente por WhatsApp. +

+
+
+ + {toastMessage && ( +
+ {toastMessage} + +
+ )} + + {/* Filters & Search Toolbar */} +
+
+ {[ + { id: 'all', label: `Todos (${requests.length})` }, + { id: 'pending', label: 'Pendientes' }, + { id: 'searching', label: 'En Búsqueda' }, + { id: 'quoted', label: 'Cotizados' }, + { id: 'completed', label: 'Concretados' }, + ].map((tab) => ( + + ))} +
+ +
+ setSearch(e.target.value)} + className="w-full bg-neutral-900 border border-neutral-800 rounded-xl px-3.5 py-2 text-xs text-white outline-hidden focus:border-purple-400" + /> +
+
+ + {/* Requests Table / Grid */} +
+ {filtered.length === 0 ? ( +
+ No hay solicitudes de encargo que coincidan con los filtros. +
+ ) : ( + filtered.map((req) => { + const hasImage = Boolean(req.imageUrl); + return ( +
+
+
+ {getStatusBadge(req.status)} + + {new Date(req.createdAt).toLocaleDateString('es-CO', { + day: '2-digit', + month: 'short', + hour: '2-digit', + minute: '2-digit', + })} + +
+ + {hasImage ? ( +
setSelectedRequest(req)} + className="relative aspect-video rounded-2xl overflow-hidden bg-black mb-3 border border-neutral-800 cursor-pointer group" + > + {req.modelName} +
+ + Ver Detalle +
+
+ ) : ( +
+ Sin foto adjunta +
+ )} + +
+
+ {req.brand} + • + Talla {req.size} +
+

{req.modelName}

+ {req.colorway &&

Color: {req.colorway}

} +
+ +
+
+ Cliente: + {req.customerName} +
+
+ Ciudad: + {req.customerCity || 'Colombia'} +
+ {req.targetBudget && ( +
+ Presupuesto: + {formatCOP(req.targetBudget)} +
+ )} + {req.quotedPrice && ( +
+ Precio Cotizado: + {formatCOP(req.quotedPrice)} +
+ )} +
+
+ + {/* Action Buttons */} +
+ + + WhatsApp + + + +
+
+ ); + }) + )} +
+ + {/* Modal Detalle / Cotización */} + {selectedRequest && isQuoting && ( +
+
+
+

Gestionar Encargo: {selectedRequest.modelName}

+ +
+ +
+ {selectedRequest.imageUrl && ( +
+ Foto +
+ )} + +
+
+ Cliente: +

{selectedRequest.customerName}

+
+
+ Teléfono: +

{selectedRequest.customerPhone}

+
+
+ Marca / Talla: +

+ {selectedRequest.brand} - Talla {selectedRequest.size} +

+
+
+ Presupuesto del Cliente: +

+ {selectedRequest.targetBudget ? formatCOP(selectedRequest.targetBudget) : 'No especificado'} +

+
+
+ + {selectedRequest.notes && ( +
+ Notas del Cliente: +

{selectedRequest.notes}

+
+ )} + +
+ + setQuotedPriceInput(e.target.value)} + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3.5 py-2 text-white font-bold text-purple-400 outline-hidden focus:border-purple-400" + /> +
+ +
+ + setAdminNotesInput(e.target.value)} + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3.5 py-2 text-white outline-hidden focus:border-purple-400" + /> +
+ +
+

Actualizar Estado:

+
+ + + + + + + +
+
+
+
+
+ )} +
+ ); +} diff --git a/dripzone-app/src/app/admin/layout.tsx b/dripzone-app/src/app/admin/layout.tsx index 38dae19..2909b18 100644 --- a/dripzone-app/src/app/admin/layout.tsx +++ b/dripzone-app/src/app/admin/layout.tsx @@ -19,6 +19,7 @@ import { ShieldAlert, FileText, LogIn, + Sparkles, } from 'lucide-react'; import { useAuth } from '@/context/AuthContext'; import { AuthModal } from '@/components/auth/AuthModal'; @@ -37,6 +38,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) { href: '/admin/configuracion', label: 'Datos de la Tienda', icon: Store }, { href: '/admin/feed-whatsapp', label: 'Feed WhatsApp & IA', icon: Radio, badge: 'Anti-Duplicados' }, { href: '/admin/productos', label: 'Catálogo de Zapatillas', icon: Package }, + { href: '/admin/encargos', label: 'Encargos Especiales', icon: Sparkles, badge: 'Clientes' }, { href: '/admin/ciudades', label: 'Ciudades & Fletes', icon: MapPin }, { href: '/admin/metodos-pago', label: 'Medios de Pago & Anticipos', icon: CreditCard }, { href: '/admin/pedidos', label: 'Pedidos & Logística', icon: ShoppingBag }, diff --git a/dripzone-app/src/app/admin/productos/page.tsx b/dripzone-app/src/app/admin/productos/page.tsx index 7262db6..7238bd1 100644 --- a/dripzone-app/src/app/admin/productos/page.tsx +++ b/dripzone-app/src/app/admin/productos/page.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useState, useEffect, useMemo } from 'react'; -import { Package, Plus, Eye, EyeOff, Check, X, Sparkles, Filter, Phone, MessageCircle, Truck, Trash2, Edit, Wand2, ArrowRight, Layers, Palette, CheckSquare, Square, Split, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'; +import { Package, Plus, Eye, EyeOff, Check, X, Sparkles, Filter, Phone, MessageCircle, Truck, Trash2, Edit, Wand2, ArrowRight, Layers, Palette, CheckSquare, Square, MinusSquare, Split, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Tag, AlertCircle, CheckCircle2 } from 'lucide-react'; import { Product, Supplier } from '@/types'; import { INITIAL_PRODUCTS, INITIAL_SUPPLIERS } from '@/lib/data-service'; import { formatCOP } from '@/lib/pricing-engine'; @@ -28,6 +28,9 @@ export default function AdminProductsPage() { const [editingProduct, setEditingProduct] = useState(null); const [managingVariantsProduct, setManagingVariantsProduct] = useState(null); const [toastMessage, setToastMessage] = useState(null); + const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false); + const [bulkCategorySelect, setBulkCategorySelect] = useState('Zapatillas'); + const [isProcessingBulk, setIsProcessingBulk] = useState(false); // Pagination State const [currentPage, setCurrentPage] = useState(1); @@ -365,6 +368,122 @@ export default function AdminProductsPage() { return filtered.slice(startIndex, startIndex + pageSize); }, [filtered, startIndex, pageSize]); + // Selección de página y global de productos + const isAllPageSelected = useMemo(() => { + if (paginatedProducts.length === 0) return false; + return paginatedProducts.every((p) => selectedProductIds.includes(p.id)); + }, [paginatedProducts, selectedProductIds]); + + const somePageSelected = useMemo(() => { + return paginatedProducts.some((p) => selectedProductIds.includes(p.id)); + }, [paginatedProducts, selectedProductIds]); + + const handleToggleSelectPage = () => { + setSelectedProductIds((prev) => { + const pageIds = paginatedProducts.map((p) => p.id); + if (isAllPageSelected) { + return prev.filter((id) => !pageIds.includes(id)); + } else { + return Array.from(new Set([...prev, ...pageIds])); + } + }); + }; + + const handleSelectAllFiltered = () => { + setSelectedProductIds(filtered.map((p) => p.id)); + }; + + const handleBulkDelete = async () => { + const count = selectedProductIds.length; + if (!count) return; + const confirmMsg = `¿Estás seguro de eliminar permanentemente ${count} ${ + count === 1 ? 'publicación seleccionada' : 'publicaciones seleccionadas' + } del catálogo? Esta acción no se puede deshacer.`; + if (!window.confirm(confirmMsg)) return; + + setIsProcessingBulk(true); + try { + const res = await fetch('/api/products', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'bulk-delete', + productIds: selectedProductIds, + }), + }); + const data = await res.json(); + if (data.success) { + setProducts((prev) => prev.filter((p) => !selectedProductIds.includes(p.id))); + setSelectedProductIds([]); + setToastMessage(`🗑️ Se eliminaron permanentemente ${data.deletedCount || count} productos del catálogo.`); + } else { + alert('Error al eliminar: ' + (data.error || 'Error desconocido')); + } + } catch (e) { + console.error('Error en eliminación masiva:', e); + alert('Error de conexión al eliminar productos.'); + } finally { + setIsProcessingBulk(false); + } + }; + + const handleBulkStatus = async (isActive: boolean) => { + const count = selectedProductIds.length; + if (!count) return; + setIsProcessingBulk(true); + try { + const res = await fetch('/api/products', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'bulk-status', + productIds: selectedProductIds, + isActive, + }), + }); + const data = await res.json(); + if (data.success) { + setProducts((prev) => + prev.map((p) => (selectedProductIds.includes(p.id) ? { ...p, isActive } : p)) + ); + setToastMessage(`✨ Se ${isActive ? 'activaron' : 'ocultaron'} ${count} productos seleccionados.`); + } + } catch (e) { + console.error(e); + } finally { + setIsProcessingBulk(false); + } + }; + + const handleBulkCategory = async (cat: string) => { + const count = selectedProductIds.length; + if (!count || !cat) return; + setIsProcessingBulk(true); + try { + const res = await fetch('/api/products', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'bulk-category', + productIds: selectedProductIds, + category: cat, + }), + }); + const data = await res.json(); + if (data.success) { + setProducts((prev) => + prev.map((p) => (selectedProductIds.includes(p.id) ? { ...p, category: cat } : p)) + ); + setIsCategoryModalOpen(false); + setToastMessage(`🏷️ Se asignó la categoría "${cat}" a ${count} productos.`); + } + } catch (e) { + console.error(e); + } finally { + setIsProcessingBulk(false); + } + }; + return (
{/* Header */} @@ -453,36 +572,152 @@ export default function AdminProductsPage() {
- {/* Floating Bulk Grouping Toolbar */} - {selectedProductIds.length >= 2 && ( -
-
-
- {selectedProductIds.length} -
-
-

- {selectedProductIds.length} Zapatillas Seleccionadas -

-

- Puedes fusionarlas en 1 solo modelo con múltiples colorways. -

-
-
+ {/* Barra de Selección de Página y Acciones Rápidas */} +
+
+ {/* Botón Seleccionar Página Actual */} + -
+ {/* Botón Seleccionar Todo el Filtro */} + {filtered.length > paginatedProducts.length && ( + )} + + {selectedProductIds.length > 0 && ( + + {selectedProductIds.length} {selectedProductIds.length === 1 ? 'producto seleccionado' : 'productos seleccionados'} + + )} +
+ + {selectedProductIds.length > 0 && ( +
+ +
+ )} +
+ + {/* Floating Bulk Actions Toolbar */} + {selectedProductIds.length > 0 && ( +
+
+
+ {selectedProductIds.length} +
+
+

+ {selectedProductIds.length} {selectedProductIds.length === 1 ? 'Producto Seleccionado' : 'Productos Seleccionados'} +

+

Acciones rápidas en lote

+
+
+ +
+ + + + + + + + + {selectedProductIds.length >= 2 && ( + + )} + +
@@ -497,16 +732,13 @@ export default function AdminProductsPage() { 0 && selectedProductIds.length === filtered.length} - onChange={(e) => { - if (e.target.checked) { - setSelectedProductIds(filtered.map((p) => p.id)); - } else { - setSelectedProductIds([]); - } + checked={isAllPageSelected} + ref={(el) => { + if (el) el.indeterminate = somePageSelected && !isAllPageSelected; }} + onChange={handleToggleSelectPage} className="w-4 h-4 rounded-md accent-purple-600 cursor-pointer" - title="Seleccionar todas" + title={isAllPageSelected ? 'Deseleccionar página actual' : 'Seleccionar página actual'} /> Estado @@ -1153,6 +1385,75 @@ export default function AdminProductsPage() {
)} + + {/* Modal 4: Cambiar Categoría en Lote */} + {isCategoryModalOpen && ( +
+
+
+
+ +

Asignar Categoría en Bloque

+
+ +
+ +

+ Selecciona la categoría a la que deseas asignar los{' '} + {selectedProductIds.length} productos seleccionados: +

+ +
+ {[ + 'Zapatillas', + 'Chanclas / Slides', + 'Bolsos y Morrales', + 'Maletines', + 'Carrieles y Bandoleras', + 'Gorras', + 'Ropa Urbana', + 'Accesorios', + ].map((cat) => ( + + ))} +
+ +
+ + +
+
+
+ )} ); } diff --git a/dripzone-app/src/app/api/custom-requests/route.ts b/dripzone-app/src/app/api/custom-requests/route.ts new file mode 100644 index 0000000..ba02cc3 --- /dev/null +++ b/dripzone-app/src/app/api/custom-requests/route.ts @@ -0,0 +1,210 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { dataService } from '@/lib/data-service'; +import { CustomProductRequest } from '@/types'; +import fs from 'fs'; +import path from 'path'; + +const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL || 'https://evolution.almeira.com.co'; +const EVOLUTION_API_KEY = process.env.EVOLUTION_API_KEY || 'EE49029EEB85-4568-8F81-C9FDFC6E2943'; +const EVOLUTION_INSTANCE = process.env.EVOLUTION_INSTANCE_NAME || 'AutomataAlmeira'; +const CARLOS_PHONE = '573167455877'; + +// Carpeta local para almacenar fotos de encargos especiales +const UPLOADS_DIR = path.join(process.cwd(), 'public', 'uploads', 'custom-requests'); + +function ensureUploadsDir() { + if (!fs.existsSync(UPLOADS_DIR)) { + fs.mkdirSync(UPLOADS_DIR, { recursive: true }); + } +} + +// Función auxiliar para enviar mensajes de WhatsApp vía Evolution API +async function sendWhatsAppMessage(number: string, text: string) { + try { + const cleanNumber = number.replace(/\D/g, ''); + const fullNumber = cleanNumber.length === 10 ? `57${cleanNumber}` : cleanNumber; + + const res = await fetch(`${EVOLUTION_API_URL}/message/sendText/${EVOLUTION_INSTANCE}`, { + method: 'POST', + headers: { + apikey: EVOLUTION_API_KEY, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + number: fullNumber, + text: text, + }), + }); + return await res.json(); + } catch (err) { + console.warn('Error enviando WhatsApp a ' + number + ':', err); + return null; + } +} + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const userId = searchParams.get('userId') || undefined; + const requests = dataService.getCustomRequests(userId); + return NextResponse.json({ success: true, requests }); + } catch (error: any) { + return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { + userId, + customerName, + customerPhone, + customerEmail, + customerCity, + brand, + modelName, + size, + colorway, + targetBudget, + notes, + imageBase64, + } = body; + + if (!customerName || !customerPhone || !brand || !modelName || !size) { + return NextResponse.json( + { + success: false, + error: 'Faltan campos obligatorios: Nombre, Teléfono, Marca, Modelo y Talla.', + }, + { status: 400 } + ); + } + + let finalImageUrl = body.imageUrl || ''; + + // Si el usuario envió una imagen en base64, guardarla en disco + if (imageBase64 && imageBase64.startsWith('data:image')) { + try { + ensureUploadsDir(); + const matches = imageBase64.match(/^data:image\/([a-zA-Z0-9]+);base64,(.+)$/); + if (matches) { + const ext = matches[1] === 'jpeg' ? 'jpg' : matches[1]; + const buffer = Buffer.from(matches[2], 'base64'); + const filename = `encargo-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.${ext}`; + const filePath = path.join(UPLOADS_DIR, filename); + fs.writeFileSync(filePath, buffer); + finalImageUrl = `/uploads/custom-requests/${filename}`; + } + } catch (saveErr) { + console.warn('No se pudo guardar la imagen del encargo en disco:', saveErr); + } + } + + // Crear la solicitud en DataService + const createdRequest = dataService.addCustomRequest({ + userId, + customerName, + customerPhone, + customerEmail, + customerCity: customerCity || 'Colombia', + brand, + modelName, + size, + colorway: colorway || 'Original / Según Foto', + targetBudget: targetBudget ? parseInt(targetBudget, 10) : undefined, + notes: notes || '', + imageUrl: finalImageUrl, + status: 'pending', + }); + + // 1. Notificación inmediata a Carlos (+57 316 745 5877) + const budgetFormatted = createdRequest.targetBudget + ? `$${createdRequest.targetBudget.toLocaleString('es-CO')}` + : 'A cotizar por bodega'; + + const adminMessage = `🚨 *NUEVA SOLICITUD DE ENCARGO ESPECIAL* 👟 +----------------------------------------- +👤 *Cliente:* ${customerName} +📱 *WhatsApp:* ${customerPhone} +🏙️ *Ciudad:* ${customerCity || 'Colombia'} +${customerEmail ? `✉️ *Correo:* ${customerEmail}\n` : ''} +🏷️ *Marca:* ${brand} +👟 *Modelo:* ${modelName} +📏 *Talla Deseada:* ${size} +🎨 *Colorway:* ${colorway || 'Original / Ver Foto'} +💰 *Presupuesto Estimado:* ${budgetFormatted} +📝 *Notas del Cliente:* ${notes || 'Ninguna'} +📅 *Fecha:* ${new Date().toLocaleString('es-CO')} +----------------------------------------- +⚡ ¡Consulta de inmediato con tus proveedores mayoristas para enviar cotización al cliente!`; + + sendWhatsAppMessage(CARLOS_PHONE, adminMessage).catch(() => {}); + + // 2. Confirmación automática al cliente por WhatsApp + const clientMessage = `👋 ¡Hola *${customerName}*! +Recibimos con éxito tu solicitud por encargo para las *${brand} ${modelName}* en talla *${size}*. 👟🔥 + +Nuestro equipo ya está verificando disponibilidad y mejor precio con nuestras bodegas y proveedores mayoristas. Te responderemos a este mismo chat con la cotización exacta en el menor tiempo posible. + +¡Gracias por confiar en DripZone! 🚀`; + + sendWhatsAppMessage(customerPhone, clientMessage).catch(() => {}); + + return NextResponse.json({ + success: true, + message: '¡Solicitud de encargo recibida con éxito! Nuestro equipo te contactará por WhatsApp.', + request: createdRequest, + }); + } catch (error: any) { + console.error('Error en POST /api/custom-requests:', error); + return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + } +} + +export async function PUT(request: NextRequest) { + try { + const body = await request.json(); + const { id, updates, notifyCustomer } = body; + + if (!id) { + return NextResponse.json({ success: false, error: 'ID de solicitud requerido' }, { status: 400 }); + } + + const updated = dataService.updateCustomRequest(id, updates); + if (!updated) { + return NextResponse.json({ success: false, error: 'Solicitud no encontrada' }, { status: 404 }); + } + + // Si el admin activó la notificación al cliente (por ejemplo al cotizar el producto) + if (notifyCustomer && updated.customerPhone && updated.quotedPrice) { + const quoteMessage = `👋 ¡Hola *${updated.customerName}*! +¡Buenas noticias! Encontramos tu par solicitado: *${updated.brand} ${updated.modelName}* en talla *${updated.size}*. 👟✨ + +💰 *Precio Cotizado:* $${updated.quotedPrice.toLocaleString('es-CO')} COP +🚚 *Envío:* Despacho directo a tu dirección con pago contraentrega disponible. + +¿Deseas confirmar tu pedido para apartarlo de bodega de inmediato? Respóndenos a este mensaje para coordinar la entrega.`; + + sendWhatsAppMessage(updated.customerPhone, quoteMessage).catch(() => {}); + } + + return NextResponse.json({ success: true, request: updated }); + } catch (error: any) { + return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + } +} + +export async function DELETE(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + if (!id) { + return NextResponse.json({ success: false, error: 'ID requerido' }, { status: 400 }); + } + const deleted = dataService.deleteCustomRequest(id); + return NextResponse.json({ success: deleted }); + } catch (error: any) { + return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + } +} diff --git a/dripzone-app/src/app/api/products/route.ts b/dripzone-app/src/app/api/products/route.ts index 398bac0..18b4cb8 100644 --- a/dripzone-app/src/app/api/products/route.ts +++ b/dripzone-app/src/app/api/products/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { dataService } from '@/lib/data-service'; import { getProductSocialProof } from '@/lib/social-proof'; +import { deleteProductFromAppwrite, saveProductToAppwrite } from '@/lib/server-appwrite'; export async function GET(request: Request) { const { searchParams } = new URL(request.url); @@ -18,6 +19,69 @@ export async function POST(request: Request) { try { const body = await request.json(); + // ACCIÓN MASIVA: Borrado en bloque de múltiples productos + if (body.action === 'bulk-delete') { + const productIds: string[] = body.productIds || body.ids || []; + if (!Array.isArray(productIds) || productIds.length === 0) { + return NextResponse.json({ success: false, error: 'Lista de IDs requerida' }, { status: 400 }); + } + let deletedCount = 0; + for (const id of productIds) { + const del = dataService.deleteProduct(id); + deleteProductFromAppwrite(id).catch(() => {}); + if (del) deletedCount++; + } + return NextResponse.json({ + success: true, + deletedCount, + message: `¡Se eliminaron permanentemente ${deletedCount} publicaciones del catálogo!`, + }); + } + + // ACCIÓN MASIVA: Activar / Ocultar en bloque + if (body.action === 'bulk-status') { + const productIds: string[] = body.productIds || body.ids || []; + const isActive = Boolean(body.isActive); + if (!Array.isArray(productIds) || productIds.length === 0) { + return NextResponse.json({ success: false, error: 'Lista de IDs requerida' }, { status: 400 }); + } + let updatedCount = 0; + for (const id of productIds) { + const updated = dataService.updateProduct(id, { isActive }); + if (updated) { + saveProductToAppwrite(updated).catch(() => {}); + updatedCount++; + } + } + return NextResponse.json({ + success: true, + updatedCount, + message: `¡Se ${isActive ? 'activaron' : 'ocultaron'} ${updatedCount} productos con éxito!`, + }); + } + + // ACCIÓN MASIVA: Cambiar categoría en bloque + if (body.action === 'bulk-category') { + const productIds: string[] = body.productIds || body.ids || []; + const category = body.category; + if (!category) { + return NextResponse.json({ success: false, error: 'Categoría requerida' }, { status: 400 }); + } + let updatedCount = 0; + for (const id of productIds) { + const updated = dataService.updateProduct(id, { category }); + if (updated) { + saveProductToAppwrite(updated).catch(() => {}); + updatedCount++; + } + } + return NextResponse.json({ + success: true, + updatedCount, + message: `¡Se actualizó la categoría a "${category}" en ${updatedCount} productos!`, + }); + } + // ACCIÓN MANUAL: Desagrupar / Separar una variante específica como producto independiente if (body.action === 'detach-variant') { const { productId, variantId, customName, customPrice } = body; @@ -107,9 +171,9 @@ export async function POST(request: Request) { }, ]; - for (const mergeId of productIdsToMerge || []) { - if (mergeId === targetProductId) continue; - const toMerge = allProducts.find((p) => p.id === mergeId); + for (const toMergeId of productIdsToMerge || []) { + if (toMergeId === targetProductId) continue; + const toMerge = allProducts.find((p) => p.id === toMergeId); if (!toMerge) continue; const mergeColors = @@ -141,6 +205,7 @@ export async function POST(request: Request) { // Eliminar el producto que fue fusionado dataService.deleteProduct(toMerge.id); + deleteProductFromAppwrite(toMerge.id).catch(() => {}); } target.colors = colors; @@ -153,6 +218,7 @@ export async function POST(request: Request) { } dataService.updateProduct(target.id, target); + saveProductToAppwrite(target).catch(() => {}); return NextResponse.json({ success: true, product: target, @@ -170,6 +236,7 @@ export async function POST(request: Request) { originBadge: body.originBadge || proof.badge, isActive: body.isActive !== undefined ? body.isActive : true, }); + saveProductToAppwrite(newProduct).catch(() => {}); return NextResponse.json({ success: true, product: newProduct }); } catch (error: any) { return NextResponse.json({ success: false, error: error.message }, { status: 400 }); @@ -189,6 +256,7 @@ export async function PUT(request: Request) { return NextResponse.json({ success: false, error: 'Producto no encontrado' }, { status: 404 }); } + saveProductToAppwrite(updated).catch(() => {}); return NextResponse.json({ success: true, product: updated }); } catch (error: any) { return NextResponse.json({ success: false, error: error.message }, { status: 400 }); @@ -198,19 +266,41 @@ export async function PUT(request: Request) { export async function DELETE(request: Request) { try { const { searchParams } = new URL(request.url); - const id = searchParams.get('id'); - if (!id) { - return NextResponse.json({ success: false, error: 'ID de producto requerido' }, { status: 400 }); + const singleId = searchParams.get('id'); + const rawIds = searchParams.get('ids'); + let body: any = null; + try { + body = await request.json(); + } catch (_) {} + + const idsToDelete: string[] = ( + body?.ids || + body?.productIds || + (rawIds ? rawIds.split(',') : singleId ? [singleId] : []) + ) + .map((x: string) => (x || '').trim()) + .filter(Boolean); + + if (idsToDelete.length === 0) { + return NextResponse.json( + { success: false, error: 'ID o lista de IDs de producto requerida' }, + { status: 400 } + ); } - const deleted = dataService.deleteProduct(id); - if (!deleted) { - return NextResponse.json({ success: false, error: 'Producto no encontrado' }, { status: 404 }); + let deletedCount = 0; + for (const id of idsToDelete) { + const deleted = dataService.deleteProduct(id); + deleteProductFromAppwrite(id).catch(() => {}); + if (deleted) deletedCount++; } - return NextResponse.json({ success: true, message: 'Producto eliminado exitosamente del catálogo' }); + return NextResponse.json({ + success: true, + deletedCount, + message: `¡Se ${deletedCount === 1 ? 'eliminó 1 producto' : `eliminaron ${deletedCount} productos`} exitosamente del catálogo!`, + }); } catch (error: any) { return NextResponse.json({ success: false, error: error.message }, { status: 400 }); } } - diff --git a/dripzone-app/src/app/api/whatsapp-feed/route.ts b/dripzone-app/src/app/api/whatsapp-feed/route.ts index 6e82373..2b619fe 100644 --- a/dripzone-app/src/app/api/whatsapp-feed/route.ts +++ b/dripzone-app/src/app/api/whatsapp-feed/route.ts @@ -113,15 +113,26 @@ export async function GET(request: Request) { } if (!fetchLive || targetJids.length === 0) { - const filteredSaved = savedPosts.filter((p) => { - // Si el mensaje es de un grupo del que salimos y estamos en pendientes, no mostrarlo - if (p.channelJid && leftJids.has(p.channelJid) && statusFilter === 'pending') return false; + const filteredSaved = savedPosts + .filter((p) => { + // Si el mensaje es de un grupo del que salimos y estamos en pendientes, no mostrarlo + if (p.channelJid && leftJids.has(p.channelJid) && statusFilter === 'pending') return false; - if (statusFilter === 'pending') return p.status === 'pending_review'; - if (statusFilter === 'published') return p.status === 'published' || p.status === 'published_store'; - if (statusFilter === 'discarded') return p.status === 'discarded'; - return true; - }); + if (statusFilter === 'pending') return p.status === 'pending_review'; + if (statusFilter === 'published') return p.status === 'published' || p.status === 'published_store'; + if (statusFilter === 'discarded') return p.status === 'discarded'; + return true; + }) + .map((p) => { + // Si tiene thumbnail de baja resolución pero cuenta con ID de WhatsApp, suministrar la URL en Alta Resolución Full HD + if (p.whatsappMessageId && Array.isArray(p.rawImages) && (p.rawImages.length === 0 || p.rawImages[0].startsWith('data:image'))) { + return { + ...p, + rawImages: [`/api/whatsapp-media?id=${encodeURIComponent(p.whatsappMessageId)}&remoteJid=${encodeURIComponent(p.channelJid || '')}`], + }; + } + return p; + }); return NextResponse.json({ success: true, @@ -261,7 +272,12 @@ export async function GET(request: Request) { const imgMsg = record.message?.imageMessage; if (imgMsg) { - if (imgMsg.jpegThumbnail) { + if (rawMsgId) { + // URL de streaming en Alta Resolución (Full HD) desde Evolution API + imageUrls.push( + `/api/whatsapp-media?id=${encodeURIComponent(rawMsgId)}&remoteJid=${encodeURIComponent(remoteJid)}` + ); + } else if (imgMsg.jpegThumbnail) { imageUrls.push(`data:image/jpeg;base64,${imgMsg.jpegThumbnail}`); } else if (imgMsg.url) { imageUrls.push(imgMsg.url); @@ -391,7 +407,14 @@ export async function POST(request: Request) { marketPriceSourceUrl: post.extractedProduct?.marketPriceSourceUrl || marketIntelligence?.marketPriceSourceUrl, marketComparisonSummary: post.extractedProduct?.marketComparisonSummary || marketIntelligence?.marketComparisonSummary, sizes: customSizes || post.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'], - images: Array.isArray(post.rawImages) && post.rawImages.length > 0 ? post.rawImages : ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'], + images: Array.isArray(post.rawImages) && post.rawImages.length > 0 + ? post.rawImages.map((img: string) => { + if (img.startsWith('data:image') && whatsappMsgId) { + return `/api/whatsapp-media?id=${encodeURIComponent(whatsappMsgId)}&remoteJid=${encodeURIComponent(post.channelJid || '')}`; + } + return img; + }) + : ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'], description: prodDescription, category: prodCategory, inStock: true, @@ -455,7 +478,14 @@ export async function POST(request: Request) { marketPriceSourceUrl: p.extractedProduct?.marketPriceSourceUrl, marketComparisonSummary: p.extractedProduct?.marketComparisonSummary, sizes: p.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'], - images: p.rawImages || ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'], + images: Array.isArray(p.rawImages) && p.rawImages.length > 0 + ? p.rawImages.map((img: string) => { + if (img.startsWith('data:image') && whatsappMsgId) { + return `/api/whatsapp-media?id=${encodeURIComponent(whatsappMsgId)}&remoteJid=${encodeURIComponent(p.channelJid || '')}`; + } + return img; + }) + : ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'], description: prodDescription, category: prodCategory, inStock: true, diff --git a/dripzone-app/src/app/api/whatsapp-media/route.ts b/dripzone-app/src/app/api/whatsapp-media/route.ts new file mode 100644 index 0000000..eea23d1 --- /dev/null +++ b/dripzone-app/src/app/api/whatsapp-media/route.ts @@ -0,0 +1,156 @@ +import { NextRequest, NextResponse } from 'next/server'; +import fs from 'fs'; +import path from 'path'; + +const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL || 'https://evolution.almeira.com.co'; +const EVOLUTION_API_KEY = process.env.EVOLUTION_API_KEY || 'EE49029EEB85-4568-8F81-C9FDFC6E2943'; +const EVOLUTION_INSTANCE = process.env.EVOLUTION_INSTANCE_NAME || 'AutomataAlmeira'; + +// Carpeta local para almacenar en caché las imágenes descargadas en alta resolución +const CACHE_DIR = path.join(process.cwd(), 'public', 'cache', 'whatsapp-media'); + +function ensureCacheDir() { + if (!fs.existsSync(CACHE_DIR)) { + fs.mkdirSync(CACHE_DIR, { recursive: true }); + } +} + +// Fallback image buffer (placeholder sneaker) +const FALLBACK_IMAGE_URL = 'https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const messageId = searchParams.get('id'); + const remoteJid = searchParams.get('remoteJid'); + const quality = searchParams.get('q') || 'high'; + + if (!messageId) { + return NextResponse.redirect(FALLBACK_IMAGE_URL); + } + + const cleanId = messageId.replace(/[^a-zA-Z0-9_-]/g, ''); + const cacheFilePath = path.join(CACHE_DIR, `${cleanId}.jpg`); + + // 1. Si ya existe en caché local en disco, servir directamente en microsegundos + if (fs.existsSync(cacheFilePath)) { + const cachedBuffer = fs.readFileSync(cacheFilePath); + return new Response(cachedBuffer, { + status: 200, + headers: { + 'Content-Type': 'image/jpeg', + 'Cache-Control': 'public, max-age=31536000, immutable', + 'ETag': `"${cleanId}"`, + 'Content-Length': cachedBuffer.length.toString(), + }, + }); + } + + // 2. Buscar el mensaje en Evolution API + let messageRecord: any = null; + + // Primer intento: búsqueda directa por clave de mensaje + try { + const whereClause: any = { key: { id: messageId } }; + if (remoteJid) { + whereClause.key.remoteJid = remoteJid; + } + + const findRes = await fetch(`${EVOLUTION_API_URL}/chat/findMessages/${EVOLUTION_INSTANCE}`, { + method: 'POST', + headers: { + apikey: EVOLUTION_API_KEY, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ where: whereClause }), + }); + + const findData = await findRes.json(); + const records = findData.messages?.records || findData.records || (Array.isArray(findData) ? findData : []); + if (records.length > 0) { + messageRecord = records[0]; + } + } catch (e) { + console.warn('Error buscando mensaje por ID exacto:', e); + } + + // Segundo intento si no se encontró con where estricto: buscar en mensajes recientes del chat + if (!messageRecord && remoteJid) { + try { + const chatRes = await fetch(`${EVOLUTION_API_URL}/chat/findMessages/${EVOLUTION_INSTANCE}?page=1&limit=50`, { + method: 'POST', + headers: { + apikey: EVOLUTION_API_KEY, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ where: { key: { remoteJid } } }), + }); + const chatData = await chatRes.json(); + const records = chatData.messages?.records || chatData.records || (Array.isArray(chatData) ? chatData : []); + messageRecord = records.find((r: any) => r.key?.id === messageId || r.key?.id?.includes(cleanId)); + } catch (e) { + console.warn('Error en segundo intento de búsqueda:', e); + } + } + + if (!messageRecord || !messageRecord.message?.imageMessage) { + // Si el mensaje no existe o no tiene imagen, redirigir a imagen fallback + return NextResponse.redirect(FALLBACK_IMAGE_URL); + } + + // 3. Solicitar el medio en Alta Resolución Full HD a Evolution API + const mediaRes = await fetch(`${EVOLUTION_API_URL}/chat/getBase64FromMediaMessage/${EVOLUTION_INSTANCE}`, { + method: 'POST', + headers: { + apikey: EVOLUTION_API_KEY, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + message: messageRecord, + convertToMp4: false, + }), + }); + + const mediaData = await mediaRes.json(); + + if (mediaData && mediaData.base64) { + const base64Clean = mediaData.base64.replace(/^data:image\/[a-z]+;base64,/, ''); + const imageBuffer = Buffer.from(base64Clean, 'base64'); + + // Guardar en caché de disco para visitas subsiguientes + try { + ensureCacheDir(); + fs.writeFileSync(cacheFilePath, imageBuffer); + } catch (cacheErr) { + console.warn('No se pudo escribir archivo de caché:', cacheErr); + } + + return new Response(imageBuffer, { + status: 200, + headers: { + 'Content-Type': mediaData.mimetype || 'image/jpeg', + 'Cache-Control': 'public, max-age=31536000, immutable', + 'ETag': `"${cleanId}"`, + 'Content-Length': imageBuffer.length.toString(), + }, + }); + } + + // Si Evolution API no retornó base64 pero el mensaje tiene jpegThumbnail, usarlo como fallback temporal + if (messageRecord.message?.imageMessage?.jpegThumbnail) { + const thumbBuffer = Buffer.from(messageRecord.message.imageMessage.jpegThumbnail, 'base64'); + return new Response(thumbBuffer, { + status: 200, + headers: { + 'Content-Type': 'image/jpeg', + 'Cache-Control': 'public, max-age=3600', + }, + }); + } + + return NextResponse.redirect(FALLBACK_IMAGE_URL); + } catch (error: any) { + console.error('Error en GET /api/whatsapp-media:', error); + return NextResponse.redirect(FALLBACK_IMAGE_URL); + } +} diff --git a/dripzone-app/src/app/page.tsx b/dripzone-app/src/app/page.tsx index 5155cdd..fd2e59b 100644 --- a/dripzone-app/src/app/page.tsx +++ b/dripzone-app/src/app/page.tsx @@ -14,9 +14,10 @@ import { AuthModal } from '@/components/auth/AuthModal'; import { Product, City, CartItem } from '@/types'; import { INITIAL_CITIES } from '@/lib/data-service'; import { groupProductsByModel } from '@/lib/product-grouper'; -import { Sparkles, ShieldCheck, Truck, RefreshCw, Zap, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'; +import { Sparkles, ShieldCheck, Truck, RefreshCw, Zap, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ArrowRight } from 'lucide-react'; import { useBrand } from '@/context/BrandContext'; import { renderClientTemplate } from '@/lib/templates-client'; +import { CustomRequestModal } from '@/components/custom-requests/CustomRequestModal'; export default function StoreHomePage() { const { currentBrand } = useBrand(); @@ -35,6 +36,7 @@ export default function StoreHomePage() { const [selectedProduct, setSelectedProduct] = useState(null); const [isCartOpen, setIsCartOpen] = useState(false); const [isCityModalOpen, setIsCityModalOpen] = useState(false); + const [isCustomRequestOpen, setIsCustomRequestOpen] = useState(false); const [cartItems, setCartItems] = useState([]); const cartCount = cartItems.reduce((acc, item) => acc + item.quantity, 0); @@ -240,6 +242,7 @@ export default function StoreHomePage() { onOpenCitySelector={() => setIsCityModalOpen(true)} searchQuery={searchQuery} onSearchChange={setSearchQuery} + onOpenCustomRequest={() => setIsCustomRequestOpen(true)} /> {/* Main Container */} @@ -492,6 +495,29 @@ export default function StoreHomePage() { )} )} + + {/* Call to Action: Pedidos Especiales / Encargos */} +
+
+
+ + ¿No encuentras tus zapatillas favoritas en el catálogo? +
+

¡Pídelas por Encargo y las Conseguimos!

+

+ Sube una foto o dinos la marca y modelo exacto que deseas. Lo cotizamos de inmediato con nuestras bodegas mayoristas al mejor precio del mercado con pago contraentrega. +

+
+ +
{/* Footer */} @@ -540,6 +566,11 @@ export default function StoreHomePage() { onSelectCity={setSelectedCity} /> + setIsCustomRequestOpen(false)} + /> + {/* Mobile App Dock */} void; + initialBrand?: string; + initialModel?: string; +} + +const POPULAR_BRANDS = [ + 'Nike', + 'Jordan', + 'Adidas', + 'New Balance', + 'Asics', + 'Puma', + 'Balenciaga', + 'Vans', + 'Otra', +]; + +const POPULAR_SIZES = ['36', '37', '38', '39', '40', '41', '42', '43', '44', '45']; + +export const CustomRequestModal: React.FC = ({ + isOpen, + onClose, + initialBrand = 'Nike', + initialModel = '', +}) => { + const [brand, setBrand] = useState(initialBrand); + const [customBrand, setCustomBrand] = useState(''); + const [modelName, setModelName] = useState(initialModel); + const [size, setSize] = useState('40'); + const [customSize, setCustomSize] = useState(''); + const [colorway, setColorway] = useState(''); + const [targetBudget, setTargetBudget] = useState(''); + const [notes, setNotes] = useState(''); + const [customerName, setCustomerName] = useState(''); + const [customerPhone, setCustomerPhone] = useState(''); + const [customerCity, setCustomerCity] = useState(''); + const [imageBase64, setImageBase64] = useState(null); + + const [isLoading, setIsLoading] = useState(false); + const [isSuccess, setIsSuccess] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const fileInputRef = useRef(null); + + if (!isOpen) return null; + + const handleImageChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (file.size > 10 * 1024 * 1024) { + setErrorMessage('La foto no debe superar los 10MB.'); + return; + } + + const reader = new FileReader(); + reader.onload = () => { + setImageBase64(reader.result as string); + setErrorMessage(null); + }; + reader.readAsDataURL(file); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setErrorMessage(null); + + const effectiveBrand = brand === 'Otra' && customBrand.trim() ? customBrand.trim() : brand; + const effectiveSize = size === 'custom' && customSize.trim() ? customSize.trim() : size; + + if (!customerName.trim() || !customerPhone.trim() || !modelName.trim()) { + setErrorMessage('Por favor completa tu nombre, WhatsApp y el modelo de las zapatillas.'); + return; + } + + setIsLoading(true); + + try { + const res = await fetch('/api/custom-requests', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + customerName: customerName.trim(), + customerPhone: customerPhone.trim(), + customerCity: customerCity.trim() || 'Colombia', + brand: effectiveBrand, + modelName: modelName.trim(), + size: effectiveSize, + colorway: colorway.trim(), + targetBudget: targetBudget ? parseInt(targetBudget.replace(/\D/g, ''), 10) : undefined, + notes: notes.trim(), + imageBase64: imageBase64, + }), + }); + + const data = await res.json(); + if (data.success) { + setIsSuccess(true); + } else { + setErrorMessage(data.error || 'Ocurrió un error al enviar tu encargo.'); + } + } catch (err: any) { + setErrorMessage('Error de conexión. Inténtalo nuevamente.'); + } finally { + setIsLoading(false); + } + }; + + const handleResetAndClose = () => { + setIsSuccess(false); + setImageBase64(null); + setModelName(''); + setColorway(''); + setTargetBudget(''); + setNotes(''); + setErrorMessage(null); + onClose(); + }; + + return ( +
+
+ {/* Close button */} + + + {isSuccess ? ( +
+
+ +
+ +
+

¡Encargo Recibido con Éxito!

+

+ Hemos registrado tu solicitud para las{' '} + {brand} {modelName} en talla{' '} + {size}. +

+
+ +
+
+ + ¿Qué pasará ahora? +
+

+ 1. Nuestro equipo rastrea el par en nuestras bodegas y proveedores mayoristas.
+ 2. Te enviamos la cotización exacta con fotos y precio a tu WhatsApp {customerPhone}.
+ 3. Si estás de acuerdo, lo apartamos y coordinamos el despacho con pago contraentrega. +

+
+ +
+ + + + Escribir por WhatsApp + +
+
+ ) : ( +
+ {/* Header */} +
+
+ + Servicio Personalizado de Búsqueda +
+

¿No encuentras tus Zapatillas?

+

+ Sube una foto o describe el modelo exacto que deseas. Lo cotizamos directamente con nuestras bodegas mayoristas al mejor precio. +

+
+ + {errorMessage && ( +
+ + {errorMessage} +
+ )} + + {/* Photo Uploader */} +
+ + + + + {imageBase64 ? ( +
+ Referencia +
+ +
+
+ ) : ( +
fileInputRef.current?.click()} + className="border-2 border-dashed border-neutral-700 hover:border-purple-500/60 rounded-2xl p-4 sm:p-5 text-center cursor-pointer transition-all bg-neutral-950/50 hover:bg-neutral-950/80 group" + > +
+ +
+

Haz clic aquí para subir o tomar una foto

+

JPG, PNG o WEBP (Captura de pantalla, Instagram, etc.)

+
+ )} +
+ + {/* Brand Pills */} +
+ +
+ {POPULAR_BRANDS.map((b) => ( + + ))} +
+ + {brand === 'Otra' && ( + setCustomBrand(e.target.value)} + className="mt-2 w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3.5 py-2 text-xs text-white outline-hidden focus:border-purple-400" + /> + )} +
+ + {/* Model Name & Colorway */} +
+
+ + setModelName(e.target.value)} + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3.5 py-2 text-xs text-white outline-hidden focus:border-purple-400" + /> +
+ +
+ + setColorway(e.target.value)} + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3.5 py-2 text-xs text-white outline-hidden focus:border-purple-400" + /> +
+
+ + {/* Size Selector */} +
+ +
+ {POPULAR_SIZES.map((sz) => ( + + ))} +
+
+ + {/* Budget & Details */} +
+
+ + setTargetBudget(e.target.value)} + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3.5 py-2 text-xs text-white outline-hidden focus:border-purple-400" + /> +
+ +
+ + setCustomerCity(e.target.value)} + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3.5 py-2 text-xs text-white outline-hidden focus:border-purple-400" + /> +
+
+ + {/* Notes */} +
+ +