feat: user registration with WhatsApp OTP verification, platform order registration with auto WhatsApp alerts, and splash screen overflow fix

This commit is contained in:
AI Bot 2026-09-05 11:51:08 -05:00
parent fef85c3ff4
commit b7f4d600fc
21 changed files with 1741 additions and 203 deletions

View File

@ -35,7 +35,7 @@ class MainActivity : AppCompatActivity() {
private var allProducts: List<Product> = emptyList()
private var availableCities: List<City> = City.defaultCities()
private var selectedCity: City = City.defaultCities().first()
var selectedCity: City = City.defaultCities().first()
private var selectedCategory: String = "Todos"
private var searchQuery: String = ""

View File

@ -10,8 +10,10 @@ import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.ConnectionSpec
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.TlsVersion
import java.io.IOException
import java.util.concurrent.TimeUnit
@ -189,5 +191,160 @@ object ApiClient {
}
co.dripzone.app.model.City.defaultCities()
}
/**
* Envía código OTP de 6 dígitos al WhatsApp del cliente
*/
suspend fun sendOtp(phone: String, name: String): Result<String> =
withContext(Dispatchers.IO) {
try {
val jsonPayload = gson.toJson(
mapOf(
"identifier" to phone,
"name" to name
)
)
val mediaType = "application/json; charset=utf-8".toMediaTypeOrNull()
val requestBody = jsonPayload.toRequestBody(mediaType)
val request = Request.Builder()
.url("https://dripzone.almeira.com.co/api/auth/send-otp")
.post(requestBody)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.build()
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: ""
val json = gson.fromJson(responseBody, com.google.gson.JsonObject::class.java)
if (response.isSuccessful && json.get("success")?.asBoolean == true) {
val message = json.get("message")?.asString ?: "Código OTP enviado por WhatsApp"
Result.success(message)
} else {
val errorMsg = json.get("error")?.asString ?: "Error al enviar código OTP"
Result.failure(Exception(errorMsg))
}
} catch (e: Exception) {
Log.e(TAG, "Error sending OTP: ${e.message}")
Result.failure(e)
}
}
/**
* Valida el código OTP de WhatsApp y registra/actualiza el usuario en la plataforma
*/
suspend fun verifyOtp(
phone: String,
code: String,
name: String,
city: String,
address: String
): Result<Boolean> =
withContext(Dispatchers.IO) {
try {
val jsonPayload = gson.toJson(
mapOf(
"identifier" to phone,
"code" to code,
"name" to name,
"city" to city,
"address" to address
)
)
val mediaType = "application/json; charset=utf-8".toMediaTypeOrNull()
val requestBody = jsonPayload.toRequestBody(mediaType)
val request = Request.Builder()
.url("https://dripzone.almeira.com.co/api/auth/verify-otp")
.post(requestBody)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.build()
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: ""
val json = gson.fromJson(responseBody, com.google.gson.JsonObject::class.java)
if (response.isSuccessful && json.get("success")?.asBoolean == true) {
Result.success(true)
} else {
val errorMsg = json.get("error")?.asString ?: "Código inválido o expirado"
Result.failure(Exception(errorMsg))
}
} catch (e: Exception) {
Log.e(TAG, "Error verifying OTP: ${e.message}")
Result.failure(e)
}
}
/**
* Registra un pedido en la plataforma (Appwrite/DataService) y dispara notificación por WhatsApp a Carlos
*/
suspend fun submitOrder(orderData: Map<String, Any?>): Result<String> =
withContext(Dispatchers.IO) {
try {
val jsonPayload = gson.toJson(orderData)
val mediaType = "application/json; charset=utf-8".toMediaTypeOrNull()
val requestBody = jsonPayload.toRequestBody(mediaType)
val request = Request.Builder()
.url("https://dripzone.almeira.com.co/api/orders")
.post(requestBody)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.build()
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: ""
val json = gson.fromJson(responseBody, com.google.gson.JsonObject::class.java)
if (response.isSuccessful && json.get("success")?.asBoolean == true) {
val orderObj = json.getAsJsonObject("order")
val orderId = orderObj?.get("id")?.asString ?: "ORD-${System.currentTimeMillis()}"
Result.success(orderId)
} else {
val errorMsg = json.get("error")?.asString ?: "Error al registrar pedido en la plataforma"
Result.failure(Exception(errorMsg))
}
} catch (e: Exception) {
Log.e(TAG, "Error submitting order: ${e.message}")
Result.failure(e)
}
}
/**
* Registra una solicitud de encargo especial en la plataforma y dispara notificación a Carlos
*/
suspend fun submitCustomRequest(requestData: Map<String, Any?>): Result<String> =
withContext(Dispatchers.IO) {
try {
val jsonPayload = gson.toJson(requestData)
val mediaType = "application/json; charset=utf-8".toMediaTypeOrNull()
val requestBody = jsonPayload.toRequestBody(mediaType)
val request = Request.Builder()
.url("https://dripzone.almeira.com.co/api/custom-requests")
.post(requestBody)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.build()
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: ""
val json = gson.fromJson(responseBody, com.google.gson.JsonObject::class.java)
if (response.isSuccessful && json.get("success")?.asBoolean == true) {
val msg = json.get("message")?.asString ?: "¡Solicitud de encargo recibida con éxito!"
Result.success(msg)
} else {
val errorMsg = json.get("error")?.asString ?: "Error al registrar encargo"
Result.failure(Exception(errorMsg))
}
} catch (e: Exception) {
Log.e(TAG, "Error submitting custom request: ${e.message}")
Result.failure(e)
}
}
}

View File

@ -0,0 +1,55 @@
package co.dripzone.app.model
import android.content.Context
import co.dripzone.app.security.SecurityManager
data class UserProfile(
val name: String = "",
val phone: String = "",
val city: String = "Cali",
val address: String = "",
val isPhoneVerified: Boolean = false
)
object UserManager {
private const val KEY_USER_NAME = "user_profile_name"
private const val KEY_USER_PHONE = "user_profile_phone"
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"
fun getUser(context: Context): UserProfile {
val prefs = SecurityManager.getSecurePreferences(context)
return UserProfile(
name = prefs.getString(KEY_USER_NAME, "") ?: "",
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)
)
}
fun saveUser(
context: Context,
name: String,
phone: String,
city: String,
address: String,
isVerified: Boolean
) {
val prefs = SecurityManager.getSecurePreferences(context)
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()
}
fun setPhoneVerified(context: Context, isVerified: Boolean) {
val prefs = SecurityManager.getSecurePreferences(context)
prefs.edit().putBoolean(KEY_PHONE_VERIFIED, isVerified).apply()
}
}

View File

@ -60,34 +60,13 @@ class CartBottomSheet : BottomSheetDialogFragment() {
val cartList = CartManager.getItems()
if (cartList.isEmpty()) return@setOnClickListener
val subtotal = CartManager.getSubtotal()
val total = subtotal + selectedCity.shippingCost
val balanceDue = if (total > 10000.0) total - 10000.0 else 0.0
val itemsText = cartList.joinToString("\n") {
"👟 *${it.product.name}* (Talla: *${it.selectedSize}*, Cant: ${it.quantity}) - ${it.formattedItemTotal()}"
}
val format = NumberFormat.getCurrencyInstance(Locale("es", "CO"))
format.maximumFractionDigits = 0
val formattedTotal = format.format(total) + " COP"
val formattedBalance = format.format(balanceDue) + " COP"
val message = buildString {
append("¡Hola DripZone! 🔥 Quiero ordenar este pedido desde la app móvil:\n\n")
append("📦 *RESUMEN DEL PEDIDO:*\n")
append(itemsText)
append("\n\n📍 *Ciudad de Despacho:* ${selectedCity.name} (${selectedCity.formattedShipping()})\n")
append("💰 *TOTAL A PAGAR:* $formattedTotal\n")
append("⚡ *Condición:* Pago Contraentrega (Separo con $10.000 COP y cancelo saldo de $formattedBalance al recibir en mi puerta).\n\n")
append("¿Me confirman disponibilidad para programar el despacho?")
}
SecurityManager.openWhatsApp(
requireContext(),
SecurityManager.WHATSAPP_STORE_PHONE,
message
val verificationSheet = OrderVerificationBottomSheet.newInstance(
city = selectedCity,
isDirect = false
)
parentFragmentManager.let {
verificationSheet.show(it, OrderVerificationBottomSheet.TAG)
}
dismiss()
}

View File

@ -5,9 +5,13 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.lifecycle.lifecycleScope
import co.dripzone.app.api.ApiClient
import co.dripzone.app.databinding.DialogCustomOrderBinding
import co.dripzone.app.model.UserManager
import co.dripzone.app.security.SecurityManager
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import kotlinx.coroutines.launch
class CustomOrderBottomSheet : BottomSheetDialogFragment() {
@ -17,6 +21,13 @@ class CustomOrderBottomSheet : BottomSheetDialogFragment() {
private var initialModel: String = ""
private var initialBrand: String = ""
private var customerName: String = ""
private var customerPhone: String = ""
private var customerCity: String = ""
private var modelDesired: String = ""
private var sizeDesired: String = ""
private var notes: String = ""
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.let {
@ -37,36 +48,34 @@ class CustomOrderBottomSheet : BottomSheetDialogFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.btnCloseCustomModal.setOnClickListener { dismiss() }
// Pre-fill user data if registered locally
val user = UserManager.getUser(requireContext())
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 (initialModel.isNotBlank()) {
binding.etCustomModel.setText(initialModel)
}
binding.btnSubmitCustomOrder.setOnClickListener {
val rawModel = binding.etCustomModel.text?.toString()
val rawSize = binding.etCustomSize.text?.toString()
val rawNotes = binding.etCustomNotes.text?.toString()
handleContinueToCustomOtp()
}
val sanitizedModel = SecurityManager.sanitizeInput(rawModel)
val sanitizedSize = SecurityManager.sanitizeInput(rawSize)
val sanitizedNotes = SecurityManager.sanitizeInput(rawNotes)
binding.btnVerifyCustomOtp.setOnClickListener {
handleVerifyCustomOtp()
}
if (sanitizedModel.isBlank()) {
binding.etCustomModel.error = "Por favor indica el modelo deseado"
return@setOnClickListener
}
binding.tvResendCustomOtp.setOnClickListener {
sendCustomOtp()
}
val message = buildString {
append("¡Hola DripZone! 🚀 Quiero consultar por un *Pedido Especial / Encargo*:\n\n")
append("👟 *Modelo deseado:* $sanitizedModel\n")
if (sanitizedSize.isNotBlank()) {
append("📏 *Talla estimada:* $sanitizedSize\n")
}
if (sanitizedNotes.isNotBlank()) {
append("📝 *Detalles / Colorway:* $sanitizedNotes\n")
}
append("\n¿Tienen disponibilidad o pueden cotizarlo con sus proveedores mayoristas?")
}
binding.btnFinishCustom.setOnClickListener { dismiss() }
binding.btnCustomOpenWhatsApp.setOnClickListener {
val message = "¡Hola Carlos! Acabo de registrar un encargo en la app de DripZone para las *$modelDesired* en talla *$sizeDesired*. ¿Me confirmas cotización con los proveedores mayoristas?"
SecurityManager.openWhatsApp(
requireContext(),
SecurityManager.WHATSAPP_STORE_PHONE,
@ -76,6 +85,141 @@ class CustomOrderBottomSheet : BottomSheetDialogFragment() {
}
}
private fun handleContinueToCustomOtp() {
val rawName = binding.etCustomCustomerName.text?.toString()?.trim() ?: ""
val rawPhone = binding.etCustomCustomerPhone.text?.toString()?.replace("\\D".toRegex(), "") ?: ""
val rawCity = binding.etCustomCustomerCity.text?.toString()?.trim() ?: ""
val rawModel = binding.etCustomModel.text?.toString()?.trim() ?: ""
val rawSize = binding.etCustomSize.text?.toString()?.trim() ?: ""
val rawNotes = binding.etCustomNotes.text?.toString()?.trim() ?: ""
if (rawName.isBlank()) {
binding.etCustomCustomerName.error = "Ingresa tu nombre completo"
return
}
if (rawPhone.length < 10) {
binding.etCustomCustomerPhone.error = "Ingresa tu WhatsApp a 10 dígitos"
return
}
if (rawModel.isBlank()) {
binding.etCustomModel.error = "Indica el modelo o referencia"
return
}
if (rawSize.isBlank()) {
binding.etCustomSize.error = "Indica la talla deseada"
return
}
customerName = rawName
customerPhone = rawPhone
customerCity = if (rawCity.isNotBlank()) rawCity else "Colombia"
modelDesired = rawModel
sizeDesired = rawSize
notes = rawNotes
val user = UserManager.getUser(requireContext())
if (user.isPhoneVerified && user.phone == customerPhone) {
submitFinalCustomRequest()
} else {
sendCustomOtp()
}
}
private fun sendCustomOtp() {
binding.layoutCustomForm.visibility = View.GONE
binding.layoutCustomOtp.visibility = View.VISIBLE
binding.pbCustomLoading.visibility = View.VISIBLE
binding.btnVerifyCustomOtp.isEnabled = false
val masked = if (customerPhone.length >= 10) {
"${customerPhone.take(3)}***${customerPhone.takeLast(4)}"
} else customerPhone
binding.tvCustomOtpInstruction.text = "Enviamos un código de seguridad de 6 dígitos a tu WhatsApp (+57 $masked) para validar tu cuenta y registrar tu encargo:"
lifecycleScope.launch {
val result = ApiClient.sendOtp(customerPhone, customerName)
binding.pbCustomLoading.visibility = View.GONE
binding.btnVerifyCustomOtp.isEnabled = true
result.onSuccess {
Toast.makeText(requireContext(), "Código enviado a tu WhatsApp", Toast.LENGTH_SHORT).show()
}.onFailure { err ->
Toast.makeText(requireContext(), "Aviso: ${err.message}", Toast.LENGTH_LONG).show()
}
}
}
private fun handleVerifyCustomOtp() {
val code = binding.etCustomOtpCode.text?.toString()?.trim() ?: ""
if (code.length < 6) {
binding.etCustomOtpCode.error = "Ingresa el código de 6 dígitos"
return
}
binding.pbCustomLoading.visibility = View.VISIBLE
binding.btnVerifyCustomOtp.isEnabled = false
lifecycleScope.launch {
val verifyResult = ApiClient.verifyOtp(
phone = customerPhone,
code = code,
name = customerName,
city = customerCity,
address = ""
)
if (verifyResult.isSuccess && verifyResult.getOrNull() == true) {
UserManager.saveUser(
requireContext(),
customerName,
customerPhone,
customerCity,
"",
isVerified = true
)
submitFinalCustomRequest()
} else {
binding.pbCustomLoading.visibility = View.GONE
binding.btnVerifyCustomOtp.isEnabled = true
val errorMsg = verifyResult.exceptionOrNull()?.message ?: "Código incorrecto o expirado"
binding.etCustomOtpCode.error = errorMsg
Toast.makeText(requireContext(), errorMsg, Toast.LENGTH_SHORT).show()
}
}
}
private fun submitFinalCustomRequest() {
binding.pbCustomLoading.visibility = View.VISIBLE
val effectiveBrand = if (initialBrand.isNotBlank()) initialBrand else "Sneakers"
val requestData = mapOf(
"customerName" to customerName,
"customerPhone" to customerPhone,
"customerCity" to customerCity,
"brand" to effectiveBrand,
"modelName" to modelDesired,
"size" to sizeDesired,
"colorway" to notes,
"notes" to "Solicitud registrada desde App Android Nativa (WhatsApp OTP Verificado)"
)
lifecycleScope.launch {
val submitResult = ApiClient.submitCustomRequest(requestData)
binding.pbCustomLoading.visibility = View.GONE
submitResult.onSuccess {
binding.layoutCustomForm.visibility = View.GONE
binding.layoutCustomOtp.visibility = View.GONE
binding.layoutCustomSuccess.visibility = View.VISIBLE
binding.tvCustomSuccessMessage.text = "¡Tu número de WhatsApp (+57 $customerPhone) fue verificado y tu encargo para las '$modelDesired' fue registrado en la plataforma!\n\nLa plataforma notificó al administrador Carlos (+57 316 745 5877) y te contactaremos por WhatsApp con la cotización exacta."
}.onFailure { err ->
binding.btnVerifyCustomOtp.isEnabled = true
Toast.makeText(requireContext(), "Error al registrar: ${err.message}", Toast.LENGTH_LONG).show()
}
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null

View File

@ -0,0 +1,299 @@
package co.dripzone.app.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.lifecycle.lifecycleScope
import co.dripzone.app.databinding.BottomSheetOrderVerificationBinding
import co.dripzone.app.api.ApiClient
import co.dripzone.app.model.CartItem
import co.dripzone.app.model.CartManager
import co.dripzone.app.model.City
import co.dripzone.app.model.UserManager
import co.dripzone.app.security.SecurityManager
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import kotlinx.coroutines.launch
import java.text.NumberFormat
import java.util.Locale
class OrderVerificationBottomSheet : BottomSheetDialogFragment() {
private var _binding: BottomSheetOrderVerificationBinding? = null
private val binding get() = _binding!!
private var selectedCity: City = City.defaultCities().first()
private var isDirectProductOrder: Boolean = false
private var directItem: CartItem? = null
private var customerName: String = ""
private var customerPhone: String = ""
private var customerAddress: String = ""
private var createdOrderId: String = ""
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.let {
@Suppress("DEPRECATION")
val cityArg = it.getSerializable(ARG_CITY) as? City
if (cityArg != null) selectedCity = cityArg
isDirectProductOrder = it.getBoolean(ARG_IS_DIRECT, false)
@Suppress("DEPRECATION")
directItem = it.getSerializable(ARG_DIRECT_ITEM) as? CartItem
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = BottomSheetOrderVerificationBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.btnCloseOrderModal.setOnClickListener { dismiss() }
// Pre-fill user data if already registered locally
val user = UserManager.getUser(requireContext())
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)
// Setup economic calculations
val subtotal = if (isDirectProductOrder && directItem != null) {
directItem!!.itemTotal
} else {
CartManager.getSubtotal()
}
val total = subtotal + selectedCity.shippingCost
val balanceDue = if (total > 10000.0) total - 10000.0 else 0.0
val format = NumberFormat.getCurrencyInstance(Locale("es", "CO")).apply {
maximumFractionDigits = 0
}
binding.tvOrderSummaryCity.text = "Destino: ${selectedCity.name} (${selectedCity.formattedShipping()})"
binding.tvOrderSummaryTotal.text = "${format.format(total)} COP"
binding.tvOrderSummaryBreakdown.text = "Pago Contraentrega • Separas con $10.000 COP y cancelas ${format.format(balanceDue)} COP al recibir."
binding.btnContinueToOtp.setOnClickListener {
handleContinueToOtp()
}
binding.btnVerifyOtpAndSubmit.setOnClickListener {
handleVerifyOtpAndSubmit()
}
binding.tvResendOtp.setOnClickListener {
sendOtpCode()
}
binding.btnFinishOrder.setOnClickListener {
dismiss()
}
binding.btnOpenWhatsAppChat.setOnClickListener {
val message = "¡Hola Carlos! Acabo de registrar mi pedido en la app oficial de DripZone con la orden *$createdOrderId*. ¿Me confirmas la programación del despacho contraentrega a ${selectedCity.name}?"
SecurityManager.openWhatsApp(
requireContext(),
SecurityManager.WHATSAPP_STORE_PHONE,
message
)
dismiss()
}
}
private fun handleContinueToOtp() {
val rawName = binding.etCustomerName.text?.toString()?.trim() ?: ""
val rawPhone = binding.etCustomerPhone.text?.toString()?.replace("\\D".toRegex(), "") ?: ""
val rawAddress = binding.etCustomerAddress.text?.toString()?.trim() ?: ""
if (rawName.isBlank()) {
binding.etCustomerName.error = "Por favor ingresa tu nombre completo"
return
}
if (rawPhone.length < 10) {
binding.etCustomerPhone.error = "Ingresa tu celular de WhatsApp a 10 dígitos"
return
}
if (rawAddress.isBlank()) {
binding.etCustomerAddress.error = "Ingresa tu dirección completa de entrega"
return
}
customerName = rawName
customerPhone = rawPhone
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) {
submitFinalOrder()
} else {
sendOtpCode()
}
}
private fun sendOtpCode() {
binding.layoutStepCustomer.visibility = View.GONE
binding.layoutStepOtp.visibility = View.VISIBLE
binding.pbOtpLoading.visibility = View.VISIBLE
binding.btnVerifyOtpAndSubmit.isEnabled = false
val masked = if (customerPhone.length >= 10) {
"${customerPhone.take(3)}***${customerPhone.takeLast(4)}"
} else customerPhone
binding.tvOtpInstruction.text = "Enviamos un código de seguridad de 6 dígitos a tu WhatsApp (+57 $masked) para validar tu cuenta y registrar tu orden:"
lifecycleScope.launch {
val result = ApiClient.sendOtp(customerPhone, customerName)
binding.pbOtpLoading.visibility = View.GONE
binding.btnVerifyOtpAndSubmit.isEnabled = true
result.onSuccess { msg ->
Toast.makeText(requireContext(), "Código enviado por WhatsApp: Revisa tu chat", Toast.LENGTH_LONG).show()
}.onFailure { err ->
Toast.makeText(requireContext(), "Aviso: ${err.message}", Toast.LENGTH_LONG).show()
}
}
}
private fun handleVerifyOtpAndSubmit() {
val otpCode = binding.etOtpCode.text?.toString()?.trim() ?: ""
if (otpCode.length < 6) {
binding.etOtpCode.error = "Ingresa el código de 6 dígitos"
return
}
binding.pbOtpLoading.visibility = View.VISIBLE
binding.btnVerifyOtpAndSubmit.isEnabled = false
lifecycleScope.launch {
val verifyResult = ApiClient.verifyOtp(
phone = customerPhone,
code = otpCode,
name = customerName,
city = selectedCity.name,
address = customerAddress
)
if (verifyResult.isSuccess && verifyResult.getOrNull() == true) {
// Save verified user locally
UserManager.saveUser(
requireContext(),
customerName,
customerPhone,
selectedCity.name,
customerAddress,
isVerified = true
)
submitFinalOrder()
} else {
binding.pbOtpLoading.visibility = View.GONE
binding.btnVerifyOtpAndSubmit.isEnabled = true
val errorMsg = verifyResult.exceptionOrNull()?.message ?: "Código inválido o expirado"
binding.etOtpCode.error = errorMsg
Toast.makeText(requireContext(), errorMsg, Toast.LENGTH_SHORT).show()
}
}
}
private fun submitFinalOrder() {
binding.pbOtpLoading.visibility = View.VISIBLE
val items = if (isDirectProductOrder && directItem != null) {
listOf(directItem!!)
} else {
CartManager.getItems()
}
val subtotal = items.sumOf { it.itemTotal }
val total = subtotal + selectedCity.shippingCost
val balanceDue = if (total > 10000.0) total - 10000.0 else 0.0
val itemsPayload = items.map {
mapOf(
"productId" to it.product.id,
"productName" to it.product.name,
"brand" to it.product.brand,
"size" to it.selectedSize,
"quantity" to it.quantity,
"price" to it.product.retailPrice,
"image" to it.product.firstImage()
)
}
val orderData = mapOf(
"customerName" to customerName,
"customerPhone" to customerPhone,
"address" to customerAddress,
"cityId" to selectedCity.id,
"cityName" to selectedCity.name,
"items" to itemsPayload,
"subtotal" to subtotal,
"shippingCost" to selectedCity.shippingCost,
"totalAmount" to total,
"depositAmount" to 10000.0,
"balanceDue" to balanceDue,
"paymentMethodName" to "Pago Contraentrega",
"notes" to "Pedido registrado desde App Android Nativa (WhatsApp OTP Verificado)"
)
lifecycleScope.launch {
val orderResult = ApiClient.submitOrder(orderData)
binding.pbOtpLoading.visibility = View.GONE
orderResult.onSuccess { orderId ->
createdOrderId = orderId
// Limpiar carrito
if (!isDirectProductOrder) {
CartManager.clear(requireContext())
}
// Mostrar pantalla de éxito
binding.layoutStepCustomer.visibility = View.GONE
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."
}.onFailure { err ->
binding.btnVerifyOtpAndSubmit.isEnabled = true
Toast.makeText(requireContext(), "Error al registrar orden: ${err.message}", Toast.LENGTH_LONG).show()
}
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
companion object {
const val TAG = "OrderVerificationBottomSheet"
private const val ARG_CITY = "arg_city"
private const val ARG_IS_DIRECT = "arg_is_direct"
private const val ARG_DIRECT_ITEM = "arg_direct_item"
fun newInstance(
city: City,
isDirect: Boolean = false,
directItem: CartItem? = null
): OrderVerificationBottomSheet {
return OrderVerificationBottomSheet().apply {
arguments = Bundle().apply {
putSerializable(ARG_CITY, city)
putBoolean(ARG_IS_DIRECT, isDirect)
if (directItem != null) {
putSerializable(ARG_DIRECT_ITEM, directItem)
}
}
}
}
}
}

View File

@ -78,27 +78,23 @@ class ProductDetailBottomSheet : BottomSheetDialogFragment() {
dismiss()
}
// WhatsApp Order Checkout Button
// Direct Order Checkout Button with Verification
binding.btnOrderWhatsApp.setOnClickListener {
val sizeToOrder = if (selectedSize.isNotBlank()) selectedSize else sizeAdapter?.getSelectedSize() ?: "Por coordinar"
val whatsappMessage = buildString {
append("¡Hola DripZone! 🔥 Me interesa ordenar este producto:\n\n")
append("👟 *Producto:* ${p.name}\n")
append("🏷️ *Marca:* ${p.brand}\n")
append("📏 *Talla solicitada:* $sizeToOrder\n")
append("💰 *Precio:* ${p.formattedPrice()}\n")
append("🚚 *Método:* Envío con Pago Contraentrega\n")
if (imageUrl.isNotBlank()) {
append("\n🔗 *Foto:* $imageUrl\n")
}
append("\n¿Tienen disponibilidad inmediata para despacho?")
}
SecurityManager.openWhatsApp(
requireContext(),
SecurityManager.WHATSAPP_STORE_PHONE,
whatsappMessage
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()
}

View File

@ -8,16 +8,18 @@
android:id="@+id/ivSplash"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:adjustViewBounds="true"
android:contentDescription="DripZone Splash Screen"
android:scaleType="centerCrop"
android:scaleType="fitCenter"
android:src="@drawable/dripzone_splash_clean" />
<!-- Subtle bottom indicator -->
<ProgressBar
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_gravity="bottom|center_horizontal"
android:layout_marginBottom="48dp"
android:layout_marginBottom="36dp"
android:indeterminateTint="@color/drip_fuchsia" />
</FrameLayout>

View File

@ -0,0 +1,358 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_bottom_sheet"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<!-- Drag handle -->
<View
android:layout_width="48dp"
android:layout_height="4dp"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="16dp"
android:background="#40FFFFFF" />
<!-- Header -->
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/tvOrderModalTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:fontFamily="sans-serif-black"
android:text="CONFIRMAR PEDIDO"
android:textColor="@color/text_primary"
android:textSize="17sp" />
<ImageButton
android:id="@+id/btnCloseOrderModal"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_alignParentEnd="true"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Cerrar"
android:src="@drawable/ic_close"
app:tint="@color/text_secondary" />
</RelativeLayout>
<!-- STEP 1: Customer & Shipping Data Layout -->
<LinearLayout
android:id="@+id/layoutStepCustomer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Ingresa tus datos para registrar tu pedido en la plataforma y coordinar el despacho contraentrega."
android:textColor="@color/text_secondary"
android:textSize="12sp" />
<!-- Nombre -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:fontFamily="sans-serif-medium"
android:text="Nombre y Apellido *"
android:textColor="@color/text_primary"
android:textSize="12sp" />
<EditText
android:id="@+id/etCustomerName"
android:layout_width="match_parent"
android:layout_height="46dp"
android:layout_marginTop="6dp"
android:background="@drawable/bg_search_input"
android:hint="Ej. Juan Carlos Pérez"
android:inputType="textPersonName"
android:paddingHorizontal="14dp"
android:textColor="@color/text_primary"
android:textColorHint="@color/text_muted"
android:textSize="13sp" />
<!-- Celular WhatsApp -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:fontFamily="sans-serif-medium"
android:text="Celular / WhatsApp (10 dígitos) *"
android:textColor="@color/text_primary"
android:textSize="12sp" />
<EditText
android:id="@+id/etCustomerPhone"
android:layout_width="match_parent"
android:layout_height="46dp"
android:layout_marginTop="6dp"
android:background="@drawable/bg_search_input"
android:hint="Ej. 3167455877"
android:inputType="phone"
android:maxLength="10"
android:paddingHorizontal="14dp"
android:textColor="@color/text_primary"
android:textColorHint="@color/text_muted"
android:textSize="13sp" />
<!-- Dirección -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:fontFamily="sans-serif-medium"
android:text="Dirección de entrega y Barrio *"
android:textColor="@color/text_primary"
android:textSize="12sp" />
<EditText
android:id="@+id/etCustomerAddress"
android:layout_width="match_parent"
android:layout_height="46dp"
android:layout_marginTop="6dp"
android:background="@drawable/bg_search_input"
android:hint="Ej. Calle 15 #4-22, Barrio San Antonio"
android:inputType="textPostalAddress"
android:paddingHorizontal="14dp"
android:textColor="@color/text_primary"
android:textColorHint="@color/text_muted"
android:textSize="13sp" />
<!-- Resumen económico -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:background="@drawable/bg_btn_secondary"
android:orientation="vertical"
android:padding="12dp">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/tvOrderSummaryCity"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-medium"
android:text="Destino: Cali"
android:textColor="@color/text_primary"
android:textSize="12sp" />
<TextView
android:id="@+id/tvOrderSummaryTotal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:fontFamily="sans-serif-black"
android:text="$ 280.000 COP"
android:textColor="@color/drip_purple_glow"
android:textSize="13sp" />
</RelativeLayout>
<TextView
android:id="@+id/tvOrderSummaryBreakdown"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Pago Contraentrega • Separas con $10.000 COP y pagas el saldo al recibir"
android:textColor="@color/text_secondary"
android:textSize="10sp" />
</LinearLayout>
<!-- Botón Continuar -->
<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: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:iconGravity="textStart"
app:iconPadding="8dp"
app:iconSize="18dp" />
</LinearLayout>
<!-- STEP 2: OTP Verification Layout -->
<LinearLayout
android:id="@+id/layoutStepOtp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="vertical"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-black"
android:text="🔐 Verificación de WhatsApp"
android:textColor="@color/text_primary"
android:textSize="15sp" />
<TextView
android:id="@+id/tvOtpInstruction"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="Ingresa el código de seguridad de 6 dígitos que enviamos por WhatsApp a tu celular para validar tu cuenta y confirmar el pedido:"
android:textColor="@color/text_secondary"
android:textSize="12sp" />
<!-- Input OTP de 6 dígitos -->
<EditText
android:id="@+id/etOtpCode"
android:layout_width="match_parent"
android:layout_height="54dp"
android:layout_marginTop="16dp"
android:background="@drawable/bg_search_input"
android:fontFamily="monospace"
android:gravity="center"
android:hint="• • • • • •"
android:inputType="number"
android:letterSpacing="0.3"
android:maxLength="6"
android:textColor="@color/text_primary"
android:textColorHint="@color/text_muted"
android:textSize="22sp"
android:textStyle="bold" />
<!-- Loading indicator -->
<ProgressBar
android:id="@+id/pbOtpLoading"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_gravity="center_horizontal"
android:layout_marginTop="14dp"
android:indeterminateTint="@color/drip_fuchsia"
android:visibility="gone" />
<!-- Botón Verificar y Completar -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnVerifyOtpAndSubmit"
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginTop="16dp"
android:filterTouchesWhenObscured="true"
android:text="Confirmar Código OTP"
android:textAllCaps="false"
android:textColor="#FFFFFF"
android:textSize="14sp"
android:textStyle="bold"
app:backgroundTint="@color/drip_purple"
app:cornerRadius="14dp" />
<!-- Reenviar código -->
<TextView
android:id="@+id/tvResendOtp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="14dp"
android:clickable="true"
android:focusable="true"
android:text="¿No recibiste el código? Reenviar por WhatsApp"
android:textColor="@color/drip_purple_glow"
android:textSize="11sp" />
</LinearLayout>
<!-- STEP 3: Success Confirmation Layout -->
<LinearLayout
android:id="@+id/layoutStepSuccess"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center_horizontal"
android:orientation="vertical"
android:visibility="gone">
<ImageView
android:layout_width="64dp"
android:layout_height="64dp"
android:src="@drawable/ic_sparkle"
app:tint="@color/drip_fuchsia" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:fontFamily="sans-serif-black"
android:text="¡PEDIDO REGISTRADO CON ÉXITO!"
android:textColor="@color/text_primary"
android:textSize="16sp" />
<TextView
android:id="@+id/tvSuccessOrderId"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:fontFamily="monospace"
android:text="Orden #P-123456"
android:textColor="@color/drip_purple_glow"
android:textSize="13sp" />
<TextView
android:id="@+id/tvSuccessMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:gravity="center"
android:text="Tu número fue verificado con éxito y tu pedido ya está registrado en nuestra plataforma.\n\nLa plataforma ha notificado automáticamente al administrador Carlos (+57 316 745 5877) y recibirás confirmación oficial a tu WhatsApp."
android:textColor="@color/text_secondary"
android:textSize="12sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnOpenWhatsAppChat"
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginTop="20dp"
android:filterTouchesWhenObscured="true"
android:text="Abrir Chat con el Asesor en WhatsApp"
android:textAllCaps="false"
android:textColor="#FFFFFF"
android:textSize="13sp"
android:textStyle="bold"
app:backgroundTint="@color/accent_whatsapp"
app:cornerRadius="14dp"
app:icon="@drawable/ic_whatsapp"
app:iconGravity="textStart"
app:iconPadding="8dp"
app:iconSize="18dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnFinishOrder"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="match_parent"
android:layout_height="46dp"
android:layout_marginTop="8dp"
android:text="Cerrar y Seguir Comprando"
android:textAllCaps="false"
android:textColor="@color/text_secondary"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View File

@ -19,109 +19,346 @@
android:layout_marginBottom="16dp"
android:background="#40FFFFFF" />
<TextView
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-black"
android:text="🔥 Pedido Especial / Encargo"
android:textColor="@color/text_primary"
android:textSize="20sp" />
android:layout_height="wrap_content">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:fontFamily="sans-serif-black"
android:text="🔥 Pedido Especial / Encargo"
android:textColor="@color/text_primary"
android:textSize="18sp" />
<ImageButton
android:id="@+id/btnCloseCustomModal"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_alignParentEnd="true"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Cerrar"
android:src="@drawable/ic_close"
app:tint="@color/text_secondary" />
</RelativeLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="¿Buscas un modelo exclusivo o una talla específica de nuestros proveedores? Te lo conseguimos bajo pedido."
android:layout_marginTop="4dp"
android:text="¿Buscas un modelo exclusivo no listado? Te lo cotizamos directamente con nuestras bodegas mayoristas."
android:textColor="@color/text_secondary"
android:textSize="13sp" />
android:textSize="12sp" />
<!-- Reference / Model input -->
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
<!-- Form Step Layout -->
<LinearLayout
android:id="@+id/layoutCustomForm"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:hint="Modelo / Referencia (ej. Jordan 4 Military Black)"
android:textColorHint="@color/text_muted"
app:boxBackgroundColor="@color/bg_input"
app:boxCornerRadiusBottomEnd="12dp"
app:boxCornerRadiusBottomStart="12dp"
app:boxCornerRadiusTopEnd="12dp"
app:boxCornerRadiusTopStart="12dp"
app:boxStrokeColor="@color/drip_fuchsia">
android:layout_marginTop="12dp"
android:orientation="vertical">
<com.google.android.material.textfield.TextInputEditText
<!-- Nombre -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-medium"
android:text="Tu Nombre Completo *"
android:textColor="@color/text_primary"
android:textSize="12sp" />
<EditText
android:id="@+id/etCustomCustomerName"
android:layout_width="match_parent"
android:layout_height="46dp"
android:layout_marginTop="4dp"
android:background="@drawable/bg_search_input"
android:hint="Ej. Carlos Mendoza"
android:inputType="textPersonName"
android:paddingHorizontal="14dp"
android:textColor="@color/text_primary"
android:textColorHint="@color/text_muted"
android:textSize="13sp" />
<!-- WhatsApp -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:fontFamily="sans-serif-medium"
android:text="Tu WhatsApp (10 dígitos) *"
android:textColor="@color/text_primary"
android:textSize="12sp" />
<EditText
android:id="@+id/etCustomCustomerPhone"
android:layout_width="match_parent"
android:layout_height="46dp"
android:layout_marginTop="4dp"
android:background="@drawable/bg_search_input"
android:hint="Ej. 3167455877"
android:inputType="phone"
android:maxLength="10"
android:paddingHorizontal="14dp"
android:textColor="@color/text_primary"
android:textColorHint="@color/text_muted"
android:textSize="13sp" />
<!-- Ciudad -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:fontFamily="sans-serif-medium"
android:text="Ciudad *"
android:textColor="@color/text_primary"
android:textSize="12sp" />
<EditText
android:id="@+id/etCustomCustomerCity"
android:layout_width="match_parent"
android:layout_height="46dp"
android:layout_marginTop="4dp"
android:background="@drawable/bg_search_input"
android:hint="Ej. Cali"
android:inputType="textCapWords"
android:paddingHorizontal="14dp"
android:textColor="@color/text_primary"
android:textColorHint="@color/text_muted"
android:textSize="13sp" />
<!-- Modelo -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:fontFamily="sans-serif-medium"
android:text="Modelo / Referencia de Zapatilla *"
android:textColor="@color/text_primary"
android:textSize="12sp" />
<EditText
android:id="@+id/etCustomModel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_height="46dp"
android:layout_marginTop="4dp"
android:background="@drawable/bg_search_input"
android:hint="Ej. Jordan 4 Retro Military Black"
android:inputType="textCapWords"
android:paddingHorizontal="14dp"
android:textColor="@color/text_primary"
android:textSize="14sp" />
</com.google.android.material.textfield.TextInputLayout>
android:textColorHint="@color/text_muted"
android:textSize="13sp" />
<!-- Size input -->
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="Talla deseada (ej. 41 EUR / 8 US)"
android:textColorHint="@color/text_muted"
app:boxBackgroundColor="@color/bg_input"
app:boxCornerRadiusBottomEnd="12dp"
app:boxCornerRadiusBottomStart="12dp"
app:boxCornerRadiusTopEnd="12dp"
app:boxCornerRadiusTopStart="12dp"
app:boxStrokeColor="@color/drip_fuchsia">
<!-- Talla -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:fontFamily="sans-serif-medium"
android:text="Talla deseada *"
android:textColor="@color/text_primary"
android:textSize="12sp" />
<com.google.android.material.textfield.TextInputEditText
<EditText
android:id="@+id/etCustomSize"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_height="46dp"
android:layout_marginTop="4dp"
android:background="@drawable/bg_search_input"
android:hint="Ej. 41 EUR (8 US)"
android:inputType="text"
android:paddingHorizontal="14dp"
android:textColor="@color/text_primary"
android:textSize="14sp" />
</com.google.android.material.textfield.TextInputLayout>
android:textColorHint="@color/text_muted"
android:textSize="13sp" />
<!-- Notes input -->
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
<!-- Notas / Presupuesto -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:fontFamily="sans-serif-medium"
android:text="Colorway / Presupuesto / Detalles"
android:textColor="@color/text_primary"
android:textSize="12sp" />
<EditText
android:id="@+id/etCustomNotes"
android:layout_width="match_parent"
android:layout_height="46dp"
android:layout_marginTop="4dp"
android:background="@drawable/bg_search_input"
android:hint="Ej. Color blanco/negro, presupuesto hasta $300.000"
android:inputType="text"
android:paddingHorizontal="14dp"
android:textColor="@color/text_primary"
android:textColorHint="@color/text_muted"
android:textSize="13sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSubmitCustomOrder"
android:layout_width="match_parent"
android:layout_height="52dp"
android:layout_marginTop="18dp"
android:filterTouchesWhenObscured="true"
android:text="Validar y Registrar Encargo"
android:textAllCaps="false"
android:textColor="#FFFFFF"
android:textSize="14sp"
android:textStyle="bold"
app:backgroundTint="@color/drip_purple"
app:cornerRadius="14dp"
app:icon="@drawable/ic_encargos"
app:iconGravity="textStart"
app:iconPadding="8dp"
app:iconSize="20dp" />
</LinearLayout>
<!-- OTP Step Layout for Custom Request -->
<LinearLayout
android:id="@+id/layoutCustomOtp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="Colorway o detalles adicionales"
android:textColorHint="@color/text_muted"
app:boxBackgroundColor="@color/bg_input"
app:boxCornerRadiusBottomEnd="12dp"
app:boxCornerRadiusBottomStart="12dp"
app:boxCornerRadiusTopEnd="12dp"
app:boxCornerRadiusTopStart="12dp"
app:boxStrokeColor="@color/drip_fuchsia">
android:orientation="vertical"
android:visibility="gone">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etCustomNotes"
android:layout_width="match_parent"
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:lines="2"
android:fontFamily="sans-serif-black"
android:text="🔐 Verificación de WhatsApp"
android:textColor="@color/text_primary"
android:textSize="14sp" />
</com.google.android.material.textfield.TextInputLayout>
android:textSize="15sp" />
<!-- Submit to WhatsApp Button with Tapjacking Protection -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSubmitCustomOrder"
<TextView
android:id="@+id/tvCustomOtpInstruction"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="Ingresa el código de 6 dígitos que enviamos a tu WhatsApp para validar tu cuenta y registrar tu encargo:"
android:textColor="@color/text_secondary"
android:textSize="12sp" />
<EditText
android:id="@+id/etCustomOtpCode"
android:layout_width="match_parent"
android:layout_height="54dp"
android:layout_marginTop="14dp"
android:background="@drawable/bg_search_input"
android:fontFamily="monospace"
android:gravity="center"
android:hint="• • • • • •"
android:inputType="number"
android:letterSpacing="0.3"
android:maxLength="6"
android:textColor="@color/text_primary"
android:textColorHint="@color/text_muted"
android:textSize="22sp"
android:textStyle="bold" />
<ProgressBar
android:id="@+id/pbCustomLoading"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_gravity="center_horizontal"
android:layout_marginTop="12dp"
android:indeterminateTint="@color/drip_fuchsia"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnVerifyCustomOtp"
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginTop="14dp"
android:filterTouchesWhenObscured="true"
android:text="Confirmar Código y Registrar Encargo"
android:textAllCaps="false"
android:textColor="#FFFFFF"
android:textSize="14sp"
android:textStyle="bold"
app:backgroundTint="@color/drip_purple"
app:cornerRadius="14dp" />
<TextView
android:id="@+id/tvResendCustomOtp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="12dp"
android:clickable="true"
android:focusable="true"
android:text="¿No recibiste el código? Reenviar por WhatsApp"
android:textColor="@color/drip_purple_glow"
android:textSize="11sp" />
</LinearLayout>
<!-- Success Step Layout -->
<LinearLayout
android:id="@+id/layoutCustomSuccess"
android:layout_width="match_parent"
android:layout_height="52dp"
android:layout_marginTop="20dp"
android:filterTouchesWhenObscured="true"
android:text="Consultar Encargo en WhatsApp"
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" />
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center_horizontal"
android:orientation="vertical"
android:visibility="gone">
<ImageView
android:layout_width="56dp"
android:layout_height="56dp"
android:src="@drawable/ic_sparkle"
app:tint="@color/drip_fuchsia" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:fontFamily="sans-serif-black"
android:text="¡ENCARGO REGISTRADO CON ÉXITO!"
android:textColor="@color/text_primary"
android:textSize="16sp" />
<TextView
android:id="@+id/tvCustomSuccessMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:text="Hemos validado tu número de WhatsApp y registrado tu solicitud en la plataforma.\n\nLa plataforma notificó al administrador Carlos (+57 316 745 5877) y te contactaremos por WhatsApp con la cotización exacta."
android:textColor="@color/text_secondary"
android:textSize="12sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnCustomOpenWhatsApp"
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginTop="18dp"
android:filterTouchesWhenObscured="true"
android:text="Abrir WhatsApp del Asesor Carlos"
android:textAllCaps="false"
android:textColor="#FFFFFF"
android:textSize="13sp"
android:textStyle="bold"
app:backgroundTint="@color/accent_whatsapp"
app:cornerRadius="14dp"
app:icon="@drawable/ic_whatsapp"
app:iconGravity="textStart"
app:iconPadding="8dp"
app:iconSize="18dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnFinishCustom"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="match_parent"
android:layout_height="46dp"
android:layout_marginTop="6dp"
android:text="Listo, Volver a la Tienda"
android:textAllCaps="false"
android:textColor="@color/text_secondary"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>

View File

@ -30,7 +30,7 @@
<!-- Splash Theme -->
<style name="Theme.DripZone.Splash" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="android:windowBackground">@drawable/dripzone_splash_clean</item>
<item name="android:windowBackground">@color/bg_dark</item>
<item name="android:statusBarColor">@color/bg_dark</item>
<item name="android:navigationBarColor">@color/bg_dark</item>
</style>

View File

@ -5,7 +5,7 @@ import { sendWhatsAppMessage } from '@/lib/evolution-api';
export async function POST(request: Request) {
try {
const { identifier } = await request.json();
const { identifier, name } = await request.json();
if (!identifier) {
return NextResponse.json({ success: false, error: 'Ingresa tu número de celular o correo' }, { status: 400 });
}
@ -15,14 +15,13 @@ export async function POST(request: Request) {
const user = dataService.authenticate(cleanInput);
let phoneToSend = cleanInput.replace(/\D/g, '');
let userName = 'Cliente';
let userName = name?.trim() || (user ? user.name : 'Cliente');
if (user) {
if (!user.isActive) {
return NextResponse.json({ success: false, error: 'Esta cuenta se encuentra inactiva.' }, { status: 403 });
}
phoneToSend = user.phone.replace(/\D/g, '');
userName = user.name;
}
if (!phoneToSend || phoneToSend.length < 7) {

View File

@ -4,7 +4,7 @@ import { otpStore } from '@/lib/otp-store';
export async function POST(request: Request) {
try {
const { identifier, code } = await request.json();
const { identifier, code, name, city, address } = await request.json();
if (!identifier || !code) {
return NextResponse.json({ success: false, error: 'Identificador y código son requeridos' }, { status: 400 });
}
@ -16,18 +16,20 @@ export async function POST(request: Request) {
return NextResponse.json({ success: false, error: verification.reason || 'Código inválido o expirado' }, { status: 401 });
}
// Código válido -> Obtener o crear sesión
// Código válido -> Obtener o crear usuario registrado en la plataforma
let user = dataService.authenticate(cleanInput);
if (!user) {
// Si es un nuevo cliente autenticado por primera vez con su celular
const cleanPhone = cleanInput.replace(/\D/g, '');
const assignedName = name?.trim() || `Cliente Verificado (${cleanPhone.slice(-4)})`;
user = dataService.addUser({
id: `usr-${Date.now()}`,
name: `Cliente Verificado (${cleanPhone.slice(-4)})`,
name: assignedName,
email: `${cleanPhone}@cliente.dripzone.co`,
phone: cleanPhone,
city: 'Cali',
city: city?.trim() || 'Cali',
address: address?.trim() || '',
role: 'customer',
isActive: true,
phoneVerified: true,
@ -39,9 +41,12 @@ export async function POST(request: Request) {
lastLogin: 'Justo ahora',
});
} else {
// Usuario existente: si verificó su teléfono, actualizar estado
// Usuario existente: si verificó su teléfono, actualizar estado y datos si se suministraron
const hadRejections = (user.rejectedOrdersCount || 0) > 0;
dataService.updateUser(user.id, {
name: name?.trim() || user.name,
city: city?.trim() || user.city,
address: address?.trim() || user.address,
phoneVerified: true,
requiresShippingDeposit: hadRejections ? true : false,
depositReason: hadRejections

View File

@ -140,6 +140,7 @@ ${customerEmail ? `✉️ *Correo:* ${customerEmail}\n` : ''}
⚡ ¡Consulta de inmediato con tus proveedores mayoristas para enviar cotización al cliente!`;
sendWhatsAppMessage(CARLOS_PHONE, adminMessage).catch(() => {});
sendWhatsAppMessage('120363428012905103@g.us', adminMessage).catch(() => {});
// 2. Confirmación automática al cliente por WhatsApp
const clientMessage = `👋 ¡Hola *${customerName}*!

View File

@ -5,7 +5,11 @@ import {
updateOrderInAppwrite,
deleteOrderInAppwrite,
} from '@/lib/server-appwrite';
import { sendWhatsAppMessage, buildOrderConfirmationMessage } from '@/lib/evolution-api';
import {
sendWhatsAppMessage,
buildOrderConfirmationMessage,
notifyAdminNewOrder,
} from '@/lib/evolution-api';
import { dataService } from '@/lib/data-service';
export async function GET() {
@ -24,13 +28,20 @@ export async function POST(request: Request) {
const newOrder = await createOrderInAppwrite(body);
dataService.addOrder(newOrder);
// Enviar confirmación por WhatsApp en background si tiene teléfono válido
// 1. Notificación INMEDIATA al WhatsApp de Carlos (+57 316 745 5877)
try {
await notifyAdminNewOrder(newOrder);
} catch (adminWaErr) {
console.warn('No se pudo enviar WhatsApp al administrador Carlos:', adminWaErr);
}
// 2. Enviar confirmación por WhatsApp al cliente en background si tiene teléfono válido
if (newOrder.customerPhone) {
try {
const msg = buildOrderConfirmationMessage(newOrder);
await sendWhatsAppMessage(newOrder.customerPhone, msg);
} catch (waErr) {
console.warn('No se pudo enviar WhatsApp automático:', waErr);
console.warn('No se pudo enviar WhatsApp automático al cliente:', waErr);
}
}

View File

@ -56,6 +56,10 @@ export default function CheckoutPage() {
const [orderCompleted, setOrderCompleted] = useState<Order | null>(null);
const [userRequiresDeposit, setUserRequiresDeposit] = useState<boolean>(false);
const [userDepositReason, setUserDepositReason] = useState<string>('');
const [showOtpModal, setShowOtpModal] = useState<boolean>(false);
const [otpCode, setOtpCode] = useState<string>('');
const [otpLoading, setOtpLoading] = useState<boolean>(false);
const [otpError, setOtpError] = useState<string>('');
useEffect(() => {
fetch('/api/cities')
@ -117,15 +121,8 @@ export default function CheckoutPage() {
const officialWhatsapp = storeSettings?.whatsappNumber || '573167455877';
const boldLink = storeSettings?.boldPaymentLink || 'https://bold.co/p/carlos-lasso';
const handleSubmitOrder = async (e: React.FormEvent) => {
e.preventDefault();
if (!customerName || !customerPhone || !address) {
alert('Por favor completa tu nombre, celular y dirección para el despacho.');
return;
}
const processOrderSubmission = async () => {
setIsSubmitting(true);
try {
const orderPayload = {
customerName,
@ -158,14 +155,90 @@ export default function CheckoutPage() {
spread: 70,
origin: { y: 0.6 },
});
} else {
alert(data.error || 'Error al registrar pedido.');
}
} catch (err) {
console.error('Error creando orden:', err);
alert('Error de conexión al registrar pedido.');
} finally {
setIsSubmitting(false);
}
};
const handleSubmitOrder = async (e: React.FormEvent) => {
e.preventDefault();
if (!customerName || !customerPhone || !address) {
alert('Por favor completa tu nombre, celular y dirección para el despacho.');
return;
}
const cleanPhone = customerPhone.replace(/\D/g, '');
if (cleanPhone.length < 10) {
alert('Por favor ingresa un número de celular/WhatsApp válido a 10 dígitos.');
return;
}
if (user?.phoneVerified && user.phone === cleanPhone) {
await processOrderSubmission();
return;
}
// Solicitar OTP por WhatsApp
setOtpLoading(true);
setOtpError('');
try {
const res = await fetch('/api/auth/send-otp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: cleanPhone, name: customerName }),
});
const data = await res.json();
if (data.success) {
setShowOtpModal(true);
} else {
alert(data.error || 'No se pudo enviar el código OTP a WhatsApp.');
}
} catch (err) {
alert('Error al solicitar código OTP por WhatsApp.');
} finally {
setOtpLoading(false);
}
};
const handleVerifyOtpAndPlaceOrder = async () => {
if (otpCode.length < 6) {
setOtpError('Ingresa el código de 6 dígitos enviado a tu WhatsApp.');
return;
}
setOtpLoading(true);
setOtpError('');
try {
const cleanPhone = customerPhone.replace(/\D/g, '');
const res = await fetch('/api/auth/verify-otp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
identifier: cleanPhone,
code: otpCode,
name: customerName,
city: activeCity.name,
address: `${address} - Barrio: ${neighborhood || 'Centro'}`,
}),
});
const data = await res.json();
if (data.success) {
setShowOtpModal(false);
await processOrderSubmission();
} else {
setOtpError(data.error || 'Código incorrecto o expirado.');
}
} catch (err) {
setOtpError('Error verificando código.');
} finally {
setOtpLoading(false);
}
};
const handleOpenWhatsAppOrder = (order: Order) => {
const itemsSummary = order.items
.map((i) => `• ${i.productName} (Talla ${i.size}) x${i.quantity} = ${formatCOP(i.price * i.quantity)}`)
@ -540,6 +613,49 @@ export default function CheckoutPage() {
</div>
</div>
</form>
{showOtpModal && (
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4 animate-in fade-in">
<div className="bg-neutral-900 border border-purple-500/40 rounded-3xl max-w-md w-full p-6 text-white space-y-4 shadow-2xl">
<div className="flex justify-between items-center border-b border-neutral-800 pb-3">
<h3 className="text-base font-black flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-purple-400" />
Verificación de WhatsApp (OTP)
</h3>
<button
type="button"
onClick={() => setShowOtpModal(false)}
className="text-neutral-400 hover:text-white text-sm cursor-pointer p-1"
>
✕
</button>
</div>
<p className="text-xs text-neutral-300 leading-relaxed">
Para validar tu cuenta y registrar tu orden contraentrega en la plataforma, te enviamos un código de seguridad de 6 dígitos a tu WhatsApp <strong>+57 {customerPhone}</strong>:
</p>
<input
type="text"
maxLength={6}
value={otpCode}
onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, ''))}
placeholder="• • • • • •"
className="w-full bg-neutral-950 border border-neutral-700 focus:border-purple-500 rounded-xl px-4 py-3 text-center text-2xl font-mono tracking-widest text-white outline-none"
/>
{otpError && <p className="text-xs text-rose-400 text-center">{otpError}</p>}
<button
type="button"
disabled={otpLoading || otpCode.length < 6}
onClick={handleVerifyOtpAndPlaceOrder}
className="w-full bg-gradient-to-r from-purple-600 to-fuchsia-600 text-white font-bold py-3.5 rounded-xl hover:opacity-95 disabled:opacity-50 text-sm shadow-md transition-all cursor-pointer"
>
{otpLoading ? 'Verificando...' : 'Confirmar Código y Registrar Pedido'}
</button>
<p className="text-[11px] text-neutral-500 text-center">
El código expira en 5 minutos.
</p>
</div>
</div>
)}
</div>
</div>
);

View File

@ -46,6 +46,10 @@ export const CustomRequestModal: React.FC<CustomRequestModalProps> = ({
const [isLoading, setIsLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [showOtp, setShowOtp] = useState(false);
const [otpCode, setOtpCode] = useState('');
const [otpLoading, setOtpLoading] = useState(false);
const [otpError, setOtpError] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
@ -68,20 +72,12 @@ export const CustomRequestModal: React.FC<CustomRequestModalProps> = ({
reader.readAsDataURL(file);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const processCustomRequest = async () => {
setIsLoading(true);
setErrorMessage(null);
const effectiveBrand = brand === 'Otra' && customBrand.trim() ? customBrand.trim() : brand;
const effectiveSize = size === 'custom' && customSize.trim() ? customSize.trim() : size;
if (!customerName.trim() || !customerPhone.trim() || !modelName.trim()) {
setErrorMessage('Por favor completa tu nombre, WhatsApp y el modelo de las zapatillas.');
return;
}
setIsLoading(true);
try {
const res = await fetch('/api/custom-requests', {
method: 'POST',
@ -113,6 +109,72 @@ export const CustomRequestModal: React.FC<CustomRequestModalProps> = ({
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrorMessage(null);
const cleanPhone = customerPhone.replace(/\D/g, '');
if (!customerName.trim() || cleanPhone.length < 10 || !modelName.trim()) {
setErrorMessage('Por favor completa tu nombre, número de WhatsApp a 10 dígitos y el modelo deseado.');
return;
}
// Solicitar código OTP por WhatsApp
setIsLoading(true);
try {
const res = await fetch('/api/auth/send-otp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: cleanPhone, name: customerName.trim() }),
});
const data = await res.json();
if (data.success) {
setShowOtp(true);
} else {
setErrorMessage(data.error || 'No se pudo enviar el código OTP a WhatsApp.');
}
} catch (err: any) {
setErrorMessage('Error al solicitar código OTP por WhatsApp.');
} finally {
setIsLoading(false);
}
};
const handleVerifyOtpAndSubmitRequest = async () => {
if (otpCode.length < 6) {
setOtpError('Ingresa el código de 6 dígitos que enviamos a tu WhatsApp.');
return;
}
setOtpLoading(true);
setOtpError('');
try {
const cleanPhone = customerPhone.replace(/\D/g, '');
const verifyRes = await fetch('/api/auth/verify-otp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
identifier: cleanPhone,
code: otpCode,
name: customerName.trim(),
city: customerCity.trim() || 'Colombia',
}),
});
const verifyData = await verifyRes.json();
if (!verifyData.success) {
setOtpError(verifyData.error || 'Código incorrecto o expirado.');
setOtpLoading(false);
return;
}
setShowOtp(false);
await processCustomRequest();
} catch (err) {
setOtpError('Error verificando código.');
} finally {
setOtpLoading(false);
}
};
const handleResetAndClose = () => {
setIsSuccess(false);
setImageBase64(null);
@ -426,6 +488,49 @@ export const CustomRequestModal: React.FC<CustomRequestModalProps> = ({
</div>
</form>
)}
{showOtp && (
<div className="fixed inset-0 z-60 bg-black/85 backdrop-blur-sm flex items-center justify-center p-4 animate-in fade-in">
<div className="bg-neutral-900 border border-purple-500/40 rounded-3xl max-w-md w-full p-6 text-white space-y-4 shadow-2xl">
<div className="flex justify-between items-center border-b border-neutral-800 pb-3">
<h3 className="text-base font-black flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-purple-400" />
Verificación de WhatsApp (OTP)
</h3>
<button
type="button"
onClick={() => setShowOtp(false)}
className="text-neutral-400 hover:text-white text-sm cursor-pointer p-1"
>
✕
</button>
</div>
<p className="text-xs text-neutral-300 leading-relaxed">
Para validar tu cuenta y registrar tu encargo en la plataforma, enviamos un código de 6 dígitos a tu WhatsApp <strong>+57 {customerPhone}</strong>:
</p>
<input
type="text"
maxLength={6}
value={otpCode}
onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, ''))}
placeholder="• • • • • •"
className="w-full bg-neutral-950 border border-neutral-700 focus:border-purple-500 rounded-xl px-4 py-3 text-center text-2xl font-mono tracking-widest text-white outline-none"
/>
{otpError && <p className="text-xs text-rose-400 text-center">{otpError}</p>}
<button
type="button"
disabled={otpLoading || otpCode.length < 6}
onClick={handleVerifyOtpAndSubmitRequest}
className="w-full bg-gradient-to-r from-purple-600 to-fuchsia-600 text-white font-bold py-3.5 rounded-xl hover:opacity-95 disabled:opacity-50 text-sm shadow-md transition-all cursor-pointer"
>
{otpLoading ? 'Verificando...' : 'Confirmar Código y Registrar Encargo'}
</button>
<p className="text-[11px] text-neutral-500 text-center">
El código expira en 5 minutos.
</p>
</div>
</div>
)}
</div>
</div>
);

View File

@ -247,13 +247,14 @@ export const CartDrawer: React.FC<CartDrawerProps> = ({
<ArrowRight className="w-4 h-4" />
</Link>
<button
onClick={handleWhatsAppCheckout}
<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 por WhatsApp</span>
</button>
<span>Completar Pedido con Verificación WhatsApp</span>
</Link>
</div>
</div>
)}

View File

@ -178,3 +178,71 @@ 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).
*/
export async function notifyAdminNewOrder(order: any): Promise<boolean> {
const CARLOS_PHONE = '573167455877';
const NOTIFICATION_GROUP = '120363428012905103@g.us';
const itemsSummary = (order.items || [])
.map((item: any) => `• 👟 *${item.productName || item.name}*\n 📏 Talla: *${item.size || 'Única'}* | Cantidad: *${item.quantity || 1}* | Valor: *${formatCOP((item.price || item.retailPrice || 0) * (item.quantity || 1))}*`)
.join('\n\n');
const paymentCondition = (order.depositAmount && order.depositAmount > 0)
? `⚡ Separa con *${formatCOP(order.depositAmount)}* • Saldo Contraentrega: *${formatCOP(order.balanceDue || (order.totalAmount - order.depositAmount))}*`
: `🤝 100% Pago Contraentrega (*${formatCOP(order.totalAmount || 0)}*)`;
const orderId = order.id || `P-${Date.now().toString().slice(-6)}`;
const dateStr = new Date().toLocaleString('es-CO', { timeZone: 'America/Bogota' });
const adminMessage = (
`🚨 *¡NUEVO PEDIDO REGISTRADO EN DRIPZONE!* 🛍️📦\n` +
`━━━━━━━━━━━━━━━━━━━━━━\n` +
`🆔 *Orden #:* ${orderId}\n` +
`📅 *Fecha:* ${dateStr}\n\n` +
`👤 *DATOS DEL CLIENTE:*\n` +
`• *Nombre:* ${order.customerName || 'Cliente DripZone'}\n` +
`• *WhatsApp:* ${order.customerPhone || 'Sin registrar'}\n` +
`• *Ciudad Destino:* ${order.cityName || 'Colombia'}\n` +
`• *Dirección:* ${order.address || 'Por coordinar'}\n` +
(order.notes ? `• *Notas:* ${order.notes}\n` : '') +
`\n🛒 *PRODUCTOS SOLICITADOS:*\n` +
`${itemsSummary || '1x Zapatillas DripZone'}\n\n` +
`💰 *LIQUIDACIÓN ECONÓMICA:*\n` +
`• *Subtotal:* ${formatCOP(order.subtotal || order.totalAmount || 0)}\n` +
`• *Envío:* ${order.shippingCost === 0 ? 'GRATIS' : formatCOP(order.shippingCost || 0)}\n` +
`• *TOTAL PEDIDO:* *${formatCOP(order.totalAmount || 0)}*\n` +
`• *Condición:* ${paymentCondition}\n` +
`• *Saldo a Cobrar Contraentrega:* *${formatCOP(order.balanceDue || order.totalAmount || 0)}*\n` +
`━━━━━━━━━━━━━━━━━━━━━━\n` +
`⚡ *Gestionar en el Panel de Administración:*\n` +
`👉 https://dripzone.almeira.com.co/admin/pedidos`
);
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(() => {});
}
// 3. Notificación al canal de alertas
await sendWhatsAppMessage(NOTIFICATION_GROUP, adminMessage).catch(() => {});
} catch (err: any) {
console.error('Error enviando notificación de pedido a Carlos:', err.message);
}
return success;
}

10
scripts/check_vps.py Normal file
View File

@ -0,0 +1,10 @@
import paramiko
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect('161.97.93.156', username='carlos', password='Innova1978+*?', timeout=20)
stdin, stdout, stderr = client.exec_command('docker inspect dripzone-production --format "{{.Config.WorkingDir}} - {{.Config.Cmd}}"')
print("dripzone-production cmd:", stdout.read().decode('utf-8'))
stdin, stdout, stderr = client.exec_command('docker inspect bfmnoga3lb0ckomhdmxe0rco-021053217798 --format "{{.Config.WorkingDir}} - {{.Config.Cmd}}"')
print("coolify dripzone cmd:", stdout.read().decode('utf-8'))
client.close()

View File

@ -64,28 +64,23 @@ def send_apk_whatsapp():
print("[3/3] Enviando APK y notificación oficial al WhatsApp de Carlos (+57 316 745 5877)...")
message_text = (
"📲 *DripZone - App Nativa Android v1.2 (100% Funcionalidad Web)*\n\n"
"🔥 *¡Actualización Desplegada! Toda la funcionalidad y estética de la Web ahora en la App Nativa:*\n\n"
"🎨 *1. Estilo Visual, Splash Screen y Paleta Web Idénticos:*\n"
"• *Splash Screen:* Integrado con el recurso oficial solicitado `dripzone_splash_clean_1787718656035` con transición fluida.\n"
"• *Paleta Dark Streetwear DripZone:* Fondo `#0A0A0A`, detalles en púrpura `#9333EA` / `#A855F7`, fucsia neón `#D946EF`, rosa `#EC4899`, badges violeta `#2E1065` y glow borders.\n"
"• *Banner Web Superior:* Degradado en vivo 'ENVÍOS CONTRAENTREGA EN TODO COLOMBIA'.\n\n"
"⚡ *2. Misma Funcionalidad que la Versión Web:*\n"
"• *Selector de Ciudad:* Modal interactivo con ciudades colombianas (Cali, Bogotá, Medellín, Barranquilla, Bucaramanga, etc.), tiempos de entrega y garantía de 1er cambio gratis.\n"
"• *Carrito de Compras Multi-Producto:* Drawer interactivo (`CartBottomSheet`), sumatoria automática, desglose de separación ('Separa con $10.000 COP y paga saldo al recibir') y checkout multi-item a WhatsApp.\n"
"• *Detalle de Producto:* Selector interactivo de tallas en chips, botón 'Agregar al Carrito' y 'Comprar Directo por WhatsApp' con foto y formato de orden contraentrega.\n"
"• *Dock de Navegación Móvil (5 Pestañas):* Inicio, Buscar, Carrito con badge dinámico en tiempo real, Encargos y Asesor WhatsApp.\n"
"• *Módulo de Encargos / Pedidos Especiales:* Formulario para cotizar modelos no listados directamente con proveedores mayoristas.\n"
"• *Filtros y Búsqueda en Vivo:* Categorías dinámicas (Sneakers, Chanclas, Bolsos, Gorras) y filtrado instantáneo por texto.\n"
"• *Pull-to-Refresh:* Sincronización instantánea con los drops de WhatsApp.\n\n"
"🛡️ *3. Seguridad de Nivel Corporativo:*\n"
"• *Network Security Config:* Tráfico en texto claro (HTTP) bloqueado, TLS 1.2+ estricto.\n"
"• *Cifrado de Datos:* `EncryptedSharedPreferences` respaldado por Android KeyStore (AES-256 GCM).\n"
"• *Protección Anti-Tapjacking:* `filterTouchesWhenObscured=\"true\"` en botones clave.\n"
"• *Lanzamiento Seguro de Intents:* Validación rigurosa de URLs y esquemas de WhatsApp.\n\n"
f"⬇️ *Descarga directa del APK:* {APK_URL}\n"
"📲 *DripZone - App Nativa Android v1.3 (OTP WhatsApp + Registro Plataforma)*\n\n"
"🔥 *¡Actualización Desplegada! Corrección de Splash Screen y Sistema de Validación OTP:*\n\n"
"✨ *1. Corrección Total de Splash Screen:*\n"
"• Solucionado el desbordamiento de pantalla: ahora se ajusta perfectamente con escalado proporcional (`fitCenter` / `adjustViewBounds`) y fondo `#0A0A0A` sin distorsión ni recortes en ningún modelo de celular.\n\n"
"🔐 *2. Registro de Usuario y Validación de WhatsApp con OTP:*\n"
"• Al solicitar cualquier pedido o encargo, el usuario registra su nombre, celular y dirección.\n"
"• La plataforma envía un código de seguridad de 6 dígitos al WhatsApp del cliente vía Evolution API.\n"
"• El cliente valida su número introduciendo el código OTP en la app o web, quedando registrado y verificado en la plataforma.\n\n"
"📦 *3. Registro en Plataforma y Notificación Automática a Carlos:*\n"
"• Los pedidos (`/api/orders`) y encargos (`/api/custom-requests`) se guardan directamente en la base de datos de la plataforma.\n"
"• El servidor de la plataforma es el encargado de enviarte la información completa de inmediato a tu WhatsApp (+57 316 745 5877) con datos del cliente, ciudad, productos, tallas, liquidación contraentrega y foto de soporte.\n"
"• El cliente recibe su confirmación oficial por WhatsApp.\n\n"
"🌐 *4. Misma Funcionalidad en Web y App Móvil:*\n"
"• Tanto la tienda web (`checkout/page.tsx`, `CustomRequestModal.tsx`) como la app nativa en Kotlin comparten exactamente los mismos endpoints, reglas de verificación OTP y plantillas de Evolution API.\n\n"
f"⬇️ *Descarga directa del APK actualizado:* {APK_URL}\n"
f"⬇️ *Enlace alternativo:* https://dripzone.almeira.com.co/DripZone.apk\n\n"
"Adjunto el instalador APK para su instalación y prueba inmediata."
"Adjunto el archivo APK instalador actualizado para prueba inmediata en tu dispositivo."
)
req_text = urllib.request.Request(