Fix product persistence in Appwrite/disk, remove direct WA button, cart separation breakdown and Bold payment flow, include product image in WhatsApp notifications
This commit is contained in:
parent
b7f4d600fc
commit
3c5c67e4eb
@ -8,7 +8,8 @@ data class UserProfile(
|
|||||||
val phone: String = "",
|
val phone: String = "",
|
||||||
val city: String = "Cali",
|
val city: String = "Cali",
|
||||||
val address: String = "",
|
val address: String = "",
|
||||||
val isPhoneVerified: Boolean = false
|
val isPhoneVerified: Boolean = false,
|
||||||
|
val totalOrders: Int = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
object UserManager {
|
object UserManager {
|
||||||
@ -18,6 +19,7 @@ object UserManager {
|
|||||||
private const val KEY_USER_CITY = "user_profile_city"
|
private const val KEY_USER_CITY = "user_profile_city"
|
||||||
private const val KEY_USER_ADDRESS = "user_profile_address"
|
private const val KEY_USER_ADDRESS = "user_profile_address"
|
||||||
private const val KEY_PHONE_VERIFIED = "user_phone_verified"
|
private const val KEY_PHONE_VERIFIED = "user_phone_verified"
|
||||||
|
private const val KEY_TOTAL_ORDERS = "user_total_orders"
|
||||||
|
|
||||||
fun getUser(context: Context): UserProfile {
|
fun getUser(context: Context): UserProfile {
|
||||||
val prefs = SecurityManager.getSecurePreferences(context)
|
val prefs = SecurityManager.getSecurePreferences(context)
|
||||||
@ -26,7 +28,8 @@ object UserManager {
|
|||||||
phone = prefs.getString(KEY_USER_PHONE, "") ?: "",
|
phone = prefs.getString(KEY_USER_PHONE, "") ?: "",
|
||||||
city = prefs.getString(KEY_USER_CITY, "Cali") ?: "Cali",
|
city = prefs.getString(KEY_USER_CITY, "Cali") ?: "Cali",
|
||||||
address = prefs.getString(KEY_USER_ADDRESS, "") ?: "",
|
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,
|
phone: String,
|
||||||
city: String,
|
city: String,
|
||||||
address: String,
|
address: String,
|
||||||
isVerified: Boolean
|
isVerified: Boolean,
|
||||||
|
totalOrders: Int? = null
|
||||||
) {
|
) {
|
||||||
val prefs = SecurityManager.getSecurePreferences(context)
|
val prefs = SecurityManager.getSecurePreferences(context)
|
||||||
prefs.edit()
|
val editor = prefs.edit()
|
||||||
.putString(KEY_USER_NAME, name.trim())
|
.putString(KEY_USER_NAME, name.trim())
|
||||||
.putString(KEY_USER_PHONE, phone.trim())
|
.putString(KEY_USER_PHONE, phone.trim())
|
||||||
.putString(KEY_USER_CITY, city.trim())
|
.putString(KEY_USER_CITY, city.trim())
|
||||||
.putString(KEY_USER_ADDRESS, address.trim())
|
.putString(KEY_USER_ADDRESS, address.trim())
|
||||||
.putBoolean(KEY_PHONE_VERIFIED, isVerified)
|
.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) {
|
fun setPhoneVerified(context: Context, isVerified: Boolean) {
|
||||||
|
|||||||
@ -56,7 +56,7 @@ class CartBottomSheet : BottomSheetDialogFragment() {
|
|||||||
)
|
)
|
||||||
binding.rvCartItems.adapter = cartAdapter
|
binding.rvCartItems.adapter = cartAdapter
|
||||||
|
|
||||||
binding.btnCheckoutWhatsApp.setOnClickListener {
|
binding.btnCartAction.setOnClickListener {
|
||||||
val cartList = CartManager.getItems()
|
val cartList = CartManager.getItems()
|
||||||
if (cartList.isEmpty()) return@setOnClickListener
|
if (cartList.isEmpty()) return@setOnClickListener
|
||||||
|
|
||||||
@ -101,7 +101,13 @@ class CartBottomSheet : BottomSheetDialogFragment() {
|
|||||||
val subtotal = CartManager.getSubtotal()
|
val subtotal = CartManager.getSubtotal()
|
||||||
val shipping = selectedCity.shippingCost
|
val shipping = selectedCity.shippingCost
|
||||||
val total = subtotal + shipping
|
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"))
|
val format = NumberFormat.getCurrencyInstance(Locale("es", "CO"))
|
||||||
format.maximumFractionDigits = 0
|
format.maximumFractionDigits = 0
|
||||||
@ -109,7 +115,28 @@ class CartBottomSheet : BottomSheetDialogFragment() {
|
|||||||
binding.tvSubtotal.text = format.format(subtotal) + " COP"
|
binding.tvSubtotal.text = format.format(subtotal) + " COP"
|
||||||
binding.tvShippingTotal.text = selectedCity.formattedShipping()
|
binding.tvShippingTotal.text = selectedCity.formattedShipping()
|
||||||
binding.tvCartTotalAmount.text = format.format(total) + " COP"
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
package co.dripzone.app.ui
|
package co.dripzone.app.ui
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@ -215,7 +217,16 @@ class OrderVerificationBottomSheet : BottomSheetDialogFragment() {
|
|||||||
|
|
||||||
val subtotal = items.sumOf { it.itemTotal }
|
val subtotal = items.sumOf { it.itemTotal }
|
||||||
val total = subtotal + selectedCity.shippingCost
|
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 {
|
val itemsPayload = items.map {
|
||||||
mapOf(
|
mapOf(
|
||||||
@ -239,9 +250,9 @@ class OrderVerificationBottomSheet : BottomSheetDialogFragment() {
|
|||||||
"subtotal" to subtotal,
|
"subtotal" to subtotal,
|
||||||
"shippingCost" to selectedCity.shippingCost,
|
"shippingCost" to selectedCity.shippingCost,
|
||||||
"totalAmount" to total,
|
"totalAmount" to total,
|
||||||
"depositAmount" to 10000.0,
|
"depositAmount" to depositAmount,
|
||||||
"balanceDue" to balanceDue,
|
"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)"
|
"notes" to "Pedido registrado desde App Android Nativa (WhatsApp OTP Verificado)"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -251,6 +262,8 @@ class OrderVerificationBottomSheet : BottomSheetDialogFragment() {
|
|||||||
|
|
||||||
orderResult.onSuccess { orderId ->
|
orderResult.onSuccess { orderId ->
|
||||||
createdOrderId = orderId
|
createdOrderId = orderId
|
||||||
|
UserManager.incrementCompletedOrders(requireContext())
|
||||||
|
|
||||||
// Limpiar carrito
|
// Limpiar carrito
|
||||||
if (!isDirectProductOrder) {
|
if (!isDirectProductOrder) {
|
||||||
CartManager.clear(requireContext())
|
CartManager.clear(requireContext())
|
||||||
@ -261,7 +274,30 @@ class OrderVerificationBottomSheet : BottomSheetDialogFragment() {
|
|||||||
binding.layoutStepOtp.visibility = View.GONE
|
binding.layoutStepOtp.visibility = View.GONE
|
||||||
binding.layoutStepSuccess.visibility = View.VISIBLE
|
binding.layoutStepSuccess.visibility = View.VISIBLE
|
||||||
binding.tvSuccessOrderId.text = "Orden #$orderId"
|
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 ->
|
}.onFailure { err ->
|
||||||
binding.btnVerifyOtpAndSubmit.isEnabled = true
|
binding.btnVerifyOtpAndSubmit.isEnabled = true
|
||||||
Toast.makeText(requireContext(), "Error al registrar orden: ${err.message}", Toast.LENGTH_LONG).show()
|
Toast.makeText(requireContext(), "Error al registrar orden: ${err.message}", Toast.LENGTH_LONG).show()
|
||||||
|
|||||||
@ -78,26 +78,6 @@ class ProductDetailBottomSheet : BottomSheetDialogFragment() {
|
|||||||
dismiss()
|
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
|
// Custom order button inside bottom sheet
|
||||||
binding.btnCustomOrderSheet.setOnClickListener {
|
binding.btnCustomOrderSheet.setOnClickListener {
|
||||||
dismiss()
|
dismiss()
|
||||||
|
|||||||
@ -259,41 +259,92 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:fontFamily="sans-serif-black"
|
android:fontFamily="sans-serif-bold"
|
||||||
android:text="TOTAL A PAGAR:"
|
android:text="Valor Total de la Orden:"
|
||||||
android:textColor="@color/text_primary"
|
android:textColor="@color/text_primary"
|
||||||
android:textSize="15sp" />
|
android:textSize="14sp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tvCartTotalAmount"
|
android:id="@+id/tvCartTotalAmount"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_alignParentEnd="true"
|
android:layout_alignParentEnd="true"
|
||||||
|
android:fontFamily="sans-serif-bold"
|
||||||
|
android:text="$ 0 COP"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textSize="15sp" />
|
||||||
|
</RelativeLayout>
|
||||||
|
|
||||||
|
<!-- Valor de Separación -->
|
||||||
|
<RelativeLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvLabelSeparation"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:fontFamily="sans-serif-medium"
|
||||||
|
android:text="⚡ Separación (1er Pedido):"
|
||||||
|
android:textColor="@color/drip_purple_glow"
|
||||||
|
android:textSize="13sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvSeparationAmount"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_alignParentEnd="true"
|
||||||
|
android:fontFamily="sans-serif-bold"
|
||||||
|
android:text="$ 10.000 COP"
|
||||||
|
android:textColor="@color/drip_purple_glow"
|
||||||
|
android:textSize="13sp" />
|
||||||
|
</RelativeLayout>
|
||||||
|
|
||||||
|
<!-- Saldo Restante Contraentrega -->
|
||||||
|
<RelativeLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:fontFamily="sans-serif-black"
|
||||||
|
android:text="🤝 SALDO CONTRAENTREGA:"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvBalanceDueAmount"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_alignParentEnd="true"
|
||||||
android:fontFamily="sans-serif-black"
|
android:fontFamily="sans-serif-black"
|
||||||
android:text="$ 0 COP"
|
android:text="$ 0 COP"
|
||||||
android:textColor="@color/drip_purple_glow"
|
android:textColor="@color/accent_emerald"
|
||||||
android:textSize="18sp" />
|
android:textSize="16sp" />
|
||||||
</RelativeLayout>
|
</RelativeLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- WhatsApp Checkout Button with Tapjacking Protection -->
|
<!-- Dynamic Checkout / Pay Separation Action Button -->
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/btnCheckoutWhatsApp"
|
android:id="@+id/btnCartAction"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="54dp"
|
android:layout_height="54dp"
|
||||||
android:layout_marginTop="18dp"
|
android:layout_marginTop="18dp"
|
||||||
android:filterTouchesWhenObscured="true"
|
android:filterTouchesWhenObscured="true"
|
||||||
android:text="Pedir Carrito por WhatsApp (Contraentrega)"
|
android:text="Pagar Separación ($10.000 COP)"
|
||||||
android:textAllCaps="false"
|
android:textAllCaps="false"
|
||||||
android:textColor="#FFFFFF"
|
android:textColor="#FFFFFF"
|
||||||
android:textSize="15sp"
|
android:textSize="15sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
app:backgroundTint="@color/accent_whatsapp"
|
app:backgroundTint="@color/drip_purple"
|
||||||
app:cornerRadius="16dp"
|
app:cornerRadius="16dp"
|
||||||
app:icon="@drawable/ic_whatsapp"
|
app:icon="@drawable/ic_sparkle"
|
||||||
app:iconGravity="textStart"
|
app:iconGravity="textStart"
|
||||||
app:iconPadding="8dp"
|
app:iconPadding="8dp"
|
||||||
app:iconSize="22dp" />
|
app:iconSize="20dp" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
|||||||
@ -323,13 +323,32 @@
|
|||||||
android:textColor="@color/text_secondary"
|
android:textColor="@color/text_secondary"
|
||||||
android:textSize="12sp" />
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
<!-- Separation Payment Button (Pasarela Bold) -->
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnPaySeparationBold"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="52dp"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:filterTouchesWhenObscured="true"
|
||||||
|
android:text="Pagar Separación con Bold ($10.000 COP)"
|
||||||
|
android:textAllCaps="false"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
app:backgroundTint="@color/drip_purple"
|
||||||
|
app:cornerRadius="14dp"
|
||||||
|
app:icon="@drawable/ic_sparkle"
|
||||||
|
app:iconGravity="textStart"
|
||||||
|
app:iconPadding="8dp"
|
||||||
|
app:iconSize="18dp" />
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/btnOpenWhatsAppChat"
|
android:id="@+id/btnOpenWhatsAppChat"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="50dp"
|
android:layout_height="50dp"
|
||||||
android:layout_marginTop="20dp"
|
android:layout_marginTop="10dp"
|
||||||
android:filterTouchesWhenObscured="true"
|
android:filterTouchesWhenObscured="true"
|
||||||
android:text="Abrir Chat con el Asesor en WhatsApp"
|
android:text="Notificar Pago por WhatsApp"
|
||||||
android:textAllCaps="false"
|
android:textAllCaps="false"
|
||||||
android:textColor="#FFFFFF"
|
android:textColor="#FFFFFF"
|
||||||
android:textSize="13sp"
|
android:textSize="13sp"
|
||||||
|
|||||||
@ -170,25 +170,6 @@
|
|||||||
app:iconPadding="8dp"
|
app:iconPadding="8dp"
|
||||||
app:iconSize="20dp" />
|
app:iconSize="20dp" />
|
||||||
|
|
||||||
<!-- WhatsApp Checkout Action Button with Tapjacking Protection -->
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:id="@+id/btnOrderWhatsApp"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="54dp"
|
|
||||||
android:layout_marginTop="10dp"
|
|
||||||
android:filterTouchesWhenObscured="true"
|
|
||||||
android:text="Pedir Directo por WhatsApp (Contraentrega)"
|
|
||||||
android:textAllCaps="false"
|
|
||||||
android:textColor="#FFFFFF"
|
|
||||||
android:textSize="15sp"
|
|
||||||
android:textStyle="bold"
|
|
||||||
app:backgroundTint="@color/accent_whatsapp"
|
|
||||||
app:cornerRadius="16dp"
|
|
||||||
app:icon="@drawable/ic_whatsapp"
|
|
||||||
app:iconGravity="textStart"
|
|
||||||
app:iconPadding="8dp"
|
|
||||||
app:iconSize="22dp" />
|
|
||||||
|
|
||||||
<!-- Secondary Custom Request Button -->
|
<!-- Secondary Custom Request Button -->
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/btnCustomOrderSheet"
|
android:id="@+id/btnCustomOrderSheet"
|
||||||
|
|||||||
@ -10,12 +10,36 @@ export async function GET(request: Request) {
|
|||||||
if (phone) {
|
if (phone) {
|
||||||
const cleanPhone = phone.replace(/\D/g, '');
|
const cleanPhone = phone.replace(/\D/g, '');
|
||||||
const user = dataService.getUsers().find((u) => u.phone.replace(/\D/g, '') === cleanPhone);
|
const user = dataService.getUsers().find((u) => 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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
requiresShippingDeposit: user ? user.requiresShippingDeposit === true : false,
|
isFirstPurchase: !hasCompletedPreviousOrder,
|
||||||
depositReason: user?.depositReason,
|
requiresShippingDeposit,
|
||||||
|
depositAmount,
|
||||||
|
depositReason,
|
||||||
phoneVerified: user ? user.phoneVerified === true : false,
|
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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,25 @@ function ensureCacheDir() {
|
|||||||
// Fallback image buffer (placeholder sneaker)
|
// Fallback image buffer (placeholder sneaker)
|
||||||
const FALLBACK_IMAGE_URL = 'https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80';
|
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) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
@ -26,7 +45,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const quality = searchParams.get('q') || 'high';
|
const quality = searchParams.get('q') || 'high';
|
||||||
|
|
||||||
if (!messageId) {
|
if (!messageId) {
|
||||||
return NextResponse.redirect(FALLBACK_IMAGE_URL);
|
return serveFallbackImage();
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanId = messageId.replace(/[^a-zA-Z0-9_-]/g, '');
|
const cleanId = messageId.replace(/[^a-zA-Z0-9_-]/g, '');
|
||||||
@ -94,8 +113,8 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!messageRecord || !messageRecord.message?.imageMessage) {
|
if (!messageRecord || !messageRecord.message?.imageMessage) {
|
||||||
// Si el mensaje no existe o no tiene imagen, redirigir a imagen fallback
|
// Si el mensaje no existe o no tiene imagen, servir fallback directo
|
||||||
return NextResponse.redirect(FALLBACK_IMAGE_URL);
|
return serveFallbackImage();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Solicitar el medio en Alta Resolución Full HD a Evolution API
|
// 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) {
|
} catch (error: any) {
|
||||||
console.error('Error en GET /api/whatsapp-media:', error);
|
console.error('Error en GET /api/whatsapp-media:', error);
|
||||||
return NextResponse.redirect(FALLBACK_IMAGE_URL);
|
return serveFallbackImage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,8 +54,8 @@ export default function CheckoutPage() {
|
|||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [orderCompleted, setOrderCompleted] = useState<Order | null>(null);
|
const [orderCompleted, setOrderCompleted] = useState<Order | null>(null);
|
||||||
const [userRequiresDeposit, setUserRequiresDeposit] = useState<boolean>(false);
|
const [userRequiresDeposit, setUserRequiresDeposit] = useState<boolean>(true);
|
||||||
const [userDepositReason, setUserDepositReason] = useState<string>('');
|
const [userDepositReason, setUserDepositReason] = useState<string>('Garantía de separación requerida para el primer pedido');
|
||||||
const [showOtpModal, setShowOtpModal] = useState<boolean>(false);
|
const [showOtpModal, setShowOtpModal] = useState<boolean>(false);
|
||||||
const [otpCode, setOtpCode] = useState<string>('');
|
const [otpCode, setOtpCode] = useState<string>('');
|
||||||
const [otpLoading, setOtpLoading] = useState<boolean>(false);
|
const [otpLoading, setOtpLoading] = useState<boolean>(false);
|
||||||
@ -80,7 +80,7 @@ export default function CheckoutPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
setUserRequiresDeposit(user.requiresShippingDeposit === true);
|
setUserRequiresDeposit((user.totalOrders || 0) === 0 || user.requiresShippingDeposit === true);
|
||||||
setUserDepositReason(user.depositReason || '');
|
setUserDepositReason(user.depositReason || '');
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
@ -92,7 +92,7 @@ export default function CheckoutPage() {
|
|||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setUserRequiresDeposit(data.requiresShippingDeposit === true);
|
setUserRequiresDeposit(data.requiresShippingDeposit !== false);
|
||||||
setUserDepositReason(data.depositReason || '');
|
setUserDepositReason(data.depositReason || '');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -107,15 +107,10 @@ export default function CheckoutPage() {
|
|||||||
const shippingCost = activeCity.shippingCost;
|
const shippingCost = activeCity.shippingCost;
|
||||||
const totalAmount = subtotal + 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)
|
// Regla: Para el primer pedido se cobra separación fija ($10.000 COP) sin importar cuántos productos lleve.
|
||||||
const cityShippingFee = activeCity.shippingCost > 0 ? activeCity.shippingCost : 10000;
|
// Tras la primera compra culminada satisfactoriamente, el cliente paga 100% contraentrega.
|
||||||
|
|
||||||
// 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ó
|
|
||||||
const isMandatoryDeposit = userRequiresDeposit;
|
const isMandatoryDeposit = userRequiresDeposit;
|
||||||
const depositAmount = isMandatoryDeposit
|
const depositAmount = isMandatoryDeposit ? 10000 : 0;
|
||||||
? cityShippingFee
|
|
||||||
: (activePayment.requiresDeposit ? activePayment.depositAmount : 0);
|
|
||||||
const balanceDue = Math.max(0, totalAmount - depositAmount);
|
const balanceDue = Math.max(0, totalAmount - depositAmount);
|
||||||
|
|
||||||
const officialWhatsapp = storeSettings?.whatsappNumber || '573167455877';
|
const officialWhatsapp = storeSettings?.whatsappNumber || '573167455877';
|
||||||
@ -544,40 +539,35 @@ export default function CheckoutPage() {
|
|||||||
{shippingCost === 0 ? <span className="text-emerald-400 font-bold uppercase">Gratis</span> : formatCOP(shippingCost)}
|
{shippingCost === 0 ? <span className="text-emerald-400 font-bold uppercase">Gratis</span> : formatCOP(shippingCost)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-base font-black text-white pt-2 border-t border-neutral-800">
|
<div className="flex justify-between text-sm font-bold text-white pt-2 border-t border-neutral-800">
|
||||||
<span>Total a Pagar:</span>
|
<span>Total de la Orden:</span>
|
||||||
<span className={`text-transparent bg-clip-text bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-lg`}>
|
<span className="font-bold text-white">
|
||||||
{formatCOP(totalAmount)}
|
{formatCOP(totalAmount)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Garantía de Flete / Despacho (Solo visible si está activo para el usuario) */}
|
{/* Discriminación de Separación y Saldo Contraentrega */}
|
||||||
{isMandatoryDeposit ? (
|
<div className="bg-neutral-950 border border-white/10 rounded-2xl p-3.5 space-y-2 text-xs">
|
||||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-2xl p-3.5 space-y-2 text-xs">
|
<div className="flex justify-between items-center text-xs">
|
||||||
<div className="flex items-center space-x-2 text-amber-300 font-bold">
|
<span className="text-purple-300 font-bold">
|
||||||
<ShieldCheck className="w-4 h-4 text-amber-400 shrink-0" />
|
{isMandatoryDeposit ? '⚡ Valor Separación (1er Pedido):' : '⭐ Separación (Cliente Frecuente):'}
|
||||||
<span>Garantía de Despacho & Reserva ({activeCity.name})</span>
|
</span>
|
||||||
</div>
|
<span className="font-black text-white font-mono">
|
||||||
<p className="text-neutral-300 text-[11px] leading-relaxed">
|
{isMandatoryDeposit ? formatCOP(depositAmount) : '$0 COP (Exento)'}
|
||||||
Para asegurar tu turno prioritario de entrega con la transportadora en <strong>{activeCity.name}</strong>, se realiza un anticipo de <strong>{formatCOP(depositAmount)}</strong> por concepto de flete, el cual es <strong>100% descontable</strong> del valor total al recibir tu calzado en tu domicilio.
|
</span>
|
||||||
</p>
|
|
||||||
<div className="flex justify-between items-center pt-1.5 border-t border-amber-500/20 text-[11px]">
|
|
||||||
<span className="text-amber-200">Saldo a pagar en efectivo al recibir:</span>
|
|
||||||
<span className="font-mono font-black text-white text-xs">{formatCOP(balanceDue)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : depositAmount > 0 ? (
|
|
||||||
<div className="bg-white/5 border border-white/10 rounded-xl p-2.5 text-xs text-white space-y-1">
|
<div className="flex justify-between items-center pt-2 border-t border-white/10 text-xs">
|
||||||
<div className="flex justify-between font-bold text-white">
|
<span className="text-emerald-400 font-black">🤝 Saldo a Pagar Contraentrega:</span>
|
||||||
<span>Anticipo de Separación:</span>
|
<span className="font-mono font-black text-emerald-400 text-sm">{formatCOP(balanceDue)}</span>
|
||||||
<span>{formatCOP(depositAmount)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between font-black text-white pt-1 border-t border-white/10">
|
|
||||||
<span>Saldo a pagar al recibir:</span>
|
|
||||||
<span>{formatCOP(balanceDue)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
|
<p className="text-[10px] text-neutral-400 pt-1 leading-relaxed">
|
||||||
|
{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.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="bg-white/5 border border-white/10 rounded-xl p-2.5 text-xs text-neutral-300 space-y-1">
|
<div className="bg-white/5 border border-white/10 rounded-xl p-2.5 text-xs text-neutral-300 space-y-1">
|
||||||
<div className="flex items-center space-x-1.5 font-bold text-white">
|
<div className="flex items-center space-x-1.5 font-bold text-white">
|
||||||
@ -599,9 +589,14 @@ export default function CheckoutPage() {
|
|||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<span>Procesando Pedido...</span>
|
<span>Procesando Pedido...</span>
|
||||||
|
) : isMandatoryDeposit ? (
|
||||||
|
<>
|
||||||
|
<span>Pagar Separación ($10.000 COP)</span>
|
||||||
|
<CreditCard className="w-4 h-4" />
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span>Confirmar Pedido Contraentrega</span>
|
<span>Enviar Pedido (100% Contraentrega)</span>
|
||||||
<Truck className="w-4 h-4" />
|
<Truck className="w-4 h-4" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useRef } from 'react';
|
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 {
|
interface CustomRequestModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|||||||
@ -212,22 +212,30 @@ export const CartDrawer: React.FC<CartDrawerProps> = ({
|
|||||||
{shippingCost === 0 ? <span className="text-emerald-400 uppercase font-black">Gratis</span> : formatCOP(shippingCost)}
|
{shippingCost === 0 ? <span className="text-emerald-400 uppercase font-black">Gratis</span> : formatCOP(shippingCost)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-sm font-black text-white pt-1 border-t border-slate-800">
|
<div className="flex justify-between text-sm font-bold text-white pt-1 border-t border-slate-800">
|
||||||
<span>Total de la Orden:</span>
|
<span>Total de la Orden:</span>
|
||||||
<span className={`text-transparent bg-clip-text bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-base`}>
|
<span className="font-bold text-white">
|
||||||
{formatCOP(totalAmount)}
|
{formatCOP(totalAmount)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-purple-300 font-semibold pt-1">
|
||||||
|
<span>⚡ Separación (1er Pedido):</span>
|
||||||
|
<span className="font-bold font-mono">$10.000 COP</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-emerald-400 font-black pt-1 border-t border-white/5">
|
||||||
|
<span>🤝 Saldo Restante Contraentrega:</span>
|
||||||
|
<span className="font-mono">{formatCOP(Math.max(0, totalAmount - 10000))}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Separation Highlight Banner */}
|
{/* Separation Highlight Banner */}
|
||||||
<div className="bg-white/5 border border-white/10 rounded-xl p-2.5 text-xs space-y-1">
|
<div className="bg-white/5 border border-white/10 rounded-xl p-2.5 text-xs space-y-1">
|
||||||
<p className="font-bold text-white flex items-center gap-1">
|
<p className="font-bold text-white flex items-center gap-1">
|
||||||
<Zap className="w-3.5 h-3.5 fill-white" />
|
<Zap className="w-3.5 h-3.5 fill-white" />
|
||||||
Opción de Separación con $10.000 COP
|
Separación con $10.000 COP (1er Pedido)
|
||||||
</p>
|
</p>
|
||||||
<p className="text-slate-300 text-[11px]">
|
<p className="text-slate-300 text-[11px]">
|
||||||
Separas con <strong>{formatCOP(separation.depositAmount)}</strong> y pagas <strong>{formatCOP(separation.balanceDue)}</strong> al recibir contraentrega en tu casa.
|
Separas con <strong>$10.000 COP</strong> y cancelas el saldo restante (<strong>{formatCOP(Math.max(0, totalAmount - 10000))}</strong>) en efectivo o transferencia al recibir en tu puerta.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[10px] text-emerald-400 font-semibold pt-0.5 border-t border-white/5">
|
<p className="text-[10px] text-emerald-400 font-semibold pt-0.5 border-t border-white/5">
|
||||||
🛡️ {selectedCity.firstExchangeFree
|
🛡️ {selectedCity.firstExchangeFree
|
||||||
@ -236,25 +244,16 @@ export const CartDrawer: React.FC<CartDrawerProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Button */}
|
||||||
<div className="flex flex-col gap-2 pt-1">
|
<div className="pt-1">
|
||||||
<Link
|
<Link
|
||||||
href="/checkout"
|
href="/checkout"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className={`w-full bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white font-black text-sm py-3 px-4 rounded-xl flex items-center justify-center space-x-2 shadow-lg active:scale-95 transition-all text-center`}
|
className={`w-full bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white font-black text-sm py-3.5 px-4 rounded-xl flex items-center justify-center space-x-2 shadow-lg active:scale-95 transition-all text-center`}
|
||||||
>
|
>
|
||||||
<span>Proceder al Pago Contraentrega</span>
|
<span>Continuar al Pago Contraentrega</span>
|
||||||
<ArrowRight className="w-4 h-4" />
|
<ArrowRight className="w-4 h-4" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link
|
|
||||||
href="/checkout"
|
|
||||||
onClick={onClose}
|
|
||||||
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white font-bold text-xs py-2.5 px-3 rounded-xl flex items-center justify-center space-x-1.5 transition-all cursor-pointer"
|
|
||||||
>
|
|
||||||
<MessageCircle className="w-4 h-4 fill-white" />
|
|
||||||
<span>Completar Pedido con Verificación WhatsApp</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -402,24 +402,15 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
|||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="pt-4 border-t border-neutral-800 flex flex-col gap-2.5 mt-4">
|
<div className="pt-4 border-t border-neutral-800 flex flex-col gap-2.5 mt-4">
|
||||||
{isSizeInStock ? (
|
{isSizeInStock ? (
|
||||||
<>
|
<button
|
||||||
<button
|
onClick={handleAdd}
|
||||||
onClick={handleWhatsAppDirect}
|
disabled={added}
|
||||||
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white font-bold text-sm py-3.5 px-4 rounded-2xl flex items-center justify-center space-x-2 shadow-lg shadow-emerald-600/20 active:scale-95 transition-all cursor-pointer"
|
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
|
||||||
<MessageCircle className="w-4 h-4 fill-white" />
|
? 'bg-neutral-800 text-white border border-white/20'
|
||||||
<span>Pedir por WhatsApp (Contraentrega Talla {selectedSize} • {selectedColor.colorName})</span>
|
: `bg-gradient-to-r ${currentBrand.colors.primaryGradient} hover:opacity-95 text-white shadow-lg active:scale-95`
|
||||||
</button>
|
}`}
|
||||||
|
>
|
||||||
<button
|
|
||||||
onClick={handleAdd}
|
|
||||||
disabled={added}
|
|
||||||
className={`w-full font-bold text-sm py-3.5 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 ? (
|
{added ? (
|
||||||
<>
|
<>
|
||||||
<Check className="w-4 h-4 text-white" />
|
<Check className="w-4 h-4 text-white" />
|
||||||
@ -432,7 +423,6 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<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">
|
<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">
|
||||||
|
|||||||
@ -188,8 +188,70 @@ export const INITIAL_USERS: User[] = [
|
|||||||
|
|
||||||
export const INITIAL_MONITORED_GROUPS: MonitoredWhatsAppGroup[] = [];
|
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 {
|
class DataService {
|
||||||
private products: Product[] = [...INITIAL_PRODUCTS];
|
private products: Product[] = loadPersistedProducts();
|
||||||
private cities: City[] = [...INITIAL_CITIES];
|
private cities: City[] = [...INITIAL_CITIES];
|
||||||
private paymentMethods: PaymentMethod[] = [...INITIAL_PAYMENT_METHODS];
|
private paymentMethods: PaymentMethod[] = [...INITIAL_PAYMENT_METHODS];
|
||||||
private suppliers: Supplier[] = [...INITIAL_SUPPLIERS];
|
private suppliers: Supplier[] = [...INITIAL_SUPPLIERS];
|
||||||
@ -197,9 +259,17 @@ class DataService {
|
|||||||
private supplierPosts: SupplierPost[] = [...INITIAL_SUPPLIER_POSTS];
|
private supplierPosts: SupplierPost[] = [...INITIAL_SUPPLIER_POSTS];
|
||||||
|
|
||||||
getProducts(): Product[] {
|
getProducts(): Product[] {
|
||||||
|
if (this.products.length === 0) {
|
||||||
|
this.products = loadPersistedProducts();
|
||||||
|
}
|
||||||
return groupProductsByModel(this.products);
|
return groupProductsByModel(this.products);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setProducts(products: Product[]): void {
|
||||||
|
this.products = products;
|
||||||
|
savePersistedProducts(this.products);
|
||||||
|
}
|
||||||
|
|
||||||
getProductById(id: string): Product | undefined {
|
getProductById(id: string): Product | undefined {
|
||||||
const allGrouped = groupProductsByModel(this.products);
|
const allGrouped = groupProductsByModel(this.products);
|
||||||
return allGrouped.find((p) => p.id === id || p.colors?.some((c) => c.id === id));
|
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) {
|
if (!existing.description || existing.description.length < 50) {
|
||||||
existing.description = cleanDescription;
|
existing.description = cleanDescription;
|
||||||
}
|
}
|
||||||
|
savePersistedProducts(this.products);
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -306,6 +377,7 @@ class DataService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.products.unshift(formattedProduct);
|
this.products.unshift(formattedProduct);
|
||||||
|
savePersistedProducts(this.products);
|
||||||
return formattedProduct;
|
return formattedProduct;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -313,12 +385,14 @@ class DataService {
|
|||||||
const idx = this.products.findIndex((p) => p.id === id);
|
const idx = this.products.findIndex((p) => p.id === id);
|
||||||
if (idx === -1) return null;
|
if (idx === -1) return null;
|
||||||
this.products[idx] = { ...this.products[idx], ...updates };
|
this.products[idx] = { ...this.products[idx], ...updates };
|
||||||
|
savePersistedProducts(this.products);
|
||||||
return this.products[idx];
|
return this.products[idx];
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteProduct(id: string): boolean {
|
deleteProduct(id: string): boolean {
|
||||||
const initialLen = this.products.length;
|
const initialLen = this.products.length;
|
||||||
this.products = this.products.filter((p) => p.id !== id);
|
this.products = this.products.filter((p) => p.id !== id);
|
||||||
|
savePersistedProducts(this.products);
|
||||||
return this.products.length < initialLen;
|
return this.products.length < initialLen;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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[] = [];
|
const dispatchedTo: string[] = [];
|
||||||
|
|
||||||
for (const target of NOTIFICATION_TARGETS) {
|
for (const target of NOTIFICATION_TARGETS) {
|
||||||
try {
|
try {
|
||||||
let sent = false;
|
let sent = false;
|
||||||
|
|
||||||
// Si tenemos URL de imagen completa y válida, intentamos enviar con imagen
|
// 1. Enviar prioritariamente con la foto del calzado en alta definición
|
||||||
if (mediaUrl && (mediaUrl.startsWith('http://') || mediaUrl.startsWith('https://'))) {
|
if (resolvedMediaUrl && (resolvedMediaUrl.startsWith('http://') || resolvedMediaUrl.startsWith('https://'))) {
|
||||||
sent = await sendWhatsAppMedia(target, mediaUrl, notificationCaption);
|
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) {
|
if (!sent) {
|
||||||
sent = await sendWhatsAppMessage(target, notificationCaption);
|
sent = await sendWhatsAppMessage(target, notificationCaption);
|
||||||
}
|
}
|
||||||
@ -180,7 +195,8 @@ export function buildOrderConfirmationMessage(order: any): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Notifica inmediatamente al administrador Carlos (+57 316 745 5877)
|
* 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<boolean> {
|
export async function notifyAdminNewOrder(order: any): Promise<boolean> {
|
||||||
const CARLOS_PHONE = '573167455877';
|
const CARLOS_PHONE = '573167455877';
|
||||||
@ -221,24 +237,40 @@ export async function notifyAdminNewOrder(order: any): Promise<boolean> {
|
|||||||
`👉 https://dripzone.almeira.com.co/admin/pedidos`
|
`👉 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;
|
let success = false;
|
||||||
try {
|
try {
|
||||||
// 1. Notificación principal al WhatsApp personal de Carlos
|
// 1. Notificación principal al WhatsApp personal de Carlos CON LA FOTO DEL PRODUCTO
|
||||||
const resCarlos = await sendWhatsAppMessage(CARLOS_PHONE, adminMessage);
|
let sentCarlos = false;
|
||||||
if (resCarlos) success = true;
|
if (resolvedImageUrl) {
|
||||||
|
sentCarlos = await sendWhatsAppMedia(CARLOS_PHONE, resolvedImageUrl, adminMessage);
|
||||||
// 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(() => {});
|
|
||||||
}
|
}
|
||||||
|
if (!sentCarlos) {
|
||||||
|
sentCarlos = await sendWhatsAppMessage(CARLOS_PHONE, adminMessage);
|
||||||
|
}
|
||||||
|
if (sentCarlos) success = true;
|
||||||
|
|
||||||
// 3. Notificación al canal de alertas
|
// 2. Notificación al canal de alertas (grupo) CON LA FOTO DEL PRODUCTO
|
||||||
await sendWhatsAppMessage(NOTIFICATION_GROUP, adminMessage).catch(() => {});
|
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) {
|
} catch (err: any) {
|
||||||
console.error('Error enviando notificación de pedido a Carlos:', err.message);
|
console.error('Error enviando notificación de pedido a Carlos:', err.message);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user