diff --git a/__pycache__/deploy_dripzone_vps.cpython-313.pyc b/__pycache__/deploy_dripzone_vps.cpython-313.pyc new file mode 100644 index 0000000..317467c Binary files /dev/null and b/__pycache__/deploy_dripzone_vps.cpython-313.pyc differ diff --git a/deploy_dripzone_vps.py b/deploy_dripzone_vps.py index 9b85c5d..9479567 100644 --- a/deploy_dripzone_vps.py +++ b/deploy_dripzone_vps.py @@ -79,9 +79,12 @@ def deploy(): docker compose -f docker-compose.prod.yml down --remove-orphans || true DOCKER_BUILDKIT=1 docker compose -f docker-compose.prod.yml up -d --build sleep 3 + docker exec dripzone-production chown -R nextjs:nodejs /app/data || true docker cp {REMOTE_DIR}/public/DripZone.apk dripzone-production:/app/public/DripZone.apk || true docker cp {REMOTE_DIR}/public/DripZone.apk dripzone-production:/app/DripZone.apk || true - docker exec dripzone-production chmod 644 /app/public/DripZone.apk /app/DripZone.apk || true + docker cp {REMOTE_DIR}/public/DripZone.apk dripzone-production:/app/public/dripzone.apk || true + docker cp {REMOTE_DIR}/public/DripZone.apk dripzone-production:/app/public/app-dripzone.apk || true + docker exec dripzone-production chmod 644 /app/public/DripZone.apk /app/DripZone.apk /app/public/dripzone.apk /app/public/app-dripzone.apk || true docker ps | grep dripzone """ @@ -118,15 +121,14 @@ def send_apk_to_whatsapp(): "Content-Type": "application/json" } caption = ( - "📲 *¡DRIPZONE APK ACTUALIZADA!* 🚀👟\n" + "📲 *¡DRIPZONE ACTUALIZADA EN PRODUCCIÓN!* 🚀👟\n" "━━━━━━━━━━━━━━━━━━━━━━\n" "✨ *Novedades de esta versión:*\n" - "• 📦 *Mis Pedidos*: Visualización de estado en tiempo real, desglose de calzado, flete, anticipo $10.000 y saldo contraentrega.\n" - "• 👤 *Mi Cuenta*: Acceso seguro vía WhatsApp OTP exclusivo a la línea del cliente.\n" - "• 💵 *Precios en Grid*: Corrección de visualización de precios en 1 sola línea sin recortes y botón 'Pedir' optimizado.\n" - "• 🗄️ *Persistencia Total*: Configuración, parámetros y entidad almacenados de forma segura en Base de Datos.\n" - "• 🔔 *Plantillas Dinámicas*: Notificaciones móviles y web emitidas con las plantillas registradas.\n\n" - "🌐 *Descarga directa:*\n👉 https://drip-zone.online/DripZone.apk" + "• 📦 *Nueva Vista Dedicada 'Mis Pedidos'*: Interfaz completa y no modal en `/mis-pedidos` con pedidos agrupados por estado (Pendiente de anticipo, Anticipo aprobado, Despachados, Entregados, Cancelados), mostrando información clave (ID, fecha, total, anticipo Bold, contraentrega) y detalle desplegable con 1-clic.\n" + "• 🛒 *Selección Limpia de Producto*: Eliminado botón y banners de pedir por WhatsApp en la vitrina de producto, canalizando la compra de forma directa al carrito y checkout seguro.\n" + "• 📱 *Navegación Móvil Mejorada*: Reemplazado botón duplicado de Asesor por acceso directo a 'Mis Pedidos' en el dock inferior y en el menú superior.\n" + "• 💬 *Atención Comercial en WhatsApp & Pagos en Web/App*: Consultas por chat y procesamiento seguro de órdenes y pasarela Bold ($10k anticipo) en la app.\n\n" + "🌐 *Descarga directa del APK:*\n👉 https://drip-zone.online/DripZone.apk" ) payload = { "number": "573167455877", @@ -138,7 +140,7 @@ def send_apk_to_whatsapp(): } try: req = urllib.request.Request(url, data=json.dumps(payload).encode('utf-8'), headers=headers) - with urllib.request.urlopen(req, timeout=35) as resp: + with urllib.request.urlopen(req, timeout=180) as resp: print(f"[OK] APK despachada con exito al WhatsApp de Carlos (HTTP {resp.status})") except Exception as e: print(f"[Aviso] No se pudo enviar archivo multimedia por Evolution: {e}. Enviando mensaje de texto...") diff --git a/dripzone-app/Dockerfile b/dripzone-app/Dockerfile index 8b68acd..89b3707 100644 --- a/dripzone-app/Dockerfile +++ b/dripzone-app/Dockerfile @@ -46,6 +46,7 @@ ENV HOSTNAME="0.0.0.0" RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs +RUN mkdir -p /app/data && chown -R nextjs:nodejs /app/data # Copy standalone output & public static files COPY --from=builder /app/public ./public diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/api/ApiClient.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/api/ApiClient.kt index 1272ea3..072aa6e 100644 --- a/dripzone-app/android/app/src/main/java/co/dripzone/app/api/ApiClient.kt +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/api/ApiClient.kt @@ -56,22 +56,25 @@ object ApiClient { // 1. Try primary URL val primaryResult = fetchFromUrl(getPrimaryProductsUrl()) if (primaryResult != null && primaryResult.isNotEmpty()) { - saveCache(context, primaryResult) - return@withContext primaryResult + val activeOnly = primaryResult.filter { it.isActive } + saveCache(context, activeOnly) + return@withContext activeOnly } // 2. Try fallback URL val fallbackResult = fetchFromUrl(FALLBACK_URL) if (fallbackResult != null && fallbackResult.isNotEmpty()) { - saveCache(context, fallbackResult) - return@withContext fallbackResult + val activeOnly = fallbackResult.filter { it.isActive } + saveCache(context, activeOnly) + return@withContext activeOnly } // 3. If offline or network failed, return cached products val cached = loadCache(context) if (cached.isNotEmpty()) { - Log.i(TAG, "Serving ${cached.size} products from secure local cache") - return@withContext cached + val activeOnly = cached.filter { it.isActive } + Log.i(TAG, "Serving ${activeOnly.size} active products from secure local cache") + return@withContext activeOnly } // 4. Return initial fallback sample if cache is empty diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/model/Product.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/model/Product.kt index 2cc0908..3860c75 100644 --- a/dripzone-app/android/app/src/main/java/co/dripzone/app/model/Product.kt +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/model/Product.kt @@ -45,6 +45,9 @@ data class Product( @SerializedName("isFeatured") val isFeatured: Boolean = false, + @SerializedName("isActive") + val isActive: Boolean = true, + @SerializedName("whatsappMessageId") val whatsappMessageId: String? = null ) : Serializable { diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/model/UserManager.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/model/UserManager.kt index 34bdc59..6800cd4 100644 --- a/dripzone-app/android/app/src/main/java/co/dripzone/app/model/UserManager.kt +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/model/UserManager.kt @@ -33,6 +33,11 @@ object UserManager { ) } + fun isAuthenticated(context: Context): Boolean { + val user = getUser(context) + return user.isPhoneVerified && user.phone.isNotBlank() + } + fun saveUser( context: Context, name: String, diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/CustomOrderBottomSheet.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/CustomOrderBottomSheet.kt index 203b49b..bf999b7 100644 --- a/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/CustomOrderBottomSheet.kt +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/CustomOrderBottomSheet.kt @@ -50,12 +50,18 @@ class CustomOrderBottomSheet : BottomSheetDialogFragment() { binding.btnCloseCustomModal.setOnClickListener { dismiss() } - // Pre-fill user data if registered locally val user = UserManager.getUser(requireContext()) + val isAuth = UserManager.isAuthenticated(requireContext()) || (user.isPhoneVerified && user.phone.isNotBlank()) + if (user.name.isNotBlank()) binding.etCustomCustomerName.setText(user.name) if (user.phone.isNotBlank()) binding.etCustomCustomerPhone.setText(user.phone) if (user.city.isNotBlank()) binding.etCustomCustomerCity.setText(user.city) + if (isAuth) { + binding.btnSubmitCustomOrder.text = "Registrar Encargo Directo 🚀" + binding.btnSubmitCustomOrder.setIconResource(R.drawable.ic_check) + } + if (initialModel.isNotBlank()) { binding.etCustomModel.setText(initialModel) } @@ -130,7 +136,10 @@ class CustomOrderBottomSheet : BottomSheetDialogFragment() { notes = rawNotes val user = UserManager.getUser(requireContext()) - if (user.isPhoneVerified && user.phone == customerPhone) { + val isAuth = UserManager.isAuthenticated(requireContext()) || (user.isPhoneVerified && user.phone.isNotBlank()) + + // Si el usuario ya está autenticado en la app, NO hay necesidad de validar el usuario + if (isAuth || (user.isPhoneVerified && user.phone == customerPhone)) { submitFinalCustomRequest() } else { sendCustomOtp() diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/OrderVerificationBottomSheet.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/OrderVerificationBottomSheet.kt index 629ee7b..9fd4b7d 100644 --- a/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/OrderVerificationBottomSheet.kt +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/OrderVerificationBottomSheet.kt @@ -67,10 +67,25 @@ class OrderVerificationBottomSheet : BottomSheetDialogFragment() { // Pre-fill user data if already registered locally val user = UserManager.getUser(requireContext()) + val isAuth = UserManager.isAuthenticated(requireContext()) || (user.isPhoneVerified && user.phone.isNotBlank()) + if (user.name.isNotBlank()) binding.etCustomerName.setText(user.name) if (user.phone.isNotBlank()) binding.etCustomerPhone.setText(user.phone) if (user.address.isNotBlank()) binding.etCustomerAddress.setText(user.address) + if (isAuth) { + binding.layoutAuthUserBanner.visibility = View.VISIBLE + binding.tvAuthUserNotice.text = "Sesión activa (+57 ${user.phone}) • No requieres validación adicional ✅" + binding.tvOrderModalTitle.text = "CONFIRMAR PEDIDO" + binding.btnContinueToOtp.text = "Confirmar Pedido Contraentrega 🚀" + binding.btnContinueToOtp.setIconResource(R.drawable.ic_check) + } else { + binding.layoutAuthUserBanner.visibility = View.GONE + binding.tvOrderModalTitle.text = "DATOS DE ENTREGA" + binding.btnContinueToOtp.text = "Validar Celular y Registrar Pedido" + binding.btnContinueToOtp.setIconResource(R.drawable.ic_whatsapp) + } + // Setup economic calculations val subtotal = if (isDirectProductOrder && directItem != null) { directItem!!.itemTotal @@ -140,8 +155,18 @@ class OrderVerificationBottomSheet : BottomSheetDialogFragment() { customerAddress = rawAddress val user = UserManager.getUser(requireContext()) - // Si el usuario ya está verificado para este número, procede directo a la orden - if (user.isPhoneVerified && user.phone == customerPhone) { + val isAuth = UserManager.isAuthenticated(requireContext()) || (user.isPhoneVerified && user.phone.isNotBlank()) + + // Si el usuario ya está autenticado en la app, NO hay necesidad de validar el usuario + if (isAuth || (user.isPhoneVerified && user.phone == customerPhone)) { + UserManager.saveUser( + context = requireContext(), + name = customerName, + phone = if (isAuth && user.phone.isNotBlank()) user.phone else customerPhone, + city = selectedCity.name, + address = customerAddress, + isVerified = true + ) submitFinalOrder() } else { sendOtpCode() diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/ProductDetailBottomSheet.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/ProductDetailBottomSheet.kt index 3023920..dd13ff0 100644 --- a/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/ProductDetailBottomSheet.kt +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/ProductDetailBottomSheet.kt @@ -78,22 +78,6 @@ class ProductDetailBottomSheet : BottomSheetDialogFragment() { dismiss() } - // Order or Inquire directly via WhatsApp using registered product_direct_inquiry template - binding.btnOrderWhatsAppDirect.setOnClickListener { - val sizeToOrder = if (selectedSize.isNotBlank()) selectedSize else sizeAdapter?.getSelectedSize() ?: "38-43" - val variables = mapOf( - "tienda_nombre" to "DripZone", - "zapatilla_nombre" to p.name, - "color_nombre" to "Estilo Oficial", - "talla" to sizeToOrder, - "precio" to p.formattedPrice(), - "ciudad" to "Colombia", - "marca" to p.brand - ) - val message = co.dripzone.app.manager.TemplateManager.renderTemplate(requireContext(), "product_direct_inquiry", variables) - SecurityManager.openWhatsApp(requireContext(), SecurityManager.WHATSAPP_STORE_PHONE, message) - } - // Custom order button inside bottom sheet binding.btnCustomOrderSheet.setOnClickListener { dismiss() diff --git a/dripzone-app/android/app/src/main/res/drawable/ic_check.xml b/dripzone-app/android/app/src/main/res/drawable/ic_check.xml new file mode 100644 index 0000000..4a0b8ff --- /dev/null +++ b/dripzone-app/android/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,9 @@ + + + diff --git a/dripzone-app/android/app/src/main/res/layout/bottom_sheet_order_verification.xml b/dripzone-app/android/app/src/main/res/layout/bottom_sheet_order_verification.xml index 7528cd0..76ae977 100644 --- a/dripzone-app/android/app/src/main/res/layout/bottom_sheet_order_verification.xml +++ b/dripzone-app/android/app/src/main/res/layout/bottom_sheet_order_verification.xml @@ -62,6 +62,36 @@ android:textColor="@color/text_secondary" android:textSize="12sp" /> + + + + + + + + - + diff --git a/dripzone-app/android/app/src/main/res/layout/bottom_sheet_product_detail.xml b/dripzone-app/android/app/src/main/res/layout/bottom_sheet_product_detail.xml index 50aa8ec..ad991dc 100644 --- a/dripzone-app/android/app/src/main/res/layout/bottom_sheet_product_detail.xml +++ b/dripzone-app/android/app/src/main/res/layout/bottom_sheet_product_detail.xml @@ -170,26 +170,6 @@ app:iconPadding="8dp" app:iconSize="20dp" /> - - diff --git a/dripzone-app/docker-compose.prod.yml b/dripzone-app/docker-compose.prod.yml index cedfc7d..c106900 100644 --- a/dripzone-app/docker-compose.prod.yml +++ b/dripzone-app/docker-compose.prod.yml @@ -29,6 +29,7 @@ services: - BOLD_PROD_SECRET_KEY=Yyzm4vZjWe_N_I_5c1g7Dw volumes: - ./public/DripZone.apk:/app/public/DripZone.apk:ro + - ./data:/app/data networks: - coolify labels: diff --git a/dripzone-app/src/app/admin/catalogo-whatsapp/page.tsx b/dripzone-app/src/app/admin/catalogo-whatsapp/page.tsx new file mode 100644 index 0000000..a087f82 --- /dev/null +++ b/dripzone-app/src/app/admin/catalogo-whatsapp/page.tsx @@ -0,0 +1,687 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import Image from 'next/image'; +import { + MessageCircle, + RefreshCw, + Copy, + Check, + ExternalLink, + ShieldCheck, + AlertCircle, + CheckCircle2, + Package, + Layers, + Sparkles, + Search, + Eye, + EyeOff, + Radio, + Sliders, + Zap, + Tag, + Link2, + Download, + Send, +} from 'lucide-react'; +import { Product, StoreSettings } from '@/types'; +import { formatCOP } from '@/lib/pricing-engine'; +import { WhatsAppOrderLinkModal } from '@/components/admin/WhatsAppOrderLinkModal'; + +export default function AdminWhatsAppCatalogPage() { + const [products, setProducts] = useState([]); + const [settings, setSettings] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSyncing, setIsSyncing] = useState(false); + const [syncMessage, setSyncMessage] = useState(null); + const [copiedFeed, setCopiedFeed] = useState<'xml' | 'csv' | null>(null); + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all'); + const [savingSettings, setSavingSettings] = useState(false); + + // Modal para Generación de Enlaces de Pedido WhatsApp para Asesores + const [selectedProductForLink, setSelectedProductForLink] = useState(null); + const [isOrderLinkModalOpen, setIsOrderLinkModalOpen] = useState(false); + + // Formulario de Meta API + const [metaCatalogId, setMetaCatalogId] = useState(''); + const [metaAccessToken, setMetaAccessToken] = useState(''); + const [autoSyncEnabled, setAutoSyncEnabled] = useState(true); + + const domain = typeof window !== 'undefined' ? window.location.origin : 'https://drip-zone.online'; + const xmlFeedUrl = `${domain}/api/catalog/feed.xml`; + const csvFeedUrl = `${domain}/api/catalog/feed.csv`; + + const fetchData = async () => { + setIsLoading(true); + try { + const [resProd, resSettings] = await Promise.all([ + fetch('/api/products?all=true'), + fetch('/api/settings'), + ]); + + const dataProd = await resProd.json(); + const dataSettings = await resSettings.json(); + + if (dataProd.success && Array.isArray(dataProd.products)) { + setProducts(dataProd.products); + } + + if (dataSettings.success && dataSettings.settings) { + setSettings(dataSettings.settings); + setMetaCatalogId(dataSettings.settings.metaCatalogId || ''); + setMetaAccessToken(dataSettings.settings.metaAccessToken || ''); + setAutoSyncEnabled(dataSettings.settings.whatsappCatalogAutoSync !== false); + } + } catch (err) { + console.error('Error cargando datos del catálogo de WhatsApp:', err); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchData(); + }, []); + + const handleCopyUrl = (type: 'xml' | 'csv', url: string) => { + navigator.clipboard.writeText(url); + setCopiedFeed(type); + setTimeout(() => setCopiedFeed(null), 2500); + }; + + const handleSaveMetaSettings = async (e: React.FormEvent) => { + e.preventDefault(); + setSavingSettings(true); + try { + const res = await fetch('/api/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + metaCatalogId: metaCatalogId.trim(), + metaAccessToken: metaAccessToken.trim(), + whatsappCatalogAutoSync: autoSyncEnabled, + }), + }); + + const data = await res.json(); + if (data.success) { + setSyncMessage('✅ Configuración de Meta Commerce guardada correctamente.'); + setTimeout(() => setSyncMessage(null), 4000); + } else { + setSyncMessage(`❌ Error: ${data.error || 'No se pudo guardar la configuración'}`); + } + } catch (err: any) { + setSyncMessage(`❌ Error de red: ${err.message}`); + } finally { + setSavingSettings(false); + } + }; + + const handleSyncAll = async () => { + setIsSyncing(true); + setSyncMessage(null); + try { + const res = await fetch('/api/catalog/sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ syncAll: true }), + }); + + const data = await res.json(); + if (data.success) { + setSyncMessage(`🚀 ¡Catálogo sincronizado! ${data.message}`); + fetchData(); + } else { + setSyncMessage(`⚠️ ${data.error || 'Error al sincronizar'}`); + } + } catch (err: any) { + setSyncMessage(`❌ Error: ${err.message}`); + } finally { + setIsSyncing(false); + setTimeout(() => setSyncMessage(null), 6000); + } + }; + + const handleToggleProductStatus = async (product: Product) => { + const newStatus = product.isActive === false ? true : false; + + // Actualización optimista en UI + setProducts((prev) => + prev.map((p) => (p.id === product.id ? { ...p, isActive: newStatus } : p)) + ); + + try { + const res = await fetch('/api/products', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: product.id, + updates: { isActive: newStatus }, + }), + }); + + const data = await res.json(); + if (data.success) { + setSyncMessage( + newStatus + ? `🟢 "${product.name}" activado: visible en la app y disponible en WhatsApp Catalog.` + : `⚪ "${product.name}" desactivado: oculto de la app y del catálogo de WhatsApp.` + ); + setTimeout(() => setSyncMessage(null), 4000); + } + } catch (e: any) { + console.error('Error toggling status:', e); + } + }; + + const handleSyncSingleProduct = async (product: Product) => { + try { + const res = await fetch('/api/catalog/sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ productId: product.id }), + }); + const data = await res.json(); + if (data.success) { + setSyncMessage(`✅ Producto "${product.name}" sincronizado con WhatsApp Catalog.`); + fetchData(); + setTimeout(() => setSyncMessage(null), 4000); + } + } catch (err: any) { + setSyncMessage(`❌ Error sincronizando producto: ${err.message}`); + } + }; + + const activeProducts = products.filter((p) => p.isActive !== false); + const inactiveProducts = products.filter((p) => p.isActive === false); + + const filteredProducts = products.filter((p) => { + const matchesSearch = + p.name.toLowerCase().includes(search.toLowerCase()) || + (p.brand && p.brand.toLowerCase().includes(search.toLowerCase())) || + (p.category && p.category.toLowerCase().includes(search.toLowerCase())); + + if (statusFilter === 'active') return matchesSearch && p.isActive !== false; + if (statusFilter === 'inactive') return matchesSearch && p.isActive === false; + return matchesSearch; + }); + + return ( +
+ {/* Encabezado Principal */} +
+
+
+ + + +

+ Catálogo Oficial de WhatsApp Business +

+
+

+ Sincronización automática de calzado con foto HD, precio COP, descripción completa y tallas disponibles para la línea oficial DripZone (+57 310 639 0296). +

+
+ +
+ + + +
+
+ + {/* Banner de Orientación Comercial: Atención en WhatsApp / Cierre y Pago en Web & App */} +
+
+
+ +
+
+

+ Flujo Comercial & Procesamiento de Pedidos DripZone + + Oficial + +

+

+ El catálogo de WhatsApp permite a los usuarios consultar modelos y comunicarse para asesoría. + Todos los pedidos y pagos ($10.000 anticipo Bold + contraentrega) se procesan a través de la web o app móvil para asegurar factura y seguimiento. + Usa el botón "Cerrar Pedido por WhatsApp" en cada producto para enviar al cliente su enlace directo con talla lista. +

+
+
+
+ + {/* Toast Alert */} + {syncMessage && ( +
+ + {syncMessage} +
+ )} + + {/* Métricas y Estado */} +
+
+
+ +
+
+
{products.length}
+
Total Modelos
+
+
+ +
+
+ +
+
+
{activeProducts.length}
+
Activos en WhatsApp & App
+
+
+ +
+
+ +
+
+
{inactiveProducts.length}
+
Inactivos / Ocultos
+
+
+ +
+
+ +
+
+
Línea Oficial DripZone
+
+57 310 639 0296
+
Instancia Evolution: DripZone
+
+
+
+ + {/* Sección 1: Enlaces del Feed Automático (Meta Commerce Manager) */} +
+
+
+

+ + Orígenes de Datos para Meta Commerce & WhatsApp Catalog +

+

+ Conecta estos enlaces en tu Administrador de Ventas de Meta (Commerce Manager). Meta consultará este feed de forma automática y continua para mantener sincronizado el catálogo de WhatsApp. +

+
+
+ +
+ {/* XML Feed */} +
+
+ + + Feed XML Programado (Recomendado Meta) + + + Abrir XML + + +
+ +
+ {xmlFeedUrl} + +
+ +

+ Formato estándar Google Merchant / Facebook RSS 2.0. Incluye ID, foto HD, título, precio en COP, descripción, tallas y disponibilidad. +

+
+ + {/* CSV Feed */} +
+
+ + + Feed CSV de Importación Masiva + + + Descargar CSV + + +
+ +
+ {csvFeedUrl} + +
+ +

+ Formato delimitado por comas compatible con la carga manual por archivo en Commerce Manager o herramientas de marketing. +

+
+
+
+ + {/* Sección 2: Configuración Meta Commerce Graph API (Opcional para sincronización en tiempo real) */} +
+
+
+

+ + Sincronización Directa vía Meta Graph API (Tiempo Real) +

+

+ Si cuentas con un Catálogo creado en Business Manager, ingresa tus credenciales de la API de Meta para que cada publicación se refleje en WhatsApp al instante sin esperar el ciclo de rastreo del feed. +

+
+ + +
+ +
+
+ + setMetaCatalogId(e.target.value)} + placeholder="Ej: 104829482910485" + className="w-full bg-neutral-950 border border-neutral-800 focus:border-emerald-500 rounded-xl px-3.5 py-2.5 text-xs text-white placeholder-neutral-600 focus:outline-none transition-all" + /> +
+ +
+ + setMetaAccessToken(e.target.value)} + placeholder="EAA..." + className="w-full bg-neutral-950 border border-neutral-800 focus:border-emerald-500 rounded-xl px-3.5 py-2.5 text-xs text-white placeholder-neutral-600 focus:outline-none transition-all" + /> +
+
+ +
+ + + Sincronizar automáticamente con el catálogo de WhatsApp al publicar desde grupos monitoreados o panel de control + +
+
+ + {/* Sección 3: Explorador y Control de Productos del Catálogo */} +
+
+
+

+ + Productos en Vitrina y Control de Disponibilidad +

+

+ Revisa la imagen, el valor de venta en COP, la descripción y las tallas disponibles. Activa o desactiva productos para mostrarlos u ocultarlos en la app y WhatsApp. +

+
+ +
+ {/* Buscador */} +
+ + setSearch(e.target.value)} + placeholder="Buscar zapatilla o marca..." + className="bg-neutral-950 border border-neutral-800 focus:border-emerald-500 rounded-xl pl-9 pr-3.5 py-2 text-xs text-white placeholder-neutral-500 focus:outline-none w-48 sm:w-64" + /> +
+ + {/* Filtro por estado */} +
+ + + +
+
+
+ + {/* Lista de Productos */} + {filteredProducts.length === 0 ? ( +
+ +

No se encontraron productos con los filtros seleccionados.

+
+ ) : ( +
+ {filteredProducts.map((product) => { + const isActive = product.isActive !== false; + const primaryImage = product.images?.[0] || product.colors?.[0]?.image || '/splash.png'; + const sizesList = Array.isArray(product.sizes) && product.sizes.length > 0 + ? product.sizes.join(', ') + : '37 - 43'; + + return ( +
+
+ {/* Header de la tarjeta */} +
+ {/* Imagen HD */} +
+ {product.name} + {product.images && product.images.length > 1 && ( + + +{product.images.length - 1} + + )} +
+ +
+
+ + {product.brand || 'Sneakers'} + + + {isActive ? '🟢 ACTIVO' : '⚪ OCULTO'} + +
+ +

+ {product.name} +

+ +
+ + {formatCOP(product.retailPrice || 125000)} + + {product.originalPrice && product.originalPrice > (product.retailPrice || 0) && ( + + {formatCOP(product.originalPrice)} + + )} +
+
+
+ + {/* Tallas disponibles */} +
+
+ 📏 Tallas disponibles: + Horma Nacional +
+
+ {sizesList} +
+
+ + {/* Descripción de la publicación */} +
+ {product.description || 'Zapatillas de alta calidad con garantía y cambio de talla.'} +
+ + {/* Botón para Asesor Comercial: Enlace Directo para WhatsApp */} + +
+ + {/* Acciones de la Tarjeta */} +
+ + + +
+
+ ); + })} +
+ )} +
+ + {/* Modal para Generación de Enlace de Pedido para Asesores WhatsApp */} + setIsOrderLinkModalOpen(false)} + product={selectedProductForLink} + /> +
+ ); +} diff --git a/dripzone-app/src/app/admin/layout.tsx b/dripzone-app/src/app/admin/layout.tsx index 2909b18..0bf8c8c 100644 --- a/dripzone-app/src/app/admin/layout.tsx +++ b/dripzone-app/src/app/admin/layout.tsx @@ -20,6 +20,7 @@ import { FileText, LogIn, Sparkles, + MessageCircle, } from 'lucide-react'; import { useAuth } from '@/context/AuthContext'; import { AuthModal } from '@/components/auth/AuthModal'; @@ -38,6 +39,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/catalogo-whatsapp', label: 'Catálogo de WhatsApp', icon: MessageCircle, badge: 'Meta Commerce' }, { 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 }, diff --git a/dripzone-app/src/app/admin/productos/page.tsx b/dripzone-app/src/app/admin/productos/page.tsx index 7238bd1..c3fb3f0 100644 --- a/dripzone-app/src/app/admin/productos/page.tsx +++ b/dripzone-app/src/app/admin/productos/page.tsx @@ -1,6 +1,7 @@ 'use client'; import React, { useState, useEffect, useMemo } from 'react'; +import Link from 'next/link'; 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'; @@ -12,8 +13,7 @@ import { OptimizedImage } from '@/components/ui/OptimizedImage'; import { sanitizePublicationTitle, sanitizePublicationText } from '@/lib/sanitizer'; import { cleanProductName, detectBrandFromText } from '@/lib/gemini-parser'; import { generateAttractiveProductCopy } from '@/lib/attractive-copy'; - - +import { WhatsAppOrderLinkModal } from '@/components/admin/WhatsAppOrderLinkModal'; export default function AdminProductsPage() { const [products, setProducts] = useState(INITIAL_PRODUCTS); @@ -32,6 +32,10 @@ export default function AdminProductsPage() { const [bulkCategorySelect, setBulkCategorySelect] = useState('Zapatillas'); const [isProcessingBulk, setIsProcessingBulk] = useState(false); + // Asesor WhatsApp: Modal de Generación de Enlace de Pedido + const [selectedProductForWaLink, setSelectedProductForWaLink] = useState(null); + const [isWaOrderLinkModalOpen, setIsWaOrderLinkModalOpen] = useState(false); + // Pagination State const [currentPage, setCurrentPage] = useState(1); const [pageSize, setPageSize] = useState(15); @@ -499,6 +503,14 @@ export default function AdminProductsPage() {
+ + + Catálogo WhatsApp + +
- {/* Action buttons (Manage Variants, Edit & Delete) */} + {/* Action buttons (WhatsApp Order, Manage Variants, Edit & Delete) */}
+
)} + + {/* Modal para Generación de Enlace de Pedido para Asesores WhatsApp */} + setIsWaOrderLinkModalOpen(false)} + product={selectedProductForWaLink} + /> ); } diff --git a/dripzone-app/src/app/api/catalog/feed.csv/route.ts b/dripzone-app/src/app/api/catalog/feed.csv/route.ts new file mode 100644 index 0000000..03ef6c2 --- /dev/null +++ b/dripzone-app/src/app/api/catalog/feed.csv/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from 'next/server'; +import { dataService } from '@/lib/data-service'; +import { generateWhatsAppCatalogCsv } from '@/lib/whatsapp-catalog'; +import { getProductsFromAppwrite } from '@/lib/server-appwrite'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + try { + let products = dataService.getProducts(); + if (products.length === 0) { + try { + const appwriteProducts = await getProductsFromAppwrite(); + if (appwriteProducts && appwriteProducts.length > 0) { + appwriteProducts.forEach((p) => dataService.addProduct(p)); + products = dataService.getProducts(); + } + } catch (e) { + console.warn('Error sincronizando productos con Appwrite para CSV Feed:', e); + } + } + + const csv = generateWhatsAppCatalogCsv(products); + + return new Response(csv, { + status: 200, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': 'inline; filename="dripzone_whatsapp_catalog.csv"', + 'Cache-Control': 'public, max-age=300, stale-while-revalidate=600', + }, + }); + } catch (error: any) { + console.error('[WhatsApp Catalog Feed CSV] Error:', error); + return new NextResponse('error,message\n500,"Error generating CSV catalog feed"', { + status: 500, + headers: { 'Content-Type': 'text/csv; charset=utf-8' }, + }); + } +} diff --git a/dripzone-app/src/app/api/catalog/feed.xml/route.ts b/dripzone-app/src/app/api/catalog/feed.xml/route.ts new file mode 100644 index 0000000..74fe1f3 --- /dev/null +++ b/dripzone-app/src/app/api/catalog/feed.xml/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from 'next/server'; +import { dataService } from '@/lib/data-service'; +import { generateWhatsAppCatalogXml } from '@/lib/whatsapp-catalog'; +import { getProductsFromAppwrite } from '@/lib/server-appwrite'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + try { + let products = dataService.getProducts(); + if (products.length === 0) { + try { + const appwriteProducts = await getProductsFromAppwrite(); + if (appwriteProducts && appwriteProducts.length > 0) { + appwriteProducts.forEach((p) => dataService.addProduct(p)); + products = dataService.getProducts(); + } + } catch (e) { + console.warn('Error sincronizando productos con Appwrite para XML Feed:', e); + } + } + + const settings = dataService.getSettings(); + const xml = generateWhatsAppCatalogXml(products, settings); + + return new Response(xml, { + status: 200, + headers: { + 'Content-Type': 'application/xml; charset=utf-8', + 'Cache-Control': 'public, max-age=300, stale-while-revalidate=600', + }, + }); + } catch (error: any) { + console.error('[WhatsApp Catalog Feed XML] Error:', error); + return new NextResponse( + `${error.message || 'Error generating feed'}`, + { + status: 500, + headers: { 'Content-Type': 'application/xml; charset=utf-8' }, + } + ); + } +} diff --git a/dripzone-app/src/app/api/catalog/sync/route.ts b/dripzone-app/src/app/api/catalog/sync/route.ts new file mode 100644 index 0000000..cc1a720 --- /dev/null +++ b/dripzone-app/src/app/api/catalog/sync/route.ts @@ -0,0 +1,96 @@ +import { NextResponse } from 'next/server'; +import { dataService } from '@/lib/data-service'; +import { + formatProductForWhatsAppCatalog, + syncProductToMetaCatalogApi, + registerProductInWhatsAppCatalog, +} from '@/lib/whatsapp-catalog'; +import { getProductsFromAppwrite } from '@/lib/server-appwrite'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + try { + let products = dataService.getProducts(); + if (products.length === 0) { + try { + const appwriteProducts = await getProductsFromAppwrite(); + if (appwriteProducts && appwriteProducts.length > 0) { + appwriteProducts.forEach((p) => dataService.addProduct(p)); + products = dataService.getProducts(); + } + } catch (e) {} + } + const settings = dataService.getSettings(); + const activeProducts = products.filter((p) => p.isActive !== false); + + const formattedSample = activeProducts.slice(0, 5).map((p) => formatProductForWhatsAppCatalog(p)); + + return NextResponse.json({ + success: true, + data: { + totalProducts: products.length, + activeProducts: activeProducts.length, + syncedCount: activeProducts.filter((p) => p.whatsappCatalogSynced).length, + feedXmlUrl: `${process.env.NEXT_PUBLIC_APP_URL || 'https://drip-zone.online'}/api/catalog/feed.xml`, + feedCsvUrl: `${process.env.NEXT_PUBLIC_APP_URL || 'https://drip-zone.online'}/api/catalog/feed.csv`, + metaCatalogConfigured: Boolean(settings?.metaCatalogId && settings?.metaAccessToken), + metaCatalogId: settings?.metaCatalogId || '', + autoSyncEnabled: settings?.whatsappCatalogAutoSync !== false, + sampleCatalogItems: formattedSample, + }, + }); + } catch (error: any) { + console.error('[WhatsApp Catalog Sync GET] Error:', error); + return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const body = await request.json().catch(() => ({})); + const { productId, syncAll } = body; + + const products = dataService.getProducts(); + const settings = dataService.getSettings(); + + if (productId) { + const product = products.find((p) => p.id === productId); + if (!product) { + return NextResponse.json({ success: false, error: 'Producto no encontrado' }, { status: 404 }); + } + + const syncResult = await registerProductInWhatsAppCatalog(product); + return NextResponse.json({ + success: true, + message: `Producto "${product.name}" sincronizado con el catálogo de WhatsApp`, + catalogItem: syncResult.catalogItem, + }); + } + + // Sincronizar todos los productos activos + const activeProducts = products.filter((p) => p.isActive !== false); + let syncedCount = 0; + const errors: string[] = []; + + for (const product of activeProducts) { + try { + await registerProductInWhatsAppCatalog(product); + syncedCount++; + } catch (err: any) { + errors.push(`Error en producto ${product.name}: ${err.message}`); + } + } + + return NextResponse.json({ + success: true, + message: `${syncedCount} de ${activeProducts.length} productos registrados y actualizados para el catálogo de WhatsApp`, + totalActive: activeProducts.length, + syncedCount, + errors: errors.length > 0 ? errors : undefined, + }); + } catch (error: any) { + console.error('[WhatsApp Catalog Sync POST] Error:', error); + 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 11b66d2..8667926 100644 --- a/dripzone-app/src/app/api/products/route.ts +++ b/dripzone-app/src/app/api/products/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import { dataService } from '@/lib/data-service'; import { getProductSocialProof } from '@/lib/social-proof'; import { deleteProductFromAppwrite, saveProductToAppwrite, getProductsFromAppwrite } from '@/lib/server-appwrite'; +import { registerProductInWhatsAppCatalog } from '@/lib/whatsapp-catalog'; export async function GET(request: Request) { const { searchParams } = new URL(request.url); @@ -62,6 +63,7 @@ export async function POST(request: Request) { const updated = dataService.updateProduct(id, { isActive }); if (updated) { saveProductToAppwrite(updated).catch(() => {}); + registerProductInWhatsAppCatalog(updated).catch(() => {}); updatedCount++; } } @@ -249,6 +251,9 @@ export async function POST(request: Request) { isActive: body.isActive !== undefined ? body.isActive : true, }); saveProductToAppwrite(newProduct).catch(() => {}); + registerProductInWhatsAppCatalog(newProduct).catch((e) => + console.warn('[WhatsApp Catalog Sync Error]:', e) + ); return NextResponse.json({ success: true, product: newProduct }); } catch (error: any) { return NextResponse.json({ success: false, error: error.message }, { status: 400 }); @@ -269,6 +274,9 @@ export async function PUT(request: Request) { } saveProductToAppwrite(updated).catch(() => {}); + registerProductInWhatsAppCatalog(updated).catch((e) => + console.warn('[WhatsApp Catalog Sync Error]:', e) + ); return NextResponse.json({ success: true, product: updated }); } catch (error: any) { return NextResponse.json({ success: false, error: error.message }, { status: 400 }); diff --git a/dripzone-app/src/app/api/webhook/whatsapp/route.ts b/dripzone-app/src/app/api/webhook/whatsapp/route.ts index 0ef9139..735816a 100644 --- a/dripzone-app/src/app/api/webhook/whatsapp/route.ts +++ b/dripzone-app/src/app/api/webhook/whatsapp/route.ts @@ -4,7 +4,7 @@ import { parseSupplierMessage, detectCategory } from '@/lib/gemini-parser'; import { sanitizePublicationTitle } from '@/lib/sanitizer'; import { generateAttractiveProductCopy } from '@/lib/attractive-copy'; import { getProductSocialProof } from '@/lib/social-proof'; -import { notifyNewProductPublished } from '@/lib/evolution-api'; +import { notifyNewProductPublished, sendWhatsAppMessage, sendWhatsAppMedia } from '@/lib/evolution-api'; import { getMonitoredGroupsFromAppwrite, saveProductToAppwrite, @@ -13,9 +13,13 @@ import { getProductsFromAppwrite, } from '@/lib/server-appwrite'; import { Product, SupplierPost } from '@/types'; +import { registerProductInWhatsAppCatalog } from '@/lib/whatsapp-catalog'; +import { formatCOP } from '@/lib/pricing-engine'; // Cache en memoria para evitar procesar mensajes duplicados de WhatsApp en ráfaga const processedMessagesCache = new Map(); +// Cache en memoria para control de cadencia de respuestas comerciales automáticas a clientes +const customerChatCache = new Map(); function isDuplicateMessage(msgId: string, text: string): boolean { const key = `${msgId}:${(text || '').trim().substring(0, 60)}`; @@ -89,11 +93,138 @@ export async function POST(request: Request) { return NextResponse.json({ success: true, ignored: 'Bucle de mensaje propio del bot evitado' }); } + // ========================================================================= + // CASO A: ATENCIÓN COMERCIAL VÍA MENSAJES DE WHATSAPP (Chat Directo 1 a 1) + // Cuando los clientes consultan el catálogo de WhatsApp y escriben por chat, + // se les orienta y se les envía el enlace directo para procesar el pedido + // y pago seguro ($10.000 anticipo Bold + contraentrega) en la web o app móvil. + // ========================================================================= + if (!remoteJid.includes('g.us')) { + if (fromMe) { + return NextResponse.json({ success: true, ignored: 'Mensaje saliente de la tienda (fromMe: true)' }); + } + + const customerPhone = remoteJid.replace(/\D/g, ''); + console.log(`[DripZone Asesor Comercial WhatsApp] Mensaje recibido de cliente +${customerPhone}: "${messageContent}"`); + + // Control de duplicados o respuestas en ráfaga (máximo 1 respuesta automática cada 3 minutos por cliente salvo mención explícita) + const lastReplyTime = customerChatCache.get(customerPhone) || 0; + const now = Date.now(); + const isQuickFollowup = (now - lastReplyTime) < 180000; // 3 minutos + + const lowerText = (messageContent || '').toLowerCase().trim(); + const allActiveProducts = dataService.getProducts().filter((p) => p.isActive !== false); + + // 1. Buscar si el cliente pregunta por un modelo específico, ID o referencia del catálogo + let matchedProduct = allActiveProducts.find((p) => { + const prodName = (p.name || '').toLowerCase(); + const model = (p.modelName || '').toLowerCase(); + const brand = (p.brand || '').toLowerCase(); + if (lowerText.includes(p.id.toLowerCase())) return true; + if (prodName.length > 5 && lowerText.includes(prodName)) return true; + const modelWords = model.split(/\s+/).filter((w) => w.length > 3); + return modelWords.length > 0 && modelWords.some((w) => lowerText.includes(w)) && (brand ? lowerText.includes(brand) : true); + }); + + if (!matchedProduct && lowerText.includes('snk-')) { + const matchId = lowerText.match(/snk-[a-z0-9-]+/i); + if (matchId) { + matchedProduct = allActiveProducts.find((p) => p.id === matchId[0]); + } + } + + // Si se identificó el calzado solicitado por el cliente: + if (matchedProduct) { + customerChatCache.set(customerPhone, now); + + const checkoutUrl = `https://drip-zone.online/checkout?productId=${encodeURIComponent(matchedProduct.id)}&source=whatsapp`; + const sizesText = Array.isArray(matchedProduct.sizes) && matchedProduct.sizes.length > 0 + ? matchedProduct.sizes.join(', ') + : '37, 38, 39, 40, 41, 42, 43'; + const priceFormatted = formatCOP(matchedProduct.retailPrice || 125000); + + const responseText = ( + `👟 *${matchedProduct.name}*\n` + + `━━━━━━━━━━━━━━━━━━━━━━\n` + + `📏 *Tallas Disponibles:* ${sizesText} (Horma Nacional)\n` + + `💵 *Precio Oficial:* *${priceFormatted} COP*\n` + + `🚚 *Pago Contraentrega:* Pagas un anticipo de *$10.000* para despacho garantizado y el resto al recibir.\n` + + `🛡️ *Garantía:* 1er cambio de talla gratuito.\n\n` + + `🛒 *¿Cómo procesar tu pedido y pago seguro?*\n` + + `Para tu seguridad, asignación de factura y reserva de tu talla, el pedido y pago del anticipo ($10.000 por Bold / PSE / Nequi / Tarjetas) se procesan a través de nuestra plataforma:\n\n` + + `👉 *Enlace directo de compra:* ${checkoutUrl}\n` + + `📱 *O pide en nuestra App Móvil:* https://drip-zone.online/app-dripzone.apk\n\n` + + `Diligencia tus datos de envío en el enlace y hoy mismo programamos tu despacho. Si necesitas ayuda con tu talla habitual, ¡aquí estamos para asesorarte!` + ); + + let sent = false; + const primaryImage = matchedProduct.images?.[0] || matchedProduct.colors?.[0]?.image; + if (primaryImage) { + const absImageUrl = primaryImage.startsWith('http') + ? primaryImage + : `https://drip-zone.online${primaryImage.startsWith('/') ? '' : '/'}${primaryImage}`; + sent = await sendWhatsAppMedia(customerPhone, absImageUrl, responseText); + } + if (!sent) { + await sendWhatsAppMessage(customerPhone, responseText); + } + + return NextResponse.json({ + success: true, + handled: 'product_inquiry', + productId: matchedProduct.id, + customerPhone, + }); + } + + // 2. Si es un saludo general o consulta sobre el catálogo, compra, pedidos o formas de pago: + const isCommercialGreeting = /hola|buenas|catalogo|catálogo|zapatillas|precio|precios|comprar|pedido|pedir|tallas|envio|envíos|contraentrega|bold|pagar|pago/i.test( + lowerText + ); + + if (isCommercialGreeting && !isQuickFollowup) { + customerChatCache.set(customerPhone, now); + + const generalResponse = ( + `¡Hola! 👋 Te damos la bienvenida a *DripZone Colombia* 🇨🇴\n` + + `Tu tienda oficial de sneakers, zapatillas y streetwear con horma nacional.\n` + + `━━━━━━━━━━━━━━━━━━━━━━\n\n` + + `👀 *Puedes consultar nuestro catálogo y modelos aquí en WhatsApp o en nuestra tienda online:*\n` + + `🌐 *Catálogo Completo:* https://drip-zone.online\n` + + `🛒 *Procesar Pedido:* https://drip-zone.online/checkout\n` + + `📱 *Descargar App Android:* https://drip-zone.online/app-dripzone.apk\n\n` + + `💡 *¿Cómo funciona tu compra?*\n` + + `1️⃣ Elige tu modelo y talla en el catálogo.\n` + + `2️⃣ Los pedidos y pagos se procesan en la web o app para asegurar tu factura y despacho inmediato.\n` + + `3️⃣ Realizas un anticipo de *$10.000* por Bold (PSE, tarjetas o Nequi) y pagas el saldo restante *contraentrega* al recibir en tu puerta.\n` + + `4️⃣ Tu primer cambio de talla es 100% gratuito.\n\n` + + `💬 *¿Te gustó algún modelo de nuestro catálogo de WhatsApp?* Indícanos el nombre o tu talla y con gusto te asesoramos y enviamos el enlace directo.` + ); + + await sendWhatsAppMessage(customerPhone, generalResponse); + + return NextResponse.json({ + success: true, + handled: 'general_commercial_greeting', + customerPhone, + }); + } + + return NextResponse.json({ + success: true, + handled: 'advisor_conversation_active', + customerPhone, + }); + } + + // ========================================================================= + // CASO B: GRUPOS MAYORISTAS DE PROVEEDORES (Monitoreo y Publicación Automática) + // ========================================================================= const groupSubject = data.groupInfo?.subject || data.chatName || data.pushName || - (remoteJid.includes('g.us') ? 'Grupo Mayorista' : 'Chat Directo'); + 'Grupo Mayorista'; // 1. VALIDAR SI EL GRUPO ESTÁ MONITOREADO const [appwriteGroups, localGroups] = await Promise.all([ @@ -228,7 +359,7 @@ export async function POST(request: Request) { salesCount: proof.salesCount, originBadge: proof.badge, supplierName: groupSubject, - supplierPhone: data.key?.participant?.replace(/\D/g, '') || '573167455877', + supplierPhone: data.key?.participant?.replace(/\D/g, '') || dataService.getSettings()?.phone || '573106390296', whatsappMessageId, }; @@ -236,6 +367,11 @@ export async function POST(request: Request) { const addedProduct = dataService.addProduct(newProduct); await saveProductToAppwrite(addedProduct); + // Registrar automáticamente en el Catálogo de WhatsApp de la tienda (Meta Commerce) + await registerProductInWhatsAppCatalog(addedProduct).catch((e) => + console.warn('[WhatsApp Catalog Auto-Sync from Webhook Error]:', e) + ); + // Registrar también como post publicado en el feed interno const newPost: SupplierPost = { id: `post-${whatsappMessageId}`, diff --git a/dripzone-app/src/app/api/whatsapp-feed/route.ts b/dripzone-app/src/app/api/whatsapp-feed/route.ts index 2b2d249..b21d9a3 100644 --- a/dripzone-app/src/app/api/whatsapp-feed/route.ts +++ b/dripzone-app/src/app/api/whatsapp-feed/route.ts @@ -13,6 +13,7 @@ import { getProductsFromAppwrite, } from '@/lib/server-appwrite'; import { SupplierPost, MonitoredWhatsAppGroup } from '@/types'; +import { registerProductInWhatsAppCatalog } from '@/lib/whatsapp-catalog'; const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL || 'https://evolution.almeira.com.co'; const EVOLUTION_API_KEY = process.env.EVOLUTION_FEED_API_KEY || 'EE49029EEB85-4568-8F81-C9FDFC6E2943'; @@ -424,12 +425,13 @@ export async function POST(request: Request) { salesCount: proof.salesCount, originBadge: proof.badge, supplierName: post.supplierName || post.channelName || 'Proveedor Mayorista', - supplierPhone: post.supplierPhone || '573167455877', + supplierPhone: post.supplierPhone || dataService.getSettings()?.phone || '573106390296', whatsappMessageId: whatsappMsgId, }; const added = dataService.addProduct(newProduct as any); await saveProductToAppwrite(added); + await registerProductInWhatsAppCatalog(added).catch(() => {}); await updateSupplierPostStatusInAppwrite(post.id, 'published'); dataService.updateSupplierPostStatus(post.id, 'published'); @@ -495,12 +497,13 @@ export async function POST(request: Request) { salesCount: proof.salesCount, originBadge: proof.badge, supplierName: p.supplierName || p.channelName || 'Proveedor Mayorista', - supplierPhone: p.supplierPhone || '573167455877', + supplierPhone: p.supplierPhone || dataService.getSettings()?.phone || '573106390296', whatsappMessageId: whatsappMsgId, }; const added = dataService.addProduct(newProd as any); await saveProductToAppwrite(added); + await registerProductInWhatsAppCatalog(added).catch(() => {}); await updateSupplierPostStatusInAppwrite(p.id, 'published'); dataService.updateSupplierPostStatus(p.id, 'published'); createdProducts.push(added); diff --git a/dripzone-app/src/app/checkout/page.tsx b/dripzone-app/src/app/checkout/page.tsx index b386fbe..d703df8 100644 --- a/dripzone-app/src/app/checkout/page.tsx +++ b/dripzone-app/src/app/checkout/page.tsx @@ -21,6 +21,7 @@ export default function CheckoutPage() { const [address, setAddress] = useState(''); const [neighborhood, setNeighborhood] = useState(''); const [notes, setNotes] = useState(''); + const [orderSource, setOrderSource] = useState('web'); const [cities, setCities] = useState(INITIAL_CITIES); const [paymentMethods, setPaymentMethods] = useState(INITIAL_PAYMENT_METHODS); @@ -28,7 +29,7 @@ export default function CheckoutPage() { const [selectedPaymentId, setSelectedPaymentId] = useState('pay-deposit-10k'); const [storeSettings, setStoreSettings] = useState(null); - const [checkoutItems] = useState>( - INITIAL_PRODUCTS.length > 0 - ? [ - { - productId: INITIAL_PRODUCTS[0].id, - productName: INITIAL_PRODUCTS[0].name, - brand: INITIAL_PRODUCTS[0].brand, - size: '39', - quantity: 1, - price: INITIAL_PRODUCTS[0].retailPrice, - image: INITIAL_PRODUCTS[0].images[0], - }, - ] - : [] - ); + availableSizes?: string[]; + }>>([]); + + const handleUpdateItemSize = (index: number, newSize: string) => { + setCheckoutItems((prev) => { + const copy = [...prev]; + if (copy[index]) { + copy[index] = { ...copy[index], size: newSize }; + } + try { + localStorage.setItem('dripzone_cart', JSON.stringify(copy)); + } catch (_) {} + return copy; + }); + }; const [isSubmitting, setIsSubmitting] = useState(false); const [orderCompleted, setOrderCompleted] = useState(null); @@ -86,6 +87,104 @@ export default function CheckoutPage() { .then((r) => r.json()) .then((d) => d.success && setStoreSettings(d.settings)) .catch(() => {}); + + // Carga dinámica de productos desde URL (WhatsApp Catalog / Asesor) o Carrito local + if (typeof window !== 'undefined') { + const params = new URLSearchParams(window.location.search); + const urlProductId = params.get('productId') || params.get('id'); + const urlSize = params.get('size'); + const urlPrice = params.get('price') ? parseInt(params.get('price')!, 10) : undefined; + const urlName = params.get('name'); + const urlSource = params.get('source'); + + if (urlSource === 'whatsapp' || urlProductId) { + setOrderSource('whatsapp'); + } + + if (urlProductId) { + fetch('/api/products?all=true') + .then((r) => r.json()) + .then((d) => { + if (d.success && Array.isArray(d.products)) { + const matched = d.products.find((p: any) => p.id === urlProductId); + if (matched) { + const item = { + productId: matched.id, + productName: matched.name, + brand: matched.brand || 'Sneakers', + size: urlSize || (matched.sizes?.[0] || '39'), + quantity: 1, + price: urlPrice || matched.retailPrice || 125000, + image: matched.images?.[0] || matched.colors?.[0]?.image || '/splash.png', + availableSizes: matched.sizes || ['37', '38', '39', '40', '41', '42', '43'], + }; + setCheckoutItems([item]); + localStorage.setItem('dripzone_cart', JSON.stringify([item])); + return; + } + } + }) + .catch(() => {}); + } else if (urlName) { + const item = { + productId: `custom-${Date.now()}`, + productName: decodeURIComponent(urlName), + brand: 'DripZone', + size: urlSize || '39', + quantity: 1, + price: urlPrice || 125000, + image: params.get('image') ? decodeURIComponent(params.get('image')!) : '/splash.png', + availableSizes: ['37', '38', '39', '40', '41', '42', '43'], + }; + setCheckoutItems([item]); + return; + } + + // Si no viene en URL, intentar cargar de localStorage + try { + const stored = localStorage.getItem('dripzone_cart'); + if (stored) { + const parsed = JSON.parse(stored); + if (Array.isArray(parsed) && parsed.length > 0) { + setCheckoutItems( + parsed.map((item: any) => ({ + productId: item.productId || item.product?.id || `item-${Date.now()}`, + productName: item.productName || item.product?.name || 'Zapatillas DripZone', + brand: item.brand || item.product?.brand || 'Sneakers', + size: item.size || item.selectedSize || '39', + quantity: item.quantity || 1, + price: item.price || item.product?.retailPrice || 125000, + image: item.image || item.product?.images?.[0] || '/splash.png', + availableSizes: item.availableSizes || item.product?.sizes || ['37', '38', '39', '40', '41', '42', '43'], + })) + ); + return; + } + } + } catch (_) {} + + // Fallback a productos activos + fetch('/api/products') + .then((r) => r.json()) + .then((d) => { + if (d.success && d.products?.length > 0) { + const first = d.products[0]; + setCheckoutItems([ + { + productId: first.id, + productName: first.name, + brand: first.brand || 'Sneakers', + size: first.sizes?.[0] || '39', + quantity: 1, + price: first.retailPrice || 125000, + image: first.images?.[0] || first.colors?.[0]?.image || '/splash.png', + availableSizes: first.sizes || ['37', '38', '39', '40', '41', '42', '43'], + }, + ]); + } + }) + .catch(() => {}); + } }, []); useEffect(() => { @@ -143,7 +242,8 @@ export default function CheckoutPage() { balanceDue, paymentMethodId: activePayment.id, paymentMethodName: activePayment.name, - notes, + notes: notes ? `${notes} (Origen: ${orderSource})` : `Origen: ${orderSource}`, + source: orderSource, }; const res = await fetch('/api/orders', { @@ -471,6 +571,38 @@ export default function CheckoutPage() { + {/* Banner de Pedido Originado en WhatsApp */} + {orderSource === 'whatsapp' && ( +
+
+
+ +
+
+
+

¡Pedido iniciado desde WhatsApp!

+ + Atención Comercial + +
+

+ Tu calzado y talla seleccionada están reservados. Completa tu dirección y confirma el anticipo de $10.000 por Bold (PSE, tarjetas o Nequi) para despachar hoy. El saldo restante lo pagas contraentrega al recibir en tu puerta. +

+
+
+ + + + O pedir en App Móvil + + +
+ )} +
{/* Left Column: Shipping & Payment Details (7 cols) */}
@@ -618,25 +750,52 @@ export default function CheckoutPage() {

Resumen del Pedido

{/* Items */} -
+
{checkoutItems.map((item, i) => ( -
- -
-

{item.productName}

-

Talla: {item.size} | Cant: {item.quantity}

-

- {formatCOP(item.price * item.quantity)} -

+
+
+ +
+

{item.productName}

+

Talla elegida: {item.size}

+

+ {formatCOP(item.price * item.quantity)} +

+
+ + {/* Selector de tallas interactivo para clientes desde WhatsApp */} + {item.availableSizes && item.availableSizes.length > 0 && ( +
+ + 📏 Selecciona o Cambia tu Talla: + +
+ {item.availableSizes.map((sz) => ( + + ))} +
+
+ )}
))}
diff --git a/dripzone-app/src/app/mis-pedidos/page.tsx b/dripzone-app/src/app/mis-pedidos/page.tsx new file mode 100644 index 0000000..c369e8d --- /dev/null +++ b/dripzone-app/src/app/mis-pedidos/page.tsx @@ -0,0 +1,808 @@ +'use client'; + +import React, { useState, useEffect, useMemo } from 'react'; +import Link from 'next/link'; +import Image from 'next/image'; +import { + Package, + Search, + ArrowLeft, + Clock, + Zap, + Truck, + CheckCircle2, + XCircle, + AlertCircle, + ChevronDown, + ChevronUp, + Copy, + Check, + ExternalLink, + MessageCircle, + MapPin, + CreditCard, + ShieldCheck, + Phone, + Calendar, + Filter, + ShoppingBag, + RefreshCw, + User, + SlidersHorizontal, +} from 'lucide-react'; +import { Order, OrderItem } from '@/types'; +import { formatCOP } from '@/lib/pricing-engine'; +import { useAuth } from '@/context/AuthContext'; +import { useBrand } from '@/context/BrandContext'; + +type StatusGroup = 'all' | 'pending' | 'processing' | 'dispatched' | 'delivered' | 'cancelled'; + +export default function MisPedidosPage() { + const { currentBrand } = useBrand(); + const { user, openAuthModal } = useAuth(); + + const [orders, setOrders] = useState([]); + const [phoneSearch, setPhoneSearch] = useState(''); + const [activeStatusFilter, setActiveStatusFilter] = useState('all'); + const [expandedOrderIds, setExpandedOrderIds] = useState>(new Set()); + const [isLoading, setIsLoading] = useState(true); + const [copiedOrderId, setCopiedOrderId] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + + // Carga inicial: Si el usuario está autenticado, usar su teléfono; sino, intentar leer de localStorage + useEffect(() => { + let initialPhone = ''; + if (user?.phone) { + initialPhone = user.phone.replace(/\D/g, ''); + } else if (typeof window !== 'undefined') { + const stored = localStorage.getItem('dripzone_customer_phone'); + if (stored) { + initialPhone = stored.replace(/\D/g, ''); + } + } + + if (initialPhone) { + setPhoneSearch(initialPhone); + fetchOrders(initialPhone); + } else { + fetchOrders(); + } + }, [user]); + + const fetchOrders = async (phoneFilter?: string) => { + setIsLoading(true); + try { + const clean = (phoneFilter !== undefined ? phoneFilter : phoneSearch).replace(/\D/g, ''); + const url = clean.length >= 7 ? `/api/orders?phone=${clean}` : '/api/orders'; + const res = await fetch(url); + const data = await res.json(); + if (data.success && Array.isArray(data.orders)) { + setOrders(data.orders); + // Si hay un solo pedido, expandirlo automáticamente + if (data.orders.length === 1) { + setExpandedOrderIds(new Set([data.orders[0].id])); + } + } else { + setOrders([]); + } + } catch (err) { + console.error('Error consultando pedidos:', err); + setOrders([]); + } finally { + setIsLoading(false); + } + }; + + const handleSearchSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const clean = phoneSearch.replace(/\D/g, ''); + if (typeof window !== 'undefined' && clean) { + try { + localStorage.setItem('dripzone_customer_phone', clean); + } catch (_) {} + } + fetchOrders(clean); + }; + + const toggleExpandOrder = (orderId: string) => { + setExpandedOrderIds((prev) => { + const copy = new Set(prev); + if (copy.has(orderId)) { + copy.delete(orderId); + } else { + copy.add(orderId); + } + return copy; + }); + }; + + const handleCopyOrderId = (id: string, e: React.MouseEvent) => { + e.stopPropagation(); + navigator.clipboard.writeText(id); + setCopiedOrderId(id); + setTimeout(() => setCopiedOrderId(null), 2000); + }; + + // Mapeo de Estados para Agrupación + const getStatusMeta = (status: Order['status'], depositPaid?: boolean) => { + switch (status) { + case 'pending': + return { + label: 'Pendiente de Anticipo', + sublabel: 'Esperando confirmación de separación ($10.000 COP)', + badgeColor: 'bg-amber-500/10 text-amber-400 border-amber-500/30', + icon: Clock, + group: 'pending', + }; + case 'deposit_received': + return { + label: 'Anticipo Confirmado', + sublabel: 'Alistando tu calzado en bodega', + badgeColor: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30', + icon: Zap, + group: 'processing', + }; + case 'supplier_notified': + return { + label: 'En Preparación', + sublabel: 'Verificando horma y empaque con proveedor', + badgeColor: 'bg-purple-500/10 text-purple-400 border-purple-500/30', + icon: Package, + group: 'processing', + }; + case 'dispatched': + return { + label: 'Despachado en Camino', + sublabel: 'En ruta con transportadora a tu domicilio', + badgeColor: 'bg-blue-500/10 text-blue-400 border-blue-500/30', + icon: Truck, + group: 'dispatched', + }; + case 'delivered': + return { + label: 'Entregado con Éxito', + sublabel: 'Pedido completado y recibido por el cliente', + badgeColor: 'bg-emerald-600/20 text-emerald-300 border-emerald-500/40', + icon: CheckCircle2, + group: 'delivered', + }; + case 'rejected': + case 'cancelled': + return { + label: status === 'rejected' ? 'Rechazado' : 'Cancelado', + sublabel: 'Pedido cancelado o no recibido en destino', + badgeColor: 'bg-red-500/10 text-red-400 border-red-500/30', + icon: XCircle, + group: 'cancelled', + }; + default: + return { + label: 'En Proceso', + sublabel: 'Procesando orden', + badgeColor: 'bg-neutral-800 text-neutral-300 border-neutral-700', + icon: Clock, + group: 'pending', + }; + } + }; + + // Contadores por Grupo de Estado + const counts = useMemo(() => { + const res = { + all: orders.length, + pending: 0, + processing: 0, + dispatched: 0, + delivered: 0, + cancelled: 0, + }; + orders.forEach((o) => { + const meta = getStatusMeta(o.status, o.depositPaid); + if (meta.group in res) { + res[meta.group as keyof typeof res]++; + } + }); + return res; + }, [orders]); + + // Filtrado de pedidos según Pestaña de Estado y Buscador interno + const filteredOrders = useMemo(() => { + return orders.filter((order) => { + const meta = getStatusMeta(order.status, order.depositPaid); + if (activeStatusFilter !== 'all' && meta.group !== activeStatusFilter) { + return false; + } + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase(); + const matchId = order.id.toLowerCase().includes(q); + const matchCity = (order.cityName || '').toLowerCase().includes(q); + const matchName = (order.customerName || '').toLowerCase().includes(q); + const matchItem = order.items.some( + (it) => it.productName.toLowerCase().includes(q) || it.brand.toLowerCase().includes(q) + ); + if (!matchId && !matchCity && !matchName && !matchItem) return false; + } + return true; + }); + }, [orders, activeStatusFilter, searchQuery]); + + const formatDate = (dateStr: string) => { + try { + const d = new Date(dateStr); + if (isNaN(d.getTime())) return dateStr; + return d.toLocaleDateString('es-CO', { + day: 'numeric', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return dateStr; + } + }; + + const handleWhatsAppConsult = (order: Order, e: React.MouseEvent) => { + e.stopPropagation(); + const cleanPhone = '573106390296'; + const message = encodeURIComponent( + `¡Hola DripZone! 👋 Consulto el estado de mi pedido *#${order.id}* a nombre de *${order.customerName}* para entrega en *${order.cityName}* (${formatCOP(order.totalAmount)} COP). ¿Me podrían confirmar información de despacho? Muchas gracias.` + ); + window.open(`https://wa.me/${cleanPhone}?text=${message}`, '_blank'); + }; + + return ( +
+
+ {/* Header de Navegación */} +
+
+ + + +
+

+ Mis Pedidos + + Historial & Despachos + +

+

+ Consulta el estado de tus zapatillas, pagos de anticipo por Bold y saldo a pagar contraentrega. +

+
+
+ + {/* Estado de Usuario / Mi Cuenta */} +
+ {user ? ( +
+
+ {user.name.charAt(0).toUpperCase()} +
+
+
{user.name.split(' ')[0]}
+
+57 {user.phone}
+
+
+ ) : ( + + )} +
+
+ + {/* Barra de Búsqueda por Celular/WhatsApp (Para clientes invitados o registrados) */} +
+
+ + + Consultar pedidos por número de celular / WhatsApp: + + + Busca las órdenes registradas con tu línea telefónica + +
+ + +
+ + setPhoneSearch(e.target.value)} + placeholder="Ingresa tu número (ej: 316 745 5877)" + className="w-full bg-neutral-950 border border-neutral-800 focus:border-purple-500 rounded-2xl pl-10 pr-4 py-2.5 text-xs sm:text-sm text-white placeholder-neutral-500 focus:outline-none transition-all" + /> +
+ + + +
+ + {/* Pestañas de Agrupación por Estado */} +
+
+ + + Agrupar por Estado del Pedido: + + + {/* Sub-buscador por palabra clave o modelo */} + {orders.length > 0 && ( +
+ + setSearchQuery(e.target.value)} + placeholder="Filtrar por modelo o ciudad..." + className="w-full bg-neutral-900/90 border border-neutral-800 focus:border-purple-500 rounded-xl pl-8 pr-3 py-1.5 text-[11px] text-white placeholder-neutral-500 focus:outline-none" + /> +
+ )} +
+ +
+ + + + + + + + + + + +
+
+ + {/* Listado de Pedidos (UI Desplegable / Acordeón por Tarjeta) */} + {isLoading ? ( +
+ +

Cargando historial de pedidos...

+
+ ) : filteredOrders.length === 0 ? ( +
+ +
+

No se encontraron pedidos

+

+ {phoneSearch + ? `No hay órdenes registradas para el número ${phoneSearch}. Verifica el número o explora nuestro catálogo.` + : 'Aún no has registrado pedidos o la búsqueda no arrojó resultados.'} +

+
+ + + Explorar Catálogo de Zapatillas + +
+ ) : ( +
+ {filteredOrders.map((order) => { + const isExpanded = expandedOrderIds.has(order.id); + const statusMeta = getStatusMeta(order.status, order.depositPaid); + const StatusIcon = statusMeta.icon; + const totalPairs = order.items.reduce((acc, it) => acc + (it.quantity || 1), 0); + const isDepositPending = order.depositAmount > 0 && !order.depositPaid && order.status === 'pending'; + + return ( +
+ {/* Vista Resumen (Header Clicable) */} +
toggleExpandOrder(order.id)} + className="p-5 sm:p-6 cursor-pointer flex flex-col gap-4 select-none hover:bg-neutral-900/40 transition-colors" + > + {/* Fila 1: ID, Fecha, Ciudad y Estado */} +
+
+ + #{order.id} + + + + + + {formatDate(order.createdAt)} + + + + + {order.cityName} + +
+ + {/* Insignia de Estado */} +
+ + {statusMeta.label} +
+
+ + {/* Fila 2: Vista previa de calzados, valores económicos y botón desplegable */} +
+ {/* Miniaturas de Calzado & Cantidad */} +
+
+ {order.items.slice(0, 3).map((item, idx) => ( +
+ {item.productName} +
+ ))} +
+
+
+ {order.items[0]?.productName || 'Zapatillas DripZone'} + {order.items.length > 1 && ( + +{order.items.length - 1} más + )} +
+
+ Total: {totalPairs} {totalPairs === 1 ? 'par' : 'pares'} • Horma Nacional +
+
+
+ + {/* Desglose Financiero Rápido & Indicador Expandir */} +
+
+
+ {formatCOP(order.totalAmount)} +
+
+ {order.depositAmount > 0 ? ( + <> + + Anticipo: {formatCOP(order.depositAmount)} + + + Contraentrega: {formatCOP(order.balanceDue)} + + + ) : ( + + 100% Contraentrega + + )} +
+
+ + {/* Botón Indicador de Acordeón */} +
+ {isExpanded ? : } +
+
+
+
+ + {/* Vista Detallada Desplegable (Accordion) */} + {isExpanded && ( +
+ {/* Sub-alerta si el anticipo está pendiente */} + {isDepositPending && ( +
+
+ +
+ Anticipo de $10.000 pendiente por confirmar +

+ Para iniciar el empaque y despacho de tus zapatillas, por favor confirma el anticipo de $10.000 a través de Bold (PSE / Tarjeta / Nequi). +

+
+
+ + + Pagar Anticipo en Bold + +
+ )} + + {/* 1. Detalle de Calzado y Tallas */} +
+

+ + Zapatillas Incluidas en el Pedido ({order.items.length}) +

+ +
+ {order.items.map((item, idx) => ( +
+
+ {item.productName} +
+
+ + {item.brand || 'Sneakers'} + +
+ {item.productName} +
+
+ + Talla: {item.size} + + + Cant: {item.quantity} • {formatCOP(item.price * item.quantity)} + +
+
+
+ ))} +
+
+ + {/* 2. Desglose Financiero & Condición de Pago */} +
+ {/* Resumen de Cobro */} +
+
+ Detalle de Valores + +
+ +
+ Subtotal Zapatillas: + {formatCOP(order.subtotal)} +
+
+ Costo de Envío: + + {order.shippingCost === 0 ? 'GRATIS' : formatCOP(order.shippingCost)} + +
+
+ Total de la Orden: + {formatCOP(order.totalAmount)} +
+ +
+
+ ⚡ Anticipo de Separación: + + {formatCOP(order.depositAmount)} COP + +
+
+ {order.depositPaid || order.status !== 'pending' ? ( + + + Pago de anticipo aprobado en pasarela Bold + + ) : ( + + + Pendiente de confirmación en Bold + + )} +
+ +
+ 🤝 Saldo Contraentrega: + + {formatCOP(order.balanceDue)} COP + +
+

+ Cancelas este saldo en efectivo o transferencia cuando el mensajero te entregue el paquete en tu puerta. +

+
+
+ + {/* Datos de Despacho & Destinatario */} +
+
+ Datos de Entrega + +
+ +
+
Destinatario:
+
{order.customerName}
+
+ +
+
Celular de Contacto:
+
+57 {order.customerPhone}
+
+ +
+
Dirección de Despacho:
+
{order.address}
+
{order.cityName}
+
+ + {order.notes && ( +
+ Notas del pedido: + {order.notes} +
+ )} +
+
+ + {/* 3. Acciones de Asistencia */} +
+
+ + Tu pedido incluye garantía y primer cambio de talla gratuito (Horma Nacional). +
+ + +
+
+ )} +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/dripzone-app/src/components/admin/WhatsAppOrderLinkModal.tsx b/dripzone-app/src/components/admin/WhatsAppOrderLinkModal.tsx new file mode 100644 index 0000000..d184d7a --- /dev/null +++ b/dripzone-app/src/components/admin/WhatsAppOrderLinkModal.tsx @@ -0,0 +1,325 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import Image from 'next/image'; +import { + MessageCircle, + Copy, + Check, + ExternalLink, + X, + Send, + Sparkles, + ShieldCheck, + Truck, + Phone, + User, + CreditCard, +} from 'lucide-react'; +import { Product } from '@/types'; +import { formatCOP } from '@/lib/pricing-engine'; + +interface WhatsAppOrderLinkModalProps { + isOpen: boolean; + onClose: () => void; + product: Product | null; +} + +export function WhatsAppOrderLinkModal({ + isOpen, + onClose, + product, +}: WhatsAppOrderLinkModalProps) { + const [selectedSize, setSelectedSize] = useState('39'); + const [customerPhone, setCustomerPhone] = useState(''); + const [customerName, setCustomerName] = useState(''); + const [customNote, setCustomNote] = useState(''); + const [copiedUrl, setCopiedUrl] = useState(false); + const [copiedMessage, setCopiedMessage] = useState(false); + + const availableSizes = + product && Array.isArray(product.sizes) && product.sizes.length > 0 + ? product.sizes + : ['37', '38', '39', '40', '41', '42', '43']; + + useEffect(() => { + if (product) { + if (availableSizes.length > 0) { + setSelectedSize(availableSizes[0]); + } + setCopiedUrl(false); + setCopiedMessage(false); + } + }, [product]); + + if (!isOpen || !product) return null; + + const domain = + typeof window !== 'undefined' && window.location.origin + ? window.location.origin + : 'https://drip-zone.online'; + + const checkoutUrl = `${domain}/checkout?productId=${encodeURIComponent( + product.id + )}&size=${encodeURIComponent(selectedSize)}&source=whatsapp`; + + const primaryImage = + product.images?.[0] || product.colors?.[0]?.image || '/splash.png'; + const formattedPrice = formatCOP(product.retailPrice || 125000); + const greeting = customerName.trim() + ? `¡Hola ${customerName.trim()}! 👋` + : '¡Hola! 👋'; + + const commercialMessage = ( + `${greeting} Qué gusto saludarte de parte de *DripZone Colombia* 🇨🇴\n\n` + + `Para confirmar tu pedido de las *${product.name}* en talla *${selectedSize}* (Horma Nacional) y despacharlo con total garantía y factura:\n\n` + + `💎 *Precio Oficial:* ${formattedPrice} COP\n` + + `🚚 *Pago Contraentrega:* Pagas un anticipo de *$10.000* por Bold (PSE, tarjetas o Nequi) para asegurar tu despacho y el saldo restante contraentrega al recibir.\n` + + `🛡️ *Beneficio Exclusivo:* 1er cambio de talla 100% gratuito.\n\n` + + `🛒 *Por favor completa tus datos de entrega y anticipo seguro aquí:*\n` + + `👉 ${checkoutUrl}\n\n` + + `📱 *O si prefieres, descárgate nuestra App Móvil oficial:*\n` + + `https://drip-zone.online/app-dripzone.apk\n\n` + + (customNote.trim() ? `📝 *Nota del Asesor:* ${customNote.trim()}\n\n` : '') + + `Cualquier inquietud con tu dirección o el pago, ¡aquí estamos para ayudarte!` + ).trim(); + + const handleCopyUrl = () => { + navigator.clipboard.writeText(checkoutUrl); + setCopiedUrl(true); + setTimeout(() => setCopiedUrl(false), 2500); + }; + + const handleCopyMessage = () => { + navigator.clipboard.writeText(commercialMessage); + setCopiedMessage(true); + setTimeout(() => setCopiedMessage(false), 2500); + }; + + const handleSendToWhatsApp = () => { + const cleanPhone = customerPhone.replace(/\D/g, ''); + const encoded = encodeURIComponent(commercialMessage); + let waUrl = `https://wa.me/?text=${encoded}`; + if (cleanPhone.length >= 10) { + const fullNumber = cleanPhone.startsWith('57') ? cleanPhone : `57${cleanPhone}`; + waUrl = `https://wa.me/${fullNumber}?text=${encoded}`; + } + window.open(waUrl, '_blank'); + }; + + return ( +
+
+ {/* Modal Header */} +
+
+
+ +
+
+

+ Asesor Comercial WhatsApp + + Cierre de Venta + +

+

+ Genera el enlace directo para que el cliente procese su pedido y pago seguro en la web o app. +

+
+
+ +
+ + {/* Modal Body */} +
+ {/* Card Resumen Producto */} +
+
+ {product.name} +
+
+
+ {product.brand || 'Sneakers'} +
+

{product.name}

+
+ {formattedPrice} + + Anticipo Bold: $10.000 COP • Saldo Contraentrega + +
+
+
+ + {/* Configuración de la Venta */} +
+ {/* Selector de Talla */} +
+ +
+ {availableSizes.map((size) => ( + + ))} +
+
+ + {/* Teléfono del Cliente */} +
+ + setCustomerPhone(e.target.value)} + placeholder="Ej: 316 745 5877" + className="w-full bg-neutral-900 border border-neutral-800 focus:border-emerald-500 rounded-xl px-3.5 py-2 text-xs text-white placeholder-neutral-500 focus:outline-none" + /> +
+ + {/* Nombre del Cliente */} +
+ + setCustomerName(e.target.value)} + placeholder="Ej: Carlos" + className="w-full bg-neutral-900 border border-neutral-800 focus:border-emerald-500 rounded-xl px-3.5 py-2 text-xs text-white placeholder-neutral-500 focus:outline-none" + /> +
+ + {/* Nota adicional */} +
+ + setCustomNote(e.target.value)} + placeholder="Ej: Incluye envío prioritario express hoy" + className="w-full bg-neutral-900 border border-neutral-800 focus:border-emerald-500 rounded-xl px-3.5 py-2 text-xs text-white placeholder-neutral-500 focus:outline-none" + /> +
+
+ + {/* Enlace Directo Generado */} +
+
+ + + Lleva al cliente directo a pagar y registrar dirección + +
+
+ {checkoutUrl} + +
+
+ + {/* Vista Previa del Mensaje Comercial */} +
+
+ + +
+
+              {commercialMessage}
+            
+
+
+ + {/* Modal Footer / Botones Principales */} +
+
+ + El pedido y pago de $10k por Bold se registran automáticamente en el sistema. +
+ +
+ + + +
+
+
+
+ ); +} diff --git a/dripzone-app/src/components/mobile/MobileBottomDock.tsx b/dripzone-app/src/components/mobile/MobileBottomDock.tsx index a3f3f2d..d3c8913 100644 --- a/dripzone-app/src/components/mobile/MobileBottomDock.tsx +++ b/dripzone-app/src/components/mobile/MobileBottomDock.tsx @@ -3,7 +3,7 @@ import React, { useState } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { Home, Search, ShoppingBag, MessageCircle, User, Shield } from 'lucide-react'; +import { Home, Search, ShoppingBag, Package, User, Shield } from 'lucide-react'; import { useBrand } from '@/context/BrandContext'; import { useAuth } from '@/context/AuthContext'; import { renderClientTemplate } from '@/lib/templates-client'; @@ -96,14 +96,19 @@ export const MobileBottomDock: React.FC = ({ Carrito - {/* 4. WhatsApp */} - + + Mis Pedidos + {/* 5. Solo Administradores: Acceso Admin | Clientes / Visitantes: Mi Cuenta / Ingresar */} {isAdmin ? ( diff --git a/dripzone-app/src/components/store/CartDrawer.tsx b/dripzone-app/src/components/store/CartDrawer.tsx index e1f0b77..853863e 100644 --- a/dripzone-app/src/components/store/CartDrawer.tsx +++ b/dripzone-app/src/components/store/CartDrawer.tsx @@ -30,7 +30,7 @@ export const CartDrawer: React.FC = ({ onOpenCitySelector, }) => { const { currentBrand } = useBrand(); - const [officialPhone, setOfficialPhone] = React.useState('573167455877'); + const [officialPhone, setOfficialPhone] = React.useState('573106390296'); React.useEffect(() => { fetch('/api/settings') diff --git a/dripzone-app/src/components/store/Navbar.tsx b/dripzone-app/src/components/store/Navbar.tsx index 661500b..bb11e50 100644 --- a/dripzone-app/src/components/store/Navbar.tsx +++ b/dripzone-app/src/components/store/Navbar.tsx @@ -3,7 +3,7 @@ import React from 'react'; import Link from 'next/link'; import Image from 'next/image'; -import { ShoppingBag, Search, MapPin, Zap, Sparkles, ShieldCheck, Store, User as UserIcon } from 'lucide-react'; +import { ShoppingBag, Search, MapPin, Zap, Sparkles, ShieldCheck, Store, User as UserIcon, Package } from 'lucide-react'; import { City } from '@/types'; import { useBrand } from '@/context/BrandContext'; import { useAuth } from '@/context/AuthContext'; @@ -141,6 +141,16 @@ export const Navbar: React.FC = ({ )} + {/* Mis Pedidos Link */} + + + Mis Pedidos + + {/* Admin Link - Solo para Administradores */} {user && (user.role === 'admin' || user.role === 'operator') && ( = ({ const separation = calculateSeparationBreakdown(currentPrice); const handleAdd = () => { - if (!isSizeInStock) return; onAddToCart( { ...product, @@ -105,35 +101,6 @@ export const ProductModal: React.FC = ({ }, 1200); }; - // Pedido normal por WhatsApp - const handleWhatsAppDirect = () => { - const phone = currentBrand.whatsappNumber || '573167455877'; - const rawText = renderClientTemplate('product_direct_inquiry', { - tienda_nombre: currentBrand.name, - zapatilla_nombre: product.modelName || product.name, - color_nombre: selectedColor.colorName, - talla: selectedSize, - precio: formatCOP(currentPrice), - ciudad: selectedCity.name, - marca: product.brand || 'Sneakers', - }); - window.open(`https://wa.me/${phone}?text=${encodeURIComponent(rawText)}`, '_blank'); - }; - - // Solicitud especial de talla no disponible - const handleWhatsAppRequestSize = (sizeToRequest: string) => { - const phone = currentBrand.whatsappNumber || '573167455877'; - const rawText = renderClientTemplate('product_request_size', { - tienda_nombre: currentBrand.name, - zapatilla_nombre: product.modelName || product.name, - color_nombre: selectedColor.colorName, - talla: sizeToRequest, - ciudad: selectedCity.name, - marca: product.brand || 'Sneakers', - }); - window.open(`https://wa.me/${phone}?text=${encodeURIComponent(rawText)}`, '_blank'); - }; - return (
@@ -314,28 +281,19 @@ export const ProductModal: React.FC = ({
)} - {/* Size Selector with Stock & WhatsApp sourcing option */} + {/* Size Selector */}
- Selecciona tu Talla (CO / Nacional): + Selecciona tu Talla (CO / Horma Nacional): - - {isSizeInStock ? ( - - Stock Inmediato - - ) : ( - - Solicitud Bajo Pedido - - )} + + Horma Nacional
-
+
{displaySizes.map((size) => { - const inStock = availableSizes.includes(size); const isSelected = selectedSize === size; const sizePrice = getProductEffectivePrice(product, selectedColor, size); const hasCustomSizePrice = sizePrice !== product.retailPrice; @@ -344,44 +302,20 @@ export const ProductModal: React.FC = ({ ); })}
- - {/* Banner: ¿No encuentras tu talla? */} -
-
-
- -
-
-

¿No encuentras tu talla exacta?

-

Verificamos disponibilidad con bodega.

-
-
- -
{/* Description */} @@ -401,46 +335,27 @@ export const ProductModal: React.FC = ({ {/* Action Buttons */}
- {isSizeInStock ? ( - - ) : ( -
-
- - - La talla {selectedSize} en {selectedColor.colorName} no tiene despacho inmediato en 24h, pero podemos pedirla a la bodega central para ti. - -
- - -
- )} +
diff --git a/dripzone-app/src/lib/data-service.ts b/dripzone-app/src/lib/data-service.ts index de0e1d0..c5df367 100644 --- a/dripzone-app/src/lib/data-service.ts +++ b/dripzone-app/src/lib/data-service.ts @@ -277,6 +277,8 @@ export const DEFAULT_STORE_SETTINGS: StoreSettings = { boldEnabled: true, defaultProfitMargin: 35, defaultDepositAmount: 10000, + autoPublishSupplierPosts: true, + whatsappCatalogAutoSync: true, }; class DataService { @@ -397,6 +399,7 @@ class DataService { const formattedProduct: Product = { ...product, + id: product.id || `snk-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, name: cleanName, modelName: baseModelName, colors: initialColors, diff --git a/dripzone-app/src/lib/whatsapp-catalog.ts b/dripzone-app/src/lib/whatsapp-catalog.ts new file mode 100644 index 0000000..28586c7 --- /dev/null +++ b/dripzone-app/src/lib/whatsapp-catalog.ts @@ -0,0 +1,280 @@ +import axios from 'axios'; +import { Product, StoreSettings } from '@/types'; +import { dataService } from './data-service'; +import { formatCOP } from './pricing-engine'; + +const DOMAIN = process.env.NEXT_PUBLIC_APP_URL || 'https://drip-zone.online'; + +/** + * Resuelve la URL absoluta pública y segura de la imagen del producto + * para que Meta y WhatsApp puedan descargarla e indexarla en alta resolución. + */ +export function resolveAbsoluteImageUrl(imagePath?: string): string { + if (!imagePath) { + return `${DOMAIN}/splash.png`; + } + if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) { + return imagePath; + } + const cleanPath = imagePath.startsWith('/') ? imagePath : `/${imagePath}`; + return `${DOMAIN}${cleanPath}`; +} + +/** + * Formatea los datos del producto con las especificaciones exactas requeridas por + * el Catálogo de WhatsApp Business y Meta Commerce Manager: + * 1. Imagen en alta resolución + * 2. Valor de venta en COP (precio retail) + * 3. Descripción completa y persuasiva + * 4. Tallas disponibles detalladas (horma nacional) + * 5. Estado de disponibilidad activo / inactivo + */ +export function formatProductForWhatsAppCatalog(product: Product) { + const brand = product.brand || 'Sneakers'; + const title = product.modelName || product.name; + const sizesList = Array.isArray(product.sizes) && product.sizes.length > 0 + ? product.sizes.join(', ') + : '37, 38, 39, 40, 41, 42, 43'; + + const formattedPrice = formatCOP(product.retailPrice || 125000); + const primaryImage = product.images?.[0] || product.colors?.[0]?.image || '/splash.png'; + const absoluteImageUrl = resolveAbsoluteImageUrl(primaryImage); + + // Imágenes adicionales para carrusel en WhatsApp + const additionalImages = (product.images || []) + .slice(1, 10) + .map((img) => resolveAbsoluteImageUrl(img)) + .filter((url) => url !== absoluteImageUrl); + + const directCheckoutUrl = `${DOMAIN}/checkout?productId=${encodeURIComponent(product.id)}&source=whatsapp`; + + // Descripción optimizada para WhatsApp Catalog incluyendo tallas, enlace web/app y facilidades de pago + const catalogDescription = ( + `${product.description || `Zapatillas ${title} exclusivas en horma nacional de alta calidad.`}\n\n` + + `📏 Tallas disponibles: ${sizesList} (Horma Nacional)\n` + + `💎 Precio oficial: ${formattedPrice} COP\n` + + `🚚 Pago Contraentrega disponible en toda Colombia\n` + + `🛡️ 1er Cambio de Talla sin costo adicional\n\n` + + `🛒 ¿CÓMO REALIZAR TU PEDIDO Y PAGO SEGURO?\n` + + `La atención comercial y asesoría es por este WhatsApp. Para garantizar tu despacho con factura y seguimiento en línea, el pedido y pago del anticipo ($10.000 por Bold / PSE / Nequi / Tarjetas) se procesan a través de nuestra plataforma:\n` + + `👉 Enlace directo de compra: ${directCheckoutUrl}\n` + + `📱 O descarga la App Móvil oficial: ${DOMAIN}/app-dripzone.apk\n\n` + + `💬 ¿Dudas con tu talla o envío? Escríbenos a este chat y un asesor te atenderá al instante.` + ).trim(); + + const isAvailable = product.isActive !== false && product.inStock !== false; + + return { + id: product.id || `snk-${(product.name || 'item').toLowerCase().replace(/[^a-z0-9]/g, '-').slice(0, 30)}`, + title, + brand, + description: catalogDescription, + availability: isAvailable ? 'in stock' : 'out of stock', + condition: 'new', + price: `${product.retailPrice || 125000} COP`, + priceAmount: product.retailPrice || 125000, + link: directCheckoutUrl, + image_link: absoluteImageUrl, + additional_image_links: additionalImages, + sizes: sizesList, + color: product.colorName || 'Color Original', + category: product.category || 'Zapatillas', + isActive: product.isActive !== false, + }; +} + +/** + * Genera el Feed XML en formato estándar Google Merchant / Facebook Commerce RSS + * compatible con Meta Commerce Manager para sincronización programada del catálogo de WhatsApp. + * Si includeInactive es false (default), solo exporta productos activos para que solo + * los calzados activos aparezcan disponibles para compra en WhatsApp. + */ +export function generateWhatsAppCatalogXml( + products: Product[], + storeSettings?: StoreSettings, + includeInactive = false +): string { + const storeName = storeSettings?.storeName || 'DripZone'; + const storeDescription = storeSettings?.slogan || 'Catálogo Oficial de Sneakers y Calzado Urbano'; + + const itemsToInclude = includeInactive + ? products + : products.filter((p) => p.isActive !== false); + + const itemsXml = itemsToInclude + .map((product) => { + const item = formatProductForWhatsAppCatalog(product); + const additionalImagesXml = item.additional_image_links + .map((url) => ` `) + .join('\n'); + + return ` + + + + + + +${additionalImagesXml} + + new + ${item.availability === 'in stock' ? 'in_stock' : 'out_of_stock'} + ${item.priceAmount} COP + + + + + + `; + }) + .join(''); + + return ` + + + <![CDATA[${storeName} - Catálogo Oficial de WhatsApp]]> + + + ${itemsXml} + +`; +} + +/** + * Genera el archivo CSV para importación directa en Meta Commerce Manager / WhatsApp Catalog. + */ +export function generateWhatsAppCatalogCsv(products: Product[], includeInactive = false): string { + const headers = [ + 'id', + 'title', + 'description', + 'availability', + 'condition', + 'price', + 'link', + 'image_link', + 'additional_image_link', + 'brand', + 'size', + ]; + + const itemsToInclude = includeInactive + ? products + : products.filter((p) => p.isActive !== false); + + const rows = itemsToInclude.map((product) => { + const item = formatProductForWhatsAppCatalog(product); + return [ + `"${item.id}"`, + `"${item.title.replace(/"/g, '""')}"`, + `"${item.description.replace(/"/g, '""')}"`, + `"${item.availability}"`, + `"${item.condition}"`, + `"${item.price}"`, + `"${item.link}"`, + `"${item.image_link}"`, + `"${item.additional_image_links.join(',')}"`, + `"${item.brand.replace(/"/g, '""')}"`, + `"${item.sizes.replace(/"/g, '""')}"`, + ].join(','); + }); + + return [headers.join(','), ...rows].join('\n'); +} + +/** + * Registra o actualiza en tiempo real un producto en Meta Commerce Manager + * para que aparezca o se actualice inmediatamente en el catálogo de WhatsApp. + * Si el producto está inactivo (isActive: false), se envía availability: 'out of stock'. + */ +export async function syncProductToMetaCatalogApi( + product: Product, + customSettings?: StoreSettings +): Promise<{ success: boolean; error?: string }> { + const settings = customSettings || dataService.getSettings(); + const catalogId = settings?.metaCatalogId || process.env.META_CATALOG_ID; + const accessToken = settings?.metaAccessToken || process.env.META_ACCESS_TOKEN; + + // Si no se han configurado credenciales directas de Meta Graph API, + // el feed continuo XML/CSV sigue activo como mecanismo principal de sincronización. + if (!catalogId || !accessToken) { + return { + success: true, + error: 'Feed XML/CSV activo. Para sincronización instantánea vía API ingresa el Meta Catalog ID y Token.', + }; + } + + try { + const item = formatProductForWhatsAppCatalog(product); + const endpoint = `https://graph.facebook.com/v19.0/${catalogId}/items_batch`; + + const payload = { + requests: [ + { + method: 'UPDATE', + retailer_id: item.id, + data: { + name: item.title, + description: item.description, + availability: item.availability, + condition: 'new', + price: item.priceAmount * 100, // Centavos en COP + currency: 'COP', + url: item.link, + image_url: item.image_link, + additional_image_urls: item.additional_image_links, + brand: item.brand, + size: item.sizes, + }, + }, + ], + }; + + const response = await axios.post(endpoint, payload, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + timeout: 15000, + }); + + return { success: response.status === 200 || response.status === 201 }; + } catch (error: any) { + console.warn('[WhatsApp Catalog] Error en Meta Graph API:', error?.response?.data || error?.message); + return { + success: false, + error: error?.response?.data?.error?.message || error?.message || 'Error comunicando con Meta Commerce API', + }; + } +} + +/** + * Función principal disparada tras la creación, publicación o actualización de cualquier + * producto en la app móvil y web. Garantiza que el producto se registre o actualice + * en el Catálogo de WhatsApp de DripZone. + */ +export async function registerProductInWhatsAppCatalog( + product: Product +): Promise<{ success: boolean; catalogItem: ReturnType }> { + const catalogItem = formatProductForWhatsAppCatalog(product); + + // 1. Sincronización directa vía Meta Commerce API si está configurada + await syncProductToMetaCatalogApi(product).catch(() => ({ success: false })); + + // 2. Marcar en el producto el estado de sincronización sin bucle + if (!product.whatsappCatalogSynced) { + dataService.updateProduct(product.id, { + whatsappCatalogSynced: true, + whatsappCatalogSyncAt: new Date().toISOString(), + } as any); + } + + console.log( + `📦 [WhatsApp Catalog] Producto ${catalogItem.isActive ? 'ACTIVO' : 'INACTIVO'} sincronizado para WhatsApp: "${catalogItem.title}" (${catalogItem.price}) | Disponibilidad: ${catalogItem.availability} | Tallas: ${catalogItem.sizes}` + ); + + return { + success: true, + catalogItem, + }; +} diff --git a/dripzone-app/src/types/index.ts b/dripzone-app/src/types/index.ts index 8fdfb85..cae4804 100644 --- a/dripzone-app/src/types/index.ts +++ b/dripzone-app/src/types/index.ts @@ -79,6 +79,8 @@ export interface Product { supplierPhone?: string; supplierSku?: string; whatsappMessageId?: string; + whatsappCatalogSynced?: boolean; + whatsappCatalogSyncAt?: string; } @@ -141,6 +143,11 @@ export interface Order { status: 'pending' | 'deposit_received' | 'supplier_notified' | 'dispatched' | 'delivered' | 'cancelled' | 'rejected'; rejectionReason?: string; notes?: string; + source?: 'web' | 'whatsapp' | 'app' | string; + depositPaid?: boolean; + depositPaidAt?: string; + deliveryPaid?: boolean; + deliveryPaidAt?: string; createdAt: string; } @@ -211,6 +218,9 @@ export interface StoreSettings { defaultProfitMargin: number; defaultDepositAmount: number; facebookPageId?: string; + metaCatalogId?: string; + metaAccessToken?: string; + whatsappCatalogAutoSync?: boolean; } export interface MonitoredWhatsAppGroup {