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:
parent
d7e7ab4d8b
commit
9f25aabcd8
@ -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'
|
||||
}
|
||||
|
||||
59
dripzone-app/android/app/proguard-rules.pro
vendored
59
dripzone-app/android/app/proguard-rules.pro
vendored
@ -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.** { *; }
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
package co.dripzone.app;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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 & 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>
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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" />
|
||||
140
dripzone-app/android/app/src/main/res/layout/item_product.xml
Normal file
140
dripzone-app/android/app/src/main/res/layout/item_product.xml
Normal 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>
|
||||
@ -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" />
|
||||
31
dripzone-app/android/app/src/main/res/values/colors.xml
Normal file
31
dripzone-app/android/app/src/main/res/values/colors.xml
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
33
scripts/fix_whatsapp_msg_id_attr.js
Normal file
33
scripts/fix_whatsapp_msg_id_attr.js
Normal 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();
|
||||
67
scripts/seed_monitored_groups.js
Normal file
67
scripts/seed_monitored_groups.js
Normal 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();
|
||||
125
scripts/upload_apk_and_send.py
Normal file
125
scripts/upload_apk_and_send.py
Normal 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()
|
||||
@ -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(() => {});
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user