diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/model/UserManager.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/model/UserManager.kt index a63572d..8c5d759 100644 --- a/dripzone-app/android/app/src/main/java/co/dripzone/app/model/UserManager.kt +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/model/UserManager.kt @@ -8,7 +8,8 @@ data class UserProfile( val phone: String = "", val city: String = "Cali", val address: String = "", - val isPhoneVerified: Boolean = false + val isPhoneVerified: Boolean = false, + val totalOrders: Int = 0 ) object UserManager { @@ -18,6 +19,7 @@ object UserManager { private const val KEY_USER_CITY = "user_profile_city" private const val KEY_USER_ADDRESS = "user_profile_address" private const val KEY_PHONE_VERIFIED = "user_phone_verified" + private const val KEY_TOTAL_ORDERS = "user_total_orders" fun getUser(context: Context): UserProfile { val prefs = SecurityManager.getSecurePreferences(context) @@ -26,7 +28,8 @@ object UserManager { phone = prefs.getString(KEY_USER_PHONE, "") ?: "", city = prefs.getString(KEY_USER_CITY, "Cali") ?: "Cali", address = prefs.getString(KEY_USER_ADDRESS, "") ?: "", - isPhoneVerified = prefs.getBoolean(KEY_PHONE_VERIFIED, false) + isPhoneVerified = prefs.getBoolean(KEY_PHONE_VERIFIED, false), + totalOrders = prefs.getInt(KEY_TOTAL_ORDERS, 0) ) } @@ -36,16 +39,26 @@ object UserManager { phone: String, city: String, address: String, - isVerified: Boolean + isVerified: Boolean, + totalOrders: Int? = null ) { val prefs = SecurityManager.getSecurePreferences(context) - prefs.edit() + val editor = prefs.edit() .putString(KEY_USER_NAME, name.trim()) .putString(KEY_USER_PHONE, phone.trim()) .putString(KEY_USER_CITY, city.trim()) .putString(KEY_USER_ADDRESS, address.trim()) .putBoolean(KEY_PHONE_VERIFIED, isVerified) - .apply() + if (totalOrders != null) { + editor.putInt(KEY_TOTAL_ORDERS, totalOrders) + } + editor.apply() + } + + fun incrementCompletedOrders(context: Context) { + val prefs = SecurityManager.getSecurePreferences(context) + val current = prefs.getInt(KEY_TOTAL_ORDERS, 0) + prefs.edit().putInt(KEY_TOTAL_ORDERS, current + 1).apply() } fun setPhoneVerified(context: Context, isVerified: Boolean) { diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/CartBottomSheet.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/CartBottomSheet.kt index 29b1624..1aec7fa 100644 --- a/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/CartBottomSheet.kt +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/CartBottomSheet.kt @@ -56,7 +56,7 @@ class CartBottomSheet : BottomSheetDialogFragment() { ) binding.rvCartItems.adapter = cartAdapter - binding.btnCheckoutWhatsApp.setOnClickListener { + binding.btnCartAction.setOnClickListener { val cartList = CartManager.getItems() if (cartList.isEmpty()) return@setOnClickListener @@ -101,7 +101,13 @@ class CartBottomSheet : BottomSheetDialogFragment() { val subtotal = CartManager.getSubtotal() val shipping = selectedCity.shippingCost val total = subtotal + shipping - val balanceDue = if (total > 10000.0) total - 10000.0 else 0.0 + + val user = co.dripzone.app.model.UserManager.getUser(requireContext()) + // Regla de Negocio: Primer pedido requiere separación de $10.000 COP. + // Si el cliente ya completó su primera compra (totalOrders > 0), paga 100% contraentrega sin separación. + val requiresSeparation = user.totalOrders == 0 + val separationAmount = if (requiresSeparation) 10000.0 else 0.0 + val balanceDue = if (total > separationAmount) total - separationAmount else 0.0 val format = NumberFormat.getCurrencyInstance(Locale("es", "CO")) format.maximumFractionDigits = 0 @@ -109,7 +115,28 @@ class CartBottomSheet : BottomSheetDialogFragment() { binding.tvSubtotal.text = format.format(subtotal) + " COP" binding.tvShippingTotal.text = selectedCity.formattedShipping() binding.tvCartTotalAmount.text = format.format(total) + " COP" - binding.tvSeparationBreakdown.text = "Separas tu pedido con $10.000 COP y cancelas el saldo restante (${format.format(balanceDue)} COP) en efectivo al recibir." + + if (requiresSeparation) { + binding.tvLabelSeparation.text = "⚡ Separación (1er Pedido):" + binding.tvSeparationAmount.text = "$ 10.000 COP" + binding.tvBalanceDueAmount.text = format.format(balanceDue) + " COP" + binding.tvSeparationBreakdown.text = "Separas tu pedido con $10.000 COP y cancelas el saldo restante (${format.format(balanceDue)} COP) en efectivo al recibir." + binding.btnCartAction.text = "Pagar Separación ($10.000 COP)" + binding.btnCartAction.backgroundTintList = android.content.res.ColorStateList.valueOf( + androidx.core.content.ContextCompat.getColor(requireContext(), co.dripzone.app.R.color.drip_purple) + ) + binding.btnCartAction.setIconResource(co.dripzone.app.R.drawable.ic_sparkle) + } else { + binding.tvLabelSeparation.text = "⭐ Separación (Cliente Frecuente):" + binding.tvSeparationAmount.text = "$ 0 COP (Exento)" + binding.tvBalanceDueAmount.text = format.format(total) + " COP" + binding.tvSeparationBreakdown.text = "¡Cliente Frecuente! Pagas el 100% del pedido (${format.format(total)} COP) contraentrega al recibir en tu puerta." + binding.btnCartAction.text = "Enviar Pedido (100% Contraentrega)" + binding.btnCartAction.backgroundTintList = android.content.res.ColorStateList.valueOf( + androidx.core.content.ContextCompat.getColor(requireContext(), co.dripzone.app.R.color.accent_emerald) + ) + binding.btnCartAction.setIconResource(co.dripzone.app.R.drawable.ic_truck) + } } } diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/OrderVerificationBottomSheet.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/OrderVerificationBottomSheet.kt index 442d067..9aca6dd 100644 --- a/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/OrderVerificationBottomSheet.kt +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/OrderVerificationBottomSheet.kt @@ -1,5 +1,7 @@ package co.dripzone.app.ui +import android.content.Intent +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -215,7 +217,16 @@ class OrderVerificationBottomSheet : BottomSheetDialogFragment() { val subtotal = items.sumOf { it.itemTotal } val total = subtotal + selectedCity.shippingCost - val balanceDue = if (total > 10000.0) total - 10000.0 else 0.0 + + val user = UserManager.getUser(requireContext()) + // Regla: Para el primer pedido se cobra la separación ($10.000 COP). + // Tras haber realizado la primera compra culminada satisfactoriamente (totalOrders > 0), el cliente paga 100% contraentrega. + val requiresDeposit = user.totalOrders == 0 + val depositAmount = if (requiresDeposit) 10000.0 else 0.0 + val balanceDue = if (total > depositAmount) total - depositAmount else 0.0 + + val format = NumberFormat.getCurrencyInstance(Locale("es", "CO")) + format.maximumFractionDigits = 0 val itemsPayload = items.map { mapOf( @@ -239,9 +250,9 @@ class OrderVerificationBottomSheet : BottomSheetDialogFragment() { "subtotal" to subtotal, "shippingCost" to selectedCity.shippingCost, "totalAmount" to total, - "depositAmount" to 10000.0, + "depositAmount" to depositAmount, "balanceDue" to balanceDue, - "paymentMethodName" to "Pago Contraentrega", + "paymentMethodName" to if (requiresDeposit) "Separación $10.000 + Contraentrega" else "100% Pago Contraentrega", "notes" to "Pedido registrado desde App Android Nativa (WhatsApp OTP Verificado)" ) @@ -251,6 +262,8 @@ class OrderVerificationBottomSheet : BottomSheetDialogFragment() { orderResult.onSuccess { orderId -> createdOrderId = orderId + UserManager.incrementCompletedOrders(requireContext()) + // Limpiar carrito if (!isDirectProductOrder) { CartManager.clear(requireContext()) @@ -261,7 +274,30 @@ class OrderVerificationBottomSheet : BottomSheetDialogFragment() { binding.layoutStepOtp.visibility = View.GONE binding.layoutStepSuccess.visibility = View.VISIBLE binding.tvSuccessOrderId.text = "Orden #$orderId" - binding.tvSuccessMessage.text = "¡Tu número de WhatsApp (+57 $customerPhone) fue validado y tu pedido fue registrado en la plataforma!\n\nLa plataforma notificó automáticamente al administrador Carlos (+57 316 745 5877) para preparar tu paquete." + + if (requiresDeposit) { + binding.tvSuccessMessage.text = "¡Tu número de WhatsApp (+57 $customerPhone) fue validado y tu pedido fue registrado en la plataforma!\n\n⚠️ REQUISITO PARA DESPACHO:\nPara poder programar el despacho de tu paquete, realiza el pago de la separación de $10.000 COP a través de la pasarela oficial Bold. Cancelarás el saldo restante (${format.format(balanceDue)} COP) en efectivo al recibir." + binding.btnPaySeparationBold.visibility = View.VISIBLE + binding.btnPaySeparationBold.setOnClickListener { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://bold.co/p/carlos-lasso")) + startActivity(intent) + } + binding.btnOpenWhatsAppChat.text = "Notificar Pago a Carlos en WhatsApp" + binding.btnOpenWhatsAppChat.setOnClickListener { + val message = "¡Hola Carlos! Acabo de registrar mi pedido en DripZone con la orden *$createdOrderId*. Voy a cancelar la separación de $10.000 COP para coordinar el despacho a ${selectedCity.name}. Saldo al recibir: ${format.format(balanceDue)} COP." + SecurityManager.openWhatsApp(requireContext(), SecurityManager.WHATSAPP_STORE_PHONE, message) + dismiss() + } + } else { + binding.tvSuccessMessage.text = "¡Tu número de WhatsApp (+57 $customerPhone) fue validado y tu pedido fue registrado en la plataforma!\n\n⭐ BENEFICIO CLIENTE FRECUENTE:\nComo ya realizaste compras satisfactoriamente, estás exento de separación y pagarás el 100% (${format.format(total)} COP) contraentrega al recibir tu paquete." + binding.btnPaySeparationBold.visibility = View.GONE + binding.btnOpenWhatsAppChat.text = "Coordinar Entrega por WhatsApp" + binding.btnOpenWhatsAppChat.setOnClickListener { + val message = "¡Hola Carlos! Acabo de registrar mi orden *$createdOrderId* en DripZone con 100% Contraentrega a ${selectedCity.name}. ¿Me confirmas la programación del despacho?" + SecurityManager.openWhatsApp(requireContext(), SecurityManager.WHATSAPP_STORE_PHONE, message) + dismiss() + } + } }.onFailure { err -> binding.btnVerifyOtpAndSubmit.isEnabled = true Toast.makeText(requireContext(), "Error al registrar orden: ${err.message}", Toast.LENGTH_LONG).show() diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/ProductDetailBottomSheet.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/ProductDetailBottomSheet.kt index 5e9420e..dd13ff0 100644 --- a/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/ProductDetailBottomSheet.kt +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/ProductDetailBottomSheet.kt @@ -78,26 +78,6 @@ class ProductDetailBottomSheet : BottomSheetDialogFragment() { dismiss() } - // Direct Order Checkout Button with Verification - binding.btnOrderWhatsApp.setOnClickListener { - val sizeToOrder = if (selectedSize.isNotBlank()) selectedSize else sizeAdapter?.getSelectedSize() ?: "39" - val directItem = co.dripzone.app.model.CartItem( - product = p, - selectedSize = sizeToOrder, - quantity = 1 - ) - val city = (activity as? co.dripzone.app.MainActivity)?.selectedCity ?: co.dripzone.app.model.City.defaultCities().first() - val verificationSheet = OrderVerificationBottomSheet.newInstance( - city = city, - isDirect = true, - directItem = directItem - ) - parentFragmentManager.let { - verificationSheet.show(it, OrderVerificationBottomSheet.TAG) - } - dismiss() - } - // Custom order button inside bottom sheet binding.btnCustomOrderSheet.setOnClickListener { dismiss() diff --git a/dripzone-app/android/app/src/main/res/layout/bottom_sheet_cart.xml b/dripzone-app/android/app/src/main/res/layout/bottom_sheet_cart.xml index 5193e3a..6911c90 100644 --- a/dripzone-app/android/app/src/main/res/layout/bottom_sheet_cart.xml +++ b/dripzone-app/android/app/src/main/res/layout/bottom_sheet_cart.xml @@ -259,41 +259,92 @@ + android:textSize="14sp" /> + + + + + + + + + + + + + + + + + android:textColor="@color/accent_emerald" + android:textSize="16sp" /> - + + app:iconSize="20dp" /> diff --git a/dripzone-app/android/app/src/main/res/layout/bottom_sheet_order_verification.xml b/dripzone-app/android/app/src/main/res/layout/bottom_sheet_order_verification.xml index e4f1521..563eaf1 100644 --- a/dripzone-app/android/app/src/main/res/layout/bottom_sheet_order_verification.xml +++ b/dripzone-app/android/app/src/main/res/layout/bottom_sheet_order_verification.xml @@ -323,13 +323,32 @@ android:textColor="@color/text_secondary" android:textSize="12sp" /> + + + - - - u.phone.replace(/\D/g, '') === cleanPhone); + const existingOrders = dataService.getOrders().filter( + (o) => o.customerPhone.replace(/\D/g, '') === cleanPhone + ); + const hasCompletedPreviousOrder = Boolean( + (user && (user.totalOrders || 0) > 0) || + existingOrders.some((o) => o.status === 'dispatched' || o.status === 'deposit_received' || o.status === 'supplier_notified') + ); + const requiresShippingDeposit = !hasCompletedPreviousOrder; + const depositAmount = requiresShippingDeposit ? 10000 : 0; + const depositReason = requiresShippingDeposit + ? 'Garantía de separación requerida para primer pedido' + : 'Cliente distinguido exento de anticipo (100% Contraentrega)'; + return NextResponse.json({ success: true, - requiresShippingDeposit: user ? user.requiresShippingDeposit === true : false, - depositReason: user?.depositReason, + isFirstPurchase: !hasCompletedPreviousOrder, + requiresShippingDeposit, + depositAmount, + depositReason, phoneVerified: user ? user.phoneVerified === true : false, - user: user ? { id: user.id, name: user.name, phoneVerified: user.phoneVerified, requiresShippingDeposit: user.requiresShippingDeposit, depositReason: user.depositReason } : null, + user: user + ? { + id: user.id, + name: user.name, + phoneVerified: user.phoneVerified, + totalOrders: user.totalOrders || existingOrders.length, + requiresShippingDeposit, + depositReason, + } + : null, }); } diff --git a/dripzone-app/src/app/api/whatsapp-media/route.ts b/dripzone-app/src/app/api/whatsapp-media/route.ts index eea23d1..a5b0bb1 100644 --- a/dripzone-app/src/app/api/whatsapp-media/route.ts +++ b/dripzone-app/src/app/api/whatsapp-media/route.ts @@ -18,6 +18,25 @@ function ensureCacheDir() { // Fallback image buffer (placeholder sneaker) const FALLBACK_IMAGE_URL = 'https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'; +async function serveFallbackImage() { + try { + const res = await fetch(FALLBACK_IMAGE_URL); + const arrayBuf = await res.arrayBuffer(); + return new Response(Buffer.from(arrayBuf), { + status: 200, + headers: { + 'Content-Type': 'image/jpeg', + 'Cache-Control': 'public, max-age=86400', + }, + }); + } catch (e) { + return new Response(Buffer.alloc(0), { + status: 200, + headers: { 'Content-Type': 'image/jpeg' }, + }); + } +} + export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url); @@ -26,7 +45,7 @@ export async function GET(request: NextRequest) { const quality = searchParams.get('q') || 'high'; if (!messageId) { - return NextResponse.redirect(FALLBACK_IMAGE_URL); + return serveFallbackImage(); } const cleanId = messageId.replace(/[^a-zA-Z0-9_-]/g, ''); @@ -94,8 +113,8 @@ export async function GET(request: NextRequest) { } if (!messageRecord || !messageRecord.message?.imageMessage) { - // Si el mensaje no existe o no tiene imagen, redirigir a imagen fallback - return NextResponse.redirect(FALLBACK_IMAGE_URL); + // Si el mensaje no existe o no tiene imagen, servir fallback directo + return serveFallbackImage(); } // 3. Solicitar el medio en Alta Resolución Full HD a Evolution API @@ -148,9 +167,9 @@ export async function GET(request: NextRequest) { }); } - return NextResponse.redirect(FALLBACK_IMAGE_URL); + return serveFallbackImage(); } catch (error: any) { console.error('Error en GET /api/whatsapp-media:', error); - return NextResponse.redirect(FALLBACK_IMAGE_URL); + return serveFallbackImage(); } } diff --git a/dripzone-app/src/app/checkout/page.tsx b/dripzone-app/src/app/checkout/page.tsx index 688f40c..554b43a 100644 --- a/dripzone-app/src/app/checkout/page.tsx +++ b/dripzone-app/src/app/checkout/page.tsx @@ -54,8 +54,8 @@ export default function CheckoutPage() { const [isSubmitting, setIsSubmitting] = useState(false); const [orderCompleted, setOrderCompleted] = useState(null); - const [userRequiresDeposit, setUserRequiresDeposit] = useState(false); - const [userDepositReason, setUserDepositReason] = useState(''); + const [userRequiresDeposit, setUserRequiresDeposit] = useState(true); + const [userDepositReason, setUserDepositReason] = useState('Garantía de separación requerida para el primer pedido'); const [showOtpModal, setShowOtpModal] = useState(false); const [otpCode, setOtpCode] = useState(''); const [otpLoading, setOtpLoading] = useState(false); @@ -80,7 +80,7 @@ export default function CheckoutPage() { useEffect(() => { if (user) { - setUserRequiresDeposit(user.requiresShippingDeposit === true); + setUserRequiresDeposit((user.totalOrders || 0) === 0 || user.requiresShippingDeposit === true); setUserDepositReason(user.depositReason || ''); } }, [user]); @@ -92,7 +92,7 @@ export default function CheckoutPage() { .then((r) => r.json()) .then((data) => { if (data.success) { - setUserRequiresDeposit(data.requiresShippingDeposit === true); + setUserRequiresDeposit(data.requiresShippingDeposit !== false); setUserDepositReason(data.depositReason || ''); } }) @@ -107,15 +107,10 @@ export default function CheckoutPage() { const shippingCost = activeCity.shippingCost; const totalAmount = subtotal + shippingCost; - // Valor de garantía por flete según la ciudad seleccionada (o $10.000 COP si flete local es 0) - const cityShippingFee = activeCity.shippingCost > 0 ? activeCity.shippingCost : 10000; - - // Por defecto NO se cobra anticipo a clientes nuevos - // Solo se activa si el cliente tiene un pedido rechazado previo o si un admin lo activó + // Regla: Para el primer pedido se cobra separación fija ($10.000 COP) sin importar cuántos productos lleve. + // Tras la primera compra culminada satisfactoriamente, el cliente paga 100% contraentrega. const isMandatoryDeposit = userRequiresDeposit; - const depositAmount = isMandatoryDeposit - ? cityShippingFee - : (activePayment.requiresDeposit ? activePayment.depositAmount : 0); + const depositAmount = isMandatoryDeposit ? 10000 : 0; const balanceDue = Math.max(0, totalAmount - depositAmount); const officialWhatsapp = storeSettings?.whatsappNumber || '573167455877'; @@ -544,40 +539,35 @@ export default function CheckoutPage() { {shippingCost === 0 ? Gratis : formatCOP(shippingCost)} -
- Total a Pagar: - +
+ Total de la Orden: + {formatCOP(totalAmount)}
- {/* Garantía de Flete / Despacho (Solo visible si está activo para el usuario) */} - {isMandatoryDeposit ? ( -
-
- - Garantía de Despacho & Reserva ({activeCity.name}) -
-

- Para asegurar tu turno prioritario de entrega con la transportadora en {activeCity.name}, se realiza un anticipo de {formatCOP(depositAmount)} por concepto de flete, el cual es 100% descontable del valor total al recibir tu calzado en tu domicilio. -

-
- Saldo a pagar en efectivo al recibir: - {formatCOP(balanceDue)} -
+ {/* Discriminación de Separación y Saldo Contraentrega */} +
+
+ + {isMandatoryDeposit ? '⚡ Valor Separación (1er Pedido):' : '⭐ Separación (Cliente Frecuente):'} + + + {isMandatoryDeposit ? formatCOP(depositAmount) : '$0 COP (Exento)'} +
- ) : depositAmount > 0 ? ( -
-
- Anticipo de Separación: - {formatCOP(depositAmount)} -
-
- Saldo a pagar al recibir: - {formatCOP(balanceDue)} -
+ +
+ 🤝 Saldo a Pagar Contraentrega: + {formatCOP(balanceDue)}
- ) : null} + +

+ {isMandatoryDeposit + ? 'Para tu primer pedido aseguras el despacho con solo $10.000 COP vía Bold/Nequi y cancelas el saldo restante en efectivo al recibir tu paquete.' + : '¡Gracias por tu confianza! Como cliente frecuente, cancelas el 100% del pedido contraentrega al recibir.'} +

+
@@ -599,9 +589,14 @@ export default function CheckoutPage() { > {isSubmitting ? ( Procesando Pedido... + ) : isMandatoryDeposit ? ( + <> + Pagar Separación ($10.000 COP) + + ) : ( <> - Confirmar Pedido Contraentrega + Enviar Pedido (100% Contraentrega) )} diff --git a/dripzone-app/src/components/custom-requests/CustomRequestModal.tsx b/dripzone-app/src/components/custom-requests/CustomRequestModal.tsx index 5509754..7100e2d 100644 --- a/dripzone-app/src/components/custom-requests/CustomRequestModal.tsx +++ b/dripzone-app/src/components/custom-requests/CustomRequestModal.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useState, useRef } from 'react'; -import { Sparkles, Upload, X, CheckCircle2, MessageCircle, AlertCircle, ArrowRight, Camera } from 'lucide-react'; +import { Sparkles, Upload, X, CheckCircle2, MessageCircle, AlertCircle, ArrowRight, Camera, ShieldCheck } from 'lucide-react'; interface CustomRequestModalProps { isOpen: boolean; diff --git a/dripzone-app/src/components/store/CartDrawer.tsx b/dripzone-app/src/components/store/CartDrawer.tsx index 4cd0ed2..7982da0 100644 --- a/dripzone-app/src/components/store/CartDrawer.tsx +++ b/dripzone-app/src/components/store/CartDrawer.tsx @@ -212,22 +212,30 @@ export const CartDrawer: React.FC = ({ {shippingCost === 0 ? Gratis : formatCOP(shippingCost)}
-
+
Total de la Orden: - + {formatCOP(totalAmount)}
+
+ ⚡ Separación (1er Pedido): + $10.000 COP +
+
+ 🤝 Saldo Restante Contraentrega: + {formatCOP(Math.max(0, totalAmount - 10000))} +
{/* Separation Highlight Banner */}

- Opción de Separación con $10.000 COP + Separación con $10.000 COP (1er Pedido)

- Separas con {formatCOP(separation.depositAmount)} y pagas {formatCOP(separation.balanceDue)} al recibir contraentrega en tu casa. + Separas con $10.000 COP y cancelas el saldo restante ({formatCOP(Math.max(0, totalAmount - 10000))}) en efectivo o transferencia al recibir en tu puerta.

🛡️ {selectedCity.firstExchangeFree @@ -236,25 +244,16 @@ export const CartDrawer: React.FC = ({

- {/* Action Buttons */} -
+ {/* Action Button */} +
- Proceder al Pago Contraentrega + Continuar al Pago Contraentrega - - - - Completar Pedido con Verificación WhatsApp -
)} diff --git a/dripzone-app/src/components/store/ProductModal.tsx b/dripzone-app/src/components/store/ProductModal.tsx index 9b7e5a1..411c6b0 100644 --- a/dripzone-app/src/components/store/ProductModal.tsx +++ b/dripzone-app/src/components/store/ProductModal.tsx @@ -402,24 +402,15 @@ export const ProductModal: React.FC = ({ {/* Action Buttons */}
{isSizeInStock ? ( - <> - - - - ) : (
diff --git a/dripzone-app/src/lib/data-service.ts b/dripzone-app/src/lib/data-service.ts index 56f386a..d2e4c78 100644 --- a/dripzone-app/src/lib/data-service.ts +++ b/dripzone-app/src/lib/data-service.ts @@ -188,8 +188,70 @@ export const INITIAL_USERS: User[] = [ export const INITIAL_MONITORED_GROUPS: MonitoredWhatsAppGroup[] = []; +function getFsModule() { + if (typeof window === 'undefined') { + try { + return require('fs'); + } catch (_) { + return null; + } + } + return null; +} + +function getPathModule() { + if (typeof window === 'undefined') { + try { + return require('path'); + } catch (_) { + return null; + } + } + return null; +} + +function getPersistedFilePath(): string | null { + const pathMod = getPathModule(); + if (!pathMod) return null; + return pathMod.join(process.cwd(), 'data', 'persisted_products.json'); +} + +function loadPersistedProducts(): Product[] { + const fsMod = getFsModule(); + const filePath = getPersistedFilePath(); + if (fsMod && filePath && fsMod.existsSync(filePath)) { + try { + const content = fsMod.readFileSync(filePath, 'utf-8'); + const parsed = JSON.parse(content); + if (Array.isArray(parsed) && parsed.length > 0) { + return parsed; + } + } catch (e) { + console.warn('[DataService] Error leyendo archivo de productos persistidos:', e); + } + } + return [...INITIAL_PRODUCTS]; +} + +function savePersistedProducts(products: Product[]): void { + const fsMod = getFsModule(); + const pathMod = getPathModule(); + const filePath = getPersistedFilePath(); + if (fsMod && pathMod && filePath) { + try { + const dir = pathMod.dirname(filePath); + if (!fsMod.existsSync(dir)) { + fsMod.mkdirSync(dir, { recursive: true }); + } + fsMod.writeFileSync(filePath, JSON.stringify(products, null, 2), 'utf-8'); + } catch (e) { + console.warn('[DataService] Error guardando productos en archivo persistente:', e); + } + } +} + class DataService { - private products: Product[] = [...INITIAL_PRODUCTS]; + private products: Product[] = loadPersistedProducts(); private cities: City[] = [...INITIAL_CITIES]; private paymentMethods: PaymentMethod[] = [...INITIAL_PAYMENT_METHODS]; private suppliers: Supplier[] = [...INITIAL_SUPPLIERS]; @@ -197,9 +259,17 @@ class DataService { private supplierPosts: SupplierPost[] = [...INITIAL_SUPPLIER_POSTS]; getProducts(): Product[] { + if (this.products.length === 0) { + this.products = loadPersistedProducts(); + } return groupProductsByModel(this.products); } + setProducts(products: Product[]): void { + this.products = products; + savePersistedProducts(this.products); + } + getProductById(id: string): Product | undefined { const allGrouped = groupProductsByModel(this.products); return allGrouped.find((p) => p.id === id || p.colors?.some((c) => c.id === id)); @@ -274,6 +344,7 @@ class DataService { if (!existing.description || existing.description.length < 50) { existing.description = cleanDescription; } + savePersistedProducts(this.products); return existing; } @@ -306,6 +377,7 @@ class DataService { }; this.products.unshift(formattedProduct); + savePersistedProducts(this.products); return formattedProduct; } @@ -313,12 +385,14 @@ class DataService { const idx = this.products.findIndex((p) => p.id === id); if (idx === -1) return null; this.products[idx] = { ...this.products[idx], ...updates }; + savePersistedProducts(this.products); return this.products[idx]; } deleteProduct(id: string): boolean { const initialLen = this.products.length; this.products = this.products.filter((p) => p.id !== id); + savePersistedProducts(this.products); return this.products.length < initialLen; } diff --git a/dripzone-app/src/lib/evolution-api.ts b/dripzone-app/src/lib/evolution-api.ts index a8a640d..6976842 100644 --- a/dripzone-app/src/lib/evolution-api.ts +++ b/dripzone-app/src/lib/evolution-api.ts @@ -113,18 +113,33 @@ export async function notifyNewProductPublished( `━━━━━━━━━━━━━━━━━━━━━━` ); + // Resolver URL absoluta de la imagen para garantizar que Evolution API la descargue con éxito + let resolvedMediaUrl = mediaUrl; + if (!resolvedMediaUrl && product?.images && product.images.length > 0) { + const rawImg = product.images[0]; + if (rawImg.startsWith('http://') || rawImg.startsWith('https://')) { + resolvedMediaUrl = rawImg; + } else if (rawImg.startsWith('/')) { + resolvedMediaUrl = `https://dripzone.almeira.com.co${rawImg}`; + } else { + resolvedMediaUrl = `https://dripzone.almeira.com.co/${rawImg}`; + } + } else if (resolvedMediaUrl && !resolvedMediaUrl.startsWith('http')) { + resolvedMediaUrl = `https://dripzone.almeira.com.co${resolvedMediaUrl.startsWith('/') ? '' : '/'}${resolvedMediaUrl}`; + } + const dispatchedTo: string[] = []; for (const target of NOTIFICATION_TARGETS) { try { let sent = false; - // Si tenemos URL de imagen completa y válida, intentamos enviar con imagen - if (mediaUrl && (mediaUrl.startsWith('http://') || mediaUrl.startsWith('https://'))) { - sent = await sendWhatsAppMedia(target, mediaUrl, notificationCaption); + // 1. Enviar prioritariamente con la foto del calzado en alta definición + if (resolvedMediaUrl && (resolvedMediaUrl.startsWith('http://') || resolvedMediaUrl.startsWith('https://'))) { + sent = await sendWhatsAppMedia(target, resolvedMediaUrl, notificationCaption); } - // Si no hay imagen o falló el envío multimedia, enviar como texto formateado de alta visibilidad + // 2. Si no hay imagen o falló el envío multimedia, enviar como texto formateado if (!sent) { sent = await sendWhatsAppMessage(target, notificationCaption); } @@ -180,7 +195,8 @@ export function buildOrderConfirmationMessage(order: any): string { /** * Notifica inmediatamente al administrador Carlos (+57 316 745 5877) - * y al canal de notificaciones cuando entra un nuevo pedido en la plataforma (web o app móvil). + * y al canal de notificaciones cuando entra un nuevo pedido en la plataforma (web o app móvil), + * INCLUYENDO la fotografía del calzado en la notificación oficial. */ export async function notifyAdminNewOrder(order: any): Promise { const CARLOS_PHONE = '573167455877'; @@ -221,24 +237,40 @@ export async function notifyAdminNewOrder(order: any): Promise { `👉 https://dripzone.almeira.com.co/admin/pedidos` ); + // Extraer y resolver la URL absoluta de la fotografía del calzado + const firstItem = order.items?.[0]; + const rawImage = firstItem?.image || (Array.isArray(firstItem?.images) ? firstItem.images[0] : null); + let resolvedImageUrl: string | null = null; + if (rawImage && typeof rawImage === 'string') { + if (rawImage.startsWith('http://') || rawImage.startsWith('https://')) { + resolvedImageUrl = rawImage; + } else if (rawImage.startsWith('/')) { + resolvedImageUrl = `https://dripzone.almeira.com.co${rawImage}`; + } else { + resolvedImageUrl = `https://dripzone.almeira.com.co/${rawImage}`; + } + } + let success = false; try { - // 1. Notificación principal al WhatsApp personal de Carlos - const resCarlos = await sendWhatsAppMessage(CARLOS_PHONE, adminMessage); - if (resCarlos) success = true; - - // 2. Si el producto principal tiene imagen web, enviar foto como soporte - const firstImage = order.items?.[0]?.image || (Array.isArray(order.items?.[0]?.images) ? order.items[0].images[0] : null); - if (firstImage && (firstImage.startsWith('http://') || firstImage.startsWith('https://'))) { - await sendWhatsAppMedia( - CARLOS_PHONE, - firstImage, - `👟 Foto de referencia: ${order.items[0]?.productName || order.items[0]?.name || 'Producto DripZone'}` - ).catch(() => {}); + // 1. Notificación principal al WhatsApp personal de Carlos CON LA FOTO DEL PRODUCTO + let sentCarlos = false; + if (resolvedImageUrl) { + sentCarlos = await sendWhatsAppMedia(CARLOS_PHONE, resolvedImageUrl, adminMessage); } + if (!sentCarlos) { + sentCarlos = await sendWhatsAppMessage(CARLOS_PHONE, adminMessage); + } + if (sentCarlos) success = true; - // 3. Notificación al canal de alertas - await sendWhatsAppMessage(NOTIFICATION_GROUP, adminMessage).catch(() => {}); + // 2. Notificación al canal de alertas (grupo) CON LA FOTO DEL PRODUCTO + let sentGroup = false; + if (resolvedImageUrl) { + sentGroup = await sendWhatsAppMedia(NOTIFICATION_GROUP, resolvedImageUrl, adminMessage).catch(() => false); + } + if (!sentGroup) { + await sendWhatsAppMessage(NOTIFICATION_GROUP, adminMessage).catch(() => {}); + } } catch (err: any) { console.error('Error enviando notificación de pedido a Carlos:', err.message); }