feat(mobile): Desarrollar app móvil nativa en Kotlin para DripZone con estándares modernos de seguridad, integración WhatsApp y despliegue continuo

This commit is contained in:
AI Bot 2026-09-05 10:38:27 -05:00
parent d7e7ab4d8b
commit 9f25aabcd8
49 changed files with 2850 additions and 137 deletions

View File

@ -1,54 +1,74 @@
apply plugin: 'com.android.application'
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
namespace = "co.dripzone.app"
compileSdk = rootProject.ext.compileSdkVersion
namespace 'co.dripzone.app'
compileSdk 35
defaultConfig {
applicationId "co.dripzone.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
minSdk 24
targetSdk 35
versionCode 2
versionName "2.0.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
debug {
applicationIdSuffix ""
debuggable true
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
buildFeatures {
viewBinding true
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'androidx.cardview:cardview:1.0.0'
apply from: 'capacitor.build.gradle'
// Coroutines & Lifecycle
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.4'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
// Networking & Serialization (Secure TLS & JSON)
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0'
implementation 'com.google.code.gson:gson:2.10.1'
// Image loading with Glide
implementation 'com.github.bumptech.glide:glide:4.16.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'
// Modern Security Crypto (EncryptedSharedPreferences)
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
}

View File

@ -1,21 +1,44 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# ProGuard / R8 Rules for DripZone Native Kotlin App
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Preserve model classes used for JSON serialization/deserialization
-keepclassmembers class * implements java.io.Serializable {
static final long serialVersionUID;
private static final java.io.ObjectStreamField[] serialPersistentFields;
!static !transient <fields>;
!private <fields>;
!private <methods>;
private void writeObject(java.io.ObjectOutputStream);
private void readObject(java.io.ObjectInputStream);
java.lang.Object writeReplace();
java.lang.Object readResolve();
}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# Keep DripZone Data Models
-keep class co.dripzone.app.model.** { *; }
-keepclassmembers class co.dripzone.app.model.** { *; }
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
# Gson specific rules
-keepattributes Signature
-keepattributes *Annotation*
-dontwarn sun.misc.**
-keep class com.google.gson.** { *; }
# OkHttp & Coroutines
-dontwarn okhttp3.**
-dontwarn okio.**
-keep class okhttp3.** { *; }
-keep interface okhttp3.** { *; }
-dontwarn kotlinx.coroutines.**
# Glide
-keep public class * implements com.bumptech.glide.module.GlideModule
-keep class * extends com.bumptech.glide.module.AppGlideModule {
<init>(...);
}
-keep public enum com.bumptech.glide.load.ImageHeaderParser$** {
**[] $VALUES;
public *;
}
# Android Security Crypto
-keepnames class androidx.security.crypto.** { *; }

View File

@ -1,20 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Permissions: Least privilege model -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Queries for secure intent resolution (Android 11+ package visibility) -->
<queries>
<package android:name="com.whatsapp" />
<package android:name="com.whatsapp.w4b" />
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
</queries>
<application
android:allowBackup="true"
android:allowBackup="false"
android:fullBackupContent="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.DripZone"
tools:targetApi="31">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:theme="@style/Theme.DripZone"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustPan"
android:configChanges="orientation|keyboardHidden|screenSize"
android:launchMode="singleTop"
android:exported="true">
<intent-filter>
@ -24,18 +45,6 @@
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@ -1,5 +0,0 @@
package co.dripzone.app;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}

View File

@ -0,0 +1,253 @@
package co.dripzone.app
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.View
import android.view.inputmethod.InputMethodManager
import androidx.appcompat.app.AppCompatActivity
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.model.Category
import co.dripzone.app.model.Product
import co.dripzone.app.security.SecurityManager
import co.dripzone.app.ui.CategoryAdapter
import co.dripzone.app.ui.CustomOrderBottomSheet
import co.dripzone.app.ui.ProductAdapter
import co.dripzone.app.ui.ProductDetailBottomSheet
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var productAdapter: ProductAdapter
private lateinit var categoryAdapter: CategoryAdapter
private var allProducts: List<Product> = emptyList()
private var selectedCategory: String = "Todos"
private var searchQuery: String = ""
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setupRecyclerViews()
setupCategories()
setupSearch()
setupListeners()
loadProducts(forceRefresh = false)
}
private fun setupRecyclerViews() {
productAdapter = ProductAdapter(
onProductClick = { product ->
ProductDetailBottomSheet.newInstance(product)
.show(supportFragmentManager, ProductDetailBottomSheet.TAG)
},
onQuickOrderClick = { product ->
val whatsappMsg = buildString {
append("¡Hola DripZone! 🔥 Quiero pedir este producto con *Pago Contraentrega*:\n\n")
append("👟 *${product.name}*\n")
append("🏷️ *Marca:* ${product.brand}\n")
append("💰 *Precio:* ${product.formattedPrice()}\n")
if (product.firstImage().isNotBlank()) {
append("🔗 *Foto:* ${product.firstImage()}\n")
}
append("\n¿Tienen disponibilidad para envío inmediato?")
}
SecurityManager.openWhatsApp(
this,
SecurityManager.WHATSAPP_STORE_PHONE,
whatsappMsg
)
}
)
binding.rvProducts.apply {
layoutManager = GridLayoutManager(this@MainActivity, 2)
adapter = productAdapter
setHasFixedSize(true)
}
}
private fun setupCategories() {
val categories = listOf(
Category("all", "Todos", isSelected = true),
Category("zapatillas", "Sneakers"),
Category("chanclas", "Chanclas"),
Category("bolsos", "Bolsos"),
Category("gorras", "Gorras"),
Category("bajo_pedido", "Bajo Pedido")
)
categoryAdapter = CategoryAdapter(categories) { category ->
selectedCategory = category.name
filterProducts()
}
binding.rvCategories.adapter = categoryAdapter
}
private fun setupSearch() {
binding.btnToggleSearch.setOnClickListener {
if (binding.layoutSearchBar.visibility == View.VISIBLE) {
binding.layoutSearchBar.visibility = View.GONE
binding.etSearch.text?.clear()
hideKeyboard()
} else {
binding.layoutSearchBar.visibility = View.VISIBLE
binding.etSearch.requestFocus()
showKeyboard()
}
}
binding.btnClearSearch.setOnClickListener {
binding.etSearch.text?.clear()
}
binding.etSearch.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
searchQuery = SecurityManager.sanitizeInput(s?.toString())
binding.btnClearSearch.visibility = if (searchQuery.isNotEmpty()) View.VISIBLE else View.GONE
filterProducts()
}
override fun afterTextChanged(s: Editable?) {}
})
binding.btnResetFilters.setOnClickListener {
binding.etSearch.text?.clear()
selectedCategory = "Todos"
categoryAdapter = CategoryAdapter(
listOf(
Category("all", "Todos", isSelected = true),
Category("zapatillas", "Sneakers"),
Category("chanclas", "Chanclas"),
Category("bolsos", "Bolsos"),
Category("gorras", "Gorras"),
Category("bajo_pedido", "Bajo Pedido")
)
) { category ->
selectedCategory = category.name
filterProducts()
}
binding.rvCategories.adapter = categoryAdapter
filterProducts()
}
}
private fun setupListeners() {
// Swipe to Refresh: fetches newest drops published from WhatsApp supplier groups
binding.swipeRefresh.setColorSchemeResources(R.color.accent_green)
binding.swipeRefresh.setProgressBackgroundColorSchemeResource(R.color.bg_card)
binding.swipeRefresh.setOnRefreshListener {
loadProducts(forceRefresh = true)
}
// WhatsApp direct chat from header
binding.btnHeaderWhatsApp.setOnClickListener {
val generalInquiry = "¡Hola DripZone! 🔥 Me comunico desde la aplicación móvil. Quiero información sobre los productos y envíos contraentrega."
SecurityManager.openWhatsApp(
this,
SecurityManager.WHATSAPP_STORE_PHONE,
generalInquiry
)
}
// Floating Action Button: Encargos Especiales
binding.fabCustomOrder.setOnClickListener {
CustomOrderBottomSheet.newInstance()
.show(supportFragmentManager, CustomOrderBottomSheet.TAG)
}
}
private fun loadProducts(forceRefresh: Boolean) {
if (!binding.swipeRefresh.isRefreshing) {
binding.progressBar.visibility = View.VISIBLE
}
lifecycleScope.launch {
try {
val fetched = ApiClient.getProducts(this@MainActivity, forceRefresh)
allProducts = fetched
filterProducts()
if (forceRefresh) {
Snackbar.make(
binding.rootCoordinator,
"Catálogo actualizado con los últimos drops",
Snackbar.LENGTH_SHORT
).setBackgroundTint(getColor(R.color.bg_card_elevated))
.setTextColor(getColor(R.color.accent_green))
.show()
}
} catch (e: Exception) {
Snackbar.make(
binding.rootCoordinator,
"Cargando catálogo en modo sin conexión",
Snackbar.LENGTH_LONG
).show()
} finally {
binding.progressBar.visibility = View.GONE
binding.swipeRefresh.isRefreshing = false
}
}
}
private fun filterProducts() {
val filtered = allProducts.filter { product ->
val matchesCategory = when (selectedCategory) {
"Todos" -> true
"Sneakers" -> product.category.contains("Zapatilla", ignoreCase = true) ||
product.category.contains("Sneaker", ignoreCase = true) ||
product.category.contains("Tenis", ignoreCase = true)
"Chanclas" -> product.category.contains("Chancla", ignoreCase = true) ||
product.category.contains("Slide", ignoreCase = true)
"Bolsos" -> product.category.contains("Bolso", ignoreCase = true) ||
product.category.contains("Morral", ignoreCase = true) ||
product.category.contains("Maleta", ignoreCase = true)
"Gorras" -> product.category.contains("Gorra", ignoreCase = true) ||
product.category.contains("Accesorio", ignoreCase = true)
"Bajo Pedido" -> !product.inStock
else -> true
}
val matchesSearch = if (searchQuery.isBlank()) {
true
} else {
product.name.contains(searchQuery, ignoreCase = true) ||
product.brand.contains(searchQuery, ignoreCase = true) ||
product.category.contains(searchQuery, ignoreCase = true) ||
product.sizesSummary().contains(searchQuery, ignoreCase = true)
}
matchesCategory && matchesSearch
}
productAdapter.submitList(filtered)
if (filtered.isEmpty()) {
binding.rvProducts.visibility = View.GONE
binding.layoutEmpty.visibility = View.VISIBLE
} else {
binding.rvProducts.visibility = View.VISIBLE
binding.layoutEmpty.visibility = View.GONE
}
}
private fun showKeyboard() {
val imm = getSystemService(INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.showSoftInput(binding.etSearch, InputMethodManager.SHOW_IMPLICIT)
}
private fun hideKeyboard() {
val imm = getSystemService(INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.hideSoftInputFromWindow(binding.etSearch.windowToken, 0)
}
}

View File

@ -0,0 +1,165 @@
package co.dripzone.app.api
import android.content.Context
import android.util.Log
import co.dripzone.app.model.Product
import co.dripzone.app.model.ProductsResponse
import co.dripzone.app.security.SecurityManager
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.ConnectionSpec
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.TlsVersion
import java.io.IOException
import java.util.concurrent.TimeUnit
object ApiClient {
private const val TAG = "DripApiClient"
private const val PRIMARY_URL = "https://dripzone.almeira.com.co/api/products"
private const val FALLBACK_URL = "https://news.almeira.com.co/api/products"
private const val CACHE_KEY_PRODUCTS = "cached_products_json"
private val gson = Gson()
// OkHttpClient enforcing TLS 1.3 and TLS 1.2 modern cipher suites
private val client: OkHttpClient by lazy {
val modernTlsSpec = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)
.tlsVersions(TlsVersion.TLS_1_3, TlsVersion.TLS_1_2)
.build()
OkHttpClient.Builder()
.connectionSpecs(listOf(modernTlsSpec, ConnectionSpec.COMPATIBLE_TLS))
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
}
/**
* Fetches products with fallback endpoints and secure offline cache
*/
suspend fun getProducts(context: Context, forceRefresh: Boolean = false): List<Product> =
withContext(Dispatchers.IO) {
// 1. Try primary URL
val primaryResult = fetchFromUrl(PRIMARY_URL)
if (primaryResult != null && primaryResult.isNotEmpty()) {
saveCache(context, primaryResult)
return@withContext primaryResult
}
// 2. Try fallback URL
val fallbackResult = fetchFromUrl(FALLBACK_URL)
if (fallbackResult != null && fallbackResult.isNotEmpty()) {
saveCache(context, fallbackResult)
return@withContext fallbackResult
}
// 3. If offline or network failed, return cached products
val cached = loadCache(context)
if (cached.isNotEmpty()) {
Log.i(TAG, "Serving ${cached.size} products from secure local cache")
return@withContext cached
}
// 4. Return initial fallback sample if cache is empty
return@withContext getSampleProducts()
}
private fun fetchFromUrl(url: String): List<Product>? {
return try {
val request = Request.Builder()
.url(url)
.header("Accept", "application/json")
.header("User-Agent", "DripZone-Android-Native/2.0")
.build()
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
Log.w(TAG, "Request to $url failed with code: ${response.code}")
return null
}
val bodyString = response.body?.string() ?: return null
parseProductsJson(bodyString)
} catch (e: IOException) {
Log.e(TAG, "Network error querying $url: ${e.message}")
null
} catch (e: Exception) {
Log.e(TAG, "Unexpected error querying $url: ${e.message}")
null
}
}
private fun parseProductsJson(json: String): List<Product> {
return try {
// First check if wrapped in { "products": [...] }
val wrapper = gson.fromJson(json, ProductsResponse::class.java)
if (wrapper?.products != null && wrapper.products.isNotEmpty()) {
return wrapper.products
}
// Or direct list [...]
val listType = object : TypeToken<List<Product>>() {}.type
gson.fromJson<List<Product>>(json, listType) ?: emptyList()
} catch (e: Exception) {
Log.e(TAG, "Error parsing products JSON: ${e.message}")
emptyList()
}
}
private fun saveCache(context: Context, products: List<Product>) {
try {
val prefs = SecurityManager.getSecurePreferences(context)
val json = gson.toJson(products)
prefs.edit().putString(CACHE_KEY_PRODUCTS, json).apply()
} catch (e: Exception) {
Log.e(TAG, "Failed to save secure cache", e)
}
}
private fun loadCache(context: Context): List<Product> {
return try {
val prefs = SecurityManager.getSecurePreferences(context)
val json = prefs.getString(CACHE_KEY_PRODUCTS, null) ?: return emptyList()
val listType = object : TypeToken<List<Product>>() {}.type
gson.fromJson<List<Product>>(json, listType) ?: emptyList()
} catch (e: Exception) {
Log.e(TAG, "Failed to load secure cache", e)
emptyList()
}
}
private fun getSampleProducts(): List<Product> {
return listOf(
Product(
id = "sample-1",
name = "Nike Air Jordan 4 Retro 'Military Black'",
brand = "JORDAN",
retailPrice = 285000.0,
category = "Zapatillas",
sizes = listOf("38", "39", "40", "41", "42", "43", "44"),
images = listOf("https://images.unsplash.com/photo-1552346154-21d32810aba3?w=800&q=80"),
description = "Sneakers urbanos de máxima calidad con envío contraentrega en toda Colombia.",
inStock = true,
isFeatured = true
),
Product(
id = "sample-2",
name = "Yeezy Slide 'Onyx'",
brand = "ADIDAS",
retailPrice = 160000.0,
category = "Chanclas",
sizes = listOf("37", "38", "39", "40", "41", "42"),
images = listOf("https://images.unsplash.com/photo-1608231387042-66d1773070a5?w=800&q=80"),
description = "Chanclas slides ultra cómodas estilo streetwear.",
inStock = true,
isFeatured = true
)
)
}
}

View File

@ -0,0 +1,110 @@
package co.dripzone.app.model
import com.google.gson.annotations.SerializedName
import java.io.Serializable
import java.text.NumberFormat
import java.util.Locale
data class Product(
@SerializedName("id")
val id: String = "",
@SerializedName("name")
val name: String = "",
@SerializedName("brand")
val brand: String = "DRIP",
@SerializedName("retailPrice")
val retailPrice: Double? = null,
@SerializedName("price")
val price: Double? = null,
@SerializedName("originalPrice")
val originalPrice: Double? = null,
@SerializedName("category")
val category: String = "Zapatillas",
@SerializedName("images")
val images: List<String>? = null,
@SerializedName("image")
val singleImage: String? = null,
@SerializedName("sizes")
val sizes: List<String>? = null,
@SerializedName("description")
val description: String? = null,
@SerializedName("inStock")
val inStock: Boolean = true,
@SerializedName("isFeatured")
val isFeatured: Boolean = false,
@SerializedName("whatsappMessageId")
val whatsappMessageId: String? = null
) : Serializable {
val effectivePrice: Double
get() = retailPrice ?: price ?: 0.0
fun formattedPrice(): String {
return try {
val format = NumberFormat.getCurrencyInstance(Locale("es", "CO"))
format.maximumFractionDigits = 0
format.format(effectivePrice) + " COP"
} catch (e: Exception) {
val p = effectivePrice.toLong()
"$ $p COP"
}
}
fun firstImage(): String {
if (!images.isNullOrEmpty() && images.first().isNotBlank()) {
return images.first()
}
if (!singleImage.isNullOrBlank()) {
return singleImage
}
return ""
}
fun sizesList(): List<String> {
if (!sizes.isNullOrEmpty()) {
return sizes.filter { it.isNotBlank() }
}
return listOf("38", "39", "40", "41", "42", "43")
}
fun sizesSummary(): String {
val s = sizesList()
return if (s.size > 1) {
"Tallas: ${s.first()} - ${s.last()}"
} else if (s.size == 1) {
"Talla: ${s.first()}"
} else {
"Tallas disponibles"
}
}
}
data class ProductsResponse(
@SerializedName("products")
val products: List<Product>? = null,
@SerializedName("success")
val success: Boolean = true,
@SerializedName("total")
val total: Int = 0
)
data class Category(
val id: String,
val name: String,
var isSelected: Boolean = false
)

View File

@ -0,0 +1,92 @@
package co.dripzone.app.security
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
import android.util.Log
import android.widget.Toast
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
object SecurityManager {
private const val TAG = "DripSecurity"
private const val PREFS_FILE = "dripzone_secure_prefs"
const val WHATSAPP_STORE_PHONE = "573167455877"
/**
* Initializes secure EncryptedSharedPreferences using Android KeyStore MasterKey (AES-256 GCM)
*/
fun getSecurePreferences(context: Context): SharedPreferences {
return try {
val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
EncryptedSharedPreferences.create(
PREFS_FILE,
masterKeyAlias,
context.applicationContext,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
} catch (e: Exception) {
Log.w(TAG, "Fallback to standard preferences due to security provider issue: ${e.message}")
context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)
}
}
/**
* Safely dispatches an intent to WhatsApp using strict scheme validation
* to prevent Intent Redirection, Tapjacking, and Scheme Spoofing attacks.
*/
fun openWhatsApp(context: Context, phoneNumber: String, message: String) {
try {
val sanitizedPhone = phoneNumber.filter { it.isDigit() }
val encodedMessage = URLEncoder.encode(message, StandardCharsets.UTF_8.toString())
val httpsUrl = "https://api.whatsapp.com/send?phone=$sanitizedPhone&text=$encodedMessage"
val intent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(httpsUrl)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
// Verify if WhatsApp or WhatsApp Business is installed
val packageManager = context.packageManager
val activities = packageManager.queryIntentActivities(intent, 0)
val hasWhatsApp = activities.any {
val pkg = it.activityInfo.packageName
pkg == "com.whatsapp" || pkg == "com.whatsapp.w4b"
}
if (hasWhatsApp) {
// Try targeted WhatsApp package for maximum security
val waIntent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(httpsUrl)
setPackage("com.whatsapp")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
context.startActivity(waIntent)
return
} catch (e: Exception) {
// Fall back to general chooser/browser
}
}
// Launch secure HTTPS link in browser or available handler
context.startActivity(intent)
} catch (e: Exception) {
Log.e(TAG, "Error dispatching WhatsApp intent", e)
Toast.makeText(context, "No se pudo abrir WhatsApp: ${e.localizedMessage}", Toast.LENGTH_SHORT).show()
}
}
/**
* Sanitizes user input string against XSS or command injection when preparing search or orders
*/
fun sanitizeInput(input: String?): String {
if (input.isNullOrBlank()) return ""
return input.replace(Regex("[<>&'\"\\\\]"), "").trim()
}
}

View File

@ -0,0 +1,59 @@
package co.dripzone.app.ui
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import co.dripzone.app.R
import co.dripzone.app.databinding.ItemCategoryChipBinding
import co.dripzone.app.model.Category
class CategoryAdapter(
private val categories: List<Category>,
private val onCategoryClick: (Category) -> Unit
) : RecyclerView.Adapter<CategoryAdapter.CategoryViewHolder>() {
private var selectedIndex = 0
inner class CategoryViewHolder(private val binding: ItemCategoryChipBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(category: Category, isSelected: Boolean) {
binding.tvCategoryName.text = category.name
binding.tvCategoryName.isSelected = isSelected
if (isSelected) {
binding.tvCategoryName.setTextColor(
ContextCompat.getColor(binding.root.context, R.color.text_primary)
)
} else {
binding.tvCategoryName.setTextColor(
ContextCompat.getColor(binding.root.context, R.color.text_secondary)
)
}
binding.root.setOnClickListener {
val previousIndex = selectedIndex
selectedIndex = adapterPosition
notifyItemChanged(previousIndex)
notifyItemChanged(selectedIndex)
onCategoryClick(category)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CategoryViewHolder {
val binding = ItemCategoryChipBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return CategoryViewHolder(binding)
}
override fun onBindViewHolder(holder: CategoryViewHolder, position: Int) {
holder.bind(categories[position], position == selectedIndex)
}
override fun getItemCount(): Int = categories.size
}

View File

@ -0,0 +1,98 @@
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 co.dripzone.app.databinding.DialogCustomOrderBinding
import co.dripzone.app.security.SecurityManager
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
class CustomOrderBottomSheet : BottomSheetDialogFragment() {
private var _binding: DialogCustomOrderBinding? = null
private val binding get() = _binding!!
private var initialModel: String = ""
private var initialBrand: String = ""
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.let {
initialModel = it.getString(ARG_MODEL, "")
initialBrand = it.getString(ARG_BRAND, "")
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = DialogCustomOrderBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
if (initialModel.isNotBlank()) {
binding.etCustomModel.setText(initialModel)
}
binding.btnSubmitCustomOrder.setOnClickListener {
val rawModel = binding.etCustomModel.text?.toString()
val rawSize = binding.etCustomSize.text?.toString()
val rawNotes = binding.etCustomNotes.text?.toString()
val sanitizedModel = SecurityManager.sanitizeInput(rawModel)
val sanitizedSize = SecurityManager.sanitizeInput(rawSize)
val sanitizedNotes = SecurityManager.sanitizeInput(rawNotes)
if (sanitizedModel.isBlank()) {
binding.etCustomModel.error = "Por favor indica el modelo deseado"
return@setOnClickListener
}
val message = buildString {
append("¡Hola DripZone! 🚀 Quiero consultar por un *Pedido Especial / Encargo*:\n\n")
append("👟 *Modelo deseado:* $sanitizedModel\n")
if (sanitizedSize.isNotBlank()) {
append("📏 *Talla estimada:* $sanitizedSize\n")
}
if (sanitizedNotes.isNotBlank()) {
append("📝 *Detalles / Colorway:* $sanitizedNotes\n")
}
append("\n¿Tienen disponibilidad o pueden cotizarlo con sus proveedores mayoristas?")
}
SecurityManager.openWhatsApp(
requireContext(),
SecurityManager.WHATSAPP_STORE_PHONE,
message
)
dismiss()
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
companion object {
const val TAG = "CustomOrderBottomSheet"
private const val ARG_MODEL = "arg_model"
private const val ARG_BRAND = "arg_brand"
fun newInstance(model: String = "", brand: String = ""): CustomOrderBottomSheet {
return CustomOrderBottomSheet().apply {
arguments = Bundle().apply {
putString(ARG_MODEL, model)
putString(ARG_BRAND, brand)
}
}
}
}
}

View File

@ -0,0 +1,84 @@
package co.dripzone.app.ui
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import co.dripzone.app.R
import co.dripzone.app.databinding.ItemProductBinding
import co.dripzone.app.model.Product
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
class ProductAdapter(
private val onProductClick: (Product) -> Unit,
private val onQuickOrderClick: (Product) -> Unit
) : ListAdapter<Product, ProductAdapter.ProductViewHolder>(ProductDiffCallback()) {
inner class ProductViewHolder(private val binding: ItemProductBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(product: Product) {
val context = binding.root.context
binding.tvProductName.text = product.name
binding.tvProductPrice.text = product.formattedPrice()
binding.tvSizesAvailable.text = product.sizesSummary()
binding.tvBrandBadge.text = product.brand.uppercase()
if (product.inStock) {
binding.tvStockBadge.text = "ENVÍO HOY"
binding.tvStockBadge.setTextColor(ContextCompat.getColor(context, R.color.badge_stock_text))
} else {
binding.tvStockBadge.text = "BAJO PEDIDO"
binding.tvStockBadge.setTextColor(ContextCompat.getColor(context, R.color.badge_pedido_text))
}
val imageUrl = product.firstImage()
if (imageUrl.isNotBlank()) {
Glide.with(context)
.load(imageUrl)
.transition(DrawableTransitionOptions.withCrossFade())
.centerCrop()
.placeholder(R.drawable.bg_product_card)
.into(binding.ivProductImage)
} else {
binding.ivProductImage.setImageResource(R.drawable.splash)
}
binding.cardProduct.setOnClickListener {
onProductClick(product)
}
binding.btnQuickOrder.setOnClickListener {
onQuickOrderClick(product)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProductViewHolder {
val binding = ItemProductBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return ProductViewHolder(binding)
}
override fun onBindViewHolder(holder: ProductViewHolder, position: Int) {
holder.bind(getItem(position))
}
class ProductDiffCallback : DiffUtil.ItemCallback<Product>() {
override fun areItemsTheSame(oldItem: Product, newItem: Product): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Product, newItem: Product): Boolean {
return oldItem == newItem
}
}
}

View File

@ -0,0 +1,120 @@
package co.dripzone.app.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import co.dripzone.app.R
import co.dripzone.app.databinding.BottomSheetProductDetailBinding
import co.dripzone.app.model.Product
import co.dripzone.app.security.SecurityManager
import com.bumptech.glide.Glide
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
class ProductDetailBottomSheet : BottomSheetDialogFragment() {
private var _binding: BottomSheetProductDetailBinding? = null
private val binding get() = _binding!!
private var product: Product? = null
private var sizeAdapter: SizeAdapter? = null
private var selectedSize: String = ""
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.let {
@Suppress("DEPRECATION")
product = it.getSerializable(ARG_PRODUCT) as? Product
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = BottomSheetProductDetailBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val p = product ?: return
binding.tvSheetBrand.text = p.brand.uppercase()
binding.tvSheetTitle.text = p.name
binding.tvSheetPrice.text = p.formattedPrice()
val imageUrl = p.firstImage()
if (imageUrl.isNotBlank()) {
Glide.with(this)
.load(imageUrl)
.centerCrop()
.placeholder(R.drawable.bg_product_card)
.into(binding.ivSheetImage)
}
// Setup size selection
val sizes = p.sizesList()
sizeAdapter = SizeAdapter(sizes) { size ->
selectedSize = size
}
binding.rvSizes.adapter = sizeAdapter
selectedSize = sizeAdapter?.getSelectedSize() ?: ""
binding.btnSheetClose.setOnClickListener {
dismiss()
}
// WhatsApp Order Checkout Button
binding.btnOrderWhatsApp.setOnClickListener {
val sizeToOrder = if (selectedSize.isNotBlank()) selectedSize else sizeAdapter?.getSelectedSize() ?: "Por coordinar"
val whatsappMessage = buildString {
append("¡Hola DripZone! 🔥 Me interesa ordenar este producto:\n\n")
append("👟 *Producto:* ${p.name}\n")
append("🏷️ *Marca:* ${p.brand}\n")
append("📏 *Talla solicitada:* $sizeToOrder\n")
append("💰 *Precio:* ${p.formattedPrice()}\n")
append("🚚 *Método:* Envío con Pago Contraentrega\n")
if (imageUrl.isNotBlank()) {
append("\n🔗 *Foto:* $imageUrl\n")
}
append("\n¿Tienen disponibilidad inmediata para despacho?")
}
SecurityManager.openWhatsApp(
requireContext(),
SecurityManager.WHATSAPP_STORE_PHONE,
whatsappMessage
)
dismiss()
}
// Custom order button inside bottom sheet
binding.btnCustomOrderSheet.setOnClickListener {
dismiss()
val customOrderSheet = CustomOrderBottomSheet.newInstance(p.name, p.brand)
parentFragmentManager.let {
customOrderSheet.show(it, CustomOrderBottomSheet.TAG)
}
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
companion object {
const val TAG = "ProductDetailBottomSheet"
private const val ARG_PRODUCT = "arg_product"
fun newInstance(product: Product): ProductDetailBottomSheet {
return ProductDetailBottomSheet().apply {
arguments = Bundle().apply {
putSerializable(ARG_PRODUCT, product)
}
}
}
}
}

View File

@ -0,0 +1,72 @@
package co.dripzone.app.ui
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import co.dripzone.app.R
import co.dripzone.app.databinding.ItemSizeChipBinding
class SizeAdapter(
private val sizes: List<String>,
private val onSizeSelected: (String) -> Unit
) : RecyclerView.Adapter<SizeAdapter.SizeViewHolder>() {
private var selectedIndex = 0
init {
if (sizes.isNotEmpty()) {
onSizeSelected(sizes[0])
}
}
fun getSelectedSize(): String {
return if (sizes.isNotEmpty() && selectedIndex in sizes.indices) {
sizes[selectedIndex]
} else {
"38-43"
}
}
inner class SizeViewHolder(private val binding: ItemSizeChipBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(size: String, isSelected: Boolean) {
binding.tvSizeLabel.text = size
binding.tvSizeLabel.isSelected = isSelected
if (isSelected) {
binding.tvSizeLabel.setTextColor(
ContextCompat.getColor(binding.root.context, R.color.text_primary)
)
} else {
binding.tvSizeLabel.setTextColor(
ContextCompat.getColor(binding.root.context, R.color.text_secondary)
)
}
binding.root.setOnClickListener {
val prev = selectedIndex
selectedIndex = adapterPosition
notifyItemChanged(prev)
notifyItemChanged(selectedIndex)
onSizeSelected(size)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SizeViewHolder {
val binding = ItemSizeChipBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return SizeViewHolder(binding)
}
override fun onBindViewHolder(holder: SizeViewHolder, position: Int) {
holder.bind(sizes[position], position == selectedIndex)
}
override fun getItemCount(): Int = sizes.size
}

View File

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

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#20FFFFFF">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/bg_card_elevated" />
<corners android:radius="16dp" />
<stroke android:width="1dp" android:color="@color/bg_card_stroke" />
</shape>
</item>
</ripple>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#40FFFFFF">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/accent_whatsapp" />
<corners android:radius="16dp" />
</shape>
</item>
</ripple>

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#9CA3AF"
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M19,3H5c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2zm-2,10h-4v4h-2v-4H7v-2h4V7h2v4h4v2z"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#10B981"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#9CA3AF"
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zm-6,0C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#10B981"
android:pathData="M12,1L3,5v6c0,5.55 3.84,10.74 9,12 5.16,-1.26 9,-6.45 9,-12V5l-9,-4zm-2,16l-4,-4 1.41,-1.41L10,14.17l6.59,-6.59L18,9l-8,8z"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#10B981"
android:pathData="M9,21l1.5,-4.5L15,15l-4.5,-1.5L9,9l-1.5,4.5L3,15l4.5,1.5L9,21zM19,10.5l1,-2.5 2.5,-1 -2.5,-1 -1,-2.5 -1,2.5 -2.5,1 2.5,1 1,2.5zM19,15.5l-1,2.5 -2.5,1 2.5,1 1,2.5 1,-2.5 2.5,-1 -2.5,-1 -1,-2.5z"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#F59E0B"
android:pathData="M20,8h-3V4H1v13h2c0,1.66 1.34,3 3,3s3,-1.34 3,-3h6c0,1.66 1.34,3 3,3s3,-1.34 3,-3h2v-5l-4,-4zm-5,-2v2h-4V6h4zm-6,0v2H3V6h6zm-3,12c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1 1,0.45 1,1 -0.45,1 -1,1zm12,0c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1 1,0.45 1,1 -0.45,1 -1,1zm2,-4h-2.58l-2.42,-3h2.67l2.33,3z"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M12.04,2C6.58,2 2.13,6.45 2.13,11.91C2.13,13.66 2.59,15.36 3.45,16.86L2.05,22L7.3,20.62C8.75,21.41 10.38,21.83 12.04,21.83C17.5,21.83 21.95,17.38 21.95,11.92C21.95,9.27 20.92,6.78 19.05,4.91C17.18,3.03 14.69,2 12.04,2M12.05,3.67C14.25,3.67 16.31,4.53 17.87,6.09C19.42,7.65 20.28,9.72 20.28,11.92C20.28,16.46 16.58,20.15 12.04,20.15C10.56,20.15 9.11,19.76 7.85,19L7.55,18.83L4.43,19.65L5.26,16.61L5.06,16.29C4.24,14.99 3.81,13.47 3.81,11.91C3.81,7.37 7.5,3.67 12.05,3.67M9.13,7.38C8.94,7.38 8.64,7.45 8.38,7.74C8.12,8.02 7.38,8.72 7.38,10.15C7.38,11.58 8.42,12.96 8.57,13.15C8.72,13.34 10.58,16.22 13.47,17.47C14.16,17.77 14.7,17.95 15.12,18.08C15.82,18.3 16.45,18.27 16.95,18.2C17.52,18.11 18.7,17.48 18.95,16.78C19.19,16.08 19.19,15.48 19.12,15.35C19.05,15.23 18.86,15.16 18.57,15.01C18.29,14.87 16.9,14.18 16.64,14.09C16.38,14 16.19,13.95 16,14.24C15.81,14.53 15.28,15.16 15.12,15.35C14.96,15.53 14.8,15.56 14.51,15.41C14.23,15.27 13.31,14.97 12.23,14C11.39,13.25 10.82,12.32 10.66,12.04C10.5,11.76 10.64,11.6 10.79,11.46C10.92,11.33 11.08,11.12 11.22,10.95C11.37,10.79 11.42,10.67 11.52,10.48C11.61,10.28 11.57,10.12 11.5,9.97C11.42,9.83 10.85,8.43 10.61,7.86C10.38,7.31 10.15,7.38 9.97,7.38L9.13,7.38Z"/>
</vector>

View File

@ -2,11 +2,269 @@
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/rootCoordinator"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
android:background="@color/bg_dark">
<WebView
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBarLayout"
android:layout_width="match_parent"
android:layout_height="match_parent" />
android:layout_height="wrap_content"
android:background="@color/bg_dark"
app:elevation="0dp">
<!-- Top Header Bar -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingTop="12dp"
android:paddingBottom="8dp">
<!-- Brand Logo / Title -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-black"
android:letterSpacing="0.08"
android:text="DRIPZONE"
android:textColor="@color/text_primary"
android:textSize="20sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-medium"
android:letterSpacing="0.05"
android:text="SNEAKERS &amp; STREETWEAR"
android:textColor="@color/accent_green"
android:textSize="10sp"
android:textStyle="bold" />
</LinearLayout>
<!-- Search Toggle -->
<ImageButton
android:id="@+id/btnToggleSearch"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Buscar"
android:src="@drawable/ic_search" />
<!-- WhatsApp Direct Support -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnHeaderWhatsApp"
android:layout_width="wrap_content"
android:layout_height="38dp"
android:layout_marginStart="8dp"
android:filterTouchesWhenObscured="true"
android:insetTop="0dp"
android:insetBottom="0dp"
android:paddingHorizontal="12dp"
android:text="WhatsApp"
android:textAllCaps="false"
android:textSize="12sp"
android:textStyle="bold"
app:backgroundTint="@color/accent_whatsapp"
app:cornerRadius="12dp"
app:icon="@drawable/ic_whatsapp"
app:iconGravity="textStart"
app:iconPadding="6dp"
app:iconSize="16dp" />
</LinearLayout>
<!-- Search Input Bar (Visible or Toggleable) -->
<LinearLayout
android:id="@+id/layoutSearchBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="4dp"
android:layout_marginBottom="8dp"
android:background="@drawable/bg_search_input"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="12dp">
<ImageView
android:layout_width="20dp"
android:layout_height="20dp"
android:src="@drawable/ic_search" />
<EditText
android:id="@+id/etSearch"
android:layout_width="0dp"
android:layout_height="42dp"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:background="@null"
android:hint="Buscar zapatillas, chanclas, bolsos..."
android:imeOptions="actionSearch"
android:inputType="text"
android:maxLines="1"
android:textColor="@color/text_primary"
android:textColorHint="@color/text_muted"
android:textSize="13sp" />
<ImageButton
android:id="@+id/btnClearSearch"
android:layout_width="32dp"
android:layout_height="32dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Limpiar"
android:src="@drawable/ic_close"
android:visibility="gone" />
</LinearLayout>
<!-- Live Drops Banner -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="8dp"
android:background="@drawable/bg_btn_secondary"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="12dp"
android:paddingVertical="8dp">
<ImageView
android:layout_width="16dp"
android:layout_height="16dp"
android:src="@drawable/ic_sparkle" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:fontFamily="sans-serif-bold"
android:letterSpacing="0.04"
android:text="⚡ DROPS SINCRONIZADOS • PAGO CONTRAENTREGA"
android:textColor="@color/accent_green"
android:textSize="10sp" />
</LinearLayout>
<!-- Categories Horizontal Filter -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvCategories"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:clipToPadding="false"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_category_chip" />
</com.google.android.material.appbar.AppBarLayout>
<!-- Swipe Refresh Layout containing Products Grid -->
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefresh"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- Products RecyclerView (2-column Grid) -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvProducts"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingHorizontal="10dp"
android:paddingTop="6dp"
android:paddingBottom="80dp"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:spanCount="2"
tools:listitem="@layout/item_product" />
<!-- Empty / No Results View -->
<LinearLayout
android:id="@+id/layoutEmpty"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:orientation="vertical"
android:padding="32dp"
android:visibility="gone">
<ImageView
android:layout_width="64dp"
android:layout_height="64dp"
android:src="@drawable/ic_search" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:fontFamily="sans-serif-bold"
android:text="No se encontraron productos"
android:textColor="@color/text_primary"
android:textSize="16sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:gravity="center"
android:text="Prueba con otra búsqueda o solicita tu encargo personalizado"
android:textColor="@color/text_secondary"
android:textSize="13sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnResetFilters"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:filterTouchesWhenObscured="true"
android:text="Ver todos los productos"
android:textAllCaps="false"
app:backgroundTint="@color/accent_green"
app:cornerRadius="12dp" />
</LinearLayout>
<!-- Loading Indicator -->
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center"
android:indeterminateTint="@color/accent_green"
android:visibility="gone" />
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<!-- Floating Action Button for Custom Encargos / Requests -->
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/fabCustomOrder"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:filterTouchesWhenObscured="true"
android:text="Encargos"
android:textColor="#FFFFFF"
android:textSize="13sp"
android:textStyle="bold"
app:backgroundTint="@color/accent_green"
app:icon="@drawable/ic_encargos"
app:iconPadding="8dp"
app:iconTint="#FFFFFF" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,189 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_bottom_sheet"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<!-- Drag handle bar -->
<View
android:layout_width="48dp"
android:layout_height="4dp"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="16dp"
android:background="#40FFFFFF" />
<!-- Header with brand & close button -->
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/tvSheetBrand"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:background="@drawable/bg_chip_category"
android:fontFamily="sans-serif-black"
android:paddingHorizontal="10dp"
android:paddingVertical="4dp"
android:text="NIKE AIR"
android:textColor="@color/accent_green"
android:textSize="11sp" />
<ImageButton
android:id="@+id/btnSheetClose"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_alignParentEnd="true"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Cerrar"
android:src="@drawable/ic_close" />
</RelativeLayout>
<!-- Large Product Image -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="250dp"
android:layout_marginTop="12dp"
app:cardBackgroundColor="#121216"
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
app:strokeColor="@color/bg_card_stroke"
app:strokeWidth="1dp">
<ImageView
android:id="@+id/ivSheetImage"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="Foto ampliada del producto"
android:scaleType="centerCrop" />
</com.google.android.material.card.MaterialCardView>
<!-- Product Name -->
<TextView
android:id="@+id/tvSheetTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:fontFamily="sans-serif-black"
android:text="Nike SB Dunk Low Pro 'Chicago'"
android:textColor="@color/text_primary"
android:textSize="18sp" />
<!-- Price -->
<TextView
android:id="@+id/tvSheetPrice"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:fontFamily="sans-serif-black"
android:text="$ 280.000 COP"
android:textColor="@color/accent_green"
android:textSize="22sp" />
<!-- Trust / Contraentrega Badge -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:background="@drawable/bg_btn_secondary"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="12dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_truck" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-medium"
android:text="Pago Contraentrega en Toda Colombia"
android:textColor="@color/text_primary"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Recibe tu pedido y paga en efectivo al recibir"
android:textColor="@color/text_secondary"
android:textSize="11sp" />
</LinearLayout>
</LinearLayout>
<!-- Size Selector Section -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:fontFamily="sans-serif-medium"
android:text="Selecciona tu talla:"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:textStyle="bold" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvSizes"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_size_chip" />
<!-- WhatsApp Checkout Action Button with Tapjacking Protection -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnOrderWhatsApp"
android:layout_width="match_parent"
android:layout_height="54dp"
android:layout_marginTop="22dp"
android:filterTouchesWhenObscured="true"
android:text="Pedir por WhatsApp (Contraentrega)"
android:textAllCaps="false"
android:textColor="#FFFFFF"
android:textSize="15sp"
android:textStyle="bold"
app:backgroundTint="@color/accent_whatsapp"
app:cornerRadius="16dp"
app:icon="@drawable/ic_whatsapp"
app:iconGravity="textStart"
app:iconPadding="8dp"
app:iconSize="22dp" />
<!-- Secondary Custom Request Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnCustomOrderSheet"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="10dp"
android:filterTouchesWhenObscured="true"
android:text="¿No ves tu talla? Pedir por Encargo"
android:textAllCaps="false"
android:textColor="@color/accent_green"
android:textSize="13sp"
app:cornerRadius="14dp"
app:strokeColor="@color/accent_green"
app:strokeWidth="1dp" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View File

@ -0,0 +1,128 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_bottom_sheet"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="22dp">
<View
android:layout_width="48dp"
android:layout_height="4dp"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="16dp"
android:background="#40FFFFFF" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-black"
android:text="🔥 Pedido Especial / Encargo"
android:textColor="@color/text_primary"
android:textSize="20sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="¿Buscas un modelo exclusivo o una talla específica de nuestros proveedores? Nosotros te lo conseguimos."
android:textColor="@color/text_secondary"
android:textSize="13sp" />
<!-- Reference / Model input -->
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:hint="Modelo / Referencia (ej. Jordan 4 Military Black)"
android:textColorHint="@color/text_muted"
app:boxBackgroundColor="@color/bg_input"
app:boxCornerRadiusBottomEnd="12dp"
app:boxCornerRadiusBottomStart="12dp"
app:boxCornerRadiusTopEnd="12dp"
app:boxCornerRadiusTopStart="12dp"
app:boxStrokeColor="@color/accent_green">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etCustomModel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/text_primary"
android:textSize="14sp" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Size input -->
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="Talla deseada (ej. 41 EUR / 8 US)"
android:textColorHint="@color/text_muted"
app:boxBackgroundColor="@color/bg_input"
app:boxCornerRadiusBottomEnd="12dp"
app:boxCornerRadiusBottomStart="12dp"
app:boxCornerRadiusTopEnd="12dp"
app:boxCornerRadiusTopStart="12dp"
app:boxStrokeColor="@color/accent_green">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etCustomSize"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/text_primary"
android:textSize="14sp" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Notes input -->
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="Colorway o detalles adicionales"
android:textColorHint="@color/text_muted"
app:boxBackgroundColor="@color/bg_input"
app:boxCornerRadiusBottomEnd="12dp"
app:boxCornerRadiusBottomStart="12dp"
app:boxCornerRadiusTopEnd="12dp"
app:boxCornerRadiusTopStart="12dp"
app:boxStrokeColor="@color/accent_green">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etCustomNotes"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:lines="2"
android:textColor="@color/text_primary"
android:textSize="14sp" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Submit to WhatsApp Button with Tapjacking Protection -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSubmitCustomOrder"
android:layout_width="match_parent"
android:layout_height="52dp"
android:layout_marginTop="20dp"
android:filterTouchesWhenObscured="true"
android:text="Consultar con Asesor WhatsApp"
android:textAllCaps="false"
android:textColor="#FFFFFF"
android:textSize="15sp"
android:textStyle="bold"
app:backgroundTint="@color/accent_whatsapp"
app:cornerRadius="16dp"
app:icon="@drawable/ic_whatsapp"
app:iconGravity="textStart"
app:iconPadding="8dp" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/tvCategoryName"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:layout_marginEnd="8dp"
android:background="@drawable/bg_chip_category"
android:clickable="true"
android:focusable="true"
android:gravity="center"
android:paddingHorizontal="16dp"
android:text="Todos"
android:textColor="@color/text_primary"
android:textSize="12sp"
android:textStyle="bold" />

View File

@ -0,0 +1,140 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/cardProduct"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="6dp"
android:clickable="true"
android:focusable="true"
app:cardBackgroundColor="@color/bg_card"
app:cardCornerRadius="16dp"
app:cardElevation="2dp"
app:strokeColor="@color/bg_card_stroke"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- Image Container with Badges -->
<FrameLayout
android:layout_width="match_parent"
android:layout_height="170dp"
android:background="#121216">
<ImageView
android:id="@+id/ivProductImage"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="Foto del producto"
android:scaleType="centerCrop" />
<!-- Brand Badge -->
<TextView
android:id="@+id/tvBrandBadge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|start"
android:layout_margin="8dp"
android:background="@drawable/bg_chip_category"
android:fontFamily="sans-serif-black"
android:paddingHorizontal="8dp"
android:paddingVertical="3dp"
android:text="DRIP"
android:textColor="@color/accent_green"
android:textSize="10sp"
android:textStyle="bold" />
<!-- Stock Status Badge -->
<TextView
android:id="@+id/tvStockBadge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|end"
android:layout_margin="8dp"
android:background="#99000000"
android:paddingHorizontal="6dp"
android:paddingVertical="3dp"
android:text="CONTRAENTREGA"
android:textColor="#34D399"
android:textSize="9sp"
android:textStyle="bold" />
</FrameLayout>
<!-- Card Content -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="10dp">
<TextView
android:id="@+id/tvProductName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="sans-serif-medium"
android:maxLines="2"
android:minLines="2"
android:text="Sneaker Drip Edición Especial"
android:textColor="@color/text_primary"
android:textSize="13sp" />
<TextView
android:id="@+id/tvSizesAvailable"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="1"
android:text="Tallas: 38 - 43"
android:textColor="@color/text_secondary"
android:textSize="11sp" />
<!-- Price & Action Button -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tvProductPrice"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:fontFamily="sans-serif-black"
android:text="$ 280.000"
android:textColor="@color/accent_green"
android:textSize="14sp" />
<!-- Quick Order Button with Tapjacking Protection -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnQuickOrder"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:filterTouchesWhenObscured="true"
android:insetTop="0dp"
android:insetBottom="0dp"
android:paddingHorizontal="10dp"
android:text="Pedir"
android:textAllCaps="false"
android:textSize="11sp"
android:textStyle="bold"
app:backgroundTint="@color/accent_whatsapp"
app:cornerRadius="10dp"
app:icon="@drawable/ic_whatsapp"
app:iconGravity="textStart"
app:iconPadding="4dp"
app:iconSize="14dp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/tvSizeLabel"
android:layout_width="48dp"
android:layout_height="42dp"
android:layout_margin="4dp"
android:background="@drawable/bg_chip_size"
android:clickable="true"
android:focusable="true"
android:gravity="center"
android:text="40"
android:textColor="@color/text_primary"
android:textSize="13sp"
android:textStyle="bold" />

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#10B981</color>
<color name="colorPrimaryDark">#059669</color>
<color name="colorAccent">#10B981</color>
<color name="bg_dark">#0B0B0E</color>
<color name="bg_card">#16161A</color>
<color name="bg_card_elevated">#22222A</color>
<color name="bg_card_stroke">#2D2D38</color>
<color name="bg_input">#1A1A22</color>
<color name="accent_green">#10B981</color>
<color name="accent_green_glow">#34D399</color>
<color name="accent_whatsapp">#25D366</color>
<color name="accent_whatsapp_pressed">#1EBE5D</color>
<color name="accent_orange">#F97316</color>
<color name="accent_gold">#F59E0B</color>
<color name="text_primary">#FFFFFF</color>
<color name="text_secondary">#9CA3AF</color>
<color name="text_muted">#6B7280</color>
<color name="divider">#27272A</color>
<color name="badge_exclusive_bg">#2E1065</color>
<color name="badge_exclusive_text">#C084FC</color>
<color name="badge_stock_bg">#064E3B</color>
<color name="badge_stock_text">#34D399</color>
<color name="badge_pedido_bg">#1E3A8A</color>
<color name="badge_pedido_text">#60A5FA</color>
</resources>

View File

@ -1,22 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base DripZone Native Theme -->
<style name="Theme.DripZone" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="colorPrimary">@color/accent_green</item>
<item name="colorPrimaryVariant">@color/colorPrimaryDark</item>
<item name="colorOnPrimary">@color/text_primary</item>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="colorSecondary">@color/accent_whatsapp</item>
<item name="colorSecondaryVariant">@color/accent_whatsapp_pressed</item>
<item name="colorOnSecondary">@color/text_primary</item>
<item name="android:statusBarColor">@color/bg_dark</item>
<item name="android:navigationBarColor">@color/bg_dark</item>
<item name="android:windowBackground">@color/bg_dark</item>
<item name="android:windowLightStatusBar">false</item>
<item name="bottomSheetDialogTheme">@style/DripBottomSheetDialogTheme</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
<!-- Bottom Sheet Dialog with Dark Rounded Corners -->
<style name="DripBottomSheetDialogTheme" parent="Theme.Design.Light.BottomSheetDialog">
<item name="bottomSheetStyle">@style/DripBottomSheetStyle</item>
<item name="android:windowIsFloating">false</item>
<item name="android:navigationBarColor">@color/bg_card</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
<style name="DripBottomSheetStyle" parent="Widget.Design.BottomSheet.Modal">
<item name="android:background">@drawable/bg_bottom_sheet</item>
</style>
<!-- Splash Theme -->
<style name="AppTheme.NoActionBarLaunch" parent="Theme.DripZone">
<item name="android:windowBackground">@color/bg_dark</item>
</style>
<style name="AppTheme" parent="Theme.DripZone" />
</resources>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude path="." />
</cloud-backup>
<device-transfer>
<exclude path="." />
</device-transfer>
</data-extraction-rules>

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Modern security standard: Disallow cleartext HTTP traffic globally -->
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<!-- Trust only system root CAs, never user-installed CAs (mitigates MITM attacks) -->
<certificates src="system" />
</trust-anchors>
</base-config>
<!-- Specific secure domain configurations -->
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="true">almeira.com.co</domain>
<domain includeSubdomains="true">dripzone.almeira.com.co</domain>
<domain includeSubdomains="true">news.almeira.com.co</domain>
<domain includeSubdomains="true">cloud.appwrite.io</domain>
<domain includeSubdomains="true">wa.me</domain>
<domain includeSubdomains="true">api.whatsapp.com</domain>
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</domain-config>
</network-security-config>

View File

@ -8,6 +8,7 @@ buildscript {
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.21'
classpath 'com.google.gms:google-services:4.4.4'
// NOTE: Do not place your application dependencies here; they belong

View File

@ -1,5 +1,2 @@
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'
rootProject.name = "DripZone"
include ':app'

View File

@ -1,13 +1,25 @@
import { NextResponse } from 'next/server';
import { dataService } from '@/lib/data-service';
import { getProductSocialProof } from '@/lib/social-proof';
import { deleteProductFromAppwrite, saveProductToAppwrite } from '@/lib/server-appwrite';
import { deleteProductFromAppwrite, saveProductToAppwrite, getProductsFromAppwrite } from '@/lib/server-appwrite';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const includeAll = searchParams.get('all') === 'true';
let products = dataService.getProducts();
if (products.length === 0) {
try {
const appwriteProducts = await getProductsFromAppwrite();
if (appwriteProducts && appwriteProducts.length > 0) {
appwriteProducts.forEach((p) => dataService.addProduct(p));
products = dataService.getProducts();
}
} catch (e) {
console.warn('Error sincronizando productos iniciales con Appwrite:', e);
}
}
if (!includeAll) {
products = products.filter((p) => p.isActive !== false);
}

View File

@ -1,64 +1,282 @@
import { NextResponse } from 'next/server';
import { dataService } from '@/lib/data-service';
import { parseSupplierMessage } from '@/lib/gemini-parser';
import { SupplierPost } from '@/types';
import { parseSupplierMessage, detectCategory } from '@/lib/gemini-parser';
import { sanitizePublicationTitle } from '@/lib/sanitizer';
import { generateAttractiveProductCopy } from '@/lib/attractive-copy';
import { getProductSocialProof } from '@/lib/social-proof';
import { notifyNewProductPublished } from '@/lib/evolution-api';
import {
getMonitoredGroupsFromAppwrite,
saveProductToAppwrite,
saveSupplierPostToAppwrite,
updateSupplierPostStatusInAppwrite,
getProductsFromAppwrite,
} from '@/lib/server-appwrite';
import { Product, SupplierPost } from '@/types';
// Cache en memoria para evitar procesar mensajes duplicados de WhatsApp en ráfaga
const processedMessagesCache = new Map<string, number>();
function isDuplicateMessage(msgId: string, text: string): boolean {
const key = `${msgId}:${(text || '').trim().substring(0, 60)}`;
const now = Date.now();
const lastSeen = processedMessagesCache.get(key);
// Limpieza de claves viejas (> 5 minutos)
for (const [k, time] of processedMessagesCache.entries()) {
if (now - time > 300000) {
processedMessagesCache.delete(k);
}
}
if (lastSeen && now - lastSeen < 60000) {
return true;
}
processedMessagesCache.set(key, now);
return false;
}
export async function GET() {
const posts = dataService.getSupplierPosts();
return NextResponse.json({ success: true, posts });
return NextResponse.json({ success: true, count: posts.length, posts });
}
export async function POST(request: Request) {
try {
const payload = await request.json();
console.log('📥 Webhook Evolution API recibido:', JSON.stringify(payload).slice(0, 300));
console.log('📥 Webhook WhatsApp recibido en DripZone:', JSON.stringify(payload).slice(0, 250));
const rawEvent = payload.event || '';
const eventType = rawEvent.toLowerCase().replace(/_/g, '.');
// Filtrar eventos irrelevantes (solo procesar inserción de mensajes o envíos directos)
if (
rawEvent &&
!eventType.includes('messages.upsert') &&
!eventType.includes('messages.update') &&
!eventType.includes('send.message') &&
!eventType.includes('test')
) {
return NextResponse.json({ success: true, ignored: 'Evento no relevante', event: rawEvent });
}
const data = payload.data || payload;
const remoteJid = data?.key?.remoteJid || data?.remoteJid || '';
const fromMe = Boolean(data?.key?.fromMe);
const whatsappMessageId = data?.key?.id || `msg-${Date.now()}`;
if (!remoteJid) {
return NextResponse.json({ success: true, ignored: 'Sin remoteJid' });
}
const messageContent =
data.message?.conversation ||
data.message?.extendedTextMessage?.text ||
data.message?.imageMessage?.caption ||
data.message?.videoMessage?.caption ||
data.text ||
'';
const channelName =
data.pushName ||
data.groupInfo?.subject ||
(data.key?.remoteJid?.includes('g.us') ? 'Canal Mayorista Entrada' : 'Recepción Directa');
const imageBase64 = data.base64 || (data.message?.imageMessage?.jpegThumbnail ? `data:image/jpeg;base64,${data.message.imageMessage.jpegThumbnail}` : undefined);
if (!messageContent && !imageBase64) {
return NextResponse.json({ success: true, ignored: 'Mensaje vacío o sin contenido' });
// Evitar bucles infinitos causados por mensajes generados por el propio bot
if (
fromMe &&
(messageContent.includes('NUEVO PRODUCTO PUBLICADO') ||
messageContent.includes('SOLICITUD DE APROBACIÓN') ||
messageContent.includes('PUBLICACIÓN LISTA') ||
messageContent.includes('Prueba de Sistema DripZone'))
) {
return NextResponse.json({ success: true, ignored: 'Bucle de mensaje propio del bot evitado' });
}
// Parsear con Gemini AI y Visión de Logos
const extracted = await parseSupplierMessage(messageContent || channelName, imageBase64);
const groupSubject =
data.groupInfo?.subject ||
data.chatName ||
data.pushName ||
(remoteJid.includes('g.us') ? 'Grupo Mayorista' : 'Chat Directo');
const newPost: SupplierPost = {
id: `post-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
channelName: data.groupInfo?.subject || channelName,
rawMessage: messageContent || `[Foto de Calzado recibida en ${channelName}]`,
rawImages: imageBase64
? [imageBase64.startsWith('data:') ? imageBase64 : `data:image/jpeg;base64,${imageBase64}`]
: ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'],
extractedProduct: extracted,
status: 'pending_review',
receivedAt: 'Justo ahora',
supplierName: data.groupInfo?.subject || channelName,
// 1. VALIDAR SI EL GRUPO ESTÁ MONITOREADO
const [appwriteGroups, localGroups] = await Promise.all([
getMonitoredGroupsFromAppwrite().catch(() => []),
Promise.resolve(dataService.getMonitoredGroups()),
]);
const allGroups = [...localGroups, ...appwriteGroups];
const registeredGroup = allGroups.find(
(g) => g.jid === remoteJid || g.name.toLowerCase() === groupSubject.toLowerCase()
);
// Detección inteligente de grupos de proveedores de calzado y streetwear
const isWholesaleSupplier = /exclusive|emporio|jovic|diamonds|mayorista|calzado|sneakers|zapatillas|bodega/i.test(
groupSubject
);
const isNonProductNews = /gob cauca|prensa|comunicaciones|inelpac|meliextra|sedes asociadas|notificaciones almeira/i.test(
groupSubject
);
// Está monitoreado si está explícitamente activado en la base de datos o si coincide con proveedores mayoristas reconocidos
const isMonitored = (registeredGroup && registeredGroup.isMonitored) || (isWholesaleSupplier && !isNonProductNews);
if (!isMonitored) {
console.log(`[DripZone Webhook] Chat descartado (no monitoreado para productos): "${groupSubject}" (${remoteJid})`);
return NextResponse.json({
success: true,
ignored: 'Grupo no monitoreado para publicación automática de calzado',
chat: groupSubject,
remoteJid,
});
}
// 2. VERIFICACIÓN DE DUPLICADOS
if (isDuplicateMessage(whatsappMessageId, messageContent)) {
return NextResponse.json({ success: true, ignored: 'Mensaje duplicado en ráfaga ignorado' });
}
const existingProducts = dataService.getProducts();
if (existingProducts.some((p) => p.whatsappMessageId === whatsappMessageId)) {
return NextResponse.json({
success: true,
ignored: 'Este mensaje de WhatsApp ya fue publicado en el catálogo',
whatsappMessageId,
});
}
// 3. EXTRACCIÓN MULTIMEDIA (IMAGEN HD Y THUMBNAIL)
let imageBase64: string | undefined = data.base64 || undefined;
if (!imageBase64 && data.message?.imageMessage?.jpegThumbnail) {
const thumb = data.message.imageMessage.jpegThumbnail;
imageBase64 = thumb.startsWith('data:') ? thumb : `data:image/jpeg;base64,${thumb}`;
}
// Si no hay texto ni foto, descartar
if (!messageContent && !imageBase64) {
return NextResponse.json({ success: true, ignored: 'Mensaje sin texto ni imagen' });
}
console.log(`[DripZone Webhook] Procesando producto de grupo monitoreado: "${groupSubject}" (ID: ${whatsappMessageId})...`);
// 4. PARSEO CON GEMINI AI Y VISIÓN DE CALZADO
let extracted;
try {
extracted = await parseSupplierMessage(messageContent || groupSubject, imageBase64);
} catch (parseError: any) {
console.warn('[DripZone Webhook] Error en parseSupplierMessage con Gemini:', parseError.message);
const cat = detectCategory(messageContent || groupSubject);
extracted = {
name: sanitizePublicationTitle(messageContent.slice(0, 40) || 'Sneakers Colección', [groupSubject], 'Sneakers'),
brand: 'Sneakers',
sizes: ['37', '38', '39', '40', '41', '42', '43'],
supplierPrice: 85000,
suggestedRetailPrice: 125000,
category: cat.category,
confidence: 0.75,
};
}
// Categorización segura
if (!extracted.category || extracted.category === 'Calzado / Sneakers' || extracted.category === 'Sneakers / Colección') {
extracted.category = detectCategory(`${extracted.name || ''} ${messageContent}`).category;
}
// 5. CÁLCULO DE PRECIOS Y MÁRGENES
const supplierPrice = extracted.supplierPrice || 75000;
const retailPrice =
extracted.suggestedRetailPrice && extracted.suggestedRetailPrice > supplierPrice
? extracted.suggestedRetailPrice
: Math.max(120000, supplierPrice + 35000);
const originalPrice = Math.round(retailPrice * 1.35);
// Sanitización y Copy Atractivo
const forbiddenContext = [groupSubject, data.pushName];
const prodBrand = extracted.brand || 'Sneakers';
const prodName = sanitizePublicationTitle(extracted.name || 'Artículo Urbano', forbiddenContext, prodBrand);
const proof = getProductSocialProof({ name: prodName, brand: prodBrand });
const prodDescription = generateAttractiveProductCopy({
name: prodName,
brand: prodBrand,
category: extracted.category || 'Zapatillas',
retailPrice,
supplierPrice,
forbiddenTerms: forbiddenContext,
});
// Construcción de la URL de streaming en Alta Definición Full HD
const relativeImageUrl = `/api/whatsapp-media?id=${encodeURIComponent(whatsappMessageId)}&remoteJid=${encodeURIComponent(remoteJid)}`;
const fullMediaUrl = `https://dripzone.almeira.com.co/api/whatsapp-media?id=${encodeURIComponent(whatsappMessageId)}&remoteJid=${encodeURIComponent(remoteJid)}`;
// 6. CREACIÓN Y PUBLICACIÓN DEL PRODUCTO EN LA APP Y CATÁLOGO
const newProduct: Product = {
id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
name: prodName,
modelName: prodName,
colorName: extracted.colorName || 'Estilo Principal',
brand: prodBrand,
supplierPrice,
retailPrice,
originalPrice,
sizes: Array.isArray(extracted.sizes) && extracted.sizes.length > 0
? extracted.sizes
: ['37', '38', '39', '40', '41', '42', '43'],
images: [relativeImageUrl],
description: prodDescription,
category: extracted.category || 'Zapatillas',
inStock: true,
isActive: true,
rating: proof.rating,
reviewsCount: proof.reviewsCount,
salesCount: proof.salesCount,
originBadge: proof.badge,
supplierName: groupSubject,
supplierPhone: data.key?.participant?.replace(/\D/g, '') || '573167455877',
whatsappMessageId,
};
// Publicar en memoria y persistir en Appwrite
const addedProduct = dataService.addProduct(newProduct);
await saveProductToAppwrite(addedProduct);
// Registrar también como post publicado en el feed interno
const newPost: SupplierPost = {
id: `post-${whatsappMessageId}`,
whatsappMessageId,
channelName: groupSubject,
channelJid: remoteJid,
rawMessage: messageContent || `[Foto de Calzado recibida en ${groupSubject}]`,
rawImages: [relativeImageUrl],
extractedProduct: extracted,
status: 'published',
receivedAt: 'Justo ahora',
publishedAt: new Date().toISOString(),
supplierName: groupSubject,
supplierPhone: newProduct.supplierPhone,
isRead: true,
};
dataService.addSupplierPost(newPost);
await saveSupplierPostToAppwrite(newPost);
await updateSupplierPostStatusInAppwrite(newPost.id, 'published');
console.log(`✅ [DripZone Webhook] ¡PRODUCTO PUBLICADO EN LA APP CON ÉXITO! -> "${addedProduct.name}" ($${retailPrice} COP)`);
// 7. ENVÍO DE NOTIFICACIÓN INMEDIATA DEL NUEVO PRODUCTO PUBLICADO
let notificationResult: any = null;
try {
notificationResult = await notifyNewProductPublished(addedProduct, groupSubject, fullMediaUrl);
console.log('📢 [DripZone Webhook] Notificación enviada a WhatsApp:', notificationResult);
} catch (notifErr: any) {
console.error('[DripZone Webhook] Error al enviar notificación de WhatsApp:', notifErr.message);
}
return NextResponse.json({
success: true,
message: 'Mensaje procesado con IA y Visión de Marca e ingresado al feed interno',
message: '¡Producto publicado automáticamente en la app y notificación enviada con éxito!',
product: addedProduct,
post: newPost,
notification: notificationResult,
});
} catch (error: any) {
console.error('Error procesando webhook de WhatsApp:', error);
console.error('❌ Error general en Webhook WhatsApp DripZone:', error);
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
}
}

View File

@ -1,6 +1,11 @@
import { NextResponse } from 'next/server';
import { dataService } from '@/lib/data-service';
import { MonitoredWhatsAppGroup } from '@/types';
import {
getMonitoredGroupsFromAppwrite,
saveMonitoredGroupToAppwrite,
toggleMonitoredGroupInAppwrite,
} from '@/lib/server-appwrite';
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';
@ -11,7 +16,18 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const fetchLive = searchParams.get('fetchLive') === 'true';
const localGroups = dataService.getMonitoredGroups();
let localGroups = dataService.getMonitoredGroups();
if (localGroups.length === 0) {
try {
const appwriteGroups = await getMonitoredGroupsFromAppwrite();
if (appwriteGroups.length > 0) {
dataService.saveMonitoredGroups(appwriteGroups);
localGroups = appwriteGroups;
}
} catch (err) {
console.warn('Error leyendo grupos desde Appwrite:', err);
}
}
if (!fetchLive) {
return NextResponse.json({
@ -129,12 +145,19 @@ export async function POST(request: Request) {
if (body.action === 'toggle' || body.action === 'deactivate-group') {
const { idOrJid, isMonitored } = body;
const updated = dataService.toggleGroupMonitoring(idOrJid, isMonitored !== undefined ? isMonitored : false);
const targetState = isMonitored !== undefined ? isMonitored : false;
const updated = dataService.toggleGroupMonitoring(idOrJid, targetState);
toggleMonitoredGroupInAppwrite(idOrJid, targetState).catch((e) =>
console.warn('Error sincronizando toggle con Appwrite:', e)
);
return NextResponse.json({ success: true, group: updated });
}
if (Array.isArray(body.groups)) {
const saved = dataService.saveMonitoredGroups(body.groups);
Promise.all(body.groups.map((g: any) => saveMonitoredGroupToAppwrite(g))).catch((e) =>
console.warn('Error sincronizando grupos con Appwrite:', e)
);
return NextResponse.json({ success: true, groups: saved });
}

View File

@ -5,14 +5,21 @@ const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL || 'https://evolution.al
const EVOLUTION_API_KEY = process.env.EVOLUTION_API_KEY || 'EE49029EEB85-4568-8F81-C9FDFC6E2943';
const EVOLUTION_INSTANCE_NAME = process.env.EVOLUTION_INSTANCE_NAME || 'AutomataAlmeira';
function resolveRecipient(target: string): string {
const trimmed = target.trim();
if (trimmed.includes('@')) {
return trimmed;
}
const cleanDigits = trimmed.replace(/\D/g, '');
return cleanDigits.startsWith('57') ? cleanDigits : `57${cleanDigits}`;
}
/**
* Enviar mensaje de texto por WhatsApp a través de Evolution API v2
*/
export async function sendWhatsAppMessage(toPhone: string, message: string): Promise<boolean> {
const cleanDigits = toPhone.replace(/\D/g, '');
if (!cleanDigits) return false;
const recipient = cleanDigits.startsWith('57') ? cleanDigits : `57${cleanDigits}`;
const recipient = resolveRecipient(toPhone);
if (!recipient) return false;
try {
const response = await axios.post(
@ -41,10 +48,8 @@ export async function sendWhatsAppMessage(toPhone: string, message: string): Pro
* Enviar mensaje con foto/catálogo a través de Evolution API v2
*/
export async function sendWhatsAppMedia(toPhone: string, imageUrl: string, caption: string): Promise<boolean> {
const cleanDigits = toPhone.replace(/\D/g, '');
if (!cleanDigits) return false;
const recipient = cleanDigits.startsWith('57') ? cleanDigits : `57${cleanDigits}`;
const recipient = resolveRecipient(toPhone);
if (!recipient) return false;
try {
const response = await axios.post(
@ -72,6 +77,73 @@ export async function sendWhatsAppMedia(toPhone: string, imageUrl: string, capti
}
}
/**
* Notifica inmediatamente la publicación automática de un nuevo producto
* tanto al WhatsApp personal de Carlos (+57 316 745 5877) como al canal
* de Notificaciones Almeira (120363428012905103@g.us).
*/
export async function notifyNewProductPublished(
product: any,
sourceGroup: string,
mediaUrl?: string
): Promise<{ success: boolean; dispatchedTo: string[] }> {
const NOTIFICATION_TARGETS = [
'573167455877', // Carlos (WhatsApp personal)
'120363428012905103@g.us', // Notificaciones Almeira
];
const retailFormatted = formatCOP(product.retailPrice || 125000);
const supplierFormatted = product.supplierPrice ? formatCOP(product.supplierPrice) : 'No especificado';
const sizesText = Array.isArray(product.sizes) && product.sizes.length > 0
? product.sizes.join(', ')
: '37, 38, 39, 40, 41, 42, 43';
const notificationCaption = (
`🚀 *¡NUEVO PRODUCTO PUBLICADO EN LA APP!* 🚀\n` +
`━━━━━━━━━━━━━━━━━━━━━━\n` +
`👟 *${product.name}*\n` +
`🏷️ *Marca:* ${product.brand || 'Sneakers'} • *Categoría:* ${product.category || 'Zapatillas'}\n` +
`💵 *Precio de Venta en la App:* *${retailFormatted}*\n` +
`🏭 *Costo Proveedor:* ${supplierFormatted}\n` +
`📏 *Tallas Disponibles:* ${sizesText}\n` +
`📍 *Grupo Proveedor:* ${sourceGroup || 'Canal Mayorista'}\n\n` +
`🌐 *Ver en la Tienda Web:*\n` +
`👉 https://dripzone.almeira.com.co\n\n` +
`📱 *Disponible de inmediato en la aplicación móvil DripZone para todos los clientes.*\n` +
`━━━━━━━━━━━━━━━━━━━━━━`
);
const dispatchedTo: string[] = [];
for (const target of NOTIFICATION_TARGETS) {
try {
let sent = false;
// Si tenemos URL de imagen completa y válida, intentamos enviar con imagen
if (mediaUrl && (mediaUrl.startsWith('http://') || mediaUrl.startsWith('https://'))) {
sent = await sendWhatsAppMedia(target, mediaUrl, notificationCaption);
}
// Si no hay imagen o falló el envío multimedia, enviar como texto formateado de alta visibilidad
if (!sent) {
sent = await sendWhatsAppMessage(target, notificationCaption);
}
if (sent) {
dispatchedTo.push(target);
}
} catch (err: any) {
console.warn(`[notifyNewProductPublished] Error despachando a ${target}:`, err.message);
}
}
return {
success: dispatchedTo.length > 0,
dispatchedTo,
};
}
import { dataService } from './data-service';
/**

View File

@ -702,3 +702,90 @@ export async function updateSupplierPostStatusInAppwrite(
}
}
}
// =========================================================================
// 8. GRUPOS WHATSAPP MONITOREADOS (PERSISTENCIA APPWRITE)
// =========================================================================
export async function getMonitoredGroupsFromAppwrite(): Promise<MonitoredWhatsAppGroup[]> {
try {
const res = await serverDatabases.listDocuments(DB_ID, 'monitored_groups', [
Query.limit(100),
]);
if (res.documents && res.documents.length > 0) {
return res.documents.map((d: any) => ({
id: d.$id,
jid: d.jid || '',
name: d.name || '',
isMonitored: Boolean(d.isMonitored),
autoPublish: d.autoPublish !== false,
notes: d.notes || '',
supplierName: d.name || '',
}));
}
return [];
} catch (e: any) {
console.warn('Error getMonitoredGroupsFromAppwrite:', e.message);
return [];
}
}
export async function saveMonitoredGroupToAppwrite(
group: Partial<MonitoredWhatsAppGroup>
): Promise<MonitoredWhatsAppGroup> {
const docId = group.id || `grp-${(group.jid || '').replace(/\D/g, '').slice(-8)}` || `grp-${Date.now()}`;
const payload = {
jid: group.jid || '',
name: group.name || '',
isMonitored: Boolean(group.isMonitored),
autoPublish: group.autoPublish !== false,
notes: group.notes || '',
};
try {
const doc = await serverDatabases.createDocument(DB_ID, 'monitored_groups', docId, payload);
return {
id: doc.$id,
jid: doc.jid,
name: doc.name,
isMonitored: doc.isMonitored,
autoPublish: doc.autoPublish,
notes: doc.notes,
supplierName: doc.name,
};
} catch (e: any) {
if (e.code === 409) {
try {
await serverDatabases.updateDocument(DB_ID, 'monitored_groups', docId, payload);
} catch (_) {}
}
return {
id: docId,
jid: payload.jid,
name: payload.name,
isMonitored: payload.isMonitored,
autoPublish: payload.autoPublish,
notes: payload.notes,
supplierName: payload.name,
};
}
}
export async function toggleMonitoredGroupInAppwrite(
idOrJid: string,
isMonitored: boolean
): Promise<boolean> {
try {
const groups = await getMonitoredGroupsFromAppwrite();
const found = groups.find((g) => g.id === idOrJid || g.jid === idOrJid);
if (found) {
await serverDatabases.updateDocument(DB_ID, 'monitored_groups', found.id, {
isMonitored,
});
return true;
}
return false;
} catch (e) {
return false;
}
}

View File

@ -0,0 +1,33 @@
const sdk = require('node-appwrite');
const client = new sdk.Client()
.setEndpoint('https://appwrite.almeira.com.co/v1')
.setProject('69b84df5003bd8957c5c')
.setKey('standard_cce3cdb866ea6433888d40cedd24dc45987372308fdc3dd0697de08648c0e5c7e04b02c3bcee271403fd656b00a5820f04c08b51631284a6e8a3582d8aed2aa33234f3c29cc777e9b162eb54810b789251b0d006a9a3b081ebc2da53b9baa94614b124b3508ad849fd9a4187cc6094f506d4b57f409b69d9562a74df594f3ec3');
const dbs = new sdk.Databases(client);
const DB_ID = 'sneakers_multistore_db';
const COL_ID = 'products';
async function fixAttribute() {
try {
console.log('Eliminando atributo fallido whatsappMessageId...');
try {
await dbs.deleteAttribute(DB_ID, COL_ID, 'whatsappMessageId');
console.log('✓ Atributo whatsappMessageId eliminado.');
} catch (e) {
console.warn('Aviso delete:', e.message);
}
console.log('Esperando 2 segundos...');
await new Promise(r => setTimeout(r, 2000));
console.log('Recreando atributo whatsappMessageId...');
await dbs.createStringAttribute(DB_ID, COL_ID, 'whatsappMessageId', 128, false);
console.log('✓ Atributo whatsappMessageId recreado con éxito.');
} catch (err) {
console.error('Error:', err.message);
}
}
fixAttribute();

View File

@ -0,0 +1,67 @@
const sdk = require('node-appwrite');
const client = new sdk.Client()
.setEndpoint('https://appwrite.almeira.com.co/v1')
.setProject('69b84df5003bd8957c5c')
.setKey('standard_cce3cdb866ea6433888d40cedd24dc45987372308fdc3dd0697de08648c0e5c7e04b02c3bcee271403fd656b00a5820f04c08b51631284a6e8a3582d8aed2aa33234f3c29cc777e9b162eb54810b789251b0d006a9a3b081ebc2da53b9baa94614b124b3508ad849fd9a4187cc6094f506d4b57f409b69d9562a74df594f3ec3');
const dbs = new sdk.Databases(client);
const DB_ID = 'sneakers_multistore_db';
const COL_ID = 'monitored_groups';
const GROUPS_TO_SEED = [
{
jid: '120363049363072784@g.us',
name: '💎 2️⃣ DIFUSIÓN JOVIC STORE COLOMBIA🍀',
isMonitored: true,
autoPublish: true,
notes: 'Proveedor Mayorista Jovic Store Calzado',
},
{
jid: '120363420942364759@g.us',
name: '💎TEAM EMPORIO💎🔥👑❤️‍🔥🤯',
isMonitored: true,
autoPublish: true,
notes: 'Proveedor Mayorista Team Emporio Sneakers',
},
{
jid: '120363027254133375@g.us',
name: 'EXCLUSIVE CC MÍO 3B',
isMonitored: true,
autoPublish: true,
notes: 'Proveedor Mayorista Exclusive Calzado Urbano',
},
{
jid: '120363400148850031@g.us',
name: 'SOMOS BLACK DIAMONDS 💎💎💎',
isMonitored: true,
autoPublish: true,
notes: 'Proveedor Mayorista Black Diamonds Sneakers',
},
{
jid: '120363317242539420@g.us',
name: '💎 5️⃣ DIFUSIÓN JOVIC STORE COLOMBIA🍀',
isMonitored: true,
autoPublish: true,
notes: 'Proveedor Mayorista Jovic Store Difusión 5',
},
];
async function seed() {
for (const g of GROUPS_TO_SEED) {
const docId = `grp-${g.jid.replace(/\D/g, '').slice(-8)}`;
try {
await dbs.createDocument(DB_ID, COL_ID, docId, g);
console.log(`✓ Grupo creado: ${g.name}`);
} catch (e) {
if (e.code === 409) {
await dbs.updateDocument(DB_ID, COL_ID, docId, g);
console.log(`✓ Grupo actualizado: ${g.name}`);
} else {
console.error(`Error con grupo ${g.name}:`, e.message);
}
}
}
}
seed();

View File

@ -0,0 +1,125 @@
import paramiko
import os
import urllib.request
import json
import ssl
import sys
import io
if sys.platform == 'win32':
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
HOST = "161.97.93.156"
USER = "carlos"
PASSWORD = "Innova1978+*?"
LOCAL_APK = r"c:\Users\Carlos\dev\almeira-web\dripzone-app\public\DripZone.apk"
REMOTE_APK = "/home/carlos/apps/dripzone-app/public/DripZone.apk"
EVOLUTION_API_URL = "https://evolution.almeira.com.co"
EVOLUTION_API_KEY = "EE49029EEB85-4568-8F81-C9FDFC6E2943"
INSTANCE_NAME = "AutomataAlmeira"
TARGET_PHONE = "573167455877"
APK_URL = "https://dripzone.almeira.com.co/DripZone.apk"
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
def upload_apk():
print(f"[1/3] Conectando a VPS {HOST} vía SSH/SFTP...")
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(hostname=HOST, username=USER, password=PASSWORD, timeout=30)
sftp = client.open_sftp()
local_size_mb = os.path.getsize(LOCAL_APK) / (1024 * 1024)
print(f" -> Subiendo {LOCAL_APK} ({local_size_mb:.1f} MB) a {REMOTE_APK}...")
try:
sftp.mkdir("/home/carlos/apps/dripzone-app/public")
except:
pass
sftp.put(LOCAL_APK, REMOTE_APK)
sftp.close()
print(" [OK] Archivo subido por SFTP al VPS.")
print("[2/3] Copiando APK dentro del contenedor Docker en producción...")
cmd = """
docker cp /home/carlos/apps/dripzone-app/public/DripZone.apk dripzone-production:/app/public/DripZone.apk || true
docker cp /home/carlos/apps/dripzone-app/public/DripZone.apk dripzone-production:/app/DripZone.apk || true
docker cp /home/carlos/apps/dripzone-app/public/DripZone.apk bfmnoga3lb0ckomhdmxe0rco-021053217798:/app/public/DripZone.apk || true
docker cp /home/carlos/apps/dripzone-app/public/DripZone.apk bfmnoga3lb0ckomhdmxe0rco-021053217798:/app/DripZone.apk || true
docker exec dripzone-production ls -lh /app/public/DripZone.apk || true
docker exec bfmnoga3lb0ckomhdmxe0rco-021053217798 ls -lh /app/public/DripZone.apk || true
"""
stdin, stdout, stderr = client.exec_command(cmd)
out = stdout.read().decode('utf-8')
err = stderr.read().decode('utf-8')
print(" Salida Docker:", out.strip())
if err.strip():
print(" Avisos Docker:", err.strip())
client.close()
def send_apk_whatsapp():
print("[3/3] Enviando APK y notificación oficial al WhatsApp de Carlos (+57 316 745 5877)...")
message_text = (
"📲 *DripZone - App Móvil Oficial en Kotlin Nativo*\n\n"
"🔥 *¡Desarrollo Nativo en Kotlin Completado y Desplegado!*\n\n"
"✨ *Características y Novedades de la App Nativa:*\n\n"
"⚡ *1. Código 100% Nativo en Kotlin (Android):*\n"
"• Reemplazo total de vistas genéricas por arquitectura nativa Jetpack con ViewBinding y Coroutines.\n"
"• Interfaz ultra fluida, rápida y con estética dark streetwear moderna para DripZone.\n"
"• Grid de productos a 2 columnas con tarjetas estilizadas, insignias de marca y precios en COP.\n"
"• Filtros por categorías en chips horizontales (Todos, Sneakers, Chanclas, Bolsos, Gorras, Bajo Pedido).\n"
"• Búsqueda instantánea en tiempo real integrada en el encabezado.\n"
"• SwipeRefreshLayout (Pull-to-refresh) para actualizar en vivo con los nuevos drops de WhatsApp.\n\n"
"🛡️ *2. Estándares de Seguridad Actuales Cumplidos:*\n"
"• *Network Security Config:* Tráfico en texto claro (HTTP) bloqueado globalmente, enforcing estricto de HTTPS/TLS 1.2 y TLS 1.3 con certificados de sistema.\n"
"• *Protección de Datos:* `allowBackup=\"false\"` y `dataExtractionRules` configurados para evitar extracción no autorizada vía adb o backups en la nube.\n"
"• *Cifrado Local:* Almacenamiento seguro mediante `EncryptedSharedPreferences` con Android KeyStore y MasterKey AES-256 GCM.\n"
"• *Protección Anti-Tapjacking:* `filterTouchesWhenObscured=\"true\"` en botones de compra y pedidos.\n"
"• *Lanzamiento Seguro de Intents:* Validación rigurosa de URIs de WhatsApp y esquemas para prevenir Spoofing o Redirecciones maliciosas.\n"
"• *Reglas Proguard/R8:* Ofuscación y protección de modelos JSON y dependencias.\n\n"
"👟 *3. Experiencia de Compra y Encargos:* \n"
"• Modal Bottom Sheet con vista ampliada, selección interactiva de tallas (chips) y detalles de despacho.\n"
"• Botón directo *'Pedir por WhatsApp (Contraentrega)'* que pre-arma el mensaje exacto con foto, talla y precio al WhatsApp de Carlos.\n"
"• Botón flotante y modal para *'Encargos Especiales / Pedidos'* de modelos no listados con proveedores mayoristas.\n\n"
f"⬇️ *Descarga directa del APK:* {APK_URL}\n"
f"⬇️ *Enlace alternativo:* https://dripzone.almeira.com.co/DripZone.apk\n\n"
"Adjunto el archivo APK instalador para que lo pruebes directamente en tu dispositivo."
)
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:", resp.status)
except Exception as e:
print(" [Error texto]:", e)
# Envío del archivo APK como documento
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-Native-Kotlin.apk",
"caption": "📲 *DripZone Native Kotlin - APK Oficial Actualizado*",
"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=120) as resp:
print(" [OK] Documento APK enviado:", resp.status, resp.read().decode('utf-8')[:200])
except Exception as e:
print(" [Error documento APK]:", e)
if __name__ == "__main__":
upload_apk()
send_apk_whatsapp()

View File

@ -61,7 +61,7 @@ export async function POST(req: Request) {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(3500),
signal: AbortSignal.timeout(60000),
}).catch(() => {});
}