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 allProducts: List<Product> = emptyList()
private var availableCities: List<City> = City.defaultCities() 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 selectedCategory: String = "Todos"
private var searchQuery: String = "" private var searchQuery: String = ""

View File

@ -10,8 +10,10 @@ import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.ConnectionSpec import okhttp3.ConnectionSpec
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.TlsVersion import okhttp3.TlsVersion
import java.io.IOException import java.io.IOException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -189,5 +191,160 @@ object ApiClient {
} }
co.dripzone.app.model.City.defaultCities() 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() val cartList = CartManager.getItems()
if (cartList.isEmpty()) return@setOnClickListener if (cartList.isEmpty()) return@setOnClickListener
val subtotal = CartManager.getSubtotal() val verificationSheet = OrderVerificationBottomSheet.newInstance(
val total = subtotal + selectedCity.shippingCost city = selectedCity,
val balanceDue = if (total > 10000.0) total - 10000.0 else 0.0 isDirect = false
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
) )
parentFragmentManager.let {
verificationSheet.show(it, OrderVerificationBottomSheet.TAG)
}
dismiss() dismiss()
} }

View File

@ -5,9 +5,13 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.lifecycle.lifecycleScope
import co.dripzone.app.api.ApiClient
import co.dripzone.app.databinding.DialogCustomOrderBinding import co.dripzone.app.databinding.DialogCustomOrderBinding
import co.dripzone.app.model.UserManager
import co.dripzone.app.security.SecurityManager import co.dripzone.app.security.SecurityManager
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import kotlinx.coroutines.launch
class CustomOrderBottomSheet : BottomSheetDialogFragment() { class CustomOrderBottomSheet : BottomSheetDialogFragment() {
@ -17,6 +21,13 @@ class CustomOrderBottomSheet : BottomSheetDialogFragment() {
private var initialModel: String = "" private var initialModel: String = ""
private var initialBrand: 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
arguments?.let { arguments?.let {
@ -37,36 +48,34 @@ class CustomOrderBottomSheet : BottomSheetDialogFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) 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()) { if (initialModel.isNotBlank()) {
binding.etCustomModel.setText(initialModel) binding.etCustomModel.setText(initialModel)
} }
binding.btnSubmitCustomOrder.setOnClickListener { binding.btnSubmitCustomOrder.setOnClickListener {
val rawModel = binding.etCustomModel.text?.toString() handleContinueToCustomOtp()
val rawSize = binding.etCustomSize.text?.toString() }
val rawNotes = binding.etCustomNotes.text?.toString()
val sanitizedModel = SecurityManager.sanitizeInput(rawModel) binding.btnVerifyCustomOtp.setOnClickListener {
val sanitizedSize = SecurityManager.sanitizeInput(rawSize) handleVerifyCustomOtp()
val sanitizedNotes = SecurityManager.sanitizeInput(rawNotes) }
if (sanitizedModel.isBlank()) { binding.tvResendCustomOtp.setOnClickListener {
binding.etCustomModel.error = "Por favor indica el modelo deseado" sendCustomOtp()
return@setOnClickListener }
}
val message = buildString { binding.btnFinishCustom.setOnClickListener { dismiss() }
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.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( SecurityManager.openWhatsApp(
requireContext(), requireContext(),
SecurityManager.WHATSAPP_STORE_PHONE, 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() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
_binding = null _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() dismiss()
} }
// WhatsApp Order Checkout Button // Direct Order Checkout Button with Verification
binding.btnOrderWhatsApp.setOnClickListener { binding.btnOrderWhatsApp.setOnClickListener {
val sizeToOrder = if (selectedSize.isNotBlank()) selectedSize else sizeAdapter?.getSelectedSize() ?: "Por coordinar" val sizeToOrder = if (selectedSize.isNotBlank()) selectedSize else sizeAdapter?.getSelectedSize() ?: "39"
val whatsappMessage = buildString { val directItem = co.dripzone.app.model.CartItem(
append("¡Hola DripZone! 🔥 Me interesa ordenar este producto:\n\n") product = p,
append("👟 *Producto:* ${p.name}\n") selectedSize = sizeToOrder,
append("🏷️ *Marca:* ${p.brand}\n") quantity = 1
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 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() dismiss()
} }

View File

@ -8,16 +8,18 @@
android:id="@+id/ivSplash" android:id="@+id/ivSplash"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_gravity="center"
android:adjustViewBounds="true"
android:contentDescription="DripZone Splash Screen" android:contentDescription="DripZone Splash Screen"
android:scaleType="centerCrop" android:scaleType="fitCenter"
android:src="@drawable/dripzone_splash_clean" /> android:src="@drawable/dripzone_splash_clean" />
<!-- Subtle bottom indicator --> <!-- Subtle bottom indicator -->
<ProgressBar <ProgressBar
android:layout_width="36dp" android:layout_width="32dp"
android:layout_height="36dp" android:layout_height="32dp"
android:layout_gravity="bottom|center_horizontal" android:layout_gravity="bottom|center_horizontal"
android:layout_marginBottom="48dp" android:layout_marginBottom="36dp"
android:indeterminateTint="@color/drip_fuchsia" /> android:indeterminateTint="@color/drip_fuchsia" />
</FrameLayout> </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:layout_marginBottom="16dp"
android:background="#40FFFFFF" /> android:background="#40FFFFFF" />
<TextView <RelativeLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content">
android:fontFamily="sans-serif-black"
android:text="🔥 Pedido Especial / Encargo" <TextView
android:textColor="@color/text_primary" android:layout_width="wrap_content"
android:textSize="20sp" /> 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 <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="6dp" android:layout_marginTop="4dp"
android:text="¿Buscas un modelo exclusivo o una talla específica de nuestros proveedores? Te lo conseguimos bajo pedido." android:text="¿Buscas un modelo exclusivo no listado? Te lo cotizamos directamente con nuestras bodegas mayoristas."
android:textColor="@color/text_secondary" android:textColor="@color/text_secondary"
android:textSize="13sp" /> android:textSize="12sp" />
<!-- Reference / Model input --> <!-- Form Step Layout -->
<com.google.android.material.textfield.TextInputLayout <LinearLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox" android:id="@+id/layoutCustomForm"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="18dp" android:layout_marginTop="12dp"
android:hint="Modelo / Referencia (ej. Jordan 4 Military Black)" android:orientation="vertical">
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">
<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:id="@+id/etCustomModel"
android:layout_width="match_parent" 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:textColor="@color/text_primary"
android:textSize="14sp" /> android:textColorHint="@color/text_muted"
</com.google.android.material.textfield.TextInputLayout> android:textSize="13sp" />
<!-- Size input --> <!-- Talla -->
<com.google.android.material.textfield.TextInputLayout <TextView
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox" android:layout_width="wrap_content"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_height="wrap_content" android:layout_marginTop="10dp"
android:layout_marginTop="12dp" android:fontFamily="sans-serif-medium"
android:hint="Talla deseada (ej. 41 EUR / 8 US)" android:text="Talla deseada *"
android:textColorHint="@color/text_muted" android:textColor="@color/text_primary"
app:boxBackgroundColor="@color/bg_input" android:textSize="12sp" />
app:boxCornerRadiusBottomEnd="12dp"
app:boxCornerRadiusBottomStart="12dp"
app:boxCornerRadiusTopEnd="12dp"
app:boxCornerRadiusTopStart="12dp"
app:boxStrokeColor="@color/drip_fuchsia">
<com.google.android.material.textfield.TextInputEditText <EditText
android:id="@+id/etCustomSize" android:id="@+id/etCustomSize"
android:layout_width="match_parent" 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:textColor="@color/text_primary"
android:textSize="14sp" /> android:textColorHint="@color/text_muted"
</com.google.android.material.textfield.TextInputLayout> android:textSize="13sp" />
<!-- Notes input --> <!-- Notas / Presupuesto -->
<com.google.android.material.textfield.TextInputLayout <TextView
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox" 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_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="12dp" android:layout_marginTop="12dp"
android:hint="Colorway o detalles adicionales" android:orientation="vertical"
android:textColorHint="@color/text_muted" android:visibility="gone">
app:boxBackgroundColor="@color/bg_input"
app:boxCornerRadiusBottomEnd="12dp"
app:boxCornerRadiusBottomStart="12dp"
app:boxCornerRadiusTopEnd="12dp"
app:boxCornerRadiusTopStart="12dp"
app:boxStrokeColor="@color/drip_fuchsia">
<com.google.android.material.textfield.TextInputEditText <TextView
android:id="@+id/etCustomNotes" android:layout_width="wrap_content"
android:layout_width="match_parent"
android:layout_height="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:textColor="@color/text_primary"
android:textSize="14sp" /> android:textSize="15sp" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Submit to WhatsApp Button with Tapjacking Protection --> <TextView
<com.google.android.material.button.MaterialButton android:id="@+id/tvCustomOtpInstruction"
android:id="@+id/btnSubmitCustomOrder" 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_width="match_parent"
android:layout_height="52dp" android:layout_height="wrap_content"
android:layout_marginTop="20dp" android:layout_marginTop="16dp"
android:filterTouchesWhenObscured="true" android:gravity="center_horizontal"
android:text="Consultar Encargo en WhatsApp" android:orientation="vertical"
android:textAllCaps="false" android:visibility="gone">
android:textColor="#FFFFFF"
android:textSize="15sp" <ImageView
android:textStyle="bold" android:layout_width="56dp"
app:backgroundTint="@color/accent_whatsapp" android:layout_height="56dp"
app:cornerRadius="16dp" android:src="@drawable/ic_sparkle"
app:icon="@drawable/ic_whatsapp" app:tint="@color/drip_fuchsia" />
app:iconGravity="textStart"
app:iconPadding="8dp" /> <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> </LinearLayout>

View File

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

View File

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

View File

@ -4,7 +4,7 @@ import { otpStore } from '@/lib/otp-store';
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const { identifier, code } = await request.json(); const { identifier, code, name, city, address } = await request.json();
if (!identifier || !code) { if (!identifier || !code) {
return NextResponse.json({ success: false, error: 'Identificador y código son requeridos' }, { status: 400 }); 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 }); 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); let user = dataService.authenticate(cleanInput);
if (!user) { if (!user) {
// Si es un nuevo cliente autenticado por primera vez con su celular // Si es un nuevo cliente autenticado por primera vez con su celular
const cleanPhone = cleanInput.replace(/\D/g, ''); const cleanPhone = cleanInput.replace(/\D/g, '');
const assignedName = name?.trim() || `Cliente Verificado (${cleanPhone.slice(-4)})`;
user = dataService.addUser({ user = dataService.addUser({
id: `usr-${Date.now()}`, id: `usr-${Date.now()}`,
name: `Cliente Verificado (${cleanPhone.slice(-4)})`, name: assignedName,
email: `${cleanPhone}@cliente.dripzone.co`, email: `${cleanPhone}@cliente.dripzone.co`,
phone: cleanPhone, phone: cleanPhone,
city: 'Cali', city: city?.trim() || 'Cali',
address: address?.trim() || '',
role: 'customer', role: 'customer',
isActive: true, isActive: true,
phoneVerified: true, phoneVerified: true,
@ -39,9 +41,12 @@ export async function POST(request: Request) {
lastLogin: 'Justo ahora', lastLogin: 'Justo ahora',
}); });
} else { } 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; const hadRejections = (user.rejectedOrdersCount || 0) > 0;
dataService.updateUser(user.id, { dataService.updateUser(user.id, {
name: name?.trim() || user.name,
city: city?.trim() || user.city,
address: address?.trim() || user.address,
phoneVerified: true, phoneVerified: true,
requiresShippingDeposit: hadRejections ? true : false, requiresShippingDeposit: hadRejections ? true : false,
depositReason: hadRejections 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!`; ⚡ ¡Consulta de inmediato con tus proveedores mayoristas para enviar cotización al cliente!`;
sendWhatsAppMessage(CARLOS_PHONE, adminMessage).catch(() => {}); sendWhatsAppMessage(CARLOS_PHONE, adminMessage).catch(() => {});
sendWhatsAppMessage('120363428012905103@g.us', adminMessage).catch(() => {});
// 2. Confirmación automática al cliente por WhatsApp // 2. Confirmación automática al cliente por WhatsApp
const clientMessage = `👋 ¡Hola *${customerName}*! const clientMessage = `👋 ¡Hola *${customerName}*!

View File

@ -5,7 +5,11 @@ import {
updateOrderInAppwrite, updateOrderInAppwrite,
deleteOrderInAppwrite, deleteOrderInAppwrite,
} from '@/lib/server-appwrite'; } 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'; import { dataService } from '@/lib/data-service';
export async function GET() { export async function GET() {
@ -24,13 +28,20 @@ export async function POST(request: Request) {
const newOrder = await createOrderInAppwrite(body); const newOrder = await createOrderInAppwrite(body);
dataService.addOrder(newOrder); 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) { if (newOrder.customerPhone) {
try { try {
const msg = buildOrderConfirmationMessage(newOrder); const msg = buildOrderConfirmationMessage(newOrder);
await sendWhatsAppMessage(newOrder.customerPhone, msg); await sendWhatsAppMessage(newOrder.customerPhone, msg);
} catch (waErr) { } 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 [orderCompleted, setOrderCompleted] = useState<Order | null>(null);
const [userRequiresDeposit, setUserRequiresDeposit] = useState<boolean>(false); const [userRequiresDeposit, setUserRequiresDeposit] = useState<boolean>(false);
const [userDepositReason, setUserDepositReason] = useState<string>(''); 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(() => { useEffect(() => {
fetch('/api/cities') fetch('/api/cities')
@ -117,15 +121,8 @@ export default function CheckoutPage() {
const officialWhatsapp = storeSettings?.whatsappNumber || '573167455877'; const officialWhatsapp = storeSettings?.whatsappNumber || '573167455877';
const boldLink = storeSettings?.boldPaymentLink || 'https://bold.co/p/carlos-lasso'; const boldLink = storeSettings?.boldPaymentLink || 'https://bold.co/p/carlos-lasso';
const handleSubmitOrder = async (e: React.FormEvent) => { const processOrderSubmission = async () => {
e.preventDefault();
if (!customerName || !customerPhone || !address) {
alert('Por favor completa tu nombre, celular y dirección para el despacho.');
return;
}
setIsSubmitting(true); setIsSubmitting(true);
try { try {
const orderPayload = { const orderPayload = {
customerName, customerName,
@ -158,14 +155,90 @@ export default function CheckoutPage() {
spread: 70, spread: 70,
origin: { y: 0.6 }, origin: { y: 0.6 },
}); });
} else {
alert(data.error || 'Error al registrar pedido.');
} }
} catch (err) { } catch (err) {
console.error('Error creando orden:', err); console.error('Error creando orden:', err);
alert('Error de conexión al registrar pedido.');
} finally { } finally {
setIsSubmitting(false); 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 handleOpenWhatsAppOrder = (order: Order) => {
const itemsSummary = order.items const itemsSummary = order.items
.map((i) => `• ${i.productName} (Talla ${i.size}) x${i.quantity} = ${formatCOP(i.price * i.quantity)}`) .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>
</div> </div>
</form> </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>
</div> </div>
); );

View File

@ -46,6 +46,10 @@ export const CustomRequestModal: React.FC<CustomRequestModalProps> = ({
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false); const [isSuccess, setIsSuccess] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null); 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); const fileInputRef = useRef<HTMLInputElement>(null);
@ -68,20 +72,12 @@ export const CustomRequestModal: React.FC<CustomRequestModalProps> = ({
reader.readAsDataURL(file); reader.readAsDataURL(file);
}; };
const handleSubmit = async (e: React.FormEvent) => { const processCustomRequest = async () => {
e.preventDefault(); setIsLoading(true);
setErrorMessage(null); setErrorMessage(null);
const effectiveBrand = brand === 'Otra' && customBrand.trim() ? customBrand.trim() : brand; const effectiveBrand = brand === 'Otra' && customBrand.trim() ? customBrand.trim() : brand;
const effectiveSize = size === 'custom' && customSize.trim() ? customSize.trim() : size; 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 { try {
const res = await fetch('/api/custom-requests', { const res = await fetch('/api/custom-requests', {
method: 'POST', 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 = () => { const handleResetAndClose = () => {
setIsSuccess(false); setIsSuccess(false);
setImageBase64(null); setImageBase64(null);
@ -426,6 +488,49 @@ export const CustomRequestModal: React.FC<CustomRequestModalProps> = ({
</div> </div>
</form> </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>
</div> </div>
); );

View File

@ -247,13 +247,14 @@ export const CartDrawer: React.FC<CartDrawerProps> = ({
<ArrowRight className="w-4 h-4" /> <ArrowRight className="w-4 h-4" />
</Link> </Link>
<button <Link
onClick={handleWhatsAppCheckout} 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" 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" /> <MessageCircle className="w-4 h-4 fill-white" />
<span>Completar Pedido por WhatsApp</span> <span>Completar Pedido con Verificación WhatsApp</span>
</button> </Link>
</div> </div>
</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)...") print("[3/3] Enviando APK y notificación oficial al WhatsApp de Carlos (+57 316 745 5877)...")
message_text = ( message_text = (
"📲 *DripZone - App Nativa Android v1.2 (100% Funcionalidad Web)*\n\n" "📲 *DripZone - App Nativa Android v1.3 (OTP WhatsApp + Registro Plataforma)*\n\n"
"🔥 *¡Actualización Desplegada! Toda la funcionalidad y estética de la Web ahora en la App Nativa:*\n\n" "🔥 *¡Actualización Desplegada! Corrección de Splash Screen y Sistema de Validación OTP:*\n\n"
"🎨 *1. Estilo Visual, Splash Screen y Paleta Web Idénticos:*\n" "✨ *1. Corrección Total de Splash Screen:*\n"
"• *Splash Screen:* Integrado con el recurso oficial solicitado `dripzone_splash_clean_1787718656035` con transición fluida.\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"
"• *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" "🔐 *2. Registro de Usuario y Validación de WhatsApp con OTP:*\n"
"• *Banner Web Superior:* Degradado en vivo 'ENVÍOS CONTRAENTREGA EN TODO COLOMBIA'.\n\n" "• Al solicitar cualquier pedido o encargo, el usuario registra su nombre, celular y dirección.\n"
"⚡ *2. Misma Funcionalidad que la Versión Web:*\n" "• La plataforma envía un código de seguridad de 6 dígitos al WhatsApp del cliente vía Evolution API.\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" "• 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"
"• *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" "📦 *3. Registro en Plataforma y Notificación Automática a Carlos:*\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" "• Los pedidos (`/api/orders`) y encargos (`/api/custom-requests`) se guardan directamente en la base de datos de la plataforma.\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" "• 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"
"• *Módulo de Encargos / Pedidos Especiales:* Formulario para cotizar modelos no listados directamente con proveedores mayoristas.\n" "• El cliente recibe su confirmación oficial por WhatsApp.\n\n"
"• *Filtros y Búsqueda en Vivo:* Categorías dinámicas (Sneakers, Chanclas, Bolsos, Gorras) y filtrado instantáneo por texto.\n" "🌐 *4. Misma Funcionalidad en Web y App Móvil:*\n"
"• *Pull-to-Refresh:* Sincronización instantánea con los drops de WhatsApp.\n\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"
"🛡️ *3. Seguridad de Nivel Corporativo:*\n" f"⬇️ *Descarga directa del APK actualizado:* {APK_URL}\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"
f"⬇️ *Enlace alternativo:* https://dripzone.almeira.com.co/DripZone.apk\n\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( req_text = urllib.request.Request(