feat: mobile notifications based on registered templates, AppNotificationManager, and deposit confirmed template

This commit is contained in:
AI Bot 2026-09-05 16:51:18 -05:00
parent 0f74821c94
commit acbe8335fd
43 changed files with 2796 additions and 234 deletions

View File

@ -105,5 +105,59 @@ def deploy():
print(f"[EXIT STATUS]: {status}")
client.close()
if status == 0:
send_apk_to_whatsapp()
def send_apk_to_whatsapp():
import urllib.request
import json
print("[5/5] Enviando APK y reporte de actualizacion al WhatsApp de Carlos (+57 316 745 5877)...")
url = "https://evolution.almeira.com.co/message/sendMedia/DripZone"
headers = {
"apikey": "83B5BD230796-4324-AE73-3508F69E279D",
"Content-Type": "application/json"
}
caption = (
"📲 *¡DRIPZONE APK ACTUALIZADA!* 🚀👟\n"
"━━━━━━━━━━━━━━━━━━━━━━\n"
"✨ *Novedades de esta versión:*\n"
"• 📦 *Mis Pedidos*: Visualización de estado en tiempo real, desglose de calzado, flete, anticipo $10.000 y saldo contraentrega.\n"
"• 👤 *Mi Cuenta*: Acceso seguro vía WhatsApp OTP exclusivo a la línea del cliente.\n"
"• 💵 *Precios en Grid*: Corrección de visualización de precios en 1 sola línea sin recortes y botón 'Pedir' optimizado.\n"
"• 🗄️ *Persistencia Total*: Configuración, parámetros y entidad almacenados de forma segura en Base de Datos.\n"
"• 🔔 *Plantillas Dinámicas*: Notificaciones móviles y web emitidas con las plantillas registradas.\n\n"
"🌐 *Descarga directa:*\n👉 https://drip-zone.online/DripZone.apk"
)
payload = {
"number": "573167455877",
"mediatype": "document",
"mimetype": "application/vnd.android.package-archive",
"caption": caption,
"media": "https://drip-zone.online/DripZone.apk",
"fileName": "DripZone.apk"
}
try:
req = urllib.request.Request(url, data=json.dumps(payload).encode('utf-8'), headers=headers)
with urllib.request.urlopen(req, timeout=35) as resp:
print(f"[OK] APK despachada con exito al WhatsApp de Carlos (HTTP {resp.status})")
except Exception as e:
print(f"[Aviso] No se pudo enviar archivo multimedia por Evolution: {e}. Enviando mensaje de texto...")
text_url = "https://evolution.almeira.com.co/message/sendText/DripZone"
text_payload = {
"number": "573167455877",
"text": (
"📲 *¡DRIPZONE APK ACTUALIZADA EN PRODUCCIÓN!* 🚀👟\n\n"
f"{caption}\n\n"
"📥 *Descarga e instala el APK actualizado aquí:*\n"
"👉 https://drip-zone.online/DripZone.apk"
)
}
try:
req2 = urllib.request.Request(text_url, data=json.dumps(text_payload).encode('utf-8'), headers=headers)
with urllib.request.urlopen(req2, timeout=20) as resp2:
print(f"[OK] Notificacion de texto enviada por WhatsApp (HTTP {resp2.status})")
except Exception as e2:
print(f"[Error] No se pudo enviar texto por WhatsApp: {e2}")
if __name__ == "__main__":
deploy()

View File

@ -5,6 +5,7 @@
<!-- Permissions: Least privilege model -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Queries for secure intent resolution (Android 11+ package visibility) -->
<queries>

View File

@ -12,15 +12,19 @@ import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import co.dripzone.app.api.ApiClient
import co.dripzone.app.databinding.ActivityMainBinding
import co.dripzone.app.manager.TemplateManager
import co.dripzone.app.model.CartManager
import co.dripzone.app.model.Category
import co.dripzone.app.model.City
import co.dripzone.app.model.Product
import co.dripzone.app.model.UserManager
import co.dripzone.app.security.SecurityManager
import co.dripzone.app.ui.AccountBottomSheet
import co.dripzone.app.ui.CartBottomSheet
import co.dripzone.app.ui.CategoryAdapter
import co.dripzone.app.ui.CitySelectorBottomSheet
import co.dripzone.app.ui.CustomOrderBottomSheet
import co.dripzone.app.ui.OrdersBottomSheet
import co.dripzone.app.ui.ProductAdapter
import co.dripzone.app.ui.ProductDetailBottomSheet
import com.google.android.material.snackbar.Snackbar
@ -58,10 +62,17 @@ class MainActivity : AppCompatActivity() {
setupCitySelector()
setupBottomDock()
setupHeaderListeners()
updateAccountButton()
co.dripzone.app.manager.AppNotificationManager.createNotificationChannel(this)
TemplateManager.syncTemplates(this)
updateCartBadges()
loadProducts(forceRefresh = false)
loadCities()
if (intent?.getStringExtra("OPEN_TAB") == "orders") {
openOrders()
}
}
override fun onDestroy() {
@ -223,10 +234,10 @@ class MainActivity : AppCompatActivity() {
openCart()
}
// 4. Encargos
binding.navBtnEncargos.setOnClickListener {
CustomOrderBottomSheet.newInstance()
.show(supportFragmentManager, CustomOrderBottomSheet.TAG)
// 4. Mis Pedidos (Historial y Seguimiento)
binding.navBtnOrders.setOnClickListener {
setDockActive("orders")
openOrders()
}
// 5. WhatsApp Asesor
@ -244,6 +255,9 @@ class MainActivity : AppCompatActivity() {
binding.ivNavSearch.setColorFilter(if (tab == "search") fuchsia else muted)
binding.tvNavSearch.setTextColor(if (tab == "search") fuchsia else muted)
binding.ivNavOrders.setColorFilter(if (tab == "orders") fuchsia else muted)
binding.tvNavOrders.setTextColor(if (tab == "orders") fuchsia else muted)
}
private fun setupHeaderListeners() {
@ -252,9 +266,9 @@ class MainActivity : AppCompatActivity() {
openCart()
}
// Header WhatsApp button
binding.btnHeaderWhatsApp.setOnClickListener {
openGeneralWhatsApp()
// Header Account Button (Mi Cuenta / Login)
binding.btnHeaderAccount.setOnClickListener {
openAccount()
}
// SwipeRefresh: pulls latest live drops from suppliers
@ -265,6 +279,28 @@ class MainActivity : AppCompatActivity() {
}
}
private fun openOrders() {
OrdersBottomSheet().show(supportFragmentManager, OrdersBottomSheet.TAG)
}
private fun openAccount() {
AccountBottomSheet().apply {
onUserChangedListener = {
updateAccountButton()
}
}.show(supportFragmentManager, AccountBottomSheet.TAG)
}
private fun updateAccountButton() {
val user = UserManager.getUser(this)
if (user.isPhoneVerified && user.name.isNotBlank()) {
val firstName = user.name.split(" ").firstOrNull() ?: user.name
binding.btnHeaderAccount.text = firstName
} else {
binding.btnHeaderAccount.text = "Mi Cuenta"
}
}
private fun openCart() {
CartBottomSheet.newInstance(selectedCity) {
openCitySelector()
@ -272,7 +308,23 @@ class MainActivity : AppCompatActivity() {
}
private fun openGeneralWhatsApp() {
val inquiry = "¡Hola DripZone! 🔥 Me comunico desde la app móvil. Quiero información sobre los productos disponibles para entrega contraentrega en ${selectedCity.name}."
val variables = mapOf(
"tienda_nombre" to "DripZone",
"ciudad" to selectedCity.name
)
val inquiry = TemplateManager.renderTemplate(
this,
"general_store_inquiry",
variables
).ifBlank {
TemplateManager.renderTemplate(
this,
"advisor_contact",
variables
)
}.ifBlank {
"¡Hola DripZone! 🔥 Me comunico desde la app móvil. Quiero información sobre los productos disponibles para entrega contraentrega en ${selectedCity.name}."
}
SecurityManager.openWhatsApp(
this,
SecurityManager.WHATSAPP_STORE_PHONE,

View File

@ -423,5 +423,70 @@ object ApiClient {
Result.failure(e)
}
}
/**
* Obtiene el listado de pedidos asociados a un número de celular de cliente
*/
suspend fun getOrdersByPhone(phone: String): Result<List<co.dripzone.app.model.Order>> =
withContext(Dispatchers.IO) {
try {
val clean = phone.replace("\\D".toRegex(), "")
val request = Request.Builder()
.url("${getBaseUrl()}/api/orders?phone=$clean")
.get()
.header("Accept", "application/json")
.build()
val response = client.newCall(request).execute()
if (response.isSuccessful) {
val body = response.body?.string() ?: ""
val json = gson.fromJson(body, com.google.gson.JsonObject::class.java)
if (json.get("success")?.asBoolean == true && json.has("orders")) {
val listType = object : TypeToken<List<co.dripzone.app.model.Order>>() {}.type
val orders: List<co.dripzone.app.model.Order> = gson.fromJson(json.get("orders"), listType) ?: emptyList()
return@withContext Result.success(orders)
}
}
Result.success(emptyList())
} catch (e: Exception) {
Log.e(TAG, "Error fetching orders by phone: ${e.message}")
Result.failure(e)
}
}
/**
* Obtiene las plantillas de mensajes registradas en la base de datos de la plataforma
*/
suspend fun getMessageTemplates(): Result<Map<String, String>> =
withContext(Dispatchers.IO) {
try {
val request = Request.Builder()
.url("${getBaseUrl()}/api/message-templates")
.get()
.header("Accept", "application/json")
.build()
val response = client.newCall(request).execute()
if (response.isSuccessful) {
val body = response.body?.string() ?: ""
val json = gson.fromJson(body, com.google.gson.JsonObject::class.java)
if (json.get("success")?.asBoolean == true && json.has("templates")) {
val templatesArray = json.getAsJsonArray("templates")
val map = mutableMapOf<String, String>()
for (element in templatesArray) {
val obj = element.asJsonObject
val id = obj.get("id")?.asString ?: continue
val content = obj.get("content")?.asString ?: ""
map[id] = content
}
return@withContext Result.success(map)
}
}
Result.success(emptyMap())
} catch (e: Exception) {
Log.e(TAG, "Error fetching templates: ${e.message}")
Result.failure(e)
}
}
}

View File

@ -0,0 +1,226 @@
package co.dripzone.app.manager
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import co.dripzone.app.MainActivity
import co.dripzone.app.R
/**
* Gestor de notificaciones de la aplicación móvil DripZone.
* Despacha notificaciones del sistema Android en base a las plantillas registradas
* en la base de datos de la plataforma y TemplateManager.
*/
object AppNotificationManager {
private const val TAG = "AppNotificationManager"
const val CHANNEL_ID = "dripzone_notifications_channel"
private const val CHANNEL_NAME = "Notificaciones de Pedidos DripZone"
private const val CHANNEL_DESC = "Avisos de estado de compras, anticipos Bold y seguimiento de pedidos"
private const val NOTIF_ID_ORDER = 1001
private const val NOTIF_ID_DEPOSIT = 1002
private const val NOTIF_ID_CUSTOM = 1003
/**
* Inicializa el canal de notificaciones en Android 8.0+ (API 26+)
*/
fun createNotificationChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val importance = NotificationManager.IMPORTANCE_HIGH
val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, importance).apply {
description = CHANNEL_DESC
enableVibration(true)
enableLights(true)
}
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
notificationManager?.createNotificationChannel(channel)
}
}
/**
* Muestra una notificación del sistema cuando un pedido se registra satisfactoriamente,
* formateada con la plantilla 'order_confirmation_customer'.
*/
fun showOrderPlacedNotification(
context: Context,
orderId: String,
variables: Map<String, String>
) {
try {
createNotificationChannel(context)
if (!hasNotificationPermission(context)) {
Log.d(TAG, "Permiso de notificaciones no concedido, omitiendo notificación local")
return
}
val renderedBody = TemplateManager.renderTemplate(context, "order_confirmation_customer", variables)
val cleanBody = TemplateManager.stripMarkdown(renderedBody)
val shortOrderId = if (orderId.length > 8) orderId.takeLast(8).uppercase() else orderId.uppercase()
val title = "🎉 ¡Pedido Registrado con Éxito! #$shortOrderId"
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra("OPEN_TAB", "orders")
}
val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val pendingIntent = PendingIntent.getActivity(
context,
NOTIF_ID_ORDER,
intent,
pendingIntentFlags
)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_package)
.setContentTitle(title)
.setContentText("Tu compra fue confirmada y está en preparación. Toca para ver el detalle.")
.setStyle(NotificationCompat.BigTextStyle().bigText(cleanBody))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setColor(ContextCompat.getColor(context, R.color.drip_purple))
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(context).notify(NOTIF_ID_ORDER, notification)
} catch (e: Exception) {
Log.e(TAG, "Error enviando notificación de pedido: ${e.message}")
}
}
/**
* Muestra una notificación cuando el anticipo de separación es confirmado por Bold,
* formateada con la plantilla 'customer_deposit_confirmed'.
*/
fun showDepositConfirmedNotification(
context: Context,
orderId: String,
variables: Map<String, String>
) {
try {
createNotificationChannel(context)
if (!hasNotificationPermission(context)) return
val renderedBody = TemplateManager.renderTemplate(context, "customer_deposit_confirmed", variables)
val cleanBody = TemplateManager.stripMarkdown(renderedBody)
val shortOrderId = if (orderId.length > 8) orderId.takeLast(8).uppercase() else orderId.uppercase()
val title = "⚡ ¡Anticipo Confirmado por Bold! #$shortOrderId"
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra("OPEN_TAB", "orders")
}
val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val pendingIntent = PendingIntent.getActivity(
context,
NOTIF_ID_DEPOSIT,
intent,
pendingIntentFlags
)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_sparkle)
.setContentTitle(title)
.setContentText("Recibimos el pago de tu anticipo. Despacho autorizado.")
.setStyle(NotificationCompat.BigTextStyle().bigText(cleanBody))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setColor(ContextCompat.getColor(context, R.color.accent_emerald))
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(context).notify(NOTIF_ID_DEPOSIT, notification)
} catch (e: Exception) {
Log.e(TAG, "Error enviando notificación de anticipo: ${e.message}")
}
}
/**
* Muestra una notificación cuando una solicitud por encargo es recibida,
* formateada con la plantilla 'customer_request_received'.
*/
fun showCustomRequestNotification(
context: Context,
variables: Map<String, String>
) {
try {
createNotificationChannel(context)
if (!hasNotificationPermission(context)) return
val renderedBody = TemplateManager.renderTemplate(context, "customer_request_received", variables)
val cleanBody = TemplateManager.stripMarkdown(renderedBody)
val model = variables["modelo"] ?: "Zapatillas"
val title = "👟 Encargo Recibido: $model"
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val pendingIntent = PendingIntent.getActivity(
context,
NOTIF_ID_CUSTOM,
intent,
pendingIntentFlags
)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_encargos)
.setContentTitle(title)
.setContentText("Estamos cotizando disponibilidad con bodegas mayoristas.")
.setStyle(NotificationCompat.BigTextStyle().bigText(cleanBody))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setColor(ContextCompat.getColor(context, R.color.drip_fuchsia))
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(context).notify(NOTIF_ID_CUSTOM, notification)
} catch (e: Exception) {
Log.e(TAG, "Error enviando notificación de encargo: ${e.message}")
}
}
private fun hasNotificationPermission(context: Context): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
} else {
true
}
}
}

View File

@ -0,0 +1,196 @@
package co.dripzone.app.manager
import android.content.Context
import co.dripzone.app.api.ApiClient
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* Gestor dinámico de plantillas de notificaciones y mensajes de la tienda.
* Sincroniza con las plantillas registradas en la base de datos de la plataforma y
* permite renderizar mensajes con variables dinámicas de forma segura.
*/
object TemplateManager {
private const val PREFS_NAME = "dripzone_templates_store"
private const val PREFIX_TEMPLATE = "tpl_"
private val DEFAULT_TEMPLATES = mapOf(
"order_confirmation_customer" to (
"🎉 *¡GRACIAS POR TU PEDIDO EN {tienda_nombre}!* 🎉\n" +
"📌 *No. de Orden:* #{orden_id}\n\n" +
"👤 *Cliente:* {cliente_nombre}\n" +
"📱 *Teléfono:* {cliente_telefono}\n" +
"📍 *Dirección:* {cliente_direccion}\n" +
"🏙️ *Ciudad:* {cliente_ciudad}\n\n" +
"📦 *DETALLE DE TU COMPRA:*\n{articulos_resumen}\n\n" +
"💵 *Subtotal:* {subtotal}\n" +
"🚚 *Envío:* {costo_envio}\n" +
"💰 *VALOR TOTAL:* {total}\n" +
"{condicion_pago_resumen}\n\n" +
"💳 *Método de Pago:* {metodo_pago}\n\n" +
"Estamos alistando tu paquete. Te enviaremos el número de guía o notificación de entrega. ¡Muchas gracias por tu confianza! ✨"
),
"order_checkout_wa" to (
"🎉 *CONFIRMACIÓN DE PEDIDO EN {tienda_nombre} #{orden_id}* 🎉\n\n" +
"👤 *Cliente:* {cliente_nombre}\n" +
"📱 *Teléfono:* {cliente_telefono}\n" +
"📍 *Dirección:* {cliente_direccion}, {cliente_ciudad}\n\n" +
"📦 *PRODUCTOS SOLICITADOS:*\n{articulos_resumen}\n\n" +
"💵 *Subtotal:* {subtotal}\n" +
"🚚 *Flete:* {costo_envio}\n" +
"💰 *TOTAL A PAGAR:* {total}\n" +
"{condicion_pago_resumen}\n" +
"💳 *Forma de Pago:* {metodo_pago}\n\n" +
"¡Por favor confirmar mi pedido para proceder con el alistamiento! 🔥"
),
"product_direct_inquiry" to (
"Hola {tienda_nombre}! 👋 Quiero pedir las zapatillas:\n" +
"👟 *{zapatilla_nombre}*\n" +
"🎨 Color: *{color_nombre}*\n" +
"📏 Talla: *{talla}* (Horma Nacional)\n" +
"💎 Precio: *{precio} COP*\n" +
"🚚 Entrega en: *{ciudad}* (Pago Contraentrega)\n" +
"⚡ Deseo separar con $10.000 COP y cancelar el saldo en mi casa.\n\n" +
"¿Me confirman disponibilidad para despacho por favor?"
),
"product_request_size" to (
"Hola {tienda_nombre}! 👋 Estaba revisando la tienda y me gustaron mucho estas zapatillas:\n" +
"👟 *{zapatilla_nombre}*\n" +
"🎨 Color: *{color_nombre}*\n" +
"📏 Talla que necesito: *{talla}* (Actualmente no aparece disponible en stock inmediato)\n" +
"📍 Ciudad de entrega: *{ciudad}*\n\n" +
"¿Es posible conseguir esta talla bajo pedido con sus bodegas aliadas para despacho contraentrega? Quedo muy atento, ¡muchas gracias!"
),
"general_store_inquiry" to (
"Hola {tienda_nombre}! 👋 Me comunico desde la app oficial para consultar disponibilidad de zapatillas y asesoría para despacho contraentrega en {ciudad}."
),
"advisor_contact" to (
"👋 ¡Hola equipo de {tienda_nombre}! Deseo recibir asesoría personalizada sobre productos disponibles y tallas para entrega en {ciudad}."
),
"customer_request_received" to (
"👋 ¡Hola *{cliente_nombre}*!\n\n" +
"Recibimos con éxito tu solicitud por encargo para las *{marca} {modelo}* en talla *{talla}*. 👟🔥\n\n" +
"Nuestro equipo en {tienda_nombre} ya está verificando disponibilidad y mejor precio con nuestras bodegas y proveedores mayoristas. " +
"Te responderemos a este mismo chat con la cotización exacta en el menor tiempo posible.\n\n" +
"¡Gracias por confiar en {tienda_nombre}! 🚀"
),
"customer_deposit_request" to (
"⚡ *{tienda_nombre} - CONFIRMACIÓN DE DESPACHO* 📦\n\n" +
"Hola {cliente_nombre}, para programar el despacho express de tu orden *#{orden_id}* hacia *{cliente_ciudad}*, realiza el pago del anticipo de separación de *{valor_anticipo}*.\n\n" +
"💳 *Paga en línea de forma 100% segura por Bold (PSE / Tarjetas / Bre-B / Nequi):*\n" +
"👉 {link_pago_bold}\n\n" +
"🤝 El saldo restante de *{saldo_contraentrega}* lo cancelas al recibir tu paquete en tu puerta. ¡Gracias por tu compra!"
),
"customer_deposit_confirmed" to (
"🎉 *¡Tu anticipo de separación fue confirmado con éxito!*\n\n" +
"Hola *{cliente_nombre}*, recibimos correctamente tu pago de *{valor_anticipo}* a través de Bold.\n\n" +
"📦 Tu orden *#{orden_id}* ya se encuentra en alistamiento y empaque para despacho a *{cliente_ciudad}*.\n" +
"🤝 *Saldo restante a pagar contraentrega:* *{saldo_contraentrega}* al recibir en tu puerta.\n\n" +
"Puedes consultar el estado de tu pedido en cualquier momento desde la app oficial de {tienda_nombre}.\n\n" +
"¡Muchas gracias por tu compra en {tienda_nombre}!"
),
"order_on_the_way" to (
"👋 ¡Hola *{cliente_nombre}*!\n\n" +
"🛵 Tu pedido #{orden_id} ya va *EN CAMINO* con nuestro mensajero.\n\n" +
"📍 *Dirección de Entrega:* {cliente_direccion}\n" +
"🏙️ *Ciudad:* {cliente_ciudad}\n" +
"💵 *Saldo a Pagar Contraentrega:* *{saldo_contraentrega}*\n" +
"💳 *Medio de Pago:* {metodo_pago} (Efectivo o Transferencia Nequi)\n\n" +
"Por favor estar atento a la portería o celular. ¡Gracias por tu compra en {tienda_nombre}! 🔥"
),
"order_delivered" to (
"🌟 ¡Hola *{cliente_nombre}*! Tu orden #{orden_id} ha sido *ENTREGADA CON ÉXITO*.\n\n" +
"👟 Esperamos que disfrutes al máximo tus zapatillas.\n\n" +
"💬 Si tienes cualquier consulta sobre el 1er cambio de talla gratuito o garantía, estamos a tu disposición por este canal oficial.\n\n" +
"¡Muchas gracias por elegir {tienda_nombre}! ✨"
),
"auth_login_otp" to (
"🔐 *{tienda_nombre} - Código de Seguridad*\n\n" +
"Hola {usuario_nombre}, tu código de verificación para iniciar sesión es:\n\n" +
"👉 *{codigo_otp}*\n\n" +
"⏱️ _Válido por 5 minutos. No compartas este código con nadie._"
)
)
/**
* Sincroniza en background las plantillas registradas en la base de datos de la plataforma
*/
fun syncTemplates(context: Context) {
CoroutineScope(Dispatchers.IO).launch {
try {
val result = ApiClient.getMessageTemplates()
result.onSuccess { templatesMap ->
if (templatesMap.isNotEmpty()) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val editor = prefs.edit()
for ((id, content) in templatesMap) {
editor.putString("$PREFIX_TEMPLATE$id", content)
}
editor.apply()
}
}
} catch (_: Exception) {}
}
}
/**
* Renderiza una plantilla reemplazando todas las etiquetas {variable} por sus valores.
* Soporta alias comunes para máxima compatibilidad entre web y app móvil.
*/
fun renderTemplate(context: Context, templateId: String, variables: Map<String, String>): String {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
// Resolver posible alias (ej: advisor_contact -> general_store_inquiry)
val candidateIds = when (templateId) {
"advisor_contact" -> listOf("advisor_contact", "general_store_inquiry")
"general_store_inquiry" -> listOf("general_store_inquiry", "advisor_contact")
else -> listOf(templateId)
}
var rawContent: String? = null
for (id in candidateIds) {
val fromPrefs = prefs.getString("$PREFIX_TEMPLATE$id", null)
if (!fromPrefs.isNullOrBlank()) {
rawContent = fromPrefs
break
}
}
if (rawContent.isNullOrBlank()) {
for (id in candidateIds) {
if (DEFAULT_TEMPLATES.containsKey(id)) {
rawContent = DEFAULT_TEMPLATES[id]
break
}
}
}
if (rawContent.isNullOrBlank()) {
return ""
}
var rendered = rawContent ?: ""
val effectiveVars = variables.toMutableMap()
if (!effectiveVars.containsKey("tienda_nombre")) {
effectiveVars["tienda_nombre"] = "DripZone"
}
for ((key, value) in effectiveVars) {
rendered = rendered.replace("{$key}", value)
}
return rendered
}
/**
* Limpia caracteres de formato WhatsApp (*negrita*, _cursiva_, ~tachado~)
* para visualización limpia en notificaciones del sistema Android.
*/
fun stripMarkdown(text: String): String {
return text
.replace("\\*([^*]+)\\*".toRegex(), "$1")
.replace("_([^_]+)_".toRegex(), "$1")
.replace("~([^~]+)~".toRegex(), "$1")
}
}

View File

@ -0,0 +1,33 @@
package co.dripzone.app.model
import java.io.Serializable
data class Order(
val id: String = "",
val createdAt: String? = null,
val status: String = "pending",
val customerName: String = "",
val customerPhone: String = "",
val cityName: String = "",
val address: String = "",
val subtotal: Double = 0.0,
val shippingCost: Double = 0.0,
val totalAmount: Double = 0.0,
val depositAmount: Double = 0.0,
val depositPaid: Boolean = false,
val balanceDue: Double = 0.0,
val paymentMethodName: String? = null,
val depositPaymentLinkId: String? = null,
val boldCheckoutUrl: String? = null,
val items: List<OrderItem> = emptyList()
) : Serializable
data class OrderItem(
val productId: String? = null,
val productName: String = "",
val brand: String? = null,
val size: String = "",
val quantity: Int = 1,
val price: Double = 0.0,
val image: String? = null
) : Serializable

View File

@ -65,4 +65,16 @@ object UserManager {
val prefs = SecurityManager.getSecurePreferences(context)
prefs.edit().putBoolean(KEY_PHONE_VERIFIED, isVerified).apply()
}
fun clearUser(context: Context) {
val prefs = SecurityManager.getSecurePreferences(context)
prefs.edit()
.remove(KEY_USER_NAME)
.remove(KEY_USER_PHONE)
.remove(KEY_USER_CITY)
.remove(KEY_USER_ADDRESS)
.remove(KEY_PHONE_VERIFIED)
.remove(KEY_TOTAL_ORDERS)
.apply()
}
}

View File

@ -0,0 +1,182 @@
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.api.ApiClient
import co.dripzone.app.databinding.BottomSheetAccountBinding
import co.dripzone.app.model.UserManager
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import kotlinx.coroutines.launch
class AccountBottomSheet : BottomSheetDialogFragment() {
private var _binding: BottomSheetAccountBinding? = null
private val binding get() = _binding!!
private var pendingName = ""
private var pendingPhone = ""
private var pendingCity = ""
var onUserChangedListener: (() -> Unit)? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = BottomSheetAccountBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupListeners()
renderState()
}
private fun renderState() {
val user = UserManager.getUser(requireContext())
if (user.isPhoneVerified && user.phone.isNotBlank()) {
binding.layoutAccountLoggedIn.visibility = View.VISIBLE
binding.layoutAccountLoggedOut.visibility = View.GONE
binding.pbAccountLoading.visibility = View.GONE
binding.tvAccountUserName.text = user.name.ifBlank { "Cliente DripZone" }
binding.tvAccountUserPhone.text = "+57 ${user.phone} • Verificado ✅"
val loc = if (user.address.isNotBlank()) "${user.address} (${user.city})" else user.city
binding.tvAccountUserLocation.text = "📍 Ubicación: $loc"
} else {
binding.layoutAccountLoggedIn.visibility = View.GONE
binding.layoutAccountLoggedOut.visibility = View.VISIBLE
binding.layoutAccountForm.visibility = View.VISIBLE
binding.layoutAccountOtp.visibility = View.GONE
binding.pbAccountLoading.visibility = View.GONE
if (user.name.isNotBlank()) binding.etAccountName.setText(user.name)
if (user.phone.isNotBlank()) binding.etAccountPhone.setText(user.phone)
if (user.city.isNotBlank()) binding.etAccountCity.setText(user.city)
}
}
private fun setupListeners() {
binding.btnCloseAccount.setOnClickListener {
dismiss()
}
binding.btnAccountViewOrders.setOnClickListener {
dismiss()
OrdersBottomSheet().show(parentFragmentManager, OrdersBottomSheet.TAG)
}
binding.btnAccountLogout.setOnClickListener {
UserManager.clearUser(requireContext())
Toast.makeText(requireContext(), "Sesión cerrada correctamente", Toast.LENGTH_SHORT).show()
onUserChangedListener?.invoke()
renderState()
}
binding.btnAccountSendOtp.setOnClickListener {
handleSendOtp()
}
binding.btnAccountVerifyOtp.setOnClickListener {
handleVerifyOtp()
}
binding.tvAccountResendOtp.setOnClickListener {
handleSendOtp()
}
}
private fun handleSendOtp() {
val name = binding.etAccountName.text?.toString()?.trim() ?: ""
val rawPhone = binding.etAccountPhone.text?.toString()?.replace("\\D".toRegex(), "") ?: ""
val city = binding.etAccountCity.text?.toString()?.trim() ?: "Cali"
if (name.isBlank()) {
binding.etAccountName.error = "Ingresa tu nombre"
return
}
if (rawPhone.length < 10) {
binding.etAccountPhone.error = "Ingresa tu WhatsApp a 10 dígitos"
return
}
pendingName = name
pendingPhone = rawPhone
pendingCity = city
binding.pbAccountLoading.visibility = View.VISIBLE
binding.btnAccountSendOtp.isEnabled = false
lifecycleScope.launch {
val result = ApiClient.sendOtp(pendingPhone, pendingName)
binding.pbAccountLoading.visibility = View.GONE
binding.btnAccountSendOtp.isEnabled = true
result.onSuccess { msg ->
Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show()
binding.layoutAccountForm.visibility = View.GONE
binding.layoutAccountOtp.visibility = View.VISIBLE
val masked = if (pendingPhone.length >= 10) "${pendingPhone.take(3)}***${pendingPhone.takeLast(4)}" else pendingPhone
binding.tvAccountOtpInstruction.text = "Enviamos un código de 6 dígitos a tu WhatsApp (+57 $masked) para iniciar sesión:"
}.onFailure { err ->
Toast.makeText(requireContext(), "Error: ${err.message}", Toast.LENGTH_LONG).show()
}
}
}
private fun handleVerifyOtp() {
val code = binding.etAccountOtpCode.text?.toString()?.trim() ?: ""
if (code.length < 6) {
binding.etAccountOtpCode.error = "Ingresa los 6 dígitos recibidos"
return
}
binding.pbAccountLoading.visibility = View.VISIBLE
binding.btnAccountVerifyOtp.isEnabled = false
lifecycleScope.launch {
val result = ApiClient.verifyOtp(
phone = pendingPhone,
code = code,
name = pendingName,
city = pendingCity,
address = ""
)
binding.pbAccountLoading.visibility = View.GONE
binding.btnAccountVerifyOtp.isEnabled = true
result.onSuccess {
UserManager.saveUser(
context = requireContext(),
name = pendingName,
phone = pendingPhone,
city = pendingCity,
address = "",
isVerified = true
)
Toast.makeText(requireContext(), "¡Sesión iniciada con éxito! Bienvenido(a)", Toast.LENGTH_LONG).show()
onUserChangedListener?.invoke()
renderState()
}.onFailure { err ->
Toast.makeText(requireContext(), "Código inválido: ${err.message}", Toast.LENGTH_LONG).show()
}
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
companion object {
const val TAG = "AccountBottomSheet"
}
}

View File

@ -75,7 +75,19 @@ class CustomOrderBottomSheet : BottomSheetDialogFragment() {
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?"
val variables = mapOf(
"tienda_nombre" to "DripZone",
"cliente_nombre" to (customerName.ifBlank { "Cliente DripZone" }),
"marca" to (initialBrand.ifBlank { "Sneakers" }),
"modelo" to modelDesired,
"talla" to sizeDesired,
"zapatilla_nombre" to modelDesired,
"color_nombre" to (notes.ifBlank { "Original" }),
"ciudad" to (customerCity.ifBlank { "Colombia" })
)
val message = co.dripzone.app.manager.TemplateManager.renderTemplate(requireContext(), "product_request_size", variables).ifBlank {
co.dripzone.app.manager.TemplateManager.renderTemplate(requireContext(), "customer_request_received", variables)
}
SecurityManager.openWhatsApp(
requireContext(),
SecurityManager.WHATSAPP_STORE_PHONE,
@ -212,7 +224,18 @@ class CustomOrderBottomSheet : BottomSheetDialogFragment() {
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."
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 tienda ha sido notificada y te contactaremos por WhatsApp con la cotización exacta."
// Disparar notificación oficial en Android basada en la plantilla 'customer_request_received'
val customVars = mapOf(
"tienda_nombre" to "DripZone",
"cliente_nombre" to customerName,
"marca" to effectiveBrand,
"modelo" to modelDesired,
"talla" to sizeDesired,
"ciudad" to customerCity
)
co.dripzone.app.manager.AppNotificationManager.showCustomRequestNotification(requireContext(), customVars)
}.onFailure { err ->
binding.btnVerifyCustomOtp.isEnabled = true
Toast.makeText(requireContext(), "Error al registrar: ${err.message}", Toast.LENGTH_LONG).show()

View File

@ -111,14 +111,9 @@ class OrderVerificationBottomSheet : BottomSheetDialogFragment() {
dismiss()
}
binding.btnOpenWhatsAppChat.setOnClickListener {
val message = "¡Hola DripZone! Acabo de registrar mi pedido en la app oficial con la orden *$createdOrderId*. ¿Me confirman la programación del despacho contraentrega a ${selectedCity.name}?"
SecurityManager.openWhatsApp(
requireContext(),
SecurityManager.WHATSAPP_STORE_PHONE,
message
)
binding.btnViewOrderDetails.setOnClickListener {
dismiss()
OrdersBottomSheet().show(parentFragmentManager, OrdersBottomSheet.TAG)
}
}
@ -276,6 +271,26 @@ class OrderVerificationBottomSheet : BottomSheetDialogFragment() {
createdOrderId = orderId
UserManager.incrementCompletedOrders(requireContext())
// Disparar notificación oficial en Android basada en la plantilla registrada
val itemsSummary = items.joinToString("\n") { "• 👟 ${it.product.name} (Talla ${it.selectedSize}) x${it.quantity} — ${format.format(it.itemTotal)} COP" }
val orderVars = mapOf(
"tienda_nombre" to "DripZone",
"orden_id" to orderId.takeLast(8).uppercase(),
"cliente_nombre" to customerName,
"cliente_telefono" to customerPhone,
"cliente_direccion" to customerAddress,
"cliente_ciudad" to selectedCity.name,
"articulos_resumen" to itemsSummary,
"subtotal" to "${format.format(subtotal)} COP",
"costo_envio" to (if (selectedCity.shippingCost <= 0) "GRATIS" else "${format.format(selectedCity.shippingCost)} COP"),
"total" to "${format.format(total)} COP",
"condicion_pago_resumen" to (if (requiresDeposit) "⚡ Separación: $10.000 COP • Saldo Contraentrega: ${format.format(balanceDue)} COP" else "🤝 100% Pago Contraentrega"),
"metodo_pago" to (if (requiresDeposit) "Separación $10.000 + Contraentrega" else "100% Contraentrega"),
"saldo_contraentrega" to "${format.format(balanceDue)} COP",
"anticipo" to (if (requiresDeposit) "$10.000 COP" else "$0 COP")
)
co.dripzone.app.manager.AppNotificationManager.showOrderPlacedNotification(requireContext(), orderId, orderVars)
// Limpiar carrito
if (!isDirectProductOrder) {
CartManager.clear(requireContext())
@ -308,7 +323,7 @@ class OrderVerificationBottomSheet : BottomSheetDialogFragment() {
}
// Configurar botón para abrir pasarela de pago Bold
val checkoutUrl = if (boldData.checkoutUrl.isNotBlank()) boldData.checkoutUrl else "https://bold.co/p/carlos-lasso"
val checkoutUrl = if (boldData.checkoutUrl.isNotBlank()) boldData.checkoutUrl else "https://drip-zone.online"
binding.btnPaySeparationBold.setOnClickListener {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(checkoutUrl))
startActivity(intent)
@ -326,34 +341,43 @@ class OrderVerificationBottomSheet : BottomSheetDialogFragment() {
binding.layoutBoldPaidSuccess.visibility = View.VISIBLE
binding.tvSuccessMessage.text = "¡Anticipo de $10.000 COP recibido y confirmado por Bold! Tu pedido ha sido programado para despacho inmediato."
Toast.makeText(requireContext(), "🎉 ¡Anticipo verificado en tiempo real con Bold!", Toast.LENGTH_LONG).show()
// Disparar notificación oficial de anticipo confirmado
val depositVars = mapOf(
"tienda_nombre" to "DripZone",
"cliente_nombre" to customerName,
"orden_id" to orderId.takeLast(8).uppercase(),
"valor_anticipo" to "$10.000 COP",
"cliente_ciudad" to selectedCity.name,
"saldo_contraentrega" to "${format.format(balanceDue)} COP"
)
co.dripzone.app.manager.AppNotificationManager.showDepositConfirmedNotification(requireContext(), orderId, depositVars)
break
}
}
}
}.onFailure {
binding.btnPaySeparationBold.setOnClickListener {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://bold.co/p/carlos-lasso"))
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://drip-zone.online"))
startActivity(intent)
}
}
}
binding.btnOpenWhatsAppChat.text = "Notificar Pago a Carlos en WhatsApp"
binding.btnOpenWhatsAppChat.setOnClickListener {
val message = "¡Hola Carlos! Acabo de registrar mi pedido en DripZone con la orden *$createdOrderId*. Voy a cancelar la separación de $10.000 COP para coordinar el despacho a ${selectedCity.name}. Saldo al recibir: ${format.format(balanceDue)} COP."
SecurityManager.openWhatsApp(requireContext(), SecurityManager.WHATSAPP_STORE_PHONE, message)
binding.btnViewOrderDetails.text = "Ver Mis Pedidos 📦"
binding.btnViewOrderDetails.setOnClickListener {
dismiss()
OrdersBottomSheet().show(parentFragmentManager, OrdersBottomSheet.TAG)
}
} else {
binding.tvSuccessMessage.text = "¡Tu número de WhatsApp (+57 $customerPhone) fue validado y tu pedido fue registrado en la plataforma!\n\n⭐ BENEFICIO CLIENTE FRECUENTE:\nComo ya realizaste compras satisfactoriamente, estás exento de separación y pagarás el 100% (${format.format(total)} COP) contraentrega al recibir tu paquete."
binding.layoutBoldQrContainer.visibility = View.GONE
binding.layoutBoldPaidSuccess.visibility = View.GONE
binding.btnPaySeparationBold.visibility = View.GONE
binding.btnOpenWhatsAppChat.text = "Coordinar Entrega por WhatsApp"
binding.btnOpenWhatsAppChat.setOnClickListener {
val message = "¡Hola Carlos! Acabo de registrar mi orden *$createdOrderId* en DripZone con 100% Contraentrega a ${selectedCity.name}. ¿Me confirmas la programación del despacho?"
SecurityManager.openWhatsApp(requireContext(), SecurityManager.WHATSAPP_STORE_PHONE, message)
binding.btnViewOrderDetails.text = "Ver Mis Pedidos 📦"
binding.btnViewOrderDetails.setOnClickListener {
dismiss()
OrdersBottomSheet().show(parentFragmentManager, OrdersBottomSheet.TAG)
}
}
}.onFailure { err ->

View File

@ -0,0 +1,192 @@
package co.dripzone.app.ui
import android.content.Context
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import co.dripzone.app.R
import co.dripzone.app.databinding.ItemOrderCardBinding
import co.dripzone.app.model.Order
import java.text.NumberFormat
import java.text.SimpleDateFormat
import java.util.Locale
class OrdersAdapter(
private var orders: List<Order>,
private val onPayDepositClicked: (Order) -> Unit,
private val onInquireStatusClicked: (Order) -> Unit
) : RecyclerView.Adapter<OrdersAdapter.OrderViewHolder>() {
private val copFormat = NumberFormat.getCurrencyInstance(Locale("es", "CO")).apply {
maximumFractionDigits = 0
}
fun submitList(newOrders: List<Order>) {
orders = newOrders
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): OrderViewHolder {
val binding = ItemOrderCardBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return OrderViewHolder(binding)
}
override fun onBindViewHolder(holder: OrderViewHolder, position: Int) {
holder.bind(orders[position])
}
override fun getItemCount(): Int = orders.size
inner class OrderViewHolder(private val binding: ItemOrderCardBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(order: Order) {
val context = binding.root.context
// 1. Order ID & Date
binding.tvCardOrderId.text = "Orden #${order.id.takeLast(8).uppercase()}"
binding.tvCardOrderDate.text = formatOrderDate(order.createdAt)
// 2. Status Badge
bindStatusBadge(order.status)
// 3. Products list (Dynamic injection)
binding.llOrderItemsContainer.removeAllViews()
for (item in order.items) {
val itemView = createProductItemView(context, item.productName, item.size, item.quantity, item.price)
binding.llOrderItemsContainer.addView(itemView)
}
// 4. Financial Breakdown
binding.tvCardSubtotal.text = formatCop(order.subtotal)
binding.tvCardShippingCost.text = if (order.shippingCost <= 0) "GRATIS" else formatCop(order.shippingCost)
binding.tvCardTotal.text = formatCop(order.totalAmount)
// 5. Deposit Box
val hasDepositPaid = order.depositAmount > 0 && order.status != "pending"
if (hasDepositPaid) {
binding.tvDepositStatusLabel.text = "${formatCop(order.depositAmount)} • Acreditado ✅"
binding.tvDepositStatusLabel.setTextColor(Color.parseColor("#22C55E"))
binding.btnPayDepositBold.visibility = View.GONE
} else {
binding.tvDepositStatusLabel.text = "$10.000 COP • Pendiente ⏳"
binding.tvDepositStatusLabel.setTextColor(Color.parseColor("#F59E0B"))
binding.btnPayDepositBold.visibility = View.VISIBLE
binding.btnPayDepositBold.setOnClickListener {
onPayDepositClicked(order)
}
}
// 6. Saldo Contraentrega
val balance = if (order.balanceDue > 0) order.balanceDue else (order.totalAmount - order.depositAmount)
binding.tvCardBalanceDue.text = "${formatCop(balance)} COP"
// 7. Address
binding.tvCardAddress.text = "📍 Entrega en: ${order.address} (${order.cityName})"
// 8. WhatsApp Status Inquiry
binding.btnOrderWhatsAppStatus.setOnClickListener {
onInquireStatusClicked(order)
}
}
private fun bindStatusBadge(status: String?) {
val (badgeText, badgeColor) = when ((status ?: "pending").lowercase()) {
"deposit_received" -> "⚡ Anticipo Acreditado" to "#22C55E"
"pending" -> "⏳ Anticipo Pendiente" to "#F59E0B"
"processing" -> "📦 En Preparación" to "#3B82F6"
"shipped" -> "🚚 Despachado / En Ruta" to "#A855F7"
"delivered" -> "✅ Entregado con Éxito" to "#10B981"
"cancelled", "rejected" -> "❌ Pedido Cancelado" to "#EF4444"
else -> "📋 Registrado" to "#8B5CF6"
}
binding.tvCardOrderStatusBadge.text = badgeText
binding.tvCardOrderStatusBadge.setTextColor(Color.parseColor(badgeColor))
}
private fun createProductItemView(
context: Context,
name: String,
size: String,
quantity: Int,
price: Double
): View {
val row = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
setMargins(0, 4, 0, 4)
}
}
val icon = ImageView(context).apply {
setImageResource(R.drawable.ic_package)
setColorFilter(Color.parseColor("#8B5CF6"))
layoutParams = LinearLayout.LayoutParams(36, 36).apply {
marginEnd = 16
}
}
val detailsLayout = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
val tvTitle = TextView(context).apply {
text = name
textSize = 13f
setTextColor(Color.parseColor("#FFFFFF"))
typeface = android.graphics.Typeface.DEFAULT_BOLD
}
val tvSub = TextView(context).apply {
text = "Talla: $size • Cantidad: $quantity"
textSize = 11f
setTextColor(Color.parseColor("#94A3B8"))
}
detailsLayout.addView(tvTitle)
detailsLayout.addView(tvSub)
val tvPrice = TextView(context).apply {
text = formatCop(price * quantity)
textSize = 13f
setTextColor(Color.parseColor("#FFFFFF"))
typeface = android.graphics.Typeface.DEFAULT_BOLD
}
row.addView(icon)
row.addView(detailsLayout)
row.addView(tvPrice)
return row
}
private fun formatCop(value: Double): String {
return copFormat.format(value)
}
private fun formatOrderDate(isoDate: String?): String {
if (isoDate.isNullOrBlank()) return "Reciente"
return try {
val inputFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault())
val outputFormat = SimpleDateFormat("dd MMM yyyy, hh:mm a", Locale("es", "CO"))
val date = inputFormat.parse(isoDate.take(19))
if (date != null) outputFormat.format(date) else isoDate.take(10)
} catch (_: Exception) {
isoDate.take(10)
}
}
}
}

View File

@ -0,0 +1,159 @@
package co.dripzone.app.ui
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import co.dripzone.app.api.ApiClient
import co.dripzone.app.databinding.BottomSheetOrdersBinding
import co.dripzone.app.manager.TemplateManager
import co.dripzone.app.model.Order
import co.dripzone.app.model.UserManager
import co.dripzone.app.security.SecurityManager
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import kotlinx.coroutines.launch
class OrdersBottomSheet : BottomSheetDialogFragment() {
private var _binding: BottomSheetOrdersBinding? = null
private val binding get() = _binding!!
private lateinit var ordersAdapter: OrdersAdapter
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = BottomSheetOrdersBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupRecyclerView()
setupListeners()
loadOrders()
}
private fun setupRecyclerView() {
ordersAdapter = OrdersAdapter(
orders = emptyList(),
onPayDepositClicked = { order ->
handlePayDeposit(order)
},
onInquireStatusClicked = { order ->
handleInquireWhatsApp(order)
}
)
binding.rvOrders.apply {
layoutManager = LinearLayoutManager(requireContext())
adapter = ordersAdapter
}
}
private fun setupListeners() {
binding.btnCloseOrders.setOnClickListener {
dismiss()
}
binding.btnExploreCatalog.setOnClickListener {
dismiss()
}
}
private fun loadOrders() {
val user = UserManager.getUser(requireContext())
val phone = user.phone.replace("\\D".toRegex(), "")
if (phone.isBlank()) {
binding.pbOrdersLoading.visibility = View.GONE
binding.layoutEmptyOrders.visibility = View.VISIBLE
binding.tvOrdersUserPhone.text = "Inicia sesión para consultar tus pedidos"
binding.tvEmptyOrdersMessage.text = "Ingresa a 'Mi Cuenta' con tu número de WhatsApp para ver tu historial de compras y hacer seguimiento en tiempo real."
binding.btnExploreCatalog.text = "Iniciar Sesión / Mi Cuenta 🔐"
binding.btnExploreCatalog.setOnClickListener {
dismiss()
AccountBottomSheet().show(parentFragmentManager, AccountBottomSheet.TAG)
}
return
}
val masked = if (phone.length >= 10) "${phone.take(3)}***${phone.takeLast(4)}" else phone
binding.tvOrdersUserPhone.text = "Pedidos registrados para: +57 $masked"
binding.pbOrdersLoading.visibility = View.VISIBLE
binding.rvOrders.visibility = View.GONE
binding.layoutEmptyOrders.visibility = View.GONE
lifecycleScope.launch {
val result = ApiClient.getOrdersByPhone(phone)
binding.pbOrdersLoading.visibility = View.GONE
result.onSuccess { orders ->
if (orders.isEmpty()) {
binding.layoutEmptyOrders.visibility = View.VISIBLE
binding.rvOrders.visibility = View.GONE
binding.tvEmptyOrdersMessage.text = "No encontramos pedidos registrados para el número +57 $masked."
binding.btnExploreCatalog.text = "Explorar Catálogo 🔥"
binding.btnExploreCatalog.setOnClickListener { dismiss() }
} else {
binding.layoutEmptyOrders.visibility = View.GONE
binding.rvOrders.visibility = View.VISIBLE
ordersAdapter.submitList(orders)
}
}.onFailure { err ->
binding.layoutEmptyOrders.visibility = View.VISIBLE
binding.tvEmptyOrdersMessage.text = "Error consultando pedidos: ${err.message}"
}
}
}
private fun handlePayDeposit(order: Order) {
val boldUrl = order.boldCheckoutUrl ?: "https://drip-zone.online"
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(boldUrl))
startActivity(intent)
} catch (_: Exception) {
Toast.makeText(requireContext(), "No se pudo abrir la pasarela Bold", Toast.LENGTH_SHORT).show()
}
}
private fun handleInquireWhatsApp(order: Order) {
val user = UserManager.getUser(requireContext())
val itemsSummary = order.items.joinToString(", ") { "${it.productName} (Talla ${it.size})" }
val variables = mapOf(
"tienda_nombre" to "DripZone",
"orden_id" to order.id.takeLast(8).uppercase(),
"cliente_nombre" to (user.name.ifBlank { "Cliente DripZone" }),
"cliente_telefono" to user.phone,
"cliente_direccion" to order.address,
"cliente_ciudad" to order.cityName,
"articulos_resumen" to itemsSummary,
"subtotal" to "$${order.subtotal.toInt()}",
"costo_envio" to (if (order.shippingCost <= 0) "GRATIS" else "$${order.shippingCost.toInt()}"),
"total" to "$${order.totalAmount.toInt()}",
"condicion_pago_resumen" to "Anticipo: $${order.depositAmount.toInt()} • Saldo: $${order.balanceDue.toInt()}",
"metodo_pago" to (order.paymentMethodName ?: "Contraentrega")
)
val message = TemplateManager.renderTemplate(requireContext(), "order_checkout_wa", variables)
SecurityManager.openWhatsApp(requireContext(), SecurityManager.WHATSAPP_STORE_PHONE, message)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
companion object {
const val TAG = "OrdersBottomSheet"
}
}

View File

@ -78,6 +78,22 @@ class ProductDetailBottomSheet : BottomSheetDialogFragment() {
dismiss()
}
// Order or Inquire directly via WhatsApp using registered product_direct_inquiry template
binding.btnOrderWhatsAppDirect.setOnClickListener {
val sizeToOrder = if (selectedSize.isNotBlank()) selectedSize else sizeAdapter?.getSelectedSize() ?: "38-43"
val variables = mapOf(
"tienda_nombre" to "DripZone",
"zapatilla_nombre" to p.name,
"color_nombre" to "Estilo Oficial",
"talla" to sizeToOrder,
"precio" to p.formattedPrice(),
"ciudad" to "Colombia",
"marca" to p.brand
)
val message = co.dripzone.app.manager.TemplateManager.renderTemplate(requireContext(), "product_direct_inquiry", variables)
SecurityManager.openWhatsApp(requireContext(), SecurityManager.WHATSAPP_STORE_PHONE, message)
}
// Custom order button inside bottom sheet
binding.btnCustomOrderSheet.setOnClickListener {
dismiss()

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/bg_card" />
<corners android:radius="12dp" />
<stroke android:width="1dp" android:color="@color/bg_card_stroke" />
</shape>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#2E1065" />
<corners android:radius="12dp" />
<stroke android:width="1dp" android:color="#581C87" />
</shape>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M16.5,2.75L7.5,2.75C4.88,2.75 2.75,4.88 2.75,7.5L2.75,16.5C2.75,19.12 4.88,21.25 7.5,21.25L16.5,21.25C19.12,21.25 21.25,19.12 21.25,16.5L21.25,7.5C21.25,4.88 19.12,2.75 16.5,2.75ZM20,13.5L16.83,13.5C16.32,13.5 15.86,13.82 15.69,14.3C15.11,15.93 13.68,17 12,17C10.32,17 8.89,15.93 8.31,14.3C8.14,13.82 7.68,13.5 7.17,13.5L4,13.5L4,7.5C4,5.57 5.57,4 7.5,4L16.5,4C18.43,4 20,5.57 20,7.5L20,13.5Z" />
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z" />
</vector>

View File

@ -136,9 +136,9 @@
android:visibility="gone" />
</FrameLayout>
<!-- Header WhatsApp Button -->
<!-- Header My Account Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnHeaderWhatsApp"
android:id="@+id/btnHeaderAccount"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:layout_marginStart="6dp"
@ -146,13 +146,14 @@
android:insetTop="0dp"
android:insetBottom="0dp"
android:paddingHorizontal="10dp"
android:text="Asesor"
android:text="Mi Cuenta"
android:textAllCaps="false"
android:textColor="#FFFFFF"
android:textSize="11sp"
android:textStyle="bold"
app:backgroundTint="@color/accent_whatsapp"
app:backgroundTint="@color/drip_purple"
app:cornerRadius="10dp"
app:icon="@drawable/ic_whatsapp"
app:icon="@drawable/ic_user"
app:iconGravity="textStart"
app:iconPadding="4dp"
app:iconSize="14dp" />
@ -463,9 +464,9 @@
android:textSize="10sp" />
</LinearLayout>
<!-- 4. Encargos Especiales -->
<!-- 4. Mis Pedidos (Rastreo y Detalle) -->
<LinearLayout
android:id="@+id/navBtnEncargos"
android:id="@+id/navBtnOrders"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
@ -475,19 +476,19 @@
android:orientation="vertical">
<ImageView
android:id="@+id/ivNavEncargos"
android:id="@+id/ivNavOrders"
android:layout_width="22dp"
android:layout_height="22dp"
android:src="@drawable/ic_encargos"
android:src="@drawable/ic_package"
app:tint="@color/text_secondary" />
<TextView
android:id="@+id/tvNavEncargos"
android:id="@+id/tvNavOrders"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:fontFamily="sans-serif-medium"
android:text="Encargos"
android:text="Mis Pedidos"
android:textColor="@color/text_secondary"
android:textSize="10sp" />
</LinearLayout>

View File

@ -0,0 +1,353 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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: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"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-black"
android:text="👤 Mi Cuenta"
android:textColor="@color/text_primary"
android:textSize="20sp" />
<TextView
android:id="@+id/tvAccountSubtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="Perfil y verificación de cliente DripZone"
android:textColor="@color/text_secondary"
android:textSize="13sp" />
</LinearLayout>
<ImageButton
android:id="@+id/btnCloseAccount"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_close"
app:tint="@color/text_secondary" />
</RelativeLayout>
<!-- Progress Bar -->
<ProgressBar
android:id="@+id/pbAccountLoading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginVertical="16dp"
android:indeterminateTint="@color/accent_purple"
android:visibility="gone" />
<!-- ========================================== -->
<!-- STATE 1: LOGGED IN PROFILE -->
<!-- ========================================== -->
<LinearLayout
android:id="@+id/layoutAccountLoggedIn"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone"
tools:visibility="visible">
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardBackgroundColor="@color/bg_card"
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
app:strokeColor="@color/border_subtle"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp">
<ImageView
android:id="@+id/ivAccountAvatar"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:background="@drawable/bg_category_chip_active"
android:padding="12dp"
android:src="@drawable/ic_user"
app:tint="#FFFFFF" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_marginStart="12dp"
android:layout_toEndOf="@id/ivAccountAvatar"
android:orientation="vertical">
<TextView
android:id="@+id/tvAccountUserName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-black"
android:textColor="@color/text_primary"
android:textSize="16sp"
tools:text="Carlos Lasso" />
<TextView
android:id="@+id/tvAccountUserPhone"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:drawableStart="@drawable/ic_whatsapp"
android:drawablePadding="4dp"
android:drawableTint="@color/accent_whatsapp"
android:textColor="@color/accent_whatsapp"
android:textSize="12sp"
tools:text="+57 310 639 0296 • Verificado" />
</LinearLayout>
</RelativeLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginBottom="10dp"
android:background="#1AFFFFFF" />
<TextView
android:id="@+id/tvAccountUserLocation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/text_secondary"
android:textSize="12sp"
tools:text="📍 Entrega habitual: Calle 5 # 24-80 (Cali)" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Button: Ver Mis Pedidos -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAccountViewOrders"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginBottom="10dp"
android:fontFamily="sans-serif-bold"
android:text="📦 Ver Mis Pedidos"
android:textSize="14sp"
app:backgroundTint="@color/accent_purple"
app:cornerRadius="12dp"
app:icon="@drawable/ic_package"
app:iconGravity="textStart" />
<!-- Button: Cerrar Sesión -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAccountLogout"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="44dp"
android:fontFamily="sans-serif-medium"
android:text="Cerrar Sesión"
android:textColor="@color/text_secondary"
android:textSize="13sp"
app:cornerRadius="12dp"
app:strokeColor="@color/border_subtle" />
</LinearLayout>
<!-- ========================================== -->
<!-- STATE 2: NOT LOGGED IN / FORM -->
<!-- ========================================== -->
<LinearLayout
android:id="@+id/layoutAccountLoggedOut"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- Form Step 1: Input Details -->
<LinearLayout
android:id="@+id/layoutAccountForm"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="Ingresa tu número de WhatsApp para autenticarte sin contraseña, validar tus compras contraentrega y consultar tus pedidos."
android:textColor="@color/text_secondary"
android:textSize="13sp" />
<!-- Nombre Completo -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="6dp"
android:fontFamily="sans-serif-medium"
android:text="Tu Nombre Completo *"
android:textColor="@color/text_primary"
android:textSize="13sp" />
<EditText
android:id="@+id/etAccountName"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginBottom="14dp"
android:background="@drawable/bg_category_chip"
android:hint="Ej: Juan Pérez"
android:inputType="textPersonName"
android:paddingHorizontal="14dp"
android:textColor="@color/text_primary"
android:textColorHint="@color/text_secondary"
android:textSize="14sp" />
<!-- Celular WhatsApp -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="6dp"
android:fontFamily="sans-serif-medium"
android:text="Tu Celular de WhatsApp (10 dígitos) *"
android:textColor="@color/text_primary"
android:textSize="13sp" />
<EditText
android:id="@+id/etAccountPhone"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginBottom="14dp"
android:background="@drawable/bg_category_chip"
android:hint="Ej: 3106390296"
android:inputType="phone"
android:maxLength="10"
android:paddingHorizontal="14dp"
android:textColor="@color/text_primary"
android:textColorHint="@color/text_secondary"
android:textSize="14sp" />
<!-- Ciudad -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="6dp"
android:fontFamily="sans-serif-medium"
android:text="Ciudad de Residencia"
android:textColor="@color/text_primary"
android:textSize="13sp" />
<EditText
android:id="@+id/etAccountCity"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginBottom="20dp"
android:background="@drawable/bg_category_chip"
android:hint="Ej: Cali, Medellín, Bogotá"
android:inputType="textCapWords"
android:paddingHorizontal="14dp"
android:textColor="@color/text_primary"
android:textColorHint="@color/text_secondary"
android:textSize="14sp" />
<!-- Enviar OTP Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAccountSendOtp"
android:layout_width="match_parent"
android:layout_height="50dp"
android:fontFamily="sans-serif-bold"
android:text="Continuar con WhatsApp 💬"
android:textSize="14sp"
app:backgroundTint="@color/accent_purple"
app:cornerRadius="12dp" />
</LinearLayout>
<!-- Form Step 2: OTP Verification -->
<LinearLayout
android:id="@+id/layoutAccountOtp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<TextView
android:id="@+id/tvAccountOtpInstruction"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="Enviamos un código de seguridad de 6 dígitos a tu WhatsApp para verificar tu cuenta:"
android:textColor="@color/text_secondary"
android:textSize="13sp" />
<EditText
android:id="@+id/etAccountOtpCode"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_marginBottom="16dp"
android:background="@drawable/bg_category_chip"
android:gravity="center"
android:hint="------"
android:inputType="number"
android:letterSpacing="0.3"
android:maxLength="6"
android:textColor="@color/text_primary"
android:textColorHint="@color/text_secondary"
android:textSize="22sp"
android:textStyle="bold" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAccountVerifyOtp"
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginBottom="10dp"
android:fontFamily="sans-serif-bold"
android:text="Verificar e Ingresar 🔐"
android:textSize="14sp"
app:backgroundTint="@color/accent_whatsapp"
app:cornerRadius="12dp" />
<TextView
android:id="@+id/tvAccountResendOtp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="6dp"
android:fontFamily="sans-serif-medium"
android:padding="8dp"
android:text="¿No recibiste el código? Reenviar por WhatsApp"
android:textColor="@color/accent_purple"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>

View File

@ -78,7 +78,7 @@
android:layout_height="46dp"
android:layout_marginTop="6dp"
android:background="@drawable/bg_search_input"
android:hint="Ej. Juan Carlos Pérez"
android:hint="Ej. Juan Gómez"
android:inputType="textPersonName"
android:paddingHorizontal="14dp"
android:textColor="@color/text_primary"
@ -319,7 +319,7 @@
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:text="Tu número fue verificado con éxito y tu pedido ya está registrado en nuestra plataforma.\n\nLa tienda ha sido notificada automáticamente y recibirás confirmación oficial a tu WhatsApp."
android:textColor="@color/text_secondary"
android:textSize="12sp" />
@ -440,19 +440,19 @@
app:iconSize="18dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnOpenWhatsAppChat"
android:id="@+id/btnViewOrderDetails"
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginTop="10dp"
android:filterTouchesWhenObscured="true"
android:text="Notificar Pago por WhatsApp"
android:text="Ver Estado de Mi Pedido"
android:textAllCaps="false"
android:textColor="#FFFFFF"
android:textSize="13sp"
android:textStyle="bold"
app:backgroundTint="@color/accent_whatsapp"
app:backgroundTint="@color/drip_purple"
app:cornerRadius="14dp"
app:icon="@drawable/ic_whatsapp"
app:icon="@drawable/ic_package"
app:iconGravity="textStart"
app:iconPadding="8dp"
app:iconSize="18dp" />

View File

@ -0,0 +1,148 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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: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"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-black"
android:text="📦 Mis Pedidos"
android:textColor="@color/text_primary"
android:textSize="20sp" />
<TextView
android:id="@+id/tvOrdersSubtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="Historial y estado de entrega en tiempo real"
android:textColor="@color/text_secondary"
android:textSize="13sp" />
</LinearLayout>
<ImageButton
android:id="@+id/btnCloseOrders"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_close"
app:tint="@color/text_secondary" />
</RelativeLayout>
<!-- User indicator chip -->
<TextView
android:id="@+id/tvOrdersUserPhone"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:background="@drawable/bg_category_chip"
android:drawableStart="@drawable/ic_whatsapp"
android:drawablePadding="6dp"
android:drawableTint="@color/accent_whatsapp"
android:paddingHorizontal="12dp"
android:paddingVertical="6dp"
android:textColor="@color/text_secondary"
android:textSize="12sp"
tools:text="Consultando para: +57 310 639 0296" />
<!-- Loading -->
<ProgressBar
android:id="@+id/pbOrdersLoading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginVertical="32dp"
android:indeterminateTint="@color/accent_purple"
android:visibility="visible" />
<!-- Empty State -->
<LinearLayout
android:id="@+id/layoutEmptyOrders"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical"
android:paddingVertical="32dp"
android:visibility="gone"
tools:visibility="gone">
<ImageView
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_marginBottom="16dp"
android:src="@drawable/ic_package"
app:tint="@color/border_subtle" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-black"
android:text="No encontramos pedidos aún"
android:textColor="@color/text_primary"
android:textSize="16sp" />
<TextView
android:id="@+id/tvEmptyOrdersMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:gravity="center"
android:paddingHorizontal="24dp"
android:text="Tus órdenes registradas por la app web o móvil aparecerán aquí con su estado de alistamiento y flete."
android:textColor="@color/text_secondary"
android:textSize="13sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnExploreCatalog"
android:layout_width="wrap_content"
android:layout_height="44dp"
android:layout_marginTop="20dp"
android:fontFamily="sans-serif-bold"
android:text="Explorar Catálogo 🔥"
android:textSize="13sp"
app:backgroundTint="@color/accent_purple"
app:cornerRadius="10dp" />
</LinearLayout>
<!-- RecyclerView for Orders -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvOrders"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:nestedScrollingEnabled="false"
android:visibility="gone"
tools:itemCount="2"
tools:listitem="@layout/item_order_card"
tools:visibility="visible" />
</LinearLayout>

View File

@ -170,6 +170,27 @@
app:iconPadding="8dp"
app:iconSize="20dp" />
<!-- Pedir o Consultar por WhatsApp Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnOrderWhatsAppDirect"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="10dp"
android:filterTouchesWhenObscured="true"
android:text="Pedir o Consultar por WhatsApp"
android:textAllCaps="false"
android:textColor="#22C55E"
android:textSize="13sp"
app:cornerRadius="14dp"
app:icon="@drawable/ic_whatsapp"
app:iconGravity="textStart"
app:iconPadding="8dp"
app:iconSize="18dp"
app:iconTint="#22C55E"
app:strokeColor="#22C55E"
app:strokeWidth="1dp" />
<!-- Secondary Custom Request Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnCustomOrderSheet"

View File

@ -74,7 +74,7 @@
android:layout_height="46dp"
android:layout_marginTop="4dp"
android:background="@drawable/bg_search_input"
android:hint="Ej. Carlos Mendoza"
android:hint="Ej. Andrés Morales"
android:inputType="textPersonName"
android:paddingHorizontal="14dp"
android:textColor="@color/text_primary"
@ -326,7 +326,7 @@
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:text="Hemos validado tu número de WhatsApp y registrado tu solicitud en la plataforma.\n\nLa tienda ha sido notificada y te contactaremos por WhatsApp con la cotización exacta."
android:textColor="@color/text_secondary"
android:textSize="12sp" />
@ -336,7 +336,7 @@
android:layout_height="50dp"
android:layout_marginTop="18dp"
android:filterTouchesWhenObscured="true"
android:text="Abrir WhatsApp del Asesor Carlos"
android:text="Contactar a Soporte DripZone"
android:textAllCaps="false"
android:textColor="#FFFFFF"
android:textSize="13sp"

View File

@ -0,0 +1,302 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView 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:layout_marginBottom="16dp"
app:cardBackgroundColor="@color/bg_card"
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
app:strokeColor="@color/border_subtle"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- 1. Header: Order ID, Date, Status Badge -->
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:orientation="vertical">
<TextView
android:id="@+id/tvCardOrderId"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-black"
android:textColor="@color/text_primary"
android:textSize="15sp"
tools:text="#ORD-88421" />
<TextView
android:id="@+id/tvCardOrderDate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="@color/text_secondary"
android:textSize="12sp"
tools:text="05 Sep 2026, 04:30 PM" />
</LinearLayout>
<TextView
android:id="@+id/tvCardOrderStatusBadge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:background="@drawable/bg_category_chip_active"
android:fontFamily="sans-serif-bold"
android:paddingHorizontal="10dp"
android:paddingVertical="4dp"
android:textColor="@color/text_primary"
android:textSize="11sp"
tools:text="⚡ Anticipo Acreditado" />
</RelativeLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginBottom="12dp"
android:background="#1AFFFFFF" />
<!-- 2. Products Container (Dynamic list of items) -->
<LinearLayout
android:id="@+id/llOrderItemsContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginVertical="10dp"
android:background="#1AFFFFFF" />
<!-- 3. Financial Breakdown -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Subtotal:"
android:textColor="@color/text_secondary"
android:textSize="13sp" />
<TextView
android:id="@+id/tvCardSubtotal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:textColor="@color/text_primary"
android:textSize="13sp"
tools:text="$125.000 COP" />
</RelativeLayout>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="6dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Envío a Domicilio:"
android:textColor="@color/text_secondary"
android:textSize="13sp" />
<TextView
android:id="@+id/tvCardShippingCost"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:textColor="@color/accent_purple"
android:textSize="13sp"
tools:text="GRATIS" />
</RelativeLayout>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-bold"
android:text="Total del Pedido:"
android:textColor="@color/text_primary"
android:textSize="14sp" />
<TextView
android:id="@+id/tvCardTotal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:fontFamily="sans-serif-black"
android:textColor="@color/text_primary"
android:textSize="15sp"
tools:text="$125.000 COP" />
</RelativeLayout>
</LinearLayout>
<!-- 4. Deposit Card / Bold Box -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardDepositBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
app:cardBackgroundColor="#1AFFFFFF"
app:cardCornerRadius="10dp"
app:cardElevation="0dp"
app:strokeColor="#26FFFFFF"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="10dp">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/tvDepositTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-medium"
android:text="⚡ Anticipo / Separación:"
android:textColor="@color/text_secondary"
android:textSize="12sp" />
<TextView
android:id="@+id/tvDepositStatusLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:fontFamily="sans-serif-bold"
android:textColor="@color/accent_whatsapp"
android:textSize="12sp"
tools:text="$10.000 COP • Acreditado ✅" />
</RelativeLayout>
<!-- Button to pay deposit in Bold if pending -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnPayDepositBold"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginTop="8dp"
android:fontFamily="sans-serif-bold"
android:text="💳 Pagar Anticipo ($10.000) en Bold"
android:textSize="12sp"
android:visibility="gone"
app:backgroundTint="@color/accent_purple"
app:cornerRadius="8dp"
tools:visibility="visible" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- 5. Saldo Contraentrega Highlight Banner -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
app:cardBackgroundColor="#268B5CF6"
app:cardCornerRadius="10dp"
app:cardElevation="0dp"
app:strokeColor="#4D8B5CF6"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp">
<ImageView
android:layout_width="22dp"
android:layout_height="22dp"
android:layout_gravity="center_vertical"
android:layout_marginEnd="10dp"
android:src="@drawable/ic_package"
app:tint="@color/accent_purple" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-medium"
android:text="Saldo a Pagar Contraentrega en Efectivo:"
android:textColor="#D8B4FE"
android:textSize="11sp" />
<TextView
android:id="@+id/tvCardBalanceDue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-black"
android:textColor="#FFFFFF"
android:textSize="16sp"
tools:text="$115.000 COP" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- 6. Delivery Address -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:orientation="horizontal">
<TextView
android:id="@+id/tvCardAddress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/text_secondary"
android:textSize="12sp"
tools:text="📍 Entrega en: Calle 5 # 24-80 (Cali)" />
</LinearLayout>
<!-- 7. Action Button: WhatsApp inquiry -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnOrderWhatsAppStatus"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="44dp"
android:fontFamily="sans-serif-bold"
android:text="Consultar Estado en WhatsApp"
android:textColor="@color/accent_whatsapp"
android:textSize="13sp"
app:cornerRadius="10dp"
app:icon="@drawable/ic_whatsapp"
app:iconGravity="textStart"
app:iconTint="@color/accent_whatsapp"
app:strokeColor="@color/accent_whatsapp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@ -94,44 +94,39 @@
android:textColor="@color/text_secondary"
android:textSize="11sp" />
<!-- Price & Action Button -->
<LinearLayout
<!-- Price (Full Width in single line, no clipping) -->
<TextView
android:id="@+id/tvProductPrice"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
android:fontFamily="sans-serif-black"
android:maxLines="1"
android:singleLine="true"
android:text="$ 280.000"
android:textColor="@color/drip_purple_glow"
android:textSize="15sp" />
<TextView
android:id="@+id/tvProductPrice"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:fontFamily="sans-serif-black"
android:text="$ 280.000"
android:textColor="@color/drip_purple_glow"
android:textSize="14sp" />
<!-- Quick Order Button with Tapjacking Protection -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnQuickOrder"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:filterTouchesWhenObscured="true"
android:insetTop="0dp"
android:insetBottom="0dp"
android:paddingHorizontal="10dp"
android:text="Pedir"
android:textAllCaps="false"
android:textSize="11sp"
android:textStyle="bold"
app:backgroundTint="@color/accent_whatsapp"
app:cornerRadius="10dp"
app:icon="@drawable/ic_whatsapp"
app:iconGravity="textStart"
app:iconPadding="4dp"
app:iconSize="14dp" />
</LinearLayout>
<!-- Quick Order Button directly below price -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnQuickOrder"
android:layout_width="match_parent"
android:layout_height="38dp"
android:layout_marginTop="6dp"
android:filterTouchesWhenObscured="true"
android:insetTop="0dp"
android:insetBottom="0dp"
android:paddingHorizontal="8dp"
android:text="Pedir"
android:textAllCaps="false"
android:textSize="12sp"
android:textStyle="bold"
app:backgroundTint="@color/accent_whatsapp"
app:cornerRadius="10dp"
app:icon="@drawable/ic_whatsapp"
app:iconGravity="textStart"
app:iconPadding="6dp"
app:iconSize="15dp" />
</LinearLayout>

View File

@ -15,6 +15,8 @@
<!-- Web DripZone Purple & Fuchsia Accents -->
<color name="drip_purple">#9333EA</color>
<color name="accent_purple">#9333EA</color>
<color name="border_subtle">#282830</color>
<color name="drip_purple_light">#A855F7</color>
<color name="drip_fuchsia">#D946EF</color>
<color name="drip_pink">#EC4899</color>

View File

@ -0,0 +1,215 @@
const { Client, Databases } = require('node-appwrite');
const fs = require('fs');
const path = require('path');
const endpoint = process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT || 'https://appwrite.almeira.com.co/v1';
const projectId = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID || '69b84df5003bd8957c5c';
const apiKey = process.env.APPWRITE_API_KEY || 'standard_cce3cdb866ea6433888d40cedd24dc45987372308fdc3dd0697de08648c0e5c7e04b02c3bcee271403fd656b00a5820f04c08b51631284a6e8a3582d8aed2aa33234f3c29cc777e9b162eb54810b789251b0d006a9a3b081ebc2da53b9baa94614b124b3508ad849fd9a4187cc6094f506d4b57f409b69d9562a74df594f3ec3';
const databaseId = process.env.NEXT_PUBLIC_APPWRITE_DATABASE_ID || 'sneakers_multistore_db';
const client = new Client()
.setEndpoint(endpoint)
.setProject(projectId)
.setKey(apiKey);
const databases = new Databases(client);
// Import templates from compiled or read ts file directly
async function seed() {
console.log('🌱 Seeding message templates into Appwrite...');
// Read templates-service.ts to extract default templates
const templateServiceFile = fs.readFileSync(path.resolve(__dirname, '../src/lib/templates-service.ts'), 'utf-8');
// Define templates matching DEFAULT_MESSAGE_TEMPLATES
const templates = [
{
id: 'order_confirmation_customer',
title: 'Confirmación de Pedido al Cliente',
description: 'Mensaje oficial enviado al cliente tras registrar exitosamente su pedido en la tienda.',
category: 'customer_sales',
channel: 'whatsapp',
isActive: true,
content: `🎉 *¡GRACIAS POR TU PEDIDO EN {tienda_nombre}!* 🎉\n📌 *No. de Orden:* #{orden_id}\n\n👤 *Cliente:* {cliente_nombre}\n📱 *Teléfono:* {cliente_telefono}\n📍 *Dirección:* {cliente_direccion}\n🏙️ *Ciudad:* {cliente_ciudad}\n\n📦 *DETALLE DE TU COMPRA:*\n{articulos_resumen}\n\n💵 *Subtotal:* {subtotal}\n🚚 *Envío:* {costo_envio}\n💰 *VALOR TOTAL:* {total}\n{condicion_pago_resumen}\n\n💳 *Método de Pago:* {metodo_pago}\n\nEstamos alistando tu paquete. Te enviaremos el número de guía o notificación de entrega. ¡Muchas gracias por tu confianza! ✨`,
availableVariables: JSON.stringify([
{ key: 'tienda_nombre', label: 'Nombre de Tienda', example: 'DripZone', description: 'Nombre comercial' },
{ key: 'orden_id', label: 'No. de Orden', example: 'ORD-9842', description: 'ID orden' },
{ key: 'cliente_nombre', label: 'Nombre del Cliente', example: 'Cliente DripZone', description: 'Nombre' },
{ key: 'cliente_telefono', label: 'Teléfono Cliente', example: '3106390296', description: 'Celular' },
{ key: 'cliente_direccion', label: 'Dirección de Entrega', example: 'Calle 5 # 24-80', description: 'Dirección' },
{ key: 'cliente_ciudad', label: 'Ciudad Destino', example: 'Cali', description: 'Ciudad' },
{ key: 'articulos_resumen', label: 'Lista de Artículos', example: '👟 Nike Air Force 1 x1', description: 'Pares' },
{ key: 'subtotal', label: 'Subtotal COP', example: '$125.000 COP', description: 'Subtotal' },
{ key: 'costo_envio', label: 'Costo de Envío', example: 'GRATIS', description: 'Envío' },
{ key: 'total', label: 'Total COP', example: '$125.000 COP', description: 'Total' },
{ key: 'condicion_pago_resumen', label: 'Resumen Anticipo / Saldo', example: '🤝 Pago 100% Contraentrega', description: 'Condición' },
{ key: 'metodo_pago', label: 'Método de Pago', example: 'Contraentrega', description: 'Método' },
{ key: 'saldo_contraentrega', label: 'Saldo en Puerta', example: '$115.000 COP', description: 'Saldo' },
{ key: 'anticipo', label: 'Valor Separación', example: '$10.000 COP', description: 'Anticipo' }
]),
updatedAt: new Date().toISOString()
},
{
id: 'order_checkout_wa',
title: 'Pedido Web hacia WhatsApp de Tienda',
description: 'Texto que el cliente abre en WhatsApp al finalizar su compra en el carrito o checkout web.',
category: 'customer_sales',
channel: 'whatsapp',
isActive: true,
content: `🎉 *CONFIRMACIÓN DE PEDIDO EN {tienda_nombre} #{orden_id}* 🎉\n\n👤 *Cliente:* {cliente_nombre}\n📱 *Teléfono:* {cliente_telefono}\n📍 *Dirección:* {cliente_direccion}, {cliente_ciudad}\n\n📦 *PRODUCTOS SOLICITADOS:*\n{articulos_resumen}\n\n💵 *Subtotal:* {subtotal}\n🚚 *Flete:* {costo_envio}\n💰 *TOTAL A PAGAR:* {total}\n{condicion_pago_resumen}\n💳 *Forma de Pago:* {metodo_pago}\n\n¡Por favor confirmar mi pedido para proceder con el alistamiento! 🔥`,
availableVariables: JSON.stringify([
{ key: 'tienda_nombre', label: 'Nombre de Tienda', example: 'DripZone', description: 'Nombre de la marca' },
{ key: 'orden_id', label: 'No. Orden', example: 'ORD-9842', description: 'ID de la orden' },
{ key: 'cliente_nombre', label: 'Nombre Cliente', example: 'Cliente DripZone', description: 'Nombre' },
{ key: 'cliente_telefono', label: 'Teléfono', example: '3106390296', description: 'Teléfono' },
{ key: 'cliente_direccion', label: 'Dirección', example: 'Av 6N # 28N-12', description: 'Dirección' },
{ key: 'cliente_ciudad', label: 'Ciudad', example: 'Cali', description: 'Ciudad' },
{ key: 'articulos_resumen', label: 'Productos', example: '👟 Jordan 4 Retro (41) x1', description: 'Resumen' },
{ key: 'subtotal', label: 'Subtotal', example: '$140.000 COP', description: 'Subtotal' },
{ key: 'costo_envio', label: 'Envío', example: 'GRATIS', description: 'Flete' },
{ key: 'total', label: 'Total', example: '$140.000 COP', description: 'Total' },
{ key: 'condicion_pago_resumen', label: 'Condición Pago', example: 'Separación $10.000', description: 'Condición' },
{ key: 'metodo_pago', label: 'Método', example: 'Contraentrega', description: 'Método' }
]),
updatedAt: new Date().toISOString()
},
{
id: 'order_status_dispatched',
title: 'Notificación de Pedido Despachado en Ruta',
description: 'Mensaje enviado cuando la transportadora o mensajero local recoge el paquete.',
category: 'logistics_shipping',
channel: 'whatsapp',
isActive: true,
content: `🚚 *¡TU PEDIDO VA EN CAMINO!* 📦💨\n\nHola {cliente_nombre}, tus zapatillas ya fueron despachadas para entrega en *{cliente_ciudad}*.\n\n📌 *Orden:* #{orden_id}\n📍 *Dirección de Entrega:* {cliente_direccion}\n📦 *Empresa Transportadora:* {transportadora}\n🏷️ *Número de Guía / Rastreo:* *{numero_guia}*\n🤝 *Valor a Pagar al Recibir (Contraentrega):* *{saldo_contraentrega}*\n\nPor favor estar atento a la portería o celular. ¡Gracias por tu compra en {tienda_nombre}! 🔥`,
availableVariables: JSON.stringify([
{ key: 'cliente_nombre', label: 'Nombre Cliente', example: 'Cliente DripZone', description: 'Nombre' },
{ key: 'orden_id', label: 'No. Orden', example: 'ORD-9842', description: 'ID de la orden' },
{ key: 'cliente_direccion', label: 'Dirección', example: 'Carrera 100 # 16-20', description: 'Dirección' },
{ key: 'cliente_ciudad', label: 'Ciudad', example: 'Cali', description: 'Ciudad' },
{ key: 'transportadora', label: 'Transportadora', example: 'Interrapidísimo / Mensajería Urbana', description: 'Operador' },
{ key: 'numero_guia', label: 'Número de Guía', example: 'GUIA-8839201', description: 'Guía' },
{ key: 'saldo_contraentrega', label: 'Saldo a Pagar', example: '$125.000 COP', description: 'Saldo contraentrega' },
{ key: 'tienda_nombre', label: 'Nombre Tienda', example: 'DripZone', description: 'Marca' }
]),
updatedAt: new Date().toISOString()
},
{
id: 'order_status_delivered',
title: 'Confirmación de Pedido Entregado con Éxito',
description: 'Mensaje post-venta enviado cuando el mensajero confirma entrega exitosa.',
category: 'logistics_shipping',
channel: 'whatsapp',
isActive: true,
content: `⭐ *¡PEDIDO ENTREGADO CON ÉXITO!* ⭐\n\nHola {cliente_nombre}, nuestro sistema confirma que tu orden *#{orden_id}* fue entregada exitosamente.\n\nEsperamos que disfrutes al máximo tus nuevas zapatillas. Recuerda que cuentas con garantía por defectos y cambio de talla.\n\n📸 ¡Sube tu foto a Instagram y etiquétanos en *{instagram_handle}* para un 10% de descuento en tu próxima compra!\n\n¡Muchas gracias por elegir {tienda_nombre}! ✨`,
availableVariables: JSON.stringify([
{ key: 'cliente_nombre', label: 'Nombre Cliente', example: 'Cliente DripZone', description: 'Nombre' },
{ key: 'orden_id', label: 'No. Orden', example: 'ORD-9842', description: 'ID orden' },
{ key: 'tienda_nombre', label: 'Nombre Tienda', example: 'DripZone', description: 'Marca' },
{ key: 'instagram_handle', label: 'Instagram', example: '@dripzone.co', description: 'Cuenta IG' }
]),
updatedAt: new Date().toISOString()
},
{
id: 'auth_login_otp',
title: 'Código OTP de Seguridad para Iniciar Sesión',
description: 'Código de 6 dígitos enviado por WhatsApp al usuario para autenticación sin contraseña.',
category: 'security_auth',
channel: 'whatsapp',
isActive: true,
content: `🔐 *{tienda_nombre} - Código de Seguridad*\n\nHola {usuario_nombre}, tu código de verificación para acceder a tu cuenta es:\n\n👉 *{codigo_otp}*\n\n⏱️ _Válido por 5 minutos. No compartas este código con nadie._`,
availableVariables: JSON.stringify([
{ key: 'tienda_nombre', label: 'Nombre Tienda', example: 'DripZone', description: 'Marca' },
{ key: 'usuario_nombre', label: 'Nombre Usuario', example: 'Cliente DripZone', description: 'Usuario' },
{ key: 'codigo_otp', label: 'Código de 6 Dígitos', example: '849201', description: 'Código OTP' }
]),
updatedAt: new Date().toISOString()
},
{
id: 'auth_register_otp',
title: 'Código OTP para Verificación de Nueva Cuenta',
description: 'Código enviado por WhatsApp al nuevo cliente tras registrar su cuenta en la plataforma.',
category: 'security_auth',
channel: 'whatsapp',
isActive: true,
content: `👟 *¡Bienvenido(a) a {tienda_nombre}!* ✨\n\nHola {usuario_nombre}, ingresa el siguiente código en la app para verificar tu número de WhatsApp:\n\n👉 *{codigo_otp}*\n\nAl validar tu cuenta, habilitas compras 100% contraentrega sin fricción. ¡Gracias por unirte! 🔥`,
availableVariables: JSON.stringify([
{ key: 'tienda_nombre', label: 'Nombre Tienda', example: 'DripZone', description: 'Marca' },
{ key: 'usuario_nombre', label: 'Nombre Usuario', example: 'Cliente DripZone', description: 'Nombre' },
{ key: 'codigo_otp', label: 'Código de 6 Dígitos', example: '582194', description: 'Código OTP' }
]),
updatedAt: new Date().toISOString()
},
{
id: 'customer_deposit_request',
title: 'Solicitud de Separación con $10.000 COP (Garantía de Despacho)',
description: 'Mensaje con enlace oficial Bold para pagar el anticipo de $10.000 COP y programar el envío.',
category: 'customer_sales',
channel: 'whatsapp',
isActive: true,
content: `⚡ *{tienda_nombre} - CONFIRMACIÓN DE DESPACHO* 📦\n\nHola {cliente_nombre}, para programar el despacho express de tu orden *#{orden_id}* hacia *{cliente_ciudad}*, realiza el pago del anticipo de separación de *{valor_anticipo}*.\n\n💳 *Paga en línea de forma 100% segura por Bold (PSE / Tarjetas / Bre-B / Nequi):*\n👉 {link_pago_bold}\n\n🤝 El saldo restante de *{saldo_contraentrega}* lo cancelas al recibir tu paquete en tu puerta. ¡Gracias por tu compra!`,
availableVariables: JSON.stringify([
{ key: 'tienda_nombre', label: 'Nombre Tienda', example: 'DripZone', description: 'Tienda' },
{ key: 'cliente_nombre', label: 'Nombre Cliente', example: 'Cliente DripZone', description: 'Cliente' },
{ key: 'orden_id', label: 'No. Orden', example: 'ORD-9842', description: 'ID orden' },
{ key: 'cliente_ciudad', label: 'Ciudad', example: 'Cali', description: 'Ciudad' },
{ key: 'valor_anticipo', label: 'Valor Anticipo', example: '$10.000 COP', description: 'Separación' },
{ key: 'saldo_contraentrega', label: 'Saldo Contraentrega', example: '$125.000 COP', description: 'Saldo' },
{ key: 'link_pago_bold', label: 'Link de Pago Bold', example: 'https://drip-zone.online', description: 'Enlace Bold' }
]),
updatedAt: new Date().toISOString()
},
{
id: 'customer_deposit_confirmed',
title: 'Confirmación de Anticipo Bold al Cliente',
description: 'Notificación enviada al cliente por WhatsApp cuando su pago de anticipo de separación es acreditado por Bold.',
category: 'customer_sales',
channel: 'whatsapp',
isActive: true,
content: `🎉 *¡Tu anticipo de separación fue confirmado con éxito!*\n\nHola *{cliente_nombre}*, recibimos correctamente tu pago de *{valor_anticipo}* a través de Bold.\n\n📦 Tu orden *#{orden_id}* ya se encuentra en alistamiento y empaque para despacho a *{cliente_ciudad}*.\n🤝 *Saldo restante a pagar contraentrega:* *{saldo_contraentrega}* al recibir en tu puerta.\n\nPuedes consultar el estado de tu pedido en cualquier momento desde la app oficial de {tienda_nombre}.\n\n¡Muchas gracias por tu compra en {tienda_nombre}!`,
availableVariables: JSON.stringify([
{ key: 'tienda_nombre', label: 'Nombre Tienda', example: 'DripZone', description: 'Nombre de la tienda' },
{ key: 'cliente_nombre', label: 'Nombre Cliente', example: 'Cliente DripZone', description: 'Nombre del comprador' },
{ key: 'orden_id', label: 'No. de Orden', example: 'ORD-9842', description: 'ID de la orden' },
{ key: 'valor_anticipo', label: 'Valor Anticipo', example: '$10.000 COP', description: 'Monto anticipo pagado' },
{ key: 'cliente_ciudad', label: 'Ciudad Destino', example: 'Cali', description: 'Municipio de entrega' },
{ key: 'saldo_contraentrega', label: 'Saldo Contraentrega', example: '$115.000 COP', description: 'Saldo en puerta' }
]),
updatedAt: new Date().toISOString()
}
];
for (const t of templates) {
try {
const doc = await databases.getDocument(databaseId, 'message_templates', t.id);
console.log(`ℹ️ Plantilla ${t.id} ya existe, actualizando...`);
await databases.updateDocument(databaseId, 'message_templates', t.id, {
title: t.title,
description: t.description,
category: t.category,
channel: t.channel,
content: t.content,
availableVariables: t.availableVariables,
isActive: t.isActive,
updatedAt: t.updatedAt
});
console.log(`✅ Plantilla ${t.id} actualizada.`);
} catch (e) {
console.log(`Creando plantilla ${t.id}...`);
await databases.createDocument(databaseId, 'message_templates', t.id, {
title: t.title,
description: t.description,
category: t.category,
channel: t.channel,
content: t.content,
availableVariables: t.availableVariables,
isActive: t.isActive,
updatedAt: t.updatedAt
});
console.log(`✅ Plantilla ${t.id} creada en Appwrite.`);
}
}
console.log('🎉 ¡Todas las plantillas quedaron sincronizadas en Appwrite!');
}
seed().catch(err => console.error('Error seeding templates:', err));

View File

@ -14,10 +14,10 @@ export default function AdminSettingsPage() {
instagramHandle: '@dripzone.co',
address: 'Bodega Central / Cali',
city: 'Cali, Colombia',
nequiAccount: '316 745 5877',
daviplataAccount: '316 745 5877',
accountHolder: 'Carlos Andrés Lasso',
boldPaymentLink: 'https://bold.co/p/carlos-lasso',
nequiAccount: '310 639 0296',
daviplataAccount: '310 639 0296',
accountHolder: 'DripZone Oficial',
boldPaymentLink: 'https://drip-zone.online',
boldApiKey: '',
boldMerchantId: '',
boldQrUrl: '',

View File

@ -11,22 +11,35 @@ export async function POST(request: Request) {
}
const cleanInput = identifier.trim();
// Buscar usuario en base de datos
const user = dataService.authenticate(cleanInput);
const cleanDigits = cleanInput.replace(/\D/g, '');
let phoneToSend = cleanInput.replace(/\D/g, '');
let userName = name?.trim() || (user ? user.name : 'Cliente');
let phoneToSend = '';
let userName = name?.trim() || 'Cliente';
let foundUser = null;
if (user) {
if (!user.isActive) {
// Si el identificador es un correo electrónico, buscar el teléfono de su cuenta
if (cleanInput.includes('@')) {
foundUser = dataService.authenticate(cleanInput);
if (!foundUser) {
return NextResponse.json({ success: false, error: 'No se encontró cuenta asociada a este correo.' }, { status: 404 });
}
if (!foundUser.isActive) {
return NextResponse.json({ success: false, error: 'Esta cuenta se encuentra inactiva.' }, { status: 403 });
}
phoneToSend = user.phone.replace(/\D/g, '');
phoneToSend = foundUser.phone.replace(/\D/g, '');
userName = foundUser.name || userName;
} else {
// Si es un número telefónico, enviar DIRECTA Y EXCLUSIVAMENTE a ese número ingresado por el usuario
phoneToSend = cleanDigits;
foundUser = dataService.authenticate(cleanDigits);
if (foundUser?.name) {
userName = foundUser.name;
}
}
if (!phoneToSend || phoneToSend.length < 7) {
if (!phoneToSend || phoneToSend.length < 10) {
return NextResponse.json(
{ success: false, error: 'Número de celular inválido. Ingresa un número válido de WhatsApp.' },
{ success: false, error: 'Número de celular inválido. Ingresa tu número de WhatsApp a 10 dígitos.' },
{ status: 400 }
);
}
@ -42,16 +55,14 @@ export async function POST(request: Request) {
codigo_otp: code,
});
// Enviar a WhatsApp via Evolution API
// Enviar EXCLUSIVAMENTE a la línea de WhatsApp del usuario
const sent = await sendWhatsAppMessage(phoneToSend, message);
if (!sent) {
// Si falla Evolution (o es teléfono internacional sin formato), permitir fallback seguro
console.warn(`[OTP] No se pudo enviar por WhatsApp a ${phoneToSend}. Código generado: ${code}`);
}
// Enmascarar teléfono para la respuesta pública (ej: 316***5877)
// Enmascarar teléfono para la respuesta pública (ej: 310***0296)
const maskedPhone = phoneToSend.length >= 10
? `${phoneToSend.slice(0, 3)}***${phoneToSend.slice(-4)}`
: 'tu número';
@ -60,7 +71,7 @@ export async function POST(request: Request) {
success: true,
message: `Código de 6 dígitos enviado por WhatsApp a ${maskedPhone}`,
phone: maskedPhone,
isRegistered: !!user,
isRegistered: !!foundUser,
});
} catch (error: any) {
console.error('Error enviando OTP:', error);

View File

@ -51,28 +51,36 @@ export async function GET(request: Request) {
dataService.updateOrder(order.id, updates);
await updateOrderInAppwrite(order.id, updates).catch(e => console.warn('Appwrite update warn:', e));
// Notificar a Carlos por WhatsApp
// Notificar a la tienda automáticamente con la información completa del pago
try {
const itemsText = (order.items || [])
.map((i: any) => ` 👟 ${i.productName || i.name} (Talla ${i.size}) x${i.quantity} — ${formatCOP((i.price || 0) * (i.quantity || 1))}`)
.join('\n');
const adminMsg = (
`⚡ *¡ANTICIPO DE SEPARACIÓN CONFIRMADO VÍA BOLD!*\n\n` +
`📦 *Orden:* #${order.id}\n` +
`👤 *Cliente:* ${order.customerName} (+57 ${order.customerPhone})\n` +
`📍 *Ciudad:* ${order.cityName}\n` +
`💳 *Anticipo Recibido:* ${formatCOP(depositAmount)} COP (Confirmado Bold)\n` +
`🤝 *Saldo a Cobrar Contraentrega:* ${formatCOP(newBalance)} COP\n\n` +
`📍 *Destino:* ${order.cityName} — ${order.address || 'Domicilio'}\n` +
`💳 *Anticipo Recibido:* ${formatCOP(depositAmount)} COP (Acreditado por Bold)\n` +
`🔗 *Referencia Bold:* ${effectiveLinkId}\n` +
`💵 *Total Pedido:* ${formatCOP(order.totalAmount)} COP\n` +
`🤝 *Saldo a Cobrar Contraentrega:* *${formatCOP(newBalance)} COP*\n\n` +
`📋 *Detalle de Calzado:*\n${itemsText || ' (Ver orden en panel)'}\n\n` +
`🚀 *Estado:* Despacho autorizado y habilitado para alistamiento en bodega.`
);
await sendWhatsAppMessage('573167455877', adminMsg);
// Notificar al cliente
// Notificar al cliente automáticamente con la plantilla registrada en la base de datos
if (order.customerPhone) {
const clientMsg = (
`🎉 *¡Tu anticipo de separación fue confirmado con éxito!*\n\n` +
`Hola *${order.customerName}*, recibimos correctamente tu pago de *${formatCOP(depositAmount)} COP* a través de Bold.\n\n` +
`📦 Tu orden *#${order.id}* ya se encuentra en alistamiento y empaque para despacho a *${order.cityName}*.\n` +
`🤝 *Saldo restante a pagar contraentrega:* *${formatCOP(newBalance)} COP* al recibir en tu puerta.\n\n` +
`¡Muchas gracias por tu compra en DripZone!`
);
const clientMsg = dataService.renderMessageTemplate('customer_deposit_confirmed', {
tienda_nombre: 'DripZone',
cliente_nombre: order.customerName || 'Cliente',
orden_id: order.id,
valor_anticipo: formatCOP(depositAmount),
cliente_ciudad: order.cityName || 'Colombia',
saldo_contraentrega: formatCOP(newBalance),
});
await sendWhatsAppMessage(order.customerPhone, clientMsg);
}
} catch (waErr) {

View File

@ -4,9 +4,7 @@ import { CustomProductRequest } from '@/types';
import fs from 'fs';
import path from 'path';
const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL || 'https://evolution.almeira.com.co';
const EVOLUTION_API_KEY = process.env.EVOLUTION_API_KEY || 'EE49029EEB85-4568-8F81-C9FDFC6E2943';
const EVOLUTION_INSTANCE = process.env.EVOLUTION_INSTANCE_NAME || 'AutomataAlmeira';
import { sendWhatsAppMessage } from '@/lib/evolution-api';
const CARLOS_PHONE = '573167455877';
// Carpeta local para almacenar fotos de encargos especiales
@ -18,30 +16,6 @@ function ensureUploadsDir() {
}
}
// Función auxiliar para enviar mensajes de WhatsApp vía Evolution API
async function sendWhatsAppMessage(number: string, text: string) {
try {
const cleanNumber = number.replace(/\D/g, '');
const fullNumber = cleanNumber.length === 10 ? `57${cleanNumber}` : cleanNumber;
const res = await fetch(`${EVOLUTION_API_URL}/message/sendText/${EVOLUTION_INSTANCE}`, {
method: 'POST',
headers: {
apikey: EVOLUTION_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
number: fullNumber,
text: text,
}),
});
return await res.json();
} catch (err) {
console.warn('Error enviando WhatsApp a ' + number + ':', err);
return null;
}
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
@ -142,13 +116,15 @@ ${customerEmail ? `✉️ *Correo:* ${customerEmail}\n` : ''}
sendWhatsAppMessage(CARLOS_PHONE, adminMessage).catch(() => {});
sendWhatsAppMessage('120363428012905103@g.us', adminMessage).catch(() => {});
// 2. Confirmación automática al cliente por WhatsApp
const clientMessage = `👋 ¡Hola *${customerName}*!
Recibimos con éxito tu solicitud por encargo para las *${brand} ${modelName}* en talla *${size}*. 👟🔥
Nuestro equipo ya está verificando disponibilidad y mejor precio con nuestras bodegas y proveedores mayoristas. Te responderemos a este mismo chat con la cotización exacta en el menor tiempo posible.
¡Gracias por confiar en DripZone! 🚀`;
// 2. Confirmación automática al cliente por WhatsApp usando la plantilla registrada
const settings = dataService.getSettings();
const clientMessage = dataService.renderMessageTemplate('customer_request_received', {
tienda_nombre: settings?.storeName || 'DripZone Sneakers',
cliente_nombre: customerName,
marca: brand,
modelo: modelName,
talla: size,
});
sendWhatsAppMessage(customerPhone, clientMessage).catch(() => {});

View File

@ -1,6 +1,7 @@
import { NextResponse } from 'next/server';
import { dataService } from '@/lib/data-service';
import { TEMPLATE_CATEGORIES } from '@/lib/templates-service';
import { getMessageTemplatesFromAppwrite, updateMessageTemplateInAppwrite } from '@/lib/server-appwrite';
export async function GET(request: Request) {
try {
@ -8,6 +9,16 @@ export async function GET(request: Request) {
const id = searchParams.get('id');
const category = searchParams.get('category');
// Intentar sincronizar con Appwrite si hay plantillas en BD
try {
const appwriteTemplates = await getMessageTemplatesFromAppwrite();
if (appwriteTemplates && appwriteTemplates.length > 0) {
for (const t of appwriteTemplates) {
dataService.updateMessageTemplate(t.id, t);
}
}
} catch (_) {}
if (id) {
const template = dataService.getMessageTemplateById(id);
if (!template) {
@ -37,12 +48,18 @@ export async function PUT(request: Request) {
return NextResponse.json({ success: false, error: 'ID de la plantilla es obligatorio' }, { status: 400 });
}
const updated = dataService.updateMessageTemplate(id, {
const updates = {
...(content !== undefined && { content }),
...(isActive !== undefined && { isActive }),
...(title !== undefined && { title }),
...(description !== undefined && { description }),
});
};
// Actualizar en Appwrite
const updatedInDb = await updateMessageTemplateInAppwrite(id, updates);
// Actualizar en memoria / dataService
const updated = dataService.updateMessageTemplate(id, updates);
if (!updated) {
return NextResponse.json({ success: false, error: 'Plantilla no encontrada' }, { status: 404 });
@ -50,8 +67,8 @@ export async function PUT(request: Request) {
return NextResponse.json({
success: true,
template: updated,
message: `Plantilla "${updated.title}" actualizada correctamente.`,
template: updatedInDb || updated,
message: `Plantilla "${updated.title}" actualizada correctamente en la base de datos.`,
});
} catch (error: any) {
return NextResponse.json({ success: false, error: error.message }, { status: 400 });

View File

@ -12,12 +12,38 @@ import {
} from '@/lib/evolution-api';
import { dataService } from '@/lib/data-service';
export async function GET() {
export async function GET(request: Request) {
try {
const orders = await getOrdersFromAppwrite();
const { searchParams } = new URL(request.url);
const phone = searchParams.get('phone');
let orders = await getOrdersFromAppwrite();
if (!orders || orders.length === 0) {
orders = dataService.getOrders();
}
if (phone) {
const cleanPhone = phone.replace(/\D/g, '');
orders = orders.filter((o: any) => {
const oPhone = (o.customerPhone || '').replace(/\D/g, '');
return oPhone.endsWith(cleanPhone) || cleanPhone.endsWith(oPhone);
});
}
return NextResponse.json({ success: true, orders });
} catch (error: any) {
const fallback = dataService.getOrders();
let fallback = dataService.getOrders();
try {
const { searchParams } = new URL(request.url);
const phone = searchParams.get('phone');
if (phone) {
const cleanPhone = phone.replace(/\D/g, '');
fallback = fallback.filter((o: any) => {
const oPhone = (o.customerPhone || '').replace(/\D/g, '');
return oPhone.endsWith(cleanPhone) || cleanPhone.endsWith(oPhone);
});
}
} catch (_) {}
return NextResponse.json({ success: true, orders: fallback });
}
}

View File

@ -123,8 +123,8 @@ export default function CheckoutPage() {
const depositAmount = isMandatoryDeposit ? 10000 : 0;
const balanceDue = Math.max(0, totalAmount - depositAmount);
const officialWhatsapp = storeSettings?.whatsappNumber || '573167455877';
const boldLink = storeSettings?.boldPaymentLink || 'https://bold.co/p/carlos-lasso';
const officialWhatsapp = storeSettings?.whatsappNumber || '573106390296';
const boldLink = storeSettings?.boldPaymentLink || 'https://drip-zone.online';
const processOrderSubmission = async () => {
setIsSubmitting(true);
@ -407,7 +407,7 @@ export default function CheckoutPage() {
<div className="bg-neutral-900/90 p-2.5 rounded-xl border border-neutral-800 text-xs font-mono text-neutral-300 flex justify-between items-center">
<span>📱 Transferencia Directa:</span>
<strong className="text-white">Nequi: {storeSettings?.nequiAccount || '316 745 5877'}</strong>
<strong className="text-white">Nequi: {storeSettings?.nequiAccount || '310 639 0296'}</strong>
</div>
</div>

View File

@ -128,8 +128,8 @@ export const INITIAL_PAYMENT_METHODS: PaymentMethod[] = [
description: 'Paga de forma rápida y 100% segura con tu cuenta Bold. Acepta PSE, Tarjetas Visa/Mastercard y QR de cualquier banco.',
requiresDeposit: false,
depositAmount: 0,
boldPaymentLink: 'https://bold.co/p/carlos-lasso',
accountInfo: 'Pasarela Oficial Bold.co • PSE / Tarjetas / QR',
boldPaymentLink: 'https://drip-zone.online',
accountInfo: 'Pasarela Oficial Bold.co • PSE / Tarjetas / QR Bre-B',
isActive: true,
},
{
@ -139,8 +139,8 @@ export const INITIAL_PAYMENT_METHODS: PaymentMethod[] = [
description: 'Asegura tu talla con solo $10.000 COP vía Bold/Nequi/Daviplata y cancelas el saldo restante en efectivo o transferencia cuando el mensajero llegue a tu casa.',
requiresDeposit: true,
depositAmount: 10000,
accountInfo: 'Nequi / Daviplata Oficial: 316 745 5877 (Carlos Andrés Lasso)',
boldPaymentLink: 'https://bold.co/p/carlos-lasso',
accountInfo: 'Nequi / Daviplata Oficial DripZone: 310 639 0296',
boldPaymentLink: 'https://drip-zone.online',
isActive: true,
},
{
@ -159,7 +159,7 @@ export const INITIAL_PAYMENT_METHODS: PaymentMethod[] = [
description: 'Paga el 100% anticipado y recibe prioridad de despacho express en 24 horas.',
requiresDeposit: false,
depositAmount: 0,
accountInfo: 'Nequi / Daviplata: 316 745 5877 (Carlos Andrés Lasso)',
accountInfo: 'Nequi / Daviplata Oficial DripZone: 310 639 0296',
isActive: true,
},
];
@ -172,10 +172,10 @@ export const INITIAL_ORDERS: Order[] = [];
export const INITIAL_USERS: User[] = [
{
id: 'usr-admin-carlos',
name: 'Carlos Lasso',
email: 'carlos.andres.lasso@gmail.com',
phone: '573167455877',
id: 'usr-admin-dripzone',
name: 'Administrador DripZone',
email: 'info@drip-zone.online',
phone: '573106390296',
city: 'Cali',
address: 'Sede Principal / Cali',
role: 'admin',
@ -210,53 +210,84 @@ function getPathModule() {
return null;
}
function getPersistedFilePath(): string | null {
function getPersistedFilePath(fileName: string = 'persisted_products.json'): string | null {
const pathMod = getPathModule();
if (!pathMod) return null;
return pathMod.join(process.cwd(), 'data', 'persisted_products.json');
return pathMod.join(process.cwd(), 'data', fileName);
}
function loadPersistedProducts(): Product[] {
function loadPersistedData<T>(fileName: string, defaultValue: T): T {
const fsMod = getFsModule();
const filePath = getPersistedFilePath();
const filePath = getPersistedFilePath(fileName);
if (fsMod && filePath && fsMod.existsSync(filePath)) {
try {
const content = fsMod.readFileSync(filePath, 'utf-8');
const parsed = JSON.parse(content);
if (Array.isArray(parsed) && parsed.length > 0) {
if (parsed !== undefined && parsed !== null) {
return parsed;
}
} catch (e) {
console.warn('[DataService] Error leyendo archivo de productos persistidos:', e);
console.warn(`[DataService] Error leyendo ${fileName}:`, e);
}
}
return [...INITIAL_PRODUCTS];
return defaultValue;
}
function savePersistedProducts(products: Product[]): void {
function savePersistedData<T>(fileName: string, data: T): void {
const fsMod = getFsModule();
const pathMod = getPathModule();
const filePath = getPersistedFilePath();
const filePath = getPersistedFilePath(fileName);
if (fsMod && pathMod && filePath) {
try {
const dir = pathMod.dirname(filePath);
if (!fsMod.existsSync(dir)) {
fsMod.mkdirSync(dir, { recursive: true });
}
fsMod.writeFileSync(filePath, JSON.stringify(products, null, 2), 'utf-8');
fsMod.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
} catch (e) {
console.warn('[DataService] Error guardando productos en archivo persistente:', e);
console.warn(`[DataService] Error guardando ${fileName}:`, e);
}
}
}
function loadPersistedProducts(): Product[] {
return loadPersistedData<Product[]>('persisted_products.json', [...INITIAL_PRODUCTS]);
}
function savePersistedProducts(products: Product[]): void {
savePersistedData('persisted_products.json', products);
}
export const DEFAULT_STORE_SETTINGS: StoreSettings = {
storeName: 'DripZone',
slogan: 'Exclusive Streetwear & Sneakers Oficial',
email: 'info@drip-zone.online',
phone: '3106390296',
whatsappNumber: '573106390296',
instagramHandle: '@dripzone.co',
address: 'Bodega Central / Cali',
city: 'Cali, Colombia',
nequiAccount: '310 639 0296',
daviplataAccount: '310 639 0296',
accountHolder: 'DripZone Oficial',
boldPaymentLink: 'https://drip-zone.online',
boldApiKey: '',
boldMerchantId: '',
boldQrUrl: '',
boldEnabled: true,
defaultProfitMargin: 35,
defaultDepositAmount: 10000,
};
class DataService {
private products: Product[] = loadPersistedProducts();
private cities: City[] = [...INITIAL_CITIES];
private paymentMethods: PaymentMethod[] = [...INITIAL_PAYMENT_METHODS];
private suppliers: Supplier[] = [...INITIAL_SUPPLIERS];
private orders: Order[] = [...INITIAL_ORDERS];
private cities: City[] = loadPersistedData<City[]>('persisted_cities.json', [...INITIAL_CITIES]);
private paymentMethods: PaymentMethod[] = loadPersistedData<PaymentMethod[]>('persisted_payment_methods.json', [...INITIAL_PAYMENT_METHODS]);
private suppliers: Supplier[] = loadPersistedData<Supplier[]>('persisted_suppliers.json', [...INITIAL_SUPPLIERS]);
private orders: Order[] = loadPersistedData<Order[]>('persisted_orders.json', [...INITIAL_ORDERS]);
private supplierPosts: SupplierPost[] = [...INITIAL_SUPPLIER_POSTS];
private settings: StoreSettings = loadPersistedData<StoreSettings>('persisted_settings.json', DEFAULT_STORE_SETTINGS);
private messageTemplates: MessageTemplate[] = loadPersistedData<MessageTemplate[]>('persisted_templates.json', [...DEFAULT_MESSAGE_TEMPLATES]);
getProducts(): Product[] {
if (this.products.length === 0) {
@ -661,6 +692,7 @@ class DataService {
addCity(city: City): City {
this.cities.push(city);
savePersistedData('persisted_cities.json', this.cities);
return city;
}
@ -668,12 +700,14 @@ class DataService {
const idx = this.cities.findIndex((c) => c.id === id);
if (idx === -1) return null;
this.cities[idx] = { ...this.cities[idx], ...updates };
savePersistedData('persisted_cities.json', this.cities);
return this.cities[idx];
}
deleteCity(id: string): boolean {
const initialLen = this.cities.length;
this.cities = this.cities.filter((c) => c.id !== id);
savePersistedData('persisted_cities.json', this.cities);
return this.cities.length < initialLen;
}
@ -687,6 +721,7 @@ class DataService {
addPaymentMethod(method: PaymentMethod): PaymentMethod {
this.paymentMethods.push(method);
savePersistedData('persisted_payment_methods.json', this.paymentMethods);
return method;
}
@ -694,12 +729,14 @@ class DataService {
const idx = this.paymentMethods.findIndex((p) => p.id === id);
if (idx === -1) return null;
this.paymentMethods[idx] = { ...this.paymentMethods[idx], ...updates };
savePersistedData('persisted_payment_methods.json', this.paymentMethods);
return this.paymentMethods[idx];
}
deletePaymentMethod(id: string): boolean {
const initialLen = this.paymentMethods.length;
this.paymentMethods = this.paymentMethods.filter((p) => p.id !== id);
savePersistedData('persisted_payment_methods.json', this.paymentMethods);
return this.paymentMethods.length < initialLen;
}
@ -713,6 +750,7 @@ class DataService {
addOrder(order: Order): Order {
this.orders.unshift(order);
savePersistedData('persisted_orders.json', this.orders);
return order;
}
@ -737,6 +775,7 @@ class DataService {
createdAt: orderData.createdAt || new Date().toISOString(),
};
this.orders.unshift(newOrder);
savePersistedData('persisted_orders.json', this.orders);
return newOrder;
}
@ -756,12 +795,14 @@ class DataService {
depositAmount: deposit,
balanceDue,
};
savePersistedData('persisted_orders.json', this.orders);
return this.orders[idx];
}
deleteOrder(id: string): boolean {
const initialLen = this.orders.length;
this.orders = this.orders.filter((o) => o.id !== id);
savePersistedData('persisted_orders.json', this.orders);
return this.orders.length < initialLen;
}
@ -850,33 +891,13 @@ class DataService {
}
// Configuración de la Tienda
private settings: StoreSettings = {
storeName: 'DripZone',
slogan: 'Luxury Streetwear & Collectibles',
email: 'info@drip-zone.online',
phone: '3106390296',
whatsappNumber: '573106390296',
instagramHandle: '@dripzone.co',
address: 'Bodega Central / Cali',
city: 'Cali, Colombia',
nequiAccount: '316 745 5877',
daviplataAccount: '316 745 5877',
accountHolder: 'Carlos Andrés Lasso',
boldPaymentLink: 'https://bold.co/p/carlos-lasso',
boldApiKey: '',
boldMerchantId: '',
boldQrUrl: '',
boldEnabled: true,
defaultProfitMargin: 35,
defaultDepositAmount: 10000,
};
getSettings(): StoreSettings {
return this.settings;
}
updateSettings(updates: Partial<StoreSettings>): StoreSettings {
this.settings = { ...this.settings, ...updates };
savePersistedData('persisted_settings.json', this.settings);
return this.settings;
}
@ -912,9 +933,15 @@ class DataService {
authenticate(emailOrPhone: string): User | null {
const q = emailOrPhone.trim().toLowerCase();
const user = this.users.find(
(u) => u.email.toLowerCase() === q || u.phone === q || u.phone.includes(q)
);
const cleanDigits = q.replace(/\D/g, '');
const user = this.users.find((u) => {
if (u.email.toLowerCase() === q) return true;
const uDigits = u.phone.replace(/\D/g, '');
if (cleanDigits.length >= 7 && uDigits.length >= 7) {
return uDigits === cleanDigits || uDigits.endsWith(cleanDigits) || cleanDigits.endsWith(uDigits);
}
return false;
});
return user || null;
}
@ -941,8 +968,6 @@ class DataService {
// ==========================================
// SISTEMA DE PLANTILLAS CATEGORIZADAS
// ==========================================
private messageTemplates: MessageTemplate[] = [...DEFAULT_MESSAGE_TEMPLATES];
getMessageTemplates(category?: string): MessageTemplate[] {
if (!category || category === 'all') {
return this.messageTemplates;
@ -962,6 +987,7 @@ class DataService {
...updates,
updatedAt: new Date().toISOString(),
};
savePersistedData('persisted_templates.json', this.messageTemplates);
return this.messageTemplates[idx];
}
@ -971,6 +997,7 @@ class DataService {
const idx = this.messageTemplates.findIndex((t) => t.id === id);
if (idx === -1) {
this.messageTemplates.push({ ...defaultTpl, updatedAt: new Date().toISOString() });
savePersistedData('persisted_templates.json', this.messageTemplates);
return defaultTpl;
}
this.messageTemplates[idx] = {
@ -979,6 +1006,7 @@ class DataService {
isActive: true,
updatedAt: new Date().toISOString(),
};
savePersistedData('persisted_templates.json', this.messageTemplates);
return this.messageTemplates[idx];
}
@ -988,6 +1016,7 @@ class DataService {
content: t.defaultContent,
updatedAt: new Date().toISOString(),
}));
savePersistedData('persisted_templates.json', this.messageTemplates);
return this.messageTemplates;
}
@ -1007,7 +1036,7 @@ class DataService {
{
id: 'req-1',
customerName: 'Santiago Mejía',
customerPhone: '573167455877',
customerPhone: '573105559876',
customerCity: 'Cali',
brand: 'Jordan',
modelName: 'Air Jordan 4 Retro Travis Scott Cactus Jack',

View File

@ -31,7 +31,7 @@ export const otpStore = {
const key = identifier.trim().toLowerCase();
const record = otpCache.get(key);
// Emergency Master PIN fallback for Carlos Lasso
// Emergency Master PIN fallback
if (code === '1978' || code === '197824') {
otpCache.delete(key);
return { valid: true };

View File

@ -789,3 +789,83 @@ export async function toggleMonitoredGroupInAppwrite(
}
}
// =========================================================================
// 9. PLANTILLAS DE MENSAJES & NOTIFICACIONES (CRUD APPWRITE)
// =========================================================================
export async function getMessageTemplatesFromAppwrite(): Promise<MessageTemplate[]> {
try {
const res = await serverDatabases.listDocuments(DB_ID, 'message_templates', [
Query.limit(50),
]);
if (res.documents && res.documents.length > 0) {
return res.documents.map((d: any) => {
let vars = [];
try {
vars = typeof d.availableVariables === 'string' ? JSON.parse(d.availableVariables) : (d.availableVariables || []);
} catch (_) {}
return {
id: d.$id,
title: d.title || '',
description: d.description || '',
category: d.category || 'customer_sales',
channel: d.channel || 'whatsapp',
content: d.content || '',
defaultContent: d.content || '',
availableVariables: vars,
isActive: d.isActive !== false,
updatedAt: d.updatedAt || d.$updatedAt,
};
});
}
return [];
} catch (e: any) {
console.warn('Error getMessageTemplatesFromAppwrite:', e.message);
return [];
}
}
export async function updateMessageTemplateInAppwrite(
id: string,
updates: Partial<MessageTemplate>
): Promise<MessageTemplate | null> {
try {
const payload: Record<string, any> = {
updatedAt: new Date().toISOString(),
};
if (updates.title !== undefined) payload.title = updates.title;
if (updates.description !== undefined) payload.description = updates.description;
if (updates.content !== undefined) payload.content = updates.content;
if (updates.isActive !== undefined) payload.isActive = updates.isActive;
if (updates.category !== undefined) payload.category = updates.category;
if (updates.channel !== undefined) payload.channel = updates.channel;
if (updates.availableVariables !== undefined) {
payload.availableVariables = typeof updates.availableVariables === 'string'
? updates.availableVariables
: JSON.stringify(updates.availableVariables);
}
const doc = await serverDatabases.updateDocument(DB_ID, 'message_templates', id, payload);
let vars = [];
try {
vars = typeof doc.availableVariables === 'string' ? JSON.parse(doc.availableVariables) : (doc.availableVariables || []);
} catch (_) {}
return {
id: doc.$id,
title: doc.title,
description: doc.description,
category: doc.category,
channel: doc.channel,
content: doc.content,
defaultContent: doc.content,
availableVariables: vars,
isActive: doc.isActive !== false,
updatedAt: doc.updatedAt,
};
} catch (e: any) {
console.error('Error updateMessageTemplateInAppwrite:', e.message);
return null;
}
}

View File

@ -94,7 +94,7 @@ Estamos alistando tu paquete. Te enviaremos el número de guía o notificación
availableVariables: [
{ key: 'tienda_nombre', label: 'Nombre de Tienda', example: 'DripZone Luxury', description: 'Nombre comercial configurado' },
{ key: 'orden_id', label: 'No. de Orden', example: 'ORD-9842', description: 'Identificador único del pedido' },
{ key: 'cliente_nombre', label: 'Nombre del Cliente', example: 'Carlos Lasso', description: 'Nombre registrado' },
{ key: 'cliente_nombre', label: 'Nombre del Cliente', example: 'Cliente DripZone', description: 'Nombre registrado' },
{ key: 'cliente_telefono', label: 'Teléfono Cliente', example: '3167455877', description: 'Celular del comprador' },
{ key: 'cliente_direccion', label: 'Dirección de Entrega', example: 'Calle 5 # 24-80, Apto 302', description: 'Dirección física' },
{ key: 'cliente_ciudad', label: 'Ciudad Destino', example: 'Cali', description: 'Municipio de despacho' },
@ -150,7 +150,7 @@ Estamos alistando tu paquete. Te enviaremos el número de guía o notificación
availableVariables: [
{ key: 'tienda_nombre', label: 'Nombre de Tienda', example: 'DripZone', description: 'Nombre de la marca' },
{ key: 'orden_id', label: 'No. Orden', example: 'ORD-9842', description: 'ID de la orden' },
{ key: 'cliente_nombre', label: 'Nombre Cliente', example: 'Carlos Lasso', description: 'Nombre' },
{ key: 'cliente_nombre', label: 'Nombre Cliente', example: 'Cliente DripZone', description: 'Nombre' },
{ key: 'cliente_telefono', label: 'Teléfono', example: '3167455877', description: 'Teléfono' },
{ key: 'cliente_direccion', label: 'Dirección', example: 'Av 6N # 28N-12', description: 'Dirección' },
{ key: 'cliente_ciudad', label: 'Ciudad', example: 'Cali', description: 'Ciudad' },
@ -270,7 +270,7 @@ Por favor estar atento a la portería o celular. ¡Gracias por tu compra en {tie
Por favor estar atento a la portería o celular. ¡Gracias por tu compra en {tienda_nombre}! 🔥`,
availableVariables: [
{ key: 'cliente_nombre', label: 'Nombre Cliente', example: 'Carlos Lasso', description: 'Nombre' },
{ key: 'cliente_nombre', label: 'Nombre Cliente', example: 'Cliente DripZone', description: 'Nombre' },
{ key: 'orden_id', label: 'No. Orden', example: 'ORD-9842', description: 'ID de la orden' },
{ key: 'cliente_direccion', label: 'Dirección', example: 'Carrera 100 # 16-20', description: 'Dirección' },
{ key: 'cliente_ciudad', label: 'Ciudad', example: 'Cali', description: 'Ciudad' },
@ -301,7 +301,7 @@ Por favor estar atento a la portería o celular. ¡Gracias por tu compra en {tie
¡Muchas gracias por elegir {tienda_nombre}! ✨`,
availableVariables: [
{ key: 'cliente_nombre', label: 'Nombre Cliente', example: 'Carlos Lasso', description: 'Nombre' },
{ key: 'cliente_nombre', label: 'Nombre Cliente', example: 'Cliente DripZone', description: 'Nombre' },
{ key: 'orden_id', label: 'No. Orden', example: 'ORD-9842', description: 'ID orden' },
{ key: 'tienda_nombre', label: 'Nombre Tienda', example: 'DripZone', description: 'Marca' },
],
@ -324,7 +324,7 @@ Por favor estar atento a la portería o celular. ¡Gracias por tu compra en {tie
🤝 ¿Nos confirmas en qué horario o con qué persona podemos coordinar el reintento de entrega hoy? Quedamos muy atentos.`,
availableVariables: [
{ key: 'cliente_nombre', label: 'Nombre Cliente', example: 'Carlos Lasso', description: 'Nombre' },
{ key: 'cliente_nombre', label: 'Nombre Cliente', example: 'Cliente DripZone', description: 'Nombre' },
{ key: 'orden_id', label: 'No. Orden', example: 'ORD-9842', description: 'ID orden' },
{ key: 'cliente_direccion', label: 'Dirección', example: 'Calle 9 # 44-12', description: 'Dirección' },
{ key: 'cliente_ciudad', label: 'Ciudad', example: 'Cali', description: 'Ciudad' },
@ -465,7 +465,7 @@ Hola {usuario_nombre}, tu código de verificación para iniciar sesión es:
⏱️ _Válido por 5 minutos. No compartas este código con nadie._`,
availableVariables: [
{ key: 'tienda_nombre', label: 'Nombre Tienda', example: 'DripZone Sneakers', description: 'Marca' },
{ key: 'usuario_nombre', label: 'Nombre Usuario', example: 'Carlos Lasso', description: 'Usuario' },
{ key: 'usuario_nombre', label: 'Nombre Usuario', example: 'Cliente DripZone', description: 'Usuario' },
{ key: 'codigo_otp', label: 'Código de 6 Dígitos', example: '849201', description: 'Código OTP' },
],
},
@ -506,7 +506,7 @@ Hola {usuario_nombre}! Tu código de seguridad para validar tu cuenta es:
content: `Hola {usuario_nombre}! Te escribimos del equipo de {tienda_nombre} 🟣`,
defaultContent: `Hola {usuario_nombre}! Te escribimos del equipo de {tienda_nombre} 🟣`,
availableVariables: [
{ key: 'usuario_nombre', label: 'Nombre Usuario', example: 'Carlos Lasso', description: 'Nombre del usuario' },
{ key: 'usuario_nombre', label: 'Nombre Usuario', example: 'Cliente DripZone', description: 'Nombre del usuario' },
{ key: 'tienda_nombre', label: 'Nombre Tienda', example: 'DripZone', description: 'Nombre' },
{ key: 'ciudad', label: 'Ciudad Usuario', example: 'Cali', description: 'Ciudad' },
],
@ -538,7 +538,7 @@ Hola {cliente_nombre}, para procesar tu orden #{orden_id} hacia {cliente_ciudad}
¡Una vez confirmado tu comprobante, tu despacho saldrá de inmediato!`,
availableVariables: [
{ key: 'tienda_nombre', label: 'Nombre Tienda', example: 'DripZone', description: 'Tienda' },
{ key: 'cliente_nombre', label: 'Nombre Cliente', example: 'Carlos Lasso', description: 'Cliente' },
{ key: 'cliente_nombre', label: 'Nombre Cliente', example: 'Cliente DripZone', description: 'Cliente' },
{ key: 'orden_id', label: 'No. Orden', example: 'ORD-9842', description: 'ID orden' },
{ key: 'cliente_ciudad', label: 'Ciudad', example: 'Bogotá D.C.', description: 'Ciudad' },
{ key: 'costo_envio', label: 'Costo Flete', example: '$12.000 COP', description: 'Valor del flete' },
@ -572,6 +572,41 @@ Cualquier duda o asesoría adicional, ¡estamos para servirte con todo el gusto!
{ key: 'ciudad_tienda', label: 'Ciudad Sede', example: 'Cali', description: 'Ciudad' },
],
},
{
id: 'customer_request_received',
title: 'Confirmación de Encargo Especial al Cliente',
description: 'Notificación enviada al cliente por WhatsApp cuando registra una solicitud de calzado por encargo.',
category: 'customer_sales',
channel: 'whatsapp',
isActive: true,
content: `👋 ¡Hola *{cliente_nombre}*!\n\nRecibimos con éxito tu solicitud por encargo para las *{marca} {modelo}* en talla *{talla}*. 👟🔥\n\nNuestro equipo en {tienda_nombre} ya está verificando disponibilidad y mejor precio con nuestras bodegas y proveedores mayoristas. Te responderemos a este mismo chat con la cotización exacta en el menor tiempo posible.\n\n¡Gracias por confiar en {tienda_nombre}! 🚀`,
defaultContent: `👋 ¡Hola *{cliente_nombre}*!\n\nRecibimos con éxito tu solicitud por encargo para las *{marca} {modelo}* en talla *{talla}*. 👟🔥\n\nNuestro equipo en {tienda_nombre} ya está verificando disponibilidad y mejor precio con nuestras bodegas y proveedores mayoristas. Te responderemos a este mismo chat con la cotización exacta en el menor tiempo posible.\n\n¡Gracias por confiar en {tienda_nombre}! 🚀`,
availableVariables: [
{ key: 'tienda_nombre', label: 'Nombre Tienda', example: 'DripZone', description: 'Nombre' },
{ key: 'cliente_nombre', label: 'Nombre Cliente', example: 'Cliente DripZone', description: 'Nombre' },
{ key: 'marca', label: 'Marca', example: 'Nike', description: 'Marca calzado' },
{ key: 'modelo', label: 'Modelo', example: 'Air Jordan 4', description: 'Modelo' },
{ key: 'talla', label: 'Talla', example: '41', description: 'Talla' },
],
},
{
id: 'customer_deposit_confirmed',
title: 'Confirmación de Anticipo Bold al Cliente',
description: 'Notificación enviada al cliente por WhatsApp cuando su pago de anticipo de separación es acreditado por Bold.',
category: 'customer_sales',
channel: 'whatsapp',
isActive: true,
content: `🎉 *¡Tu anticipo de separación fue confirmado con éxito!*\n\nHola *{cliente_nombre}*, recibimos correctamente tu pago de *{valor_anticipo}* a través de Bold.\n\n📦 Tu orden *#{orden_id}* ya se encuentra en alistamiento y empaque para despacho a *{cliente_ciudad}*.\n🤝 *Saldo restante a pagar contraentrega:* *{saldo_contraentrega}* al recibir en tu puerta.\n\nPuedes consultar el estado de tu pedido en cualquier momento desde la app oficial de {tienda_nombre}.\n\n¡Muchas gracias por tu compra en {tienda_nombre}!`,
defaultContent: `🎉 *¡Tu anticipo de separación fue confirmado con éxito!*\n\nHola *{cliente_nombre}*, recibimos correctamente tu pago de *{valor_anticipo}* a través de Bold.\n\n📦 Tu orden *#{orden_id}* ya se encuentra en alistamiento y empaque para despacho a *{cliente_ciudad}*.\n🤝 *Saldo restante a pagar contraentrega:* *{saldo_contraentrega}* al recibir en tu puerta.\n\nPuedes consultar el estado de tu pedido en cualquier momento desde la app oficial de {tienda_nombre}.\n\n¡Muchas gracias por tu compra en {tienda_nombre}!`,
availableVariables: [
{ key: 'tienda_nombre', label: 'Nombre Tienda', example: 'DripZone', description: 'Nombre de la tienda' },
{ key: 'cliente_nombre', label: 'Nombre Cliente', example: 'Cliente DripZone', description: 'Nombre del comprador' },
{ key: 'orden_id', label: 'No. de Orden', example: 'ORD-9842', description: 'ID de la orden' },
{ key: 'valor_anticipo', label: 'Valor Anticipo', example: '$10.000 COP', description: 'Monto anticipo pagado' },
{ key: 'cliente_ciudad', label: 'Ciudad Destino', example: 'Cali', description: 'Municipio de entrega' },
{ key: 'saldo_contraentrega', label: 'Saldo Contraentrega', example: '$115.000 COP', description: 'Saldo en puerta' },
],
},
];
/**

View File

@ -239,7 +239,7 @@ export type MessageTemplateCategory =
export interface MessageTemplateVariable {
key: string; // e.g. 'cliente_nombre'
label: string; // e.g. 'Nombre del Cliente'
example: string; // e.g. 'Carlos Lasso'
example: string; // e.g. 'Cliente DripZone'
description?: string; // e.g. 'Nombre completo registrado por el cliente en el pedido'
}

View File

@ -1,6 +1,10 @@
import paramiko
import sys
if sys.platform == 'win32':
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
HOST = "161.97.93.156"
USER = "carlos"
PASSWORD = "Innova1978+*?"

View File

@ -101,7 +101,9 @@ def main():
# Copiar APK a contenedores y permisos
cmd_copy = f"""
docker cp {REMOTE_BASE}/public/DripZone.apk dripzone-production:/app/public/DripZone.apk || true
docker cp {REMOTE_BASE}/public/DripZone.apk bfmnoga3lb0ckomhdmxe0rco-021053217798:/app/public/DripZone.apk || true
for c in $(docker ps -q --filter "name=bfmnoga3"); do
docker cp {REMOTE_BASE}/public/DripZone.apk $c:/app/public/DripZone.apk || true
done
docker exec dripzone-production ls -lh /app/public/DripZone.apk || true
"""
stdin, stdout, stderr = ssh.exec_command(cmd_copy)
@ -121,22 +123,23 @@ def main():
print("[5/5] Enviando notificación detallada y archivo APK al WhatsApp de Carlos (+57 316 745 5877)...")
message_text = (
"🚀 *DripZone v1.6 - Línea Oficial Exclusiva & Instancia DripZone Conectada*\n\n"
"Hola Carlos, se ha implementado en producción la división operativa solicitada:\n\n"
"📱 *1. Línea Celular Exclusiva DripZone (+57 310 639 0296):*\n"
"• Conectada exitosamente en Evolution API bajo la instancia *DripZone* (Token: `83B5BD230796-4324-AE73-3508F69E279D`).\n"
"• Todos los envíos de códigos de seguridad OTP, confirmaciones de pedidos, recibos de abono Bold y chats de soporte con clientes ahora se emiten de forma exclusiva desde esta línea oficial.\n\n"
"📥 *2. Recepción de Productos y Publicaciones (Tu WhatsApp Personal):*\n"
"• Se mantiene conectada tu instancia personal *AutomataAlmeira* para la lectura de los grupos de proveedores mayoristas donde recibes el calzado.\n"
"• Ninguna publicación se interrumpe y la catalogación inteligente sigue operando con tus grupos personales.\n\n"
"🌐 *3. Dominio Oficial drip-zone.online:*\n"
"• Configurado el servidor de producción (Traefik y Docker) para responder en *drip-zone.online*, *www.drip-zone.online* y el alias `dripzone.almeira.com.co`.\n\n"
"💳 *4. Pasarela Bold & QR Bre-B en Vivo:*\n"
"• Generación dinámica de QR Bre-B para el cobro de la separación de $10.000 COP y acreditación automática en tiempo real.\n\n"
"🚀 *DripZone v1.6 - Dominio drip-zone.online & Correo Oficial info@drip-zone.online Configurados*\n\n"
"Hola Carlos, se ha completado el despliegue de las nuevas configuraciones en producción:\n\n"
"🌐 *1. Dominio Oficial & Correo Electrónico:*\n"
"• URL Principal: *https://drip-zone.online*\n"
"• Correo Oficial: *info@drip-zone.online*\n"
"• Certificados SSL y Traefik operativos y validados.\n\n"
"📱 *2. Línea Celular Exclusiva DripZone (+57 310 639 0296):*\n"
"• Instancia Evolution API *DripZone* activa (`83B5BD230796-4324-AE73-3508F69E279D`).\n"
"• Los códigos OTP, confirmaciones de compras, soporte y enlaces Bold se emiten desde esta línea oficial.\n\n"
"📥 *3. Publicaciones y Catálogo (Tu WhatsApp Personal):*\n"
"• La instancia *AutomataAlmeira* sigue activa recibiendo los mensajes de los grupos de proveedores mayoristas para sincronización automática de calzado.\n\n"
"💳 *4. Pasarela Bold & QR Bre-B en Producción:*\n"
"• Cobro de anticipo de $10.000 COP con generación automática de QR Bre-B y acreditación en vivo.\n\n"
"📲 *5. App Nativa Android v1.6 Sincronizada:*\n"
"• Compilada con la nueva línea de atención oficial (+57 310 639 0296) y soporte dinámico para `drip-zone.online`.\n\n"
f"🔗 *Descarga Directa:* {APK_URL}\n\n"
"Te adjunto el APK compilado para que puedas instalarlo y probar la experiencia de usuario."
"• Compilada con el nuevo dominio y la línea oficial de atención.\n\n"
f"🔗 *Descarga Directa del APK:* {APK_URL}\n\n"
"Te adjunto el APK compilado para que puedas actualizar la aplicación en tu celular."
)
req_text = urllib.request.Request(
@ -156,8 +159,8 @@ def main():
"number": TARGET_PHONE,
"mediatype": "document",
"mimetype": "application/vnd.android.package-archive",
"fileName": "DripZone-Native-v1.5.apk",
"caption": "📲 *DripZone Native Kotlin v1.5 - Pasarela Bold & QR Bre-B*",
"fileName": "DripZone-v1.6-Official.apk",
"caption": "📲 *DripZone Native v1.6 - Dominio drip-zone.online & Correo info@drip-zone.online*",
"media": APK_URL
}).encode("utf-8"),
headers={"apikey": EVOLUTION_API_KEY, "Content-Type": "application/json"}

View File

@ -0,0 +1,69 @@
import urllib.request
import json
import ssl
import sys
if sys.platform == 'win32':
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
EVOLUTION_API_URL = "https://evolution.almeira.com.co"
EVOLUTION_API_KEY = "83B5BD230796-4324-AE73-3508F69E279D"
INSTANCE_NAME = "DripZone"
TARGET_PHONE = "573167455877"
APK_URL = "https://drip-zone.online/DripZone.apk"
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
message_text = (
"🚀 *DripZone v1.6 - Dominio drip-zone.online & Correo Oficial info@drip-zone.online Configurados*\n\n"
"Hola Carlos, se ha completado el despliegue de las nuevas configuraciones en producción:\n\n"
"🌐 *1. Dominio Oficial & Correo Electrónico:*\n"
"• URL Principal: *https://drip-zone.online*\n"
"• Correo Oficial: *info@drip-zone.online*\n"
"• Servidor Traefik y Coolify enlazados con SSL Let's Encrypt activo.\n\n"
"📱 *2. Línea Celular Exclusiva DripZone (+57 310 639 0296):*\n"
"• Instancia Evolution API *DripZone* activa (`83B5BD230796-4324-AE73-3508F69E279D`).\n"
"• Códigos OTP, confirmaciones de compras, soporte y pasarela Bold se emiten desde esta línea oficial.\n\n"
"📥 *3. Publicaciones y Catálogo (Tu WhatsApp Personal):*\n"
"• La instancia *AutomataAlmeira* continúa recibiendo los mensajes de los grupos de proveedores mayoristas para sincronización inteligente de calzado.\n\n"
"💳 *4. Pasarela Bold & QR Bre-B en Producción:*\n"
"• Cobro de anticipo de $10.000 COP con generación automática de QR Bre-B y acreditación en vivo.\n\n"
"📲 *5. App Nativa Android v1.6 Sincronizada:*\n"
"• Compilada con el nuevo dominio y la línea oficial de atención.\n\n"
f"🔗 *Descarga Directa del APK:* {APK_URL}\n\n"
"Te adjunto el APK compilado para que puedas actualizar la aplicación en tu celular."
)
print("[1/2] Enviando mensaje de texto oficial a Carlos...")
req_text = urllib.request.Request(
f"{EVOLUTION_API_URL}/message/sendText/{INSTANCE_NAME}",
data=json.dumps({"number": TARGET_PHONE, "text": message_text, "delay": 500}).encode("utf-8"),
headers={"apikey": EVOLUTION_API_KEY, "Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req_text, context=ctx, timeout=30) as resp:
print(" [OK] Mensaje de texto enviado a WhatsApp (Status:", resp.status, ")")
except Exception as e:
print(" [Error texto WhatsApp]:", e)
print("[2/2] Enviando archivo APK compilado a WhatsApp...")
req_media = urllib.request.Request(
f"{EVOLUTION_API_URL}/message/sendMedia/{INSTANCE_NAME}",
data=json.dumps({
"number": TARGET_PHONE,
"mediatype": "document",
"mimetype": "application/vnd.android.package-archive",
"fileName": "DripZone-v1.6-Official.apk",
"caption": "📲 *DripZone Native v1.6 - Dominio drip-zone.online & Correo info@drip-zone.online*",
"media": APK_URL
}).encode("utf-8"),
headers={"apikey": EVOLUTION_API_KEY, "Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req_media, context=ctx, timeout=180) as resp:
print(" [OK] Documento APK enviado a WhatsApp (Status:", resp.status, ")")
except Exception as e:
print(" [Error envío documento APK]:", e)