From 4185603f92c29682bed5fa577a81ec1ed0c3e866 Mon Sep 17 00:00:00 2001 From: AI Bot Date: Sun, 20 Sep 2026 12:18:23 -0500 Subject: [PATCH] feat: acceso al catalogo (/catalogo), categorias dinamicas y filtros en tienda, admin y app nativa Android --- .../main/java/co/dripzone/app/MainActivity.kt | 112 ++++-- .../co/dripzone/app/ui/CategoryAdapter.kt | 2 +- dripzone-app/next.config.ts | 16 + .../src/app/admin/feed-whatsapp/page.tsx | 197 ++++------ dripzone-app/src/app/admin/layout.tsx | 37 +- dripzone-app/src/app/admin/productos/page.tsx | 81 ++-- dripzone-app/src/app/api/categories/route.ts | 81 ++++ dripzone-app/src/app/catalogo/page.tsx | 19 + dripzone-app/src/app/page.tsx | 105 +---- .../src/components/auth/AuthModal.tsx | 30 ++ .../components/mobile/MobileBottomDock.tsx | 20 +- .../src/components/store/CategoryPills.tsx | 117 ++++-- .../src/components/store/FlashDealsBanner.tsx | 13 + dripzone-app/src/components/store/Navbar.tsx | 17 + dripzone-app/src/lib/category-helper.ts | 363 ++++++++++++++++++ .../deploy_and_send_catalog_categories_apk.py | 188 +++++++++ 16 files changed, 1084 insertions(+), 314 deletions(-) create mode 100644 dripzone-app/src/app/api/categories/route.ts create mode 100644 dripzone-app/src/app/catalogo/page.tsx create mode 100644 dripzone-app/src/lib/category-helper.ts create mode 100644 scripts/deploy_and_send_catalog_categories_apk.py diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/MainActivity.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/MainActivity.kt index 9c742dc..85bb50d 100644 --- a/dripzone-app/android/app/src/main/java/co/dripzone/app/MainActivity.kt +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/MainActivity.kt @@ -100,16 +100,19 @@ class MainActivity : AppCompatActivity() { } private fun setupCategories() { - val categories = listOf( + val initialCategories = listOf( Category("all", "Todos", isSelected = true), - Category("zapatillas", "Sneakers"), - Category("chanclas", "Chanclas"), + Category("zapatillas", "Zapatillas"), + Category("sandalias", "Sandalias"), Category("bolsos", "Bolsos"), + Category("maletines", "Maletines"), + Category("carrieles", "Carrieles"), Category("gorras", "Gorras"), + Category("ropa", "Ropa"), Category("bajo_pedido", "Bajo Pedido") ) - categoryAdapter = CategoryAdapter(categories) { category -> + categoryAdapter = CategoryAdapter(initialCategories) { category -> selectedCategory = category.name filterProducts() } @@ -117,6 +120,46 @@ class MainActivity : AppCompatActivity() { binding.rvCategories.adapter = categoryAdapter } + private fun updateCategoriesFromProducts() { + if (allProducts.isEmpty()) return + + val distinctNames = allProducts + .map { it.category.trim() } + .filter { it.isNotBlank() } + .distinctBy { it.lowercase() } + + val priorityOrder = listOf( + "Zapatillas", "Sandalias", "Chanclas", "Bolsos", "Maletines", "Carrieles", "Gorras", "Ropa", "Accesorios" + ) + + val sortedNames = distinctNames.sortedWith(Comparator { a, b -> + val idxA = priorityOrder.indexOfFirst { it.equals(a, ignoreCase = true) } + val idxB = priorityOrder.indexOfFirst { it.equals(b, ignoreCase = true) } + when { + idxA != -1 && idxB != -1 -> idxA.compareTo(idxB) + idxA != -1 -> -1 + idxB != -1 -> 1 + else -> a.compareTo(b, ignoreCase = true) + } + }) + + val categoriesList = mutableListOf() + categoriesList.add(Category("all", "Todos", isSelected = (selectedCategory.equals("Todos", ignoreCase = true) || selectedCategory.equals("all", ignoreCase = true)))) + + for (name in sortedNames) { + val isSelected = selectedCategory.equals(name, ignoreCase = true) + categoriesList.add(Category(name.lowercase(), name, isSelected = isSelected)) + } + + categoriesList.add(Category("bajo_pedido", "Bajo Pedido", isSelected = selectedCategory.equals("Bajo Pedido", ignoreCase = true))) + + categoryAdapter = CategoryAdapter(categoriesList) { category -> + selectedCategory = category.name + filterProducts() + } + binding.rvCategories.adapter = categoryAdapter + } + private fun setupSearch() { binding.btnToggleSearch.setOnClickListener { toggleSearch() @@ -139,20 +182,7 @@ class MainActivity : AppCompatActivity() { 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 + updateCategoriesFromProducts() filterProducts() } } @@ -372,6 +402,7 @@ class MainActivity : AppCompatActivity() { val cached = ApiClient.loadCache(this) if (cached.isNotEmpty()) { allProducts = cached + updateCategoriesFromProducts() filterProducts() binding.progressBar.visibility = View.GONE } else if (!binding.swipeRefresh.isRefreshing) { @@ -392,6 +423,7 @@ class MainActivity : AppCompatActivity() { val fetched = ApiClient.getProducts(this@MainActivity, forceRefresh) if (fetched.isNotEmpty()) { allProducts = fetched + updateCategoriesFromProducts() filterProducts() } @@ -419,20 +451,36 @@ class MainActivity : AppCompatActivity() { 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 matchesCategory = when { + selectedCategory == "Todos" || selectedCategory.equals("all", ignoreCase = true) -> true + selectedCategory == "Bajo Pedido" -> !product.inStock + else -> { + val sel = selectedCategory.lowercase() + val pCat = product.category.lowercase() + when { + pCat == sel -> true + pCat.contains(sel) || sel.contains(pCat) -> true + sel.contains("sneaker") || sel.contains("zapatilla") || sel.contains("tenis") -> { + pCat.contains("zapatilla") || pCat.contains("sneaker") || pCat.contains("tenis") + } + sel.contains("chancla") || sel.contains("sandalia") || sel.contains("slide") -> { + pCat.contains("chancla") || pCat.contains("sandalia") || pCat.contains("slide") || pCat.contains("croc") + } + sel.contains("malet") || sel.contains("morral") || sel.contains("mochila") -> { + pCat.contains("malet") || pCat.contains("morral") || pCat.contains("mochila") || pCat.contains("backpack") || pCat.contains("tula") + } + sel.contains("bolso") || sel.contains("cartera") -> { + pCat.contains("bolso") || pCat.contains("cartera") || pCat.contains("tote") + } + sel.contains("carriel") || sel.contains("bandolera") -> { + pCat.contains("carriel") || pCat.contains("bandolera") || pCat.contains("riñonera") || pCat.contains("rinonera") || pCat.contains("crossbody") || pCat.contains("pechera") + } + sel.contains("gorra") -> pCat.contains("gorra") || pCat.contains("cap") + sel.contains("ropa") -> pCat.contains("ropa") || pCat.contains("camiseta") || pCat.contains("hoodie") + sel.contains("accesorio") -> pCat.contains("accesorio") || pCat.contains("billetera") || pCat.contains("reloj") || pCat.contains("gafas") + else -> pCat.contains(sel) || sel.contains(pCat) + } + } } val matchesSearch = if (searchQuery.isBlank()) { diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/CategoryAdapter.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/CategoryAdapter.kt index 96d9c47..5400fad 100644 --- a/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/CategoryAdapter.kt +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/CategoryAdapter.kt @@ -13,7 +13,7 @@ class CategoryAdapter( private val onCategoryClick: (Category) -> Unit ) : RecyclerView.Adapter() { - private var selectedIndex = 0 + private var selectedIndex = categories.indexOfFirst { it.isSelected }.let { if (it >= 0) it else 0 } inner class CategoryViewHolder(private val binding: ItemCategoryChipBinding) : RecyclerView.ViewHolder(binding.root) { diff --git a/dripzone-app/next.config.ts b/dripzone-app/next.config.ts index 5b406fe..d424050 100644 --- a/dripzone-app/next.config.ts +++ b/dripzone-app/next.config.ts @@ -7,6 +7,22 @@ const nextConfig: NextConfig = { { protocol: "https", hostname: "**" }, ], }, + async rewrites() { + return [ + { + source: '/catalog', + destination: '/catalogo', + }, + { + source: '/coleccion', + destination: '/catalogo', + }, + { + source: '/zapatillas', + destination: '/catalogo', + }, + ]; + }, }; export default nextConfig; diff --git a/dripzone-app/src/app/admin/feed-whatsapp/page.tsx b/dripzone-app/src/app/admin/feed-whatsapp/page.tsx index 213db81..bca9b43 100644 --- a/dripzone-app/src/app/admin/feed-whatsapp/page.tsx +++ b/dripzone-app/src/app/admin/feed-whatsapp/page.tsx @@ -42,30 +42,7 @@ import { SupplierPost, Product, MonitoredWhatsAppGroup, ExtractedProductData } f import { formatCOP, calculateMarketPricingStrategies } from '@/lib/pricing-engine'; import { BRANDS, BrandId } from '@/config/brands'; import { OptimizedImage } from '@/components/ui/OptimizedImage'; - -const CATEGORY_TABS = [ - { id: 'all', label: 'Todas las Categorías', icon: '🔥' }, - { id: 'zapatillas', label: 'Zapatillas', icon: '👟' }, - { id: 'chanclas', label: 'Chanclas / Sandalias', icon: '🩴' }, - { id: 'bolsos', label: 'Bolsos / Carteras', icon: '👜' }, - { id: 'maletines', label: 'Maletines / Morrales', icon: '🎒' }, - { id: 'carrieles', label: 'Carrieles / Bandoleras', icon: '👝' }, - { id: 'gorras', label: 'Gorras / Caps', icon: '🧢' }, - { id: 'ropa', label: 'Ropa / Streetwear', icon: '👕' }, - { id: 'accesorios', label: 'Accesorios', icon: '🕶️' }, -]; - -function getCategoryIcon(category?: string): string { - const cat = (category || '').toLowerCase(); - if (cat.includes('chancla') || cat.includes('slide') || cat.includes('sandalia') || cat.includes('croc')) return '🩴'; - if (cat.includes('carriel') || cat.includes('bandolera') || cat.includes('riñonera') || cat.includes('crossbody') || cat.includes('pechera')) return '👝'; - if (cat.includes('malet') || cat.includes('morral') || cat.includes('mochila') || cat.includes('tula')) return '🎒'; - if (cat.includes('bolso') || cat.includes('cartera') || cat.includes('tote')) return '👜'; - if (cat.includes('gorra') || cat.includes('cap')) return '🧢'; - if (cat.includes('ropa') || cat.includes('camiseta') || cat.includes('hoodie')) return '👕'; - if (cat.includes('accesorio') || cat.includes('billetera')) return '🕶️'; - return '👟'; -} +import { getCategoryIcon, normalizeCategoryName, matchCategory } from '@/lib/category-helper'; export default function SupplierFeedPage() { const [posts, setPosts] = useState([]); @@ -424,108 +401,82 @@ export default function SupplierFeedPage() { }); }, [posts, tabStatus, selectedGroupFilter]); - // Contadores dinámicos por categoría - const categoryCounts = useMemo(() => { - const counts: Record = { all: postsByStatusAndGroup.length }; - for (const p of postsByStatusAndGroup) { - const cat = (p.extractedProduct?.category || '').toLowerCase(); - const combined = `${cat} ${(p.extractedProduct?.name || '').toLowerCase()} ${(p.rawMessage || '').toLowerCase()}`; - if (combined.includes('chancla') || combined.includes('slide') || combined.includes('sandalia') || combined.includes('croc')) { - counts.chanclas = (counts.chanclas || 0) + 1; - } else if ( - combined.includes('carriel') || - combined.includes('bandolera') || - combined.includes('riñonera') || - combined.includes('rinonera') || - combined.includes('crossbody') || - combined.includes('pechera') - ) { - counts.carrieles = (counts.carrieles || 0) + 1; - } else if ( - combined.includes('malet') || - combined.includes('morral') || - combined.includes('mochila') || - combined.includes('backpack') || - combined.includes('tula') - ) { - counts.maletines = (counts.maletines || 0) + 1; - } else if (combined.includes('bolso') || combined.includes('cartera') || combined.includes('tote')) { - counts.bolsos = (counts.bolsos || 0) + 1; - } else if (combined.includes('gorra') || combined.includes('cap')) { - counts.gorras = (counts.gorras || 0) + 1; - } else if (combined.includes('ropa') || combined.includes('camiseta') || combined.includes('hoodie')) { - counts.ropa = (counts.ropa || 0) + 1; - } else if (combined.includes('billetera') || combined.includes('reloj') || combined.includes('gafas')) { - counts.accesorios = (counts.accesorios || 0) + 1; - } else { - counts.zapatillas = (counts.zapatillas || 0) + 1; - } - } - return counts; - }, [postsByStatusAndGroup]); + // Categorías dinámicas calculadas a partir de los posts actuales y catálogo de productos + const dynamicCategoryTabs = useMemo(() => { + const catMap = new Map(); - // Filtrado final incluyendo categoría seleccionada + const defaultCats = [ + { id: 'zapatillas', label: 'Zapatillas', icon: '👟' }, + { id: 'sandalias', label: 'Chanclas / Sandalias', icon: '🩴' }, + { id: 'bolsos', label: 'Bolsos / Carteras', icon: '👜' }, + { id: 'maletines', label: 'Maletines / Morrales', icon: '🎒' }, + { id: 'carrieles', label: 'Carrieles / Bandoleras', icon: '👝' }, + { id: 'gorras', label: 'Gorras / Caps', icon: '🧢' }, + { id: 'ropa', label: 'Ropa / Streetwear', icon: '👕' }, + { id: 'accesorios', label: 'Accesorios', icon: '🕶️' }, + ]; + + for (const def of defaultCats) { + catMap.set(def.id, { ...def, count: 0 }); + } + + // Escanear categorías existentes en los posts del feed + postsByStatusAndGroup.forEach((p) => { + const rawCat = p.extractedProduct?.category?.trim(); + if (rawCat) { + const normId = normalizeCategoryName(rawCat); + if (!catMap.has(normId)) { + catMap.set(normId, { + id: normId, + label: rawCat, + icon: getCategoryIcon(rawCat), + count: 0, + }); + } + } + }); + + // Escanear categorías creadas en el catálogo de productos + existingProducts.forEach((prod) => { + const rawCat = prod.category?.trim(); + if (rawCat) { + const normId = normalizeCategoryName(rawCat); + if (!catMap.has(normId)) { + catMap.set(normId, { + id: normId, + label: rawCat, + icon: getCategoryIcon(rawCat), + count: 0, + }); + } + } + }); + + // Calcular conteo exacto de posts para cada categoría con matchCategory + const list = Array.from(catMap.values()); + for (const item of list) { + item.count = postsByStatusAndGroup.filter((p) => + matchCategory(p.extractedProduct?.category, item.id, `${p.extractedProduct?.name || ''} ${p.rawMessage || ''}`) + ).length; + } + + // Mostrar siempre 'all' primero, y las categorías que tienen publicaciones o son las categorías principales + return [ + { id: 'all', label: 'Todas las Categorías', icon: '🔥', count: postsByStatusAndGroup.length }, + ...list.filter((c) => c.count > 0 || ['zapatillas', 'sandalias', 'maletines', 'bolsos', 'carrieles'].includes(c.id)), + ]; + }, [postsByStatusAndGroup, existingProducts]); + + // Filtrado final incluyendo categoría seleccionada de forma dinámica const filteredPosts = useMemo(() => { if (selectedCategoryFilter === 'all') return postsByStatusAndGroup; return postsByStatusAndGroup.filter((p) => { - const cat = (p.extractedProduct?.category || '').toLowerCase(); - const combined = `${cat} ${(p.extractedProduct?.name || '').toLowerCase()} ${(p.rawMessage || '').toLowerCase()}`; - - if (selectedCategoryFilter === 'chanclas') { - return combined.includes('chancla') || combined.includes('slide') || combined.includes('sandalia') || combined.includes('croc'); - } - if (selectedCategoryFilter === 'carrieles') { - return ( - combined.includes('carriel') || - combined.includes('bandolera') || - combined.includes('riñonera') || - combined.includes('rinonera') || - combined.includes('crossbody') || - combined.includes('pechera') - ); - } - if (selectedCategoryFilter === 'maletines') { - return ( - combined.includes('malet') || - combined.includes('morral') || - combined.includes('mochila') || - combined.includes('backpack') || - combined.includes('tula') - ); - } - if (selectedCategoryFilter === 'bolsos') { - return combined.includes('bolso') || combined.includes('cartera') || combined.includes('tote'); - } - if (selectedCategoryFilter === 'gorras') { - return combined.includes('gorra') || combined.includes('cap'); - } - if (selectedCategoryFilter === 'ropa') { - return combined.includes('ropa') || combined.includes('camiseta') || combined.includes('hoodie'); - } - if (selectedCategoryFilter === 'accesorios') { - return combined.includes('billetera') || combined.includes('reloj') || combined.includes('gafas'); - } - if (selectedCategoryFilter === 'zapatillas') { - const isOther = - combined.includes('chancla') || - combined.includes('slide') || - combined.includes('sandalia') || - combined.includes('croc') || - combined.includes('carriel') || - combined.includes('bandolera') || - combined.includes('riñonera') || - combined.includes('malet') || - combined.includes('morral') || - combined.includes('mochila') || - combined.includes('bolso') || - combined.includes('cartera') || - combined.includes('gorra') || - combined.includes('ropa') || - combined.includes('billetera'); - return !isOther || cat.includes('zapatilla'); - } - return true; + return matchCategory( + p.extractedProduct?.category, + selectedCategoryFilter, + `${p.extractedProduct?.name || ''} ${p.rawMessage || ''}` + ); }); }, [postsByStatusAndGroup, selectedCategoryFilter]); @@ -805,9 +756,9 @@ export default function SupplierFeedPage() { Categorías: - {CATEGORY_TABS.map((cat) => { + {dynamicCategoryTabs.map((cat) => { const isActive = selectedCategoryFilter === cat.id; - const count = categoryCounts[cat.id] || 0; + const count = cat.count; return ( + (INITIAL_PRODUCTS); @@ -917,18 +918,37 @@ export default function AdminProductsPage() { {/* Dropdown Filters & Search */} -
+
{/* Búsqueda */} -
+
setSearch(e.target.value)} className="w-full bg-neutral-900 border border-neutral-800 rounded-xl px-3.5 py-2 text-xs text-white outline-hidden focus:border-purple-400" />
+ {/* Filtro Categoría */} +
+ +
+ {/* Filtro Proveedor */}
setCategory(e.target.value)} - className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden" + placeholder="Ej: Zapatillas, Sandalias, Gorras..." + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden focus:border-purple-400" /> + + {availableCategories.map((c) => ( +
@@ -1917,14 +1944,9 @@ export default function AdminProductsPage() { className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden focus:border-purple-400" /> -
@@ -2051,32 +2073,37 @@ export default function AdminProductsPage() { {selectedProductIds.length} productos seleccionados:

-
- {[ - 'Zapatillas', - 'Chanclas / Slides', - 'Bolsos y Morrales', - 'Maletines', - 'Carrieles y Bandoleras', - 'Gorras', - 'Ropa Urbana', - 'Accesorios', - ].map((cat) => ( +
+ {availableCategories.map((cat) => ( ))}
+
+ + setBulkCategorySelect(e.target.value)} + placeholder="Ej: Gafas, Billeteras, Relojes..." + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white text-xs outline-hidden focus:border-purple-400" + /> +
+
+
+ +
+
diff --git a/dripzone-app/src/components/mobile/MobileBottomDock.tsx b/dripzone-app/src/components/mobile/MobileBottomDock.tsx index ac3da16..8aa897c 100644 --- a/dripzone-app/src/components/mobile/MobileBottomDock.tsx +++ b/dripzone-app/src/components/mobile/MobileBottomDock.tsx @@ -3,7 +3,7 @@ import React, { useState } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { Home, Search, ShoppingBag, Package, User, Shield } from 'lucide-react'; +import { Home, Search, ShoppingBag, Package, User, Shield, Sparkles } from 'lucide-react'; import { useBrand } from '@/context/BrandContext'; import { useAuth } from '@/context/AuthContext'; import { renderClientTemplate } from '@/lib/templates-client'; @@ -49,7 +49,7 @@ export const MobileBottomDock: React.FC = ({ return (