diff --git a/deploy_dripzone_vps.py b/deploy_dripzone_vps.py index cab5a3a..9b85c5d 100644 --- a/deploy_dripzone_vps.py +++ b/deploy_dripzone_vps.py @@ -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() diff --git a/dripzone-app/android/app/src/main/AndroidManifest.xml b/dripzone-app/android/app/src/main/AndroidManifest.xml index 25774f4..5a0e472 100644 --- a/dripzone-app/android/app/src/main/AndroidManifest.xml +++ b/dripzone-app/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/MainActivity.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/MainActivity.kt index 13ac155..34ade6f 100644 --- a/dripzone-app/android/app/src/main/java/co/dripzone/app/MainActivity.kt +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/MainActivity.kt @@ -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, diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/api/ApiClient.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/api/ApiClient.kt index be3d2f8..1272ea3 100644 --- a/dripzone-app/android/app/src/main/java/co/dripzone/app/api/ApiClient.kt +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/api/ApiClient.kt @@ -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> = + 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>() {}.type + val orders: List = 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> = + 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() + 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) + } + } } diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/manager/AppNotificationManager.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/manager/AppNotificationManager.kt new file mode 100644 index 0000000..c436638 --- /dev/null +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/manager/AppNotificationManager.kt @@ -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 + ) { + 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 + ) { + 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 + ) { + 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 + } + } +} diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/manager/TemplateManager.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/manager/TemplateManager.kt new file mode 100644 index 0000000..5985096 --- /dev/null +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/manager/TemplateManager.kt @@ -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 { + 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") + } +} diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/model/Order.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/model/Order.kt new file mode 100644 index 0000000..92fe314 --- /dev/null +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/model/Order.kt @@ -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 = 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 diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/model/UserManager.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/model/UserManager.kt index 8c5d759..34bdc59 100644 --- a/dripzone-app/android/app/src/main/java/co/dripzone/app/model/UserManager.kt +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/model/UserManager.kt @@ -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() + } } diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/AccountBottomSheet.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/AccountBottomSheet.kt new file mode 100644 index 0000000..7c55d1b --- /dev/null +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/AccountBottomSheet.kt @@ -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" + } +} diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/CustomOrderBottomSheet.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/CustomOrderBottomSheet.kt index 0d05226..203b49b 100644 --- a/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/CustomOrderBottomSheet.kt +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/CustomOrderBottomSheet.kt @@ -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() diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/OrderVerificationBottomSheet.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/OrderVerificationBottomSheet.kt index bfc6c41..629ee7b 100644 --- a/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/OrderVerificationBottomSheet.kt +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/OrderVerificationBottomSheet.kt @@ -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 -> diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/OrdersAdapter.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/OrdersAdapter.kt new file mode 100644 index 0000000..1532afb --- /dev/null +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/OrdersAdapter.kt @@ -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, + private val onPayDepositClicked: (Order) -> Unit, + private val onInquireStatusClicked: (Order) -> Unit +) : RecyclerView.Adapter() { + + private val copFormat = NumberFormat.getCurrencyInstance(Locale("es", "CO")).apply { + maximumFractionDigits = 0 + } + + fun submitList(newOrders: List) { + 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) + } + } + } +} diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/OrdersBottomSheet.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/OrdersBottomSheet.kt new file mode 100644 index 0000000..e4cfdb9 --- /dev/null +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/OrdersBottomSheet.kt @@ -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" + } +} diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/ProductDetailBottomSheet.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/ProductDetailBottomSheet.kt index dd13ff0..3023920 100644 --- a/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/ProductDetailBottomSheet.kt +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/ProductDetailBottomSheet.kt @@ -78,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() diff --git a/dripzone-app/android/app/src/main/res/drawable/bg_category_chip.xml b/dripzone-app/android/app/src/main/res/drawable/bg_category_chip.xml new file mode 100644 index 0000000..9ee467c --- /dev/null +++ b/dripzone-app/android/app/src/main/res/drawable/bg_category_chip.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/dripzone-app/android/app/src/main/res/drawable/bg_category_chip_active.xml b/dripzone-app/android/app/src/main/res/drawable/bg_category_chip_active.xml new file mode 100644 index 0000000..912ba07 --- /dev/null +++ b/dripzone-app/android/app/src/main/res/drawable/bg_category_chip_active.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/dripzone-app/android/app/src/main/res/drawable/ic_package.xml b/dripzone-app/android/app/src/main/res/drawable/ic_package.xml new file mode 100644 index 0000000..54b8643 --- /dev/null +++ b/dripzone-app/android/app/src/main/res/drawable/ic_package.xml @@ -0,0 +1,9 @@ + + + diff --git a/dripzone-app/android/app/src/main/res/drawable/ic_user.xml b/dripzone-app/android/app/src/main/res/drawable/ic_user.xml new file mode 100644 index 0000000..2a5d2be --- /dev/null +++ b/dripzone-app/android/app/src/main/res/drawable/ic_user.xml @@ -0,0 +1,9 @@ + + + diff --git a/dripzone-app/android/app/src/main/res/layout/activity_main.xml b/dripzone-app/android/app/src/main/res/layout/activity_main.xml index 933a596..cc86815 100644 --- a/dripzone-app/android/app/src/main/res/layout/activity_main.xml +++ b/dripzone-app/android/app/src/main/res/layout/activity_main.xml @@ -136,9 +136,9 @@ android:visibility="gone" /> - + @@ -463,9 +464,9 @@ android:textSize="10sp" /> - + diff --git a/dripzone-app/android/app/src/main/res/layout/bottom_sheet_account.xml b/dripzone-app/android/app/src/main/res/layout/bottom_sheet_account.xml new file mode 100644 index 0000000..4ff8639 --- /dev/null +++ b/dripzone-app/android/app/src/main/res/layout/bottom_sheet_account.xml @@ -0,0 +1,353 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dripzone-app/android/app/src/main/res/layout/bottom_sheet_order_verification.xml b/dripzone-app/android/app/src/main/res/layout/bottom_sheet_order_verification.xml index aa023cd..7528cd0 100644 --- a/dripzone-app/android/app/src/main/res/layout/bottom_sheet_order_verification.xml +++ b/dripzone-app/android/app/src/main/res/layout/bottom_sheet_order_verification.xml @@ -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" /> diff --git a/dripzone-app/android/app/src/main/res/layout/bottom_sheet_orders.xml b/dripzone-app/android/app/src/main/res/layout/bottom_sheet_orders.xml new file mode 100644 index 0000000..6c90c60 --- /dev/null +++ b/dripzone-app/android/app/src/main/res/layout/bottom_sheet_orders.xml @@ -0,0 +1,148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dripzone-app/android/app/src/main/res/layout/bottom_sheet_product_detail.xml b/dripzone-app/android/app/src/main/res/layout/bottom_sheet_product_detail.xml index cb20da2..50aa8ec 100644 --- a/dripzone-app/android/app/src/main/res/layout/bottom_sheet_product_detail.xml +++ b/dripzone-app/android/app/src/main/res/layout/bottom_sheet_product_detail.xml @@ -170,6 +170,27 @@ app:iconPadding="8dp" app:iconSize="20dp" /> + + + @@ -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" diff --git a/dripzone-app/android/app/src/main/res/layout/item_order_card.xml b/dripzone-app/android/app/src/main/res/layout/item_order_card.xml new file mode 100644 index 0000000..8d99437 --- /dev/null +++ b/dripzone-app/android/app/src/main/res/layout/item_order_card.xml @@ -0,0 +1,302 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dripzone-app/android/app/src/main/res/layout/item_product.xml b/dripzone-app/android/app/src/main/res/layout/item_product.xml index 9c6baab..6889a28 100644 --- a/dripzone-app/android/app/src/main/res/layout/item_product.xml +++ b/dripzone-app/android/app/src/main/res/layout/item_product.xml @@ -94,44 +94,39 @@ android:textColor="@color/text_secondary" android:textSize="11sp" /> - - + + android:fontFamily="sans-serif-black" + android:maxLines="1" + android:singleLine="true" + android:text="$ 280.000" + android:textColor="@color/drip_purple_glow" + android:textSize="15sp" /> - - - - - + + diff --git a/dripzone-app/android/app/src/main/res/values/colors.xml b/dripzone-app/android/app/src/main/res/values/colors.xml index ea9e031..2d8f236 100644 --- a/dripzone-app/android/app/src/main/res/values/colors.xml +++ b/dripzone-app/android/app/src/main/res/values/colors.xml @@ -15,6 +15,8 @@ #9333EA + #9333EA + #282830 #A855F7 #D946EF #EC4899 diff --git a/dripzone-app/scripts/seed-templates-appwrite.js b/dripzone-app/scripts/seed-templates-appwrite.js new file mode 100644 index 0000000..e09a68f --- /dev/null +++ b/dripzone-app/scripts/seed-templates-appwrite.js @@ -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)); diff --git a/dripzone-app/src/app/admin/configuracion/page.tsx b/dripzone-app/src/app/admin/configuracion/page.tsx index f63eb3d..567254c 100644 --- a/dripzone-app/src/app/admin/configuracion/page.tsx +++ b/dripzone-app/src/app/admin/configuracion/page.tsx @@ -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: '', diff --git a/dripzone-app/src/app/api/auth/send-otp/route.ts b/dripzone-app/src/app/api/auth/send-otp/route.ts index 332e451..f40ec89 100644 --- a/dripzone-app/src/app/api/auth/send-otp/route.ts +++ b/dripzone-app/src/app/api/auth/send-otp/route.ts @@ -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); diff --git a/dripzone-app/src/app/api/bold/status/route.ts b/dripzone-app/src/app/api/bold/status/route.ts index 880af6c..e8f4c56 100644 --- a/dripzone-app/src/app/api/bold/status/route.ts +++ b/dripzone-app/src/app/api/bold/status/route.ts @@ -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) { diff --git a/dripzone-app/src/app/api/custom-requests/route.ts b/dripzone-app/src/app/api/custom-requests/route.ts index e592c93..acdfcd9 100644 --- a/dripzone-app/src/app/api/custom-requests/route.ts +++ b/dripzone-app/src/app/api/custom-requests/route.ts @@ -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(() => {}); diff --git a/dripzone-app/src/app/api/message-templates/route.ts b/dripzone-app/src/app/api/message-templates/route.ts index 74ba94e..f9716e3 100644 --- a/dripzone-app/src/app/api/message-templates/route.ts +++ b/dripzone-app/src/app/api/message-templates/route.ts @@ -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 }); diff --git a/dripzone-app/src/app/api/orders/route.ts b/dripzone-app/src/app/api/orders/route.ts index 8fe5d18..6d05be6 100644 --- a/dripzone-app/src/app/api/orders/route.ts +++ b/dripzone-app/src/app/api/orders/route.ts @@ -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 }); } } diff --git a/dripzone-app/src/app/checkout/page.tsx b/dripzone-app/src/app/checkout/page.tsx index 39f9a6f..b386fbe 100644 --- a/dripzone-app/src/app/checkout/page.tsx +++ b/dripzone-app/src/app/checkout/page.tsx @@ -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() {
📱 Transferencia Directa: - Nequi: {storeSettings?.nequiAccount || '316 745 5877'} + Nequi: {storeSettings?.nequiAccount || '310 639 0296'}
diff --git a/dripzone-app/src/lib/data-service.ts b/dripzone-app/src/lib/data-service.ts index 611f16b..de0e1d0 100644 --- a/dripzone-app/src/lib/data-service.ts +++ b/dripzone-app/src/lib/data-service.ts @@ -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(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(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('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('persisted_cities.json', [...INITIAL_CITIES]); + private paymentMethods: PaymentMethod[] = loadPersistedData('persisted_payment_methods.json', [...INITIAL_PAYMENT_METHODS]); + private suppliers: Supplier[] = loadPersistedData('persisted_suppliers.json', [...INITIAL_SUPPLIERS]); + private orders: Order[] = loadPersistedData('persisted_orders.json', [...INITIAL_ORDERS]); private supplierPosts: SupplierPost[] = [...INITIAL_SUPPLIER_POSTS]; + private settings: StoreSettings = loadPersistedData('persisted_settings.json', DEFAULT_STORE_SETTINGS); + private messageTemplates: MessageTemplate[] = loadPersistedData('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 { 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', diff --git a/dripzone-app/src/lib/otp-store.ts b/dripzone-app/src/lib/otp-store.ts index db688bb..c2e49ec 100644 --- a/dripzone-app/src/lib/otp-store.ts +++ b/dripzone-app/src/lib/otp-store.ts @@ -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 }; diff --git a/dripzone-app/src/lib/server-appwrite.ts b/dripzone-app/src/lib/server-appwrite.ts index 1a8785b..8f694fa 100644 --- a/dripzone-app/src/lib/server-appwrite.ts +++ b/dripzone-app/src/lib/server-appwrite.ts @@ -789,3 +789,83 @@ export async function toggleMonitoredGroupInAppwrite( } } +// ========================================================================= +// 9. PLANTILLAS DE MENSAJES & NOTIFICACIONES (CRUD APPWRITE) +// ========================================================================= +export async function getMessageTemplatesFromAppwrite(): Promise { + 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 +): Promise { + try { + const payload: Record = { + 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; + } +} + + diff --git a/dripzone-app/src/lib/templates-service.ts b/dripzone-app/src/lib/templates-service.ts index 076a98d..af8a5de 100644 --- a/dripzone-app/src/lib/templates-service.ts +++ b/dripzone-app/src/lib/templates-service.ts @@ -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' }, + ], + }, ]; /** diff --git a/dripzone-app/src/types/index.ts b/dripzone-app/src/types/index.ts index a027982..8fdfb85 100644 --- a/dripzone-app/src/types/index.ts +++ b/dripzone-app/src/types/index.ts @@ -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' } diff --git a/scripts/check_vps.py b/scripts/check_vps.py index 9535283..e4ef01a 100644 --- a/scripts/check_vps.py +++ b/scripts/check_vps.py @@ -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+*?" diff --git a/scripts/deploy_vps.py b/scripts/deploy_vps.py index ef50515..002850c 100644 --- a/scripts/deploy_vps.py +++ b/scripts/deploy_vps.py @@ -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"} diff --git a/scripts/send_official_whatsapp.py b/scripts/send_official_whatsapp.py new file mode 100644 index 0000000..62c04cb --- /dev/null +++ b/scripts/send_official_whatsapp.py @@ -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)