feat: implement core mobile navigation, Android API client, and production-ready Docker deployment configuration
This commit is contained in:
parent
acbe8335fd
commit
7783b6c243
BIN
__pycache__/deploy_dripzone_vps.cpython-313.pyc
Normal file
BIN
__pycache__/deploy_dripzone_vps.cpython-313.pyc
Normal file
Binary file not shown.
@ -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...")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z"/>
|
||||
</vector>
|
||||
@ -62,6 +62,36 @@
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<!-- Authenticated User Banner -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutAuthUserBanner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:background="@drawable/bg_btn_secondary"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:padding="10dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:src="@drawable/ic_shield_check"
|
||||
app:tint="#22C55E" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvAuthUserNotice"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_weight="1"
|
||||
android:fontFamily="sans-serif-medium"
|
||||
android:text="Usuario autenticado • Listo para despacho"
|
||||
android:textColor="#22C55E"
|
||||
android:textSize="11sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Nombre -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
@ -175,21 +205,21 @@
|
||||
android:textSize="10sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Botón Continuar -->
|
||||
<!-- Botón Continuar / Confirmar Pedido -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnContinueToOtp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="50dp"
|
||||
android:layout_marginTop="18dp"
|
||||
android:filterTouchesWhenObscured="true"
|
||||
android:text="Validar Celular y Registrar Pedido"
|
||||
android:text="Confirmar Pedido Contraentrega"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
app:backgroundTint="@color/drip_purple"
|
||||
app:cornerRadius="14dp"
|
||||
app:icon="@drawable/ic_whatsapp"
|
||||
app:icon="@drawable/ic_check"
|
||||
app:iconGravity="textStart"
|
||||
app:iconPadding="8dp"
|
||||
app:iconSize="18dp" />
|
||||
|
||||
@ -170,26 +170,6 @@
|
||||
app:iconPadding="8dp"
|
||||
app:iconSize="20dp" />
|
||||
|
||||
<!-- Pedir o Consultar por WhatsApp Button -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnOrderWhatsAppDirect"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginTop="10dp"
|
||||
android:filterTouchesWhenObscured="true"
|
||||
android:text="Pedir o Consultar por WhatsApp"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="#22C55E"
|
||||
android:textSize="13sp"
|
||||
app:cornerRadius="14dp"
|
||||
app:icon="@drawable/ic_whatsapp"
|
||||
app:iconGravity="textStart"
|
||||
app:iconPadding="8dp"
|
||||
app:iconSize="18dp"
|
||||
app:iconTint="#22C55E"
|
||||
app:strokeColor="#22C55E"
|
||||
app:strokeWidth="1dp" />
|
||||
|
||||
<!-- Secondary Custom Request Button -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
|
||||
@ -117,13 +117,13 @@
|
||||
android:insetTop="0dp"
|
||||
android:insetBottom="0dp"
|
||||
android:paddingHorizontal="8dp"
|
||||
android:text="Pedir"
|
||||
android:text="Ver / Pedir"
|
||||
android:textAllCaps="false"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold"
|
||||
app:backgroundTint="@color/accent_whatsapp"
|
||||
app:backgroundTint="@color/drip_purple"
|
||||
app:cornerRadius="10dp"
|
||||
app:icon="@drawable/ic_whatsapp"
|
||||
app:icon="@drawable/ic_shopping_bag"
|
||||
app:iconGravity="textStart"
|
||||
app:iconPadding="6dp"
|
||||
app:iconSize="15dp" />
|
||||
|
||||
@ -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:
|
||||
|
||||
687
dripzone-app/src/app/admin/catalogo-whatsapp/page.tsx
Normal file
687
dripzone-app/src/app/admin/catalogo-whatsapp/page.tsx
Normal file
@ -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<Product[]>([]);
|
||||
const [settings, setSettings] = useState<StoreSettings | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [syncMessage, setSyncMessage] = useState<string | null>(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<Product | null>(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 (
|
||||
<div className="space-y-8 max-w-7xl mx-auto pb-16">
|
||||
{/* Encabezado Principal */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-neutral-800 pb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="p-2 bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 rounded-xl">
|
||||
<MessageCircle className="w-6 h-6" />
|
||||
</span>
|
||||
<h1 className="text-2xl sm:text-3xl font-black text-white tracking-tight">
|
||||
Catálogo Oficial de WhatsApp Business
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-neutral-400 mt-1">
|
||||
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).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={fetchData}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2.5 bg-neutral-900 border border-neutral-700 hover:border-neutral-500 text-neutral-300 hover:text-white rounded-xl text-xs font-bold flex items-center gap-2 transition-all cursor-pointer"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
<span>Actualizar</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSyncAll}
|
||||
disabled={isSyncing}
|
||||
className="px-5 py-2.5 bg-gradient-to-r from-emerald-600 to-green-500 hover:from-emerald-500 hover:to-green-400 text-white font-black rounded-xl text-xs flex items-center gap-2 shadow-lg shadow-emerald-600/30 transition-all active:scale-95 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<Zap className={`w-4 h-4 ${isSyncing ? 'animate-bounce' : ''}`} />
|
||||
<span>{isSyncing ? 'Sincronizando...' : 'Sincronizar Todo con WhatsApp'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Banner de Orientación Comercial: Atención en WhatsApp / Cierre y Pago en Web & App */}
|
||||
<div className="p-4 rounded-2xl bg-gradient-to-r from-purple-950/40 via-neutral-900 to-emerald-950/40 border border-purple-500/30 flex flex-col md:flex-row md:items-center justify-between gap-4 shadow-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2.5 rounded-xl bg-purple-500/20 text-purple-400 border border-purple-500/30 shrink-0 mt-0.5">
|
||||
<ShieldCheck className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-black text-white flex items-center gap-2">
|
||||
<span>Flujo Comercial & Procesamiento de Pedidos DripZone</span>
|
||||
<span className="text-[10px] bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 px-2 py-0.5 rounded-full font-bold">
|
||||
Oficial
|
||||
</span>
|
||||
</h4>
|
||||
<p className="text-[11px] text-neutral-300 mt-0.5 leading-relaxed">
|
||||
El catálogo de WhatsApp permite a los usuarios consultar modelos y comunicarse para asesoría.
|
||||
<strong> Todos los pedidos y pagos ($10.000 anticipo Bold + contraentrega) se procesan a través de la web o app móvil</strong> para asegurar factura y seguimiento.
|
||||
Usa el botón <strong>"Cerrar Pedido por WhatsApp"</strong> en cada producto para enviar al cliente su enlace directo con talla lista.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toast Alert */}
|
||||
{syncMessage && (
|
||||
<div className="p-4 bg-emerald-950/60 border border-emerald-500/50 rounded-2xl flex items-center gap-3 text-emerald-200 text-xs font-semibold animate-fade-in shadow-xl backdrop-blur-md">
|
||||
<Sparkles className="w-5 h-5 text-emerald-400 shrink-0" />
|
||||
<span>{syncMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Métricas y Estado */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-neutral-900/70 border border-neutral-800 rounded-2xl p-4 flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-purple-500/10 border border-purple-500/30 text-purple-400 flex items-center justify-center shrink-0">
|
||||
<Package className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-black text-white">{products.length}</div>
|
||||
<div className="text-[11px] text-neutral-400 uppercase font-bold tracking-wider">Total Modelos</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-neutral-900/70 border border-emerald-500/30 rounded-2xl p-4 flex items-center gap-4 shadow-[0_0_15px_rgba(16,185,129,0.08)]">
|
||||
<div className="w-12 h-12 rounded-xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 flex items-center justify-center shrink-0">
|
||||
<CheckCircle2 className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-black text-emerald-400">{activeProducts.length}</div>
|
||||
<div className="text-[11px] text-neutral-400 uppercase font-bold tracking-wider">Activos en WhatsApp & App</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-neutral-900/70 border border-neutral-800 rounded-2xl p-4 flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-neutral-800 text-neutral-400 flex items-center justify-center shrink-0">
|
||||
<EyeOff className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-black text-neutral-300">{inactiveProducts.length}</div>
|
||||
<div className="text-[11px] text-neutral-400 uppercase font-bold tracking-wider">Inactivos / Ocultos</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-neutral-900/70 border border-neutral-800 rounded-2xl p-4 flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-500/10 border border-blue-500/30 text-blue-400 flex items-center justify-center shrink-0">
|
||||
<Radio className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-bold text-white">Línea Oficial DripZone</div>
|
||||
<div className="text-[11px] text-emerald-400 font-mono font-bold">+57 310 639 0296</div>
|
||||
<div className="text-[10px] text-neutral-500">Instancia Evolution: DripZone</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sección 1: Enlaces del Feed Automático (Meta Commerce Manager) */}
|
||||
<div className="bg-neutral-900/80 border border-purple-500/20 rounded-3xl p-6 space-y-5">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-2 border-b border-neutral-800 pb-4">
|
||||
<div>
|
||||
<h2 className="text-base font-black text-white flex items-center gap-2">
|
||||
<Link2 className="w-5 h-5 text-purple-400" />
|
||||
<span>Orígenes de Datos para Meta Commerce & WhatsApp Catalog</span>
|
||||
</h2>
|
||||
<p className="text-xs text-neutral-400 mt-1">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* XML Feed */}
|
||||
<div className="bg-neutral-950 border border-neutral-800 rounded-2xl p-4 space-y-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-emerald-400 flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||
Feed XML Programado (Recomendado Meta)
|
||||
</span>
|
||||
<a
|
||||
href={xmlFeedUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-neutral-400 hover:text-white flex items-center gap-1"
|
||||
>
|
||||
<span>Abrir XML</span>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 bg-neutral-900 border border-neutral-800 rounded-xl p-2 font-mono text-[11px] text-neutral-300 select-all overflow-x-auto">
|
||||
<span className="truncate flex-1">{xmlFeedUrl}</span>
|
||||
<button
|
||||
onClick={() => handleCopyUrl('xml', xmlFeedUrl)}
|
||||
className="px-2.5 py-1 bg-neutral-800 hover:bg-neutral-700 text-white rounded-lg text-xs font-bold flex items-center gap-1 shrink-0 cursor-pointer"
|
||||
>
|
||||
{copiedFeed === 'xml' ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
<span>{copiedFeed === 'xml' ? '¡Copiado!' : 'Copiar'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-neutral-500">
|
||||
Formato estándar Google Merchant / Facebook RSS 2.0. Incluye ID, foto HD, título, precio en COP, descripción, tallas y disponibilidad.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* CSV Feed */}
|
||||
<div className="bg-neutral-950 border border-neutral-800 rounded-2xl p-4 space-y-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-purple-400 flex items-center gap-1.5">
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
Feed CSV de Importación Masiva
|
||||
</span>
|
||||
<a
|
||||
href={csvFeedUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-neutral-400 hover:text-white flex items-center gap-1"
|
||||
>
|
||||
<span>Descargar CSV</span>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 bg-neutral-900 border border-neutral-800 rounded-xl p-2 font-mono text-[11px] text-neutral-300 select-all overflow-x-auto">
|
||||
<span className="truncate flex-1">{csvFeedUrl}</span>
|
||||
<button
|
||||
onClick={() => handleCopyUrl('csv', csvFeedUrl)}
|
||||
className="px-2.5 py-1 bg-neutral-800 hover:bg-neutral-700 text-white rounded-lg text-xs font-bold flex items-center gap-1 shrink-0 cursor-pointer"
|
||||
>
|
||||
{copiedFeed === 'csv' ? <Check className="w-3.5 h-3.5 text-purple-400" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
<span>{copiedFeed === 'csv' ? '¡Copiado!' : 'Copiar'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-neutral-500">
|
||||
Formato delimitado por comas compatible con la carga manual por archivo en Commerce Manager o herramientas de marketing.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sección 2: Configuración Meta Commerce Graph API (Opcional para sincronización en tiempo real) */}
|
||||
<form
|
||||
onSubmit={handleSaveMetaSettings}
|
||||
className="bg-neutral-900/60 border border-neutral-800 rounded-3xl p-6 space-y-5"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-2 border-b border-neutral-800 pb-4">
|
||||
<div>
|
||||
<h2 className="text-base font-black text-white flex items-center gap-2">
|
||||
<Sliders className="w-5 h-5 text-emerald-400" />
|
||||
<span>Sincronización Directa vía Meta Graph API (Tiempo Real)</span>
|
||||
</h2>
|
||||
<p className="text-xs text-neutral-400 mt-1">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingSettings}
|
||||
className="px-5 py-2.5 bg-neutral-800 hover:bg-neutral-700 text-white text-xs font-bold rounded-xl flex items-center gap-2 transition-all cursor-pointer shrink-0 disabled:opacity-50"
|
||||
>
|
||||
{savingSettings ? <RefreshCw className="w-4 h-4 animate-spin" /> : <ShieldCheck className="w-4 h-4 text-emerald-400" />}
|
||||
<span>{savingSettings ? 'Guardando...' : 'Guardar Credenciales'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-[11px] font-bold text-neutral-300 uppercase tracking-wider mb-1.5">
|
||||
Meta Catalog ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={metaCatalogId}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-bold text-neutral-300 uppercase tracking-wider mb-1.5">
|
||||
System User Access Token (Meta Graph)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={metaAccessToken}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoSyncEnabled}
|
||||
onChange={(e) => setAutoSyncEnabled(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-neutral-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-neutral-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-emerald-600"></div>
|
||||
</label>
|
||||
<span className="text-xs font-semibold text-neutral-300">
|
||||
Sincronizar automáticamente con el catálogo de WhatsApp al publicar desde grupos monitoreados o panel de control
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Sección 3: Explorador y Control de Productos del Catálogo */}
|
||||
<div className="bg-neutral-900/80 border border-neutral-800 rounded-3xl p-6 space-y-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-neutral-800 pb-4">
|
||||
<div>
|
||||
<h2 className="text-base font-black text-white flex items-center gap-2">
|
||||
<Tag className="w-5 h-5 text-emerald-400" />
|
||||
<span>Productos en Vitrina y Control de Disponibilidad</span>
|
||||
</h2>
|
||||
<p className="text-xs text-neutral-400 mt-0.5">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Buscador */}
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 text-neutral-500 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filtro por estado */}
|
||||
<div className="flex bg-neutral-950 p-1 rounded-xl border border-neutral-800 text-xs">
|
||||
<button
|
||||
onClick={() => setStatusFilter('all')}
|
||||
className={`px-3 py-1.5 rounded-lg font-bold transition-all cursor-pointer ${
|
||||
statusFilter === 'all' ? 'bg-neutral-800 text-white' : 'text-neutral-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Todos ({products.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('active')}
|
||||
className={`px-3 py-1.5 rounded-lg font-bold transition-all cursor-pointer ${
|
||||
statusFilter === 'active' ? 'bg-emerald-600 text-white' : 'text-neutral-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Activos ({activeProducts.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('inactive')}
|
||||
className={`px-3 py-1.5 rounded-lg font-bold transition-all cursor-pointer ${
|
||||
statusFilter === 'inactive' ? 'bg-neutral-800 text-white' : 'text-neutral-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Inactivos ({inactiveProducts.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lista de Productos */}
|
||||
{filteredProducts.length === 0 ? (
|
||||
<div className="py-16 text-center text-neutral-500 space-y-2">
|
||||
<Package className="w-12 h-12 mx-auto text-neutral-700" />
|
||||
<p className="text-sm">No se encontraron productos con los filtros seleccionados.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{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 (
|
||||
<div
|
||||
key={product.id}
|
||||
className={`bg-neutral-950 border rounded-2xl p-4 flex flex-col justify-between transition-all duration-200 ${
|
||||
isActive
|
||||
? 'border-neutral-800 hover:border-emerald-500/40 shadow-sm'
|
||||
: 'border-neutral-900 opacity-60 bg-neutral-950/40'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{/* Header de la tarjeta */}
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Imagen HD */}
|
||||
<div className="relative w-20 h-20 rounded-xl overflow-hidden bg-neutral-900 border border-neutral-800 shrink-0">
|
||||
<Image
|
||||
src={primaryImage}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="80px"
|
||||
className="object-cover"
|
||||
/>
|
||||
{product.images && product.images.length > 1 && (
|
||||
<span className="absolute bottom-1 right-1 bg-black/80 backdrop-blur-sm text-[9px] text-white px-1 rounded font-mono">
|
||||
+{product.images.length - 1}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-[10px] font-extrabold uppercase text-purple-400 truncate">
|
||||
{product.brand || 'Sneakers'}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[9px] font-black px-2 py-0.5 rounded-full border ${
|
||||
isActive
|
||||
? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30'
|
||||
: 'bg-neutral-800 text-neutral-400 border-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{isActive ? '🟢 ACTIVO' : '⚪ OCULTO'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="font-bold text-xs text-white truncate mt-0.5" title={product.name}>
|
||||
{product.name}
|
||||
</h3>
|
||||
|
||||
<div className="mt-1.5 flex items-baseline gap-2">
|
||||
<span className="text-sm font-black text-emerald-400">
|
||||
{formatCOP(product.retailPrice || 125000)}
|
||||
</span>
|
||||
{product.originalPrice && product.originalPrice > (product.retailPrice || 0) && (
|
||||
<span className="text-[10px] text-neutral-500 line-through">
|
||||
{formatCOP(product.originalPrice)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tallas disponibles */}
|
||||
<div className="bg-neutral-900/90 border border-neutral-800/80 rounded-xl p-2.5 space-y-1">
|
||||
<div className="text-[10px] text-neutral-400 font-bold uppercase tracking-wider flex items-center justify-between">
|
||||
<span>📏 Tallas disponibles:</span>
|
||||
<span className="text-emerald-400 font-mono text-[9px]">Horma Nacional</span>
|
||||
</div>
|
||||
<div className="text-xs font-semibold text-white tracking-wide">
|
||||
{sizesList}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Descripción de la publicación */}
|
||||
<div className="bg-neutral-900/50 rounded-xl p-2 text-[11px] text-neutral-400 line-clamp-3 leading-relaxed">
|
||||
{product.description || 'Zapatillas de alta calidad con garantía y cambio de talla.'}
|
||||
</div>
|
||||
|
||||
{/* Botón para Asesor Comercial: Enlace Directo para WhatsApp */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedProductForLink(product);
|
||||
setIsOrderLinkModalOpen(true);
|
||||
}}
|
||||
className="w-full py-2 px-3 bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 text-emerald-400 hover:text-emerald-300 rounded-xl text-xs font-bold flex items-center justify-center gap-1.5 transition-all cursor-pointer shadow-sm"
|
||||
>
|
||||
<MessageCircle className="w-3.5 h-3.5" />
|
||||
<span>Cerrar Pedido por WhatsApp</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Acciones de la Tarjeta */}
|
||||
<div className="pt-4 border-t border-neutral-900 flex items-center justify-between gap-2 mt-2">
|
||||
<button
|
||||
onClick={() => handleToggleProductStatus(product)}
|
||||
className={`flex-1 py-2 px-3 rounded-xl text-xs font-black flex items-center justify-center gap-1.5 transition-all cursor-pointer ${
|
||||
isActive
|
||||
? 'bg-neutral-800 hover:bg-neutral-700 text-neutral-300 hover:text-white'
|
||||
: 'bg-emerald-600 hover:bg-emerald-500 text-white shadow-lg shadow-emerald-600/30'
|
||||
}`}
|
||||
>
|
||||
{isActive ? (
|
||||
<>
|
||||
<EyeOff className="w-3.5 h-3.5" />
|
||||
<span>Desactivar</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
<span>Activar en Catálogo</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleSyncSingleProduct(product)}
|
||||
title="Sincronizar este calzado con WhatsApp Catalog"
|
||||
className="p-2 bg-neutral-900 hover:bg-neutral-800 border border-neutral-800 hover:border-emerald-500/40 text-neutral-400 hover:text-emerald-400 rounded-xl transition-all cursor-pointer"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal para Generación de Enlace de Pedido para Asesores WhatsApp */}
|
||||
<WhatsAppOrderLinkModal
|
||||
isOpen={isOrderLinkModalOpen}
|
||||
onClose={() => setIsOrderLinkModalOpen(false)}
|
||||
product={selectedProductForLink}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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 },
|
||||
|
||||
@ -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<Product[]>(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<Product | null>(null);
|
||||
const [isWaOrderLinkModalOpen, setIsWaOrderLinkModalOpen] = useState(false);
|
||||
|
||||
// Pagination State
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const [pageSize, setPageSize] = useState<number>(15);
|
||||
@ -499,6 +503,14 @@ export default function AdminProductsPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Link
|
||||
href="/admin/catalogo-whatsapp"
|
||||
className="bg-emerald-950/80 hover:bg-emerald-900 border border-emerald-500/50 text-emerald-300 font-bold text-xs px-3.5 py-2.5 rounded-xl flex items-center space-x-1.5 shadow-md cursor-pointer transition-all"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 text-emerald-400" />
|
||||
<span>Catálogo WhatsApp</span>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => setIsAiModalOpen(true)}
|
||||
className="bg-purple-950/80 hover:bg-purple-900 border border-purple-500/50 text-purple-300 font-bold text-xs px-3.5 py-2.5 rounded-xl flex items-center space-x-1.5 shadow-md cursor-pointer transition-all"
|
||||
@ -898,9 +910,19 @@ export default function AdminProductsPage() {
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Action buttons (Manage Variants, Edit & Delete) */}
|
||||
{/* Action buttons (WhatsApp Order, Manage Variants, Edit & Delete) */}
|
||||
<td className="py-3.5 px-4 text-center">
|
||||
<div className="flex items-center justify-center space-x-1.5">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedProductForWaLink(prod);
|
||||
setIsWaOrderLinkModalOpen(true);
|
||||
}}
|
||||
className="bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 hover:border-emerald-500/50 text-emerald-400 p-2 rounded-xl transition-all cursor-pointer shadow-sm"
|
||||
title="Generar enlace de pedido para WhatsApp (Cerrar venta con cliente)"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setManagingVariantsProduct(prod)}
|
||||
className={`border p-2 rounded-xl transition-all cursor-pointer ${
|
||||
@ -1454,6 +1476,13 @@ export default function AdminProductsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal para Generación de Enlace de Pedido para Asesores WhatsApp */}
|
||||
<WhatsAppOrderLinkModal
|
||||
isOpen={isWaOrderLinkModalOpen}
|
||||
onClose={() => setIsWaOrderLinkModalOpen(false)}
|
||||
product={selectedProductForWaLink}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
40
dripzone-app/src/app/api/catalog/feed.csv/route.ts
Normal file
40
dripzone-app/src/app/api/catalog/feed.csv/route.ts
Normal file
@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
43
dripzone-app/src/app/api/catalog/feed.xml/route.ts
Normal file
43
dripzone-app/src/app/api/catalog/feed.xml/route.ts
Normal file
@ -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(
|
||||
`<?xml version="1.0" encoding="UTF-8"?><error><message>${error.message || 'Error generating feed'}</message></error>`,
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/xml; charset=utf-8' },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
96
dripzone-app/src/app/api/catalog/sync/route.ts
Normal file
96
dripzone-app/src/app/api/catalog/sync/route.ts
Normal file
@ -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 });
|
||||
}
|
||||
}
|
||||
@ -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 });
|
||||
|
||||
@ -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<string, number>();
|
||||
// Cache en memoria para control de cadencia de respuestas comerciales automáticas a clientes
|
||||
const customerChatCache = new Map<string, number>();
|
||||
|
||||
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}`,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -21,6 +21,7 @@ export default function CheckoutPage() {
|
||||
const [address, setAddress] = useState('');
|
||||
const [neighborhood, setNeighborhood] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [orderSource, setOrderSource] = useState<string>('web');
|
||||
|
||||
const [cities, setCities] = useState<City[]>(INITIAL_CITIES);
|
||||
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>(INITIAL_PAYMENT_METHODS);
|
||||
@ -28,7 +29,7 @@ export default function CheckoutPage() {
|
||||
const [selectedPaymentId, setSelectedPaymentId] = useState<string>('pay-deposit-10k');
|
||||
const [storeSettings, setStoreSettings] = useState<StoreSettings | null>(null);
|
||||
|
||||
const [checkoutItems] = useState<Array<{
|
||||
const [checkoutItems, setCheckoutItems] = useState<Array<{
|
||||
productId: string;
|
||||
productName: string;
|
||||
brand: string;
|
||||
@ -36,21 +37,21 @@ export default function CheckoutPage() {
|
||||
quantity: number;
|
||||
price: number;
|
||||
image: string;
|
||||
}>>(
|
||||
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<Order | null>(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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Banner de Pedido Originado en WhatsApp */}
|
||||
{orderSource === 'whatsapp' && (
|
||||
<div className="p-4 sm:p-5 rounded-3xl bg-gradient-to-r from-emerald-950/70 via-neutral-900 to-neutral-950 border border-emerald-500/40 shadow-xl flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex items-start gap-3.5">
|
||||
<div className="w-10 h-10 rounded-2xl bg-emerald-500/20 text-emerald-400 flex items-center justify-center shrink-0 border border-emerald-500/30 mt-0.5">
|
||||
<MessageCircle className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-black text-white">¡Pedido iniciado desde WhatsApp!</h4>
|
||||
<span className="text-[10px] bg-emerald-500/20 text-emerald-400 border border-emerald-500/40 px-2 py-0.5 rounded-full font-bold uppercase tracking-wider">
|
||||
Atención Comercial
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-300 leading-relaxed">
|
||||
Tu calzado y talla seleccionada están reservados. Completa tu dirección y confirma el anticipo de <strong className="text-emerald-400">$10.000 por Bold</strong> (PSE, tarjetas o Nequi) para despachar hoy. El saldo restante lo pagas <strong>contraentrega</strong> al recibir en tu puerta.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="/app-dripzone.apk"
|
||||
download="app-dripzone.apk"
|
||||
className="px-3.5 py-2 bg-neutral-900 hover:bg-neutral-800 border border-neutral-700 hover:border-purple-500/50 text-neutral-300 hover:text-white rounded-xl text-xs font-bold flex items-center gap-1.5 shrink-0 transition-all self-start sm:self-auto"
|
||||
>
|
||||
<Zap className="w-3.5 h-3.5 text-purple-400" />
|
||||
<span>O pedir en App Móvil</span>
|
||||
<ExternalLink className="w-3 h-3 text-neutral-500" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmitOrder} className="grid grid-cols-1 md:grid-cols-12 gap-6">
|
||||
{/* Left Column: Shipping & Payment Details (7 cols) */}
|
||||
<div className="md:col-span-7 space-y-5">
|
||||
@ -618,25 +750,52 @@ export default function CheckoutPage() {
|
||||
<h3 className="font-black text-white text-base">Resumen del Pedido</h3>
|
||||
|
||||
{/* Items */}
|
||||
<div className="space-y-3 max-h-60 overflow-y-auto pr-1">
|
||||
<div className="space-y-3 max-h-80 overflow-y-auto pr-1">
|
||||
{checkoutItems.map((item, i) => (
|
||||
<div key={i} className="flex gap-3 bg-neutral-950 p-2.5 rounded-2xl border border-neutral-800">
|
||||
<OptimizedImage
|
||||
src={item.image}
|
||||
alt={item.productName}
|
||||
width={120}
|
||||
height={120}
|
||||
quality={75}
|
||||
className="w-14 h-14 rounded-xl object-cover bg-neutral-900 border border-neutral-700 shrink-0"
|
||||
containerClassName="w-14 h-14 rounded-xl shrink-0"
|
||||
/>
|
||||
<div className="flex-1 text-xs">
|
||||
<h4 className="font-bold text-white line-clamp-1">{item.productName}</h4>
|
||||
<p className="text-neutral-400">Talla: <strong className="text-white">{item.size}</strong> | Cant: {item.quantity}</p>
|
||||
<p className={`font-black mt-0.5 text-transparent bg-clip-text bg-gradient-to-r ${currentBrand.colors.primaryGradient}`}>
|
||||
{formatCOP(item.price * item.quantity)}
|
||||
</p>
|
||||
<div key={i} className="flex flex-col gap-2.5 bg-neutral-950 p-3 rounded-2xl border border-neutral-800">
|
||||
<div className="flex gap-3">
|
||||
<OptimizedImage
|
||||
src={item.image}
|
||||
alt={item.productName}
|
||||
width={120}
|
||||
height={120}
|
||||
quality={75}
|
||||
className="w-14 h-14 rounded-xl object-cover bg-neutral-900 border border-neutral-700 shrink-0"
|
||||
containerClassName="w-14 h-14 rounded-xl shrink-0"
|
||||
/>
|
||||
<div className="flex-1 text-xs">
|
||||
<h4 className="font-bold text-white line-clamp-1">{item.productName}</h4>
|
||||
<p className="text-neutral-400">Talla elegida: <strong className="text-white font-mono">{item.size}</strong></p>
|
||||
<p className={`font-black mt-0.5 text-transparent bg-clip-text bg-gradient-to-r ${currentBrand.colors.primaryGradient}`}>
|
||||
{formatCOP(item.price * item.quantity)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selector de tallas interactivo para clientes desde WhatsApp */}
|
||||
{item.availableSizes && item.availableSizes.length > 0 && (
|
||||
<div className="pt-2 border-t border-neutral-900">
|
||||
<span className="text-[10px] text-neutral-400 font-bold uppercase tracking-wider block mb-1.5">
|
||||
📏 Selecciona o Cambia tu Talla:
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{item.availableSizes.map((sz) => (
|
||||
<button
|
||||
type="button"
|
||||
key={sz}
|
||||
onClick={() => handleUpdateItemSize(i, sz)}
|
||||
className={`px-2.5 py-1 rounded-lg text-xs font-bold transition-all cursor-pointer ${
|
||||
item.size === sz
|
||||
? 'bg-purple-600 text-white shadow-md shadow-purple-600/30 font-black scale-105 border border-purple-400'
|
||||
: 'bg-neutral-900 hover:bg-neutral-800 text-neutral-300 border border-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{sz}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
808
dripzone-app/src/app/mis-pedidos/page.tsx
Normal file
808
dripzone-app/src/app/mis-pedidos/page.tsx
Normal file
@ -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<Order[]>([]);
|
||||
const [phoneSearch, setPhoneSearch] = useState<string>('');
|
||||
const [activeStatusFilter, setActiveStatusFilter] = useState<StatusGroup>('all');
|
||||
const [expandedOrderIds, setExpandedOrderIds] = useState<Set<string>>(new Set());
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [copiedOrderId, setCopiedOrderId] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
|
||||
// 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 (
|
||||
<div className="min-h-screen bg-neutral-950 text-neutral-100 py-8 px-4 sm:px-6 lg:px-8 font-sans selection:bg-purple-500 selection:text-white">
|
||||
<div className="max-w-5xl mx-auto space-y-6">
|
||||
{/* Header de Navegación */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-neutral-900 pb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="p-2.5 bg-neutral-900 border border-neutral-800 hover:border-neutral-700 text-neutral-400 hover:text-white rounded-2xl transition-all cursor-pointer shadow-xs"
|
||||
title="Volver a la tienda"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-black text-white flex items-center gap-2.5 tracking-tight">
|
||||
<span>Mis Pedidos</span>
|
||||
<span className="text-xs bg-purple-500/20 text-purple-300 border border-purple-500/30 px-2.5 py-0.5 rounded-full font-bold uppercase tracking-wider">
|
||||
Historial & Despachos
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-xs text-neutral-400 mt-0.5">
|
||||
Consulta el estado de tus zapatillas, pagos de anticipo por Bold y saldo a pagar contraentrega.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Estado de Usuario / Mi Cuenta */}
|
||||
<div className="flex items-center gap-2 self-start sm:self-auto">
|
||||
{user ? (
|
||||
<div className="flex items-center gap-2 bg-neutral-900/90 border border-neutral-800 rounded-2xl px-3.5 py-2">
|
||||
<div className="w-7 h-7 rounded-xl bg-gradient-to-br from-purple-500 to-fuchsia-600 text-white font-black text-xs flex items-center justify-center">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="text-xs font-bold text-white leading-tight">{user.name.split(' ')[0]}</div>
|
||||
<div className="text-[10px] text-neutral-400 font-mono">+57 {user.phone}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={openAuthModal}
|
||||
className="px-4 py-2 bg-neutral-900 hover:bg-neutral-800 border border-neutral-800 hover:border-purple-500/40 text-neutral-300 hover:text-white rounded-2xl text-xs font-bold flex items-center gap-2 transition-all cursor-pointer"
|
||||
>
|
||||
<User className="w-4 h-4 text-purple-400" />
|
||||
<span>Iniciar Sesión</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Barra de Búsqueda por Celular/WhatsApp (Para clientes invitados o registrados) */}
|
||||
<div className="bg-neutral-900/70 border border-neutral-800/80 rounded-3xl p-4 sm:p-5 shadow-sm space-y-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<span className="text-xs font-bold text-neutral-300 flex items-center gap-1.5">
|
||||
<Phone className="w-4 h-4 text-emerald-400" />
|
||||
<span>Consultar pedidos por número de celular / WhatsApp:</span>
|
||||
</span>
|
||||
<span className="text-[11px] text-neutral-500">
|
||||
Busca las órdenes registradas con tu línea telefónica
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSearchSubmit} className="flex flex-col sm:flex-row gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Phone className="w-4 h-4 text-neutral-500 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="tel"
|
||||
value={phoneSearch}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="px-5 py-2.5 bg-purple-600 hover:bg-purple-500 active:scale-95 text-white font-bold text-xs rounded-2xl flex items-center justify-center gap-2 transition-all cursor-pointer shadow-md shadow-purple-950 disabled:opacity-50"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
<span>{isLoading ? 'Buscando...' : 'Buscar Pedidos'}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPhoneSearch('');
|
||||
fetchOrders('');
|
||||
}}
|
||||
className="p-2.5 bg-neutral-950 hover:bg-neutral-800 border border-neutral-800 text-neutral-400 hover:text-white rounded-2xl transition-all cursor-pointer"
|
||||
title="Ver todos los pedidos recientes"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Pestañas de Agrupación por Estado */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-neutral-400 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<SlidersHorizontal className="w-3.5 h-3.5 text-purple-400" />
|
||||
<span>Agrupar por Estado del Pedido:</span>
|
||||
</span>
|
||||
|
||||
{/* Sub-buscador por palabra clave o modelo */}
|
||||
{orders.length > 0 && (
|
||||
<div className="relative w-48 sm:w-64">
|
||||
<Search className="w-3.5 h-3.5 text-neutral-500 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto pb-2 scrollbar-none">
|
||||
<button
|
||||
onClick={() => setActiveStatusFilter('all')}
|
||||
className={`px-3.5 py-2 rounded-xl text-xs font-bold transition-all cursor-pointer shrink-0 flex items-center gap-1.5 border ${
|
||||
activeStatusFilter === 'all'
|
||||
? 'bg-purple-600 text-white border-purple-500 shadow-md shadow-purple-950'
|
||||
: 'bg-neutral-900 text-neutral-400 border-neutral-800 hover:text-white hover:border-neutral-700'
|
||||
}`}
|
||||
>
|
||||
<span>Todos</span>
|
||||
<span className="text-[10px] bg-black/40 px-1.5 py-0.2 rounded-full font-mono">
|
||||
{counts.all}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveStatusFilter('pending')}
|
||||
className={`px-3.5 py-2 rounded-xl text-xs font-bold transition-all cursor-pointer shrink-0 flex items-center gap-1.5 border ${
|
||||
activeStatusFilter === 'pending'
|
||||
? 'bg-amber-600 text-white border-amber-500 shadow-md shadow-amber-950'
|
||||
: 'bg-neutral-900 text-neutral-400 border-neutral-800 hover:text-white hover:border-neutral-700'
|
||||
}`}
|
||||
>
|
||||
<Clock className="w-3.5 h-3.5 text-amber-400" />
|
||||
<span>Pendiente de Anticipo</span>
|
||||
<span className="text-[10px] bg-black/40 px-1.5 py-0.2 rounded-full font-mono">
|
||||
{counts.pending}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveStatusFilter('processing')}
|
||||
className={`px-3.5 py-2 rounded-xl text-xs font-bold transition-all cursor-pointer shrink-0 flex items-center gap-1.5 border ${
|
||||
activeStatusFilter === 'processing'
|
||||
? 'bg-emerald-600 text-white border-emerald-500 shadow-md shadow-emerald-950'
|
||||
: 'bg-neutral-900 text-neutral-400 border-neutral-800 hover:text-white hover:border-neutral-700'
|
||||
}`}
|
||||
>
|
||||
<Zap className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Anticipo Aprobado / Alistamiento</span>
|
||||
<span className="text-[10px] bg-black/40 px-1.5 py-0.2 rounded-full font-mono">
|
||||
{counts.processing}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveStatusFilter('dispatched')}
|
||||
className={`px-3.5 py-2 rounded-xl text-xs font-bold transition-all cursor-pointer shrink-0 flex items-center gap-1.5 border ${
|
||||
activeStatusFilter === 'dispatched'
|
||||
? 'bg-blue-600 text-white border-blue-500 shadow-md shadow-blue-950'
|
||||
: 'bg-neutral-900 text-neutral-400 border-neutral-800 hover:text-white hover:border-neutral-700'
|
||||
}`}
|
||||
>
|
||||
<Truck className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>En Camino</span>
|
||||
<span className="text-[10px] bg-black/40 px-1.5 py-0.2 rounded-full font-mono">
|
||||
{counts.dispatched}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveStatusFilter('delivered')}
|
||||
className={`px-3.5 py-2 rounded-xl text-xs font-bold transition-all cursor-pointer shrink-0 flex items-center gap-1.5 border ${
|
||||
activeStatusFilter === 'delivered'
|
||||
? 'bg-emerald-700 text-white border-emerald-500 shadow-md shadow-emerald-950'
|
||||
: 'bg-neutral-900 text-neutral-400 border-neutral-800 hover:text-white hover:border-neutral-700'
|
||||
}`}
|
||||
>
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Entregados</span>
|
||||
<span className="text-[10px] bg-black/40 px-1.5 py-0.2 rounded-full font-mono">
|
||||
{counts.delivered}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveStatusFilter('cancelled')}
|
||||
className={`px-3.5 py-2 rounded-xl text-xs font-bold transition-all cursor-pointer shrink-0 flex items-center gap-1.5 border ${
|
||||
activeStatusFilter === 'cancelled'
|
||||
? 'bg-red-700 text-white border-red-500 shadow-md shadow-red-950'
|
||||
: 'bg-neutral-900 text-neutral-400 border-neutral-800 hover:text-white hover:border-neutral-700'
|
||||
}`}
|
||||
>
|
||||
<XCircle className="w-3.5 h-3.5 text-red-400" />
|
||||
<span>Cancelados</span>
|
||||
<span className="text-[10px] bg-black/40 px-1.5 py-0.2 rounded-full font-mono">
|
||||
{counts.cancelled}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Listado de Pedidos (UI Desplegable / Acordeón por Tarjeta) */}
|
||||
{isLoading ? (
|
||||
<div className="p-12 text-center text-neutral-500 space-y-3 bg-neutral-900/40 border border-neutral-800 rounded-3xl">
|
||||
<RefreshCw className="w-8 h-8 mx-auto animate-spin text-purple-400" />
|
||||
<p className="text-xs font-semibold">Cargando historial de pedidos...</p>
|
||||
</div>
|
||||
) : filteredOrders.length === 0 ? (
|
||||
<div className="p-16 text-center text-neutral-400 space-y-4 bg-neutral-900/40 border border-neutral-800 rounded-3xl">
|
||||
<Package className="w-12 h-12 mx-auto text-neutral-600" />
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-base font-bold text-white">No se encontraron pedidos</h3>
|
||||
<p className="text-xs text-neutral-500 max-w-sm mx-auto">
|
||||
{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.'}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-purple-600 to-fuchsia-600 text-white font-bold text-xs rounded-2xl shadow-lg shadow-purple-950 transition-all hover:scale-105 cursor-pointer"
|
||||
>
|
||||
<ShoppingBag className="w-4 h-4" />
|
||||
<span>Explorar Catálogo de Zapatillas</span>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{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 (
|
||||
<div
|
||||
key={order.id}
|
||||
className={`bg-neutral-900/90 border rounded-3xl transition-all duration-300 overflow-hidden ${
|
||||
isExpanded
|
||||
? 'border-purple-500/50 shadow-xl shadow-purple-950/20'
|
||||
: 'border-neutral-800/80 hover:border-neutral-700 shadow-sm'
|
||||
}`}
|
||||
>
|
||||
{/* Vista Resumen (Header Clicable) */}
|
||||
<div
|
||||
onClick={() => 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 */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2.5 border-b border-neutral-800/80 pb-3">
|
||||
<div className="flex items-center gap-2.5 flex-wrap">
|
||||
<span className="font-mono text-xs font-black text-white bg-black/50 px-2.5 py-1 rounded-xl border border-neutral-800 flex items-center gap-1.5">
|
||||
<span>#{order.id}</span>
|
||||
<button
|
||||
onClick={(e) => handleCopyOrderId(order.id, e)}
|
||||
className="text-neutral-400 hover:text-white"
|
||||
title="Copiar ID de la orden"
|
||||
>
|
||||
{copiedOrderId === order.id ? (
|
||||
<Check className="w-3 h-3 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<span className="text-[11px] text-neutral-400 flex items-center gap-1">
|
||||
<Calendar className="w-3.5 h-3.5 text-neutral-500" />
|
||||
<span>{formatDate(order.createdAt)}</span>
|
||||
</span>
|
||||
|
||||
<span className="text-[11px] text-neutral-300 flex items-center gap-1 bg-neutral-950/80 px-2 py-0.5 rounded-lg border border-neutral-800/80">
|
||||
<MapPin className="w-3 h-3 text-purple-400" />
|
||||
<span>{order.cityName}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Insignia de Estado */}
|
||||
<div
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold border ${statusMeta.badgeColor}`}
|
||||
>
|
||||
<StatusIcon className="w-3.5 h-3.5" />
|
||||
<span>{statusMeta.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fila 2: Vista previa de calzados, valores económicos y botón desplegable */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
{/* Miniaturas de Calzado & Cantidad */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex -space-x-2.5 overflow-hidden">
|
||||
{order.items.slice(0, 3).map((item, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="relative w-12 h-12 rounded-xl overflow-hidden border-2 border-neutral-900 bg-neutral-950 shrink-0 shadow-sm"
|
||||
>
|
||||
<Image
|
||||
src={item.image || '/splash.png'}
|
||||
alt={item.productName}
|
||||
fill
|
||||
sizes="48px"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-bold text-white leading-snug truncate max-w-xs sm:max-w-sm">
|
||||
{order.items[0]?.productName || 'Zapatillas DripZone'}
|
||||
{order.items.length > 1 && (
|
||||
<span className="text-neutral-400 font-normal"> +{order.items.length - 1} más</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-neutral-400 mt-0.5">
|
||||
Total: <strong className="text-neutral-200">{totalPairs} {totalPairs === 1 ? 'par' : 'pares'}</strong> • Horma Nacional
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desglose Financiero Rápido & Indicador Expandir */}
|
||||
<div className="flex items-center justify-between sm:justify-end gap-4 shrink-0 border-t sm:border-t-0 border-neutral-800/60 pt-3 sm:pt-0">
|
||||
<div className="text-left sm:text-right">
|
||||
<div className="text-base font-black text-white">
|
||||
{formatCOP(order.totalAmount)}
|
||||
</div>
|
||||
<div className="text-[11px] flex items-center gap-1.5 sm:justify-end mt-0.5">
|
||||
{order.depositAmount > 0 ? (
|
||||
<>
|
||||
<span
|
||||
className={`font-mono text-[10px] px-1.5 py-0.2 rounded font-bold ${
|
||||
order.depositPaid || order.status !== 'pending'
|
||||
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/30'
|
||||
: 'bg-amber-500/10 text-amber-400 border border-amber-500/30'
|
||||
}`}
|
||||
>
|
||||
Anticipo: {formatCOP(order.depositAmount)}
|
||||
</span>
|
||||
<span className="text-neutral-400 font-mono text-[10px]">
|
||||
Contraentrega: {formatCOP(order.balanceDue)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-emerald-400 font-mono text-[10px] font-bold">
|
||||
100% Contraentrega
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Botón Indicador de Acordeón */}
|
||||
<div
|
||||
className={`p-2 rounded-2xl bg-neutral-950 border border-neutral-800 transition-all ${
|
||||
isExpanded ? 'text-purple-400 bg-purple-950/40 border-purple-500/40' : 'text-neutral-400'
|
||||
}`}
|
||||
>
|
||||
{isExpanded ? <ChevronUp className="w-5 h-5" /> : <ChevronDown className="w-5 h-5" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vista Detallada Desplegable (Accordion) */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-neutral-800 bg-neutral-950/60 p-5 sm:p-7 space-y-6 animate-in slide-in-from-top-3 duration-200">
|
||||
{/* Sub-alerta si el anticipo está pendiente */}
|
||||
{isDepositPending && (
|
||||
<div className="p-4 rounded-2xl bg-amber-950/30 border border-amber-500/40 flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<AlertCircle className="w-5 h-5 text-amber-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<strong className="text-white">Anticipo de $10.000 pendiente por confirmar</strong>
|
||||
<p className="text-neutral-300 mt-0.5">
|
||||
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).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={`/checkout?orderId=${order.id}`}
|
||||
className="px-4 py-2 bg-amber-500 hover:bg-amber-400 text-black font-black rounded-xl text-xs flex items-center justify-center gap-1.5 shrink-0 transition-all"
|
||||
>
|
||||
<CreditCard className="w-3.5 h-3.5" />
|
||||
<span>Pagar Anticipo en Bold</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 1. Detalle de Calzado y Tallas */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-black text-neutral-300 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Package className="w-4 h-4 text-purple-400" />
|
||||
<span>Zapatillas Incluidas en el Pedido ({order.items.length})</span>
|
||||
</h4>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{order.items.map((item, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="p-3.5 rounded-2xl bg-neutral-900 border border-neutral-800/90 flex items-center gap-3.5 shadow-xs"
|
||||
>
|
||||
<div className="relative w-16 h-16 rounded-xl overflow-hidden bg-neutral-950 border border-neutral-800 shrink-0">
|
||||
<Image
|
||||
src={item.image || '/splash.png'}
|
||||
alt={item.productName}
|
||||
fill
|
||||
sizes="64px"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-[9px] font-black uppercase text-purple-400">
|
||||
{item.brand || 'Sneakers'}
|
||||
</span>
|
||||
<h5 className="font-bold text-xs text-white truncate" title={item.productName}>
|
||||
{item.productName}
|
||||
</h5>
|
||||
<div className="mt-1 flex items-center justify-between text-xs">
|
||||
<span className="font-bold bg-neutral-950 border border-neutral-800 px-2 py-0.5 rounded text-[11px] text-emerald-400">
|
||||
Talla: {item.size}
|
||||
</span>
|
||||
<span className="text-neutral-400 font-mono text-xs">
|
||||
Cant: {item.quantity} • {formatCOP(item.price * item.quantity)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Desglose Financiero & Condición de Pago */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
|
||||
{/* Resumen de Cobro */}
|
||||
<div className="bg-neutral-900 p-4 rounded-2xl border border-neutral-800 space-y-2 text-xs">
|
||||
<h5 className="font-bold text-white flex items-center justify-between border-b border-neutral-800 pb-2">
|
||||
<span>Detalle de Valores</span>
|
||||
<CreditCard className="w-3.5 h-3.5 text-purple-400" />
|
||||
</h5>
|
||||
|
||||
<div className="flex justify-between text-neutral-400 pt-1">
|
||||
<span>Subtotal Zapatillas:</span>
|
||||
<span className="font-mono text-white">{formatCOP(order.subtotal)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-neutral-400">
|
||||
<span>Costo de Envío:</span>
|
||||
<span className="font-mono text-white">
|
||||
{order.shippingCost === 0 ? 'GRATIS' : formatCOP(order.shippingCost)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm font-black text-white pt-2 border-t border-neutral-800">
|
||||
<span>Total de la Orden:</span>
|
||||
<span className="text-emerald-400">{formatCOP(order.totalAmount)}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 p-3 rounded-xl bg-neutral-950 border border-neutral-800 space-y-1.5">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-neutral-300 font-semibold">⚡ Anticipo de Separación:</span>
|
||||
<span className="font-bold text-purple-300 font-mono">
|
||||
{formatCOP(order.depositAmount)} COP
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-neutral-500">
|
||||
{order.depositPaid || order.status !== 'pending' ? (
|
||||
<span className="text-emerald-400 flex items-center gap-1 font-bold">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
Pago de anticipo aprobado en pasarela Bold
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-amber-400 flex items-center gap-1">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
Pendiente de confirmación en Bold
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center text-xs pt-1.5 border-t border-neutral-800">
|
||||
<span className="text-emerald-400 font-bold">🤝 Saldo Contraentrega:</span>
|
||||
<span className="font-black text-emerald-400 font-mono">
|
||||
{formatCOP(order.balanceDue)} COP
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-neutral-400 leading-tight">
|
||||
Cancelas este saldo en efectivo o transferencia cuando el mensajero te entregue el paquete en tu puerta.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Datos de Despacho & Destinatario */}
|
||||
<div className="bg-neutral-900 p-4 rounded-2xl border border-neutral-800 space-y-2 text-xs">
|
||||
<h5 className="font-bold text-white flex items-center justify-between border-b border-neutral-800 pb-2">
|
||||
<span>Datos de Entrega</span>
|
||||
<MapPin className="w-3.5 h-3.5 text-emerald-400" />
|
||||
</h5>
|
||||
|
||||
<div className="space-y-1 pt-1">
|
||||
<div className="text-neutral-400">Destinatario:</div>
|
||||
<div className="font-bold text-white">{order.customerName}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-neutral-400">Celular de Contacto:</div>
|
||||
<div className="font-mono text-white">+57 {order.customerPhone}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-neutral-400">Dirección de Despacho:</div>
|
||||
<div className="font-medium text-white">{order.address}</div>
|
||||
<div className="text-purple-300 font-bold text-[11px]">{order.cityName}</div>
|
||||
</div>
|
||||
|
||||
{order.notes && (
|
||||
<div className="pt-2 border-t border-neutral-800 text-[11px] text-neutral-400">
|
||||
<span className="text-neutral-500 font-bold">Notas del pedido: </span>
|
||||
{order.notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Acciones de Asistencia */}
|
||||
<div className="pt-3 border-t border-neutral-800 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-[11px] text-neutral-400 flex items-center gap-1.5">
|
||||
<ShieldCheck className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||
<span>Tu pedido incluye garantía y primer cambio de talla gratuito (Horma Nacional).</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleWhatsAppConsult(order, e)}
|
||||
className="px-4 py-2 bg-emerald-600/20 hover:bg-emerald-600/30 border border-emerald-500/40 text-emerald-300 hover:text-emerald-200 rounded-xl text-xs font-bold flex items-center gap-2 transition-all cursor-pointer shadow-sm"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
<span>Consultar Asesor por WhatsApp</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
325
dripzone-app/src/components/admin/WhatsAppOrderLinkModal.tsx
Normal file
325
dripzone-app/src/components/admin/WhatsAppOrderLinkModal.tsx
Normal file
@ -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<string>('39');
|
||||
const [customerPhone, setCustomerPhone] = useState<string>('');
|
||||
const [customerName, setCustomerName] = useState<string>('');
|
||||
const [customNote, setCustomNote] = useState<string>('');
|
||||
const [copiedUrl, setCopiedUrl] = useState<boolean>(false);
|
||||
const [copiedMessage, setCopiedMessage] = useState<boolean>(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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="bg-neutral-950 border border-emerald-500/30 rounded-3xl max-w-2xl w-full max-h-[92vh] flex flex-col shadow-2xl shadow-emerald-950/40 overflow-hidden">
|
||||
{/* Modal Header */}
|
||||
<div className="p-5 border-b border-neutral-800/80 bg-neutral-900/60 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-500/20 text-emerald-400 flex items-center justify-center border border-emerald-500/30">
|
||||
<MessageCircle className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-black text-white flex items-center gap-2">
|
||||
<span>Asesor Comercial WhatsApp</span>
|
||||
<span className="text-[10px] bg-emerald-500/20 text-emerald-400 border border-emerald-500/40 px-2 py-0.5 rounded-full uppercase font-bold tracking-wider">
|
||||
Cierre de Venta
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-xs text-neutral-400">
|
||||
Genera el enlace directo para que el cliente procese su pedido y pago seguro en la web o app.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-neutral-400 hover:text-white rounded-xl hover:bg-neutral-800 transition-all cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="p-6 overflow-y-auto space-y-6 flex-1 text-xs">
|
||||
{/* Card Resumen Producto */}
|
||||
<div className="flex items-center gap-4 p-4 rounded-2xl bg-neutral-900/80 border border-neutral-800">
|
||||
<div className="relative w-16 h-16 rounded-xl overflow-hidden bg-neutral-950 border border-neutral-800 shrink-0">
|
||||
<Image
|
||||
src={primaryImage}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="64px"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[10px] font-bold text-purple-400 uppercase tracking-wider">
|
||||
{product.brand || 'Sneakers'}
|
||||
</div>
|
||||
<h3 className="font-black text-sm text-white truncate">{product.name}</h3>
|
||||
<div className="mt-1 flex items-baseline gap-2">
|
||||
<span className="text-sm font-black text-emerald-400">{formattedPrice}</span>
|
||||
<span className="text-[10px] text-neutral-400 font-mono">
|
||||
Anticipo Bold: $10.000 COP • Saldo Contraentrega
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuración de la Venta */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Selector de Talla */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-neutral-300 flex items-center justify-between">
|
||||
<span>Talla Acordada con el Cliente:</span>
|
||||
<span className="text-emerald-400 font-mono text-[10px]">Horma Nacional</span>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{availableSizes.map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
type="button"
|
||||
onClick={() => setSelectedSize(size)}
|
||||
className={`px-3 py-1.5 rounded-xl font-bold text-xs transition-all cursor-pointer border ${
|
||||
selectedSize === size
|
||||
? 'bg-emerald-600 text-white border-emerald-400 shadow-md shadow-emerald-950'
|
||||
: 'bg-neutral-900 text-neutral-400 border-neutral-800 hover:border-neutral-700 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{size}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Teléfono del Cliente */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-neutral-300 flex items-center gap-1.5">
|
||||
<Phone className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>WhatsApp del Cliente (Opcional):</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customerPhone}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Nombre del Cliente */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-neutral-300 flex items-center gap-1.5">
|
||||
<User className="w-3.5 h-3.5 text-purple-400" />
|
||||
<span>Nombre del Cliente (Para personalizar):</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customerName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Nota adicional */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-neutral-300">
|
||||
Nota o Promoción Especial (Opcional):
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customNote}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enlace Directo Generado */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-bold text-emerald-400 flex items-center gap-1.5">
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
<span>Enlace Directo de Checkout Seguro:</span>
|
||||
</label>
|
||||
<span className="text-[10px] text-neutral-500">
|
||||
Lleva al cliente directo a pagar y registrar dirección
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-neutral-900 border border-neutral-800 rounded-xl p-2 font-mono text-[11px] text-neutral-300 overflow-x-auto">
|
||||
<span className="truncate flex-1 select-all">{checkoutUrl}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyUrl}
|
||||
className="px-3 py-1.5 bg-neutral-800 hover:bg-neutral-700 text-white rounded-lg font-bold flex items-center gap-1 shrink-0 cursor-pointer"
|
||||
>
|
||||
{copiedUrl ? (
|
||||
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span>{copiedUrl ? '¡Copiado!' : 'Copiar URL'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vista Previa del Mensaje Comercial */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-bold text-neutral-300 flex items-center gap-1.5">
|
||||
<MessageCircle className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Mensaje Comercial Formateado para WhatsApp:</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyMessage}
|
||||
className="text-[11px] text-emerald-400 hover:text-emerald-300 font-bold flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
{copiedMessage ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-emerald-400" />
|
||||
<span>¡Mensaje Copiado!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3" />
|
||||
<span>Copiar Todo el Texto</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="w-full bg-neutral-900/90 border border-neutral-800/80 rounded-2xl p-4 text-[11px] text-neutral-300 font-sans whitespace-pre-wrap leading-relaxed max-h-48 overflow-y-auto select-all">
|
||||
{commercialMessage}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer / Botones Principales */}
|
||||
<div className="p-5 border-t border-neutral-800/80 bg-neutral-900/60 flex flex-col sm:flex-row items-center justify-between gap-3 shrink-0">
|
||||
<div className="text-[11px] text-neutral-400 flex items-center gap-1.5">
|
||||
<ShieldCheck className="w-4 h-4 text-emerald-400" />
|
||||
<span>El pedido y pago de $10k por Bold se registran automáticamente en el sistema.</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 hover:text-white rounded-xl font-bold cursor-pointer transition-all flex-1 sm:flex-initial"
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendToWhatsApp}
|
||||
className="px-5 py-2.5 bg-emerald-600 hover:bg-emerald-500 text-white rounded-xl font-black flex items-center justify-center gap-2 cursor-pointer transition-all shadow-lg shadow-emerald-600/30 flex-1 sm:flex-initial"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
<span>Enviar por WhatsApp (1-Click)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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<MobileBottomDockProps> = ({
|
||||
<span className="text-[10px] font-bold text-white">Carrito</span>
|
||||
</button>
|
||||
|
||||
{/* 4. WhatsApp */}
|
||||
<button
|
||||
onClick={handleWhatsApp}
|
||||
className="flex flex-col items-center gap-0.5 cursor-pointer text-emerald-400 hover:text-emerald-300 transition-all"
|
||||
{/* 4. Mis Pedidos */}
|
||||
<Link
|
||||
href="/mis-pedidos"
|
||||
onClick={() => setActiveTab('orders')}
|
||||
className={`flex flex-col items-center gap-0.5 cursor-pointer transition-all ${
|
||||
pathname === '/mis-pedidos' || activeTab === 'orders'
|
||||
? 'text-purple-400 font-bold'
|
||||
: 'text-neutral-400 hover:text-neutral-200'
|
||||
}`}
|
||||
>
|
||||
<MessageCircle className="w-5 h-5 fill-emerald-500/20" />
|
||||
<span className="text-[10px] font-bold">WhatsApp</span>
|
||||
</button>
|
||||
<Package className={`w-5 h-5 ${pathname === '/mis-pedidos' ? 'stroke-[2.5] text-purple-400' : ''}`} />
|
||||
<span className="text-[10px] font-bold">Mis Pedidos</span>
|
||||
</Link>
|
||||
|
||||
{/* 5. Solo Administradores: Acceso Admin | Clientes / Visitantes: Mi Cuenta / Ingresar */}
|
||||
{isAdmin ? (
|
||||
|
||||
@ -30,7 +30,7 @@ export const CartDrawer: React.FC<CartDrawerProps> = ({
|
||||
onOpenCitySelector,
|
||||
}) => {
|
||||
const { currentBrand } = useBrand();
|
||||
const [officialPhone, setOfficialPhone] = React.useState('573167455877');
|
||||
const [officialPhone, setOfficialPhone] = React.useState('573106390296');
|
||||
|
||||
React.useEffect(() => {
|
||||
fetch('/api/settings')
|
||||
|
||||
@ -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<NavbarProps> = ({
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Mis Pedidos Link */}
|
||||
<Link
|
||||
href="/mis-pedidos"
|
||||
className="flex items-center space-x-1.5 text-xs font-bold text-neutral-200 bg-neutral-900/90 hover:bg-neutral-800 hover:text-white px-3 py-2 rounded-full border border-neutral-700 hover:border-purple-500/40 transition-all cursor-pointer shadow-xs"
|
||||
title="Consultar historial y estado de mis pedidos"
|
||||
>
|
||||
<Package className="w-4 h-4 text-purple-400" />
|
||||
<span className="hidden sm:inline">Mis Pedidos</span>
|
||||
</Link>
|
||||
|
||||
{/* Admin Link - Solo para Administradores */}
|
||||
{user && (user.role === 'admin' || user.role === 'operator') && (
|
||||
<Link
|
||||
|
||||
@ -7,20 +7,17 @@ import {
|
||||
Truck,
|
||||
ShieldCheck,
|
||||
Zap,
|
||||
MessageCircle,
|
||||
ShoppingBag,
|
||||
Check,
|
||||
Palette,
|
||||
AlertCircle,
|
||||
HelpCircle,
|
||||
Sparkles,
|
||||
ArrowRight,
|
||||
} from 'lucide-react';
|
||||
import { Product, City, ProductColorVariant } from '@/types';
|
||||
import { formatCOP, calculateSeparationBreakdown, getProductEffectivePrice } from '@/lib/pricing-engine';
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
import { getProductSocialProof } from '@/lib/social-proof';
|
||||
import { renderClientTemplate } from '@/lib/templates-client';
|
||||
import { OptimizedImage } from '@/components/ui/OptimizedImage';
|
||||
|
||||
|
||||
@ -88,7 +85,6 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
const separation = calculateSeparationBreakdown(currentPrice);
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!isSizeInStock) return;
|
||||
onAddToCart(
|
||||
{
|
||||
...product,
|
||||
@ -105,35 +101,6 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
}, 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 (
|
||||
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center p-3 sm:p-4 overflow-y-auto animate-in fade-in">
|
||||
<div className={`bg-neutral-900 border ${currentBrand.colors.border} text-white rounded-3xl max-w-4xl w-full overflow-hidden shadow-2xl relative my-auto max-h-[94vh] flex flex-col md:flex-row`}>
|
||||
@ -314,28 +281,19 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Size Selector with Stock & WhatsApp sourcing option */}
|
||||
{/* Size Selector */}
|
||||
<div className="space-y-2 pt-1 border-t border-neutral-800/80">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-black text-white uppercase">
|
||||
Selecciona tu Talla (CO / Nacional):
|
||||
Selecciona tu Talla (CO / Horma Nacional):
|
||||
</span>
|
||||
<span className="text-[11px] text-neutral-400 font-medium">
|
||||
{isSizeInStock ? (
|
||||
<span className="text-emerald-400 font-bold flex items-center gap-1">
|
||||
<Check className="w-3 h-3" /> Stock Inmediato
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-amber-400 font-bold flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3" /> Solicitud Bajo Pedido
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[11px] text-emerald-400 font-bold flex items-center gap-1">
|
||||
<Check className="w-3 h-3" /> Horma Nacional
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-5 sm:grid-cols-5 gap-2">
|
||||
<div className="grid grid-cols-5 sm:grid-cols-7 gap-2">
|
||||
{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<ProductModalProps> = ({
|
||||
<button
|
||||
key={size}
|
||||
onClick={() => setSelectedSize(size)}
|
||||
className={`relative py-2 rounded-xl text-xs font-black transition-all cursor-pointer flex flex-col items-center justify-center ${
|
||||
className={`relative py-2.5 rounded-xl text-xs font-black transition-all cursor-pointer flex flex-col items-center justify-center border ${
|
||||
isSelected
|
||||
? inStock
|
||||
? `bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white shadow-lg scale-105 border border-white/30`
|
||||
: 'bg-amber-600 text-white shadow-lg scale-105 border border-amber-300'
|
||||
: inStock
|
||||
? 'bg-neutral-950 hover:bg-neutral-800 text-white border border-neutral-800'
|
||||
: 'bg-neutral-950/60 hover:bg-neutral-900 text-neutral-500 border border-neutral-800/60'
|
||||
? `bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white shadow-lg scale-105 border-white/40 ring-2 ring-purple-500/40`
|
||||
: 'bg-neutral-950 hover:bg-neutral-800 text-white border-neutral-800'
|
||||
}`}
|
||||
>
|
||||
<span>{size}</span>
|
||||
<span className="text-[8px] font-mono leading-none mt-0.5 opacity-80">
|
||||
{hasCustomSizePrice ? `$${Math.round(sizePrice / 1000)}k` : inStock ? 'Listo' : 'Pedir'}
|
||||
{hasCustomSizePrice ? `$${Math.round(sizePrice / 1000)}k` : 'CO'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Banner: ¿No encuentras tu talla? */}
|
||||
<div className="bg-gradient-to-r from-purple-950/40 to-neutral-950 border border-purple-500/30 rounded-2xl p-3 flex items-center justify-between gap-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-xl bg-purple-500/20 text-purple-400 flex items-center justify-center shrink-0">
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-bold text-white">¿No encuentras tu talla exacta?</p>
|
||||
<p className="text-[10px] text-neutral-400">Verificamos disponibilidad con bodega.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleWhatsAppRequestSize(selectedSize)}
|
||||
className="bg-purple-600 hover:bg-purple-500 text-white font-black text-[10px] px-2.5 py-1.5 rounded-xl shrink-0 cursor-pointer shadow flex items-center gap-1"
|
||||
>
|
||||
<span>Pedir Talla</span>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
@ -401,46 +335,27 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="pt-4 border-t border-neutral-800 flex flex-col gap-2.5 mt-4">
|
||||
{isSizeInStock ? (
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={added}
|
||||
className={`w-full font-bold text-sm py-4 px-4 rounded-2xl flex items-center justify-center space-x-2 transition-all cursor-pointer ${
|
||||
added
|
||||
? 'bg-neutral-800 text-white border border-white/20'
|
||||
: `bg-gradient-to-r ${currentBrand.colors.primaryGradient} hover:opacity-95 text-white shadow-lg active:scale-95`
|
||||
}`}
|
||||
>
|
||||
{added ? (
|
||||
<>
|
||||
<Check className="w-4 h-4 text-white" />
|
||||
<span>¡Añadido al Carrito!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShoppingBag className="w-4 h-4" />
|
||||
<span>Añadir al Carrito (Talla {selectedSize})</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-2xl p-3 text-xs text-amber-200 flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 text-amber-400" />
|
||||
<span>
|
||||
La talla <strong>{selectedSize}</strong> en <strong>{selectedColor.colorName}</strong> no tiene despacho inmediato en 24h, pero podemos pedirla a la bodega central para ti.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleWhatsAppRequestSize(selectedSize)}
|
||||
className="w-full bg-gradient-to-r from-emerald-600 to-teal-600 hover:opacity-95 text-white font-black text-sm py-3.5 px-4 rounded-2xl flex items-center justify-center space-x-2 shadow-xl shadow-emerald-600/30 active:scale-95 transition-all cursor-pointer"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 fill-white" />
|
||||
<span>Solicitar Talla {selectedSize} Bajo Pedido por WhatsApp</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={added}
|
||||
className={`w-full font-bold text-sm py-4 px-4 rounded-2xl flex items-center justify-center space-x-2 transition-all cursor-pointer ${
|
||||
added
|
||||
? 'bg-neutral-800 text-white border border-white/20'
|
||||
: `bg-gradient-to-r ${currentBrand.colors.primaryGradient} hover:opacity-95 text-white shadow-lg active:scale-95`
|
||||
}`}
|
||||
>
|
||||
{added ? (
|
||||
<>
|
||||
<Check className="w-4 h-4 text-white" />
|
||||
<span>¡Añadido al Carrito!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShoppingBag className="w-4 h-4" />
|
||||
<span>Añadir al Carrito (Talla {selectedSize})</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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,
|
||||
|
||||
280
dripzone-app/src/lib/whatsapp-catalog.ts
Normal file
280
dripzone-app/src/lib/whatsapp-catalog.ts
Normal file
@ -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) => ` <g:additional_image_link><![CDATA[${url}]]></g:additional_image_link>`)
|
||||
.join('\n');
|
||||
|
||||
return `
|
||||
<item>
|
||||
<g:id><![CDATA[${item.id}]]></g:id>
|
||||
<g:title><![CDATA[${item.title}]]></g:title>
|
||||
<g:description><![CDATA[${item.description}]]></g:description>
|
||||
<g:link><![CDATA[${item.link}]]></g:link>
|
||||
<g:image_link><![CDATA[${item.image_link}]]></g:image_link>
|
||||
${additionalImagesXml}
|
||||
<g:brand><![CDATA[${item.brand}]]></g:brand>
|
||||
<g:condition>new</g:condition>
|
||||
<g:availability>${item.availability === 'in stock' ? 'in_stock' : 'out_of_stock'}</g:availability>
|
||||
<g:price>${item.priceAmount} COP</g:price>
|
||||
<g:size><![CDATA[${item.sizes}]]></g:size>
|
||||
<g:product_type><![CDATA[${item.category}]]></g:product_type>
|
||||
<g:custom_label_0><![CDATA[Pago Contraentrega]]></g:custom_label_0>
|
||||
<g:custom_label_1><![CDATA[Tallas: ${item.sizes}]]></g:custom_label_1>
|
||||
<g:custom_label_2><![CDATA[${item.isActive ? 'Activo' : 'Inactivo'}]]></g:custom_label_2>
|
||||
</item>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:g="http://base.google.com/ns/1.0">
|
||||
<channel>
|
||||
<title><![CDATA[${storeName} - Catálogo Oficial de WhatsApp]]></title>
|
||||
<link><![CDATA[${DOMAIN}]]></link>
|
||||
<description><![CDATA[${storeDescription}]]></description>
|
||||
${itemsXml}
|
||||
</channel>
|
||||
</rss>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<typeof formatProductForWhatsAppCatalog> }> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user