feat: rebuild web — landing page + catalogo publico con navegacion por vehiculo

Removidas paginas viejas (demo, tienda, admin, pos, captura, cuentas).
Nueva landing page con estilo del design system (tokens.css, 2 temas).
Catalogo publico sin auth con navegacion Marca>Modelo>Ano>Motor>Categoria>Partes.
Endpoints /api/catalog/* publicos con filtro NORTH_AMERICA_BRANDS (36 marcas).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-01 23:21:45 +00:00
parent e7376ddaed
commit 989a178143
5 changed files with 2221 additions and 105 deletions

View File

@@ -181,35 +181,15 @@ def search_vehicles(brand=None, model=None, year=None, engine_name=None, with_pa
@app.route('/')
def index():
return redirect('/login.html')
return send_from_directory('.', 'landing.html')
@app.route('/admin')
def admin_page():
return send_from_directory('.', 'admin.html')
@app.route('/catalog')
def public_catalog():
return send_from_directory('.', 'catalog-public.html')
@app.route('/landing')
def landing_page():
return send_from_directory('.', 'customer-landing.html')
@app.route('/diagramas')
def diagrams_page():
return send_from_directory('.', 'diagrams.html')
@app.route('/index.html')
def index_html():
return send_from_directory('.', 'index.html')
@app.route('/admin.html')
def admin_html():
return send_from_directory('.', 'admin.html')
@app.route('/customer-landing.html')
def customer_landing_html():
return send_from_directory('.', 'customer-landing.html')
@app.route('/diagrams.html')
def diagrams_html():
return send_from_directory('.', 'diagrams.html')
@app.route('/catalog-public.js')
def catalog_public_js():
return send_from_directory('.', 'catalog-public.js')
@app.route('/static/<path:path>')
def static_files(path):
@@ -236,6 +216,341 @@ def enhanced_search_js():
return send_from_directory('.', 'enhanced-search.js')
# ============================================================================
# Public Catalog API — No auth required
# ============================================================================
NORTH_AMERICA_BRANDS = (
'ACURA', 'AUDI', 'BMW', 'BUICK', 'CADILLAC', 'CHEVROLET', 'CHRYSLER',
'DODGE', 'FIAT', 'FORD', 'GMC', 'HONDA', 'HYUNDAI', 'INFINITI',
'JAGUAR', 'JEEP', 'KIA', 'LAND ROVER', 'LEXUS', 'LINCOLN', 'MAZDA',
'MERCEDES-BENZ', 'MINI', 'MITSUBISHI', 'NISSAN', 'PEUGEOT', 'PORSCHE',
'RAM', 'RENAULT', 'SEAT', 'SUBARU', 'SUZUKI', 'TESLA', 'TOYOTA',
'VOLVO', 'VW',
)
@app.route('/api/catalog/brands')
def api_catalog_brands():
session = Session()
try:
rows = session.execute(text("""
SELECT DISTINCT b.id_brand, b.name_brand
FROM brands b
JOIN models m ON m.brand_id = b.id_brand
JOIN model_year_engine mye ON mye.model_id = m.id_model
WHERE b.name_brand = ANY(:brands)
ORDER BY b.name_brand
"""), {'brands': list(NORTH_AMERICA_BRANDS)}).mappings().all()
return jsonify([{'id_brand': r['id_brand'], 'name_brand': r['name_brand']} for r in rows])
finally:
session.close()
@app.route('/api/catalog/models')
def api_catalog_models():
brand_id = request.args.get('brand_id', type=int)
if not brand_id:
return jsonify({'error': 'brand_id required'}), 400
session = Session()
try:
rows = session.execute(text("""
SELECT DISTINCT m.id_model, m.name_model
FROM models m
JOIN model_year_engine mye ON mye.model_id = m.id_model
WHERE m.brand_id = :brand_id
ORDER BY m.name_model
"""), {'brand_id': brand_id}).mappings().all()
return jsonify([{'id_model': r['id_model'], 'name_model': r['name_model']} for r in rows])
finally:
session.close()
@app.route('/api/catalog/years')
def api_catalog_years():
model_id = request.args.get('model_id', type=int)
if not model_id:
return jsonify({'error': 'model_id required'}), 400
session = Session()
try:
rows = session.execute(text("""
SELECT DISTINCT y.id_year, y.year_car
FROM years y
JOIN model_year_engine mye ON mye.year_id = y.id_year
WHERE mye.model_id = :model_id
ORDER BY y.year_car DESC
"""), {'model_id': model_id}).mappings().all()
return jsonify([{'id_year': r['id_year'], 'year_car': r['year_car']} for r in rows])
finally:
session.close()
@app.route('/api/catalog/engines')
def api_catalog_engines():
model_id = request.args.get('model_id', type=int)
year_id = request.args.get('year_id', type=int)
if not model_id or not year_id:
return jsonify({'error': 'model_id and year_id required'}), 400
session = Session()
try:
rows = session.execute(text("""
SELECT mye.id_mye, e.name_engine, mye.trim_level
FROM model_year_engine mye
JOIN engines e ON e.id_engine = mye.engine_id
WHERE mye.model_id = :model_id AND mye.year_id = :year_id
ORDER BY e.name_engine, mye.trim_level
"""), {'model_id': model_id, 'year_id': year_id}).mappings().all()
return jsonify([{'id_mye': r['id_mye'], 'name_engine': r['name_engine'],
'trim_level': r['trim_level'] or ''} for r in rows])
finally:
session.close()
@app.route('/api/catalog/categories')
def api_catalog_categories():
mye_id = request.args.get('mye_id', type=int)
if not mye_id:
return jsonify({'error': 'mye_id required'}), 400
session = Session()
try:
rows = session.execute(text("""
SELECT pc.id_part_category,
COALESCE(pc.name_es, pc.name_part_category) AS name,
sub.cnt AS part_count
FROM (
SELECT pg.category_id, COUNT(*) AS cnt
FROM vehicle_parts vp
JOIN parts p ON p.id_part = vp.part_id
JOIN part_groups pg ON pg.id_part_group = p.group_id
WHERE vp.model_year_engine_id = :mye_id
GROUP BY pg.category_id
) sub
JOIN part_categories pc ON pc.id_part_category = sub.category_id
ORDER BY name
"""), {'mye_id': mye_id}).mappings().all()
return jsonify([{'id_part_category': r['id_part_category'],
'name': r['name'], 'part_count': r['part_count']} for r in rows])
finally:
session.close()
@app.route('/api/catalog/groups')
def api_catalog_groups():
mye_id = request.args.get('mye_id', type=int)
category_id = request.args.get('category_id', type=int)
if not mye_id or not category_id:
return jsonify({'error': 'mye_id and category_id required'}), 400
session = Session()
try:
rows = session.execute(text("""
SELECT pg.id_part_group,
COALESCE(pg.name_es, pg.name_part_group) AS name,
COUNT(*) AS part_count
FROM vehicle_parts vp
JOIN parts p ON p.id_part = vp.part_id
JOIN part_groups pg ON pg.id_part_group = p.group_id
WHERE vp.model_year_engine_id = :mye_id
AND pg.category_id = :category_id
GROUP BY pg.id_part_group, name
ORDER BY name
"""), {'mye_id': mye_id, 'category_id': category_id}).mappings().all()
return jsonify([{'id_part_group': r['id_part_group'],
'name': r['name'], 'part_count': r['part_count']} for r in rows])
finally:
session.close()
@app.route('/api/catalog/parts')
def api_catalog_parts():
mye_id = request.args.get('mye_id', type=int)
group_id = request.args.get('group_id', type=int)
if not mye_id or not group_id:
return jsonify({'error': 'mye_id and group_id required'}), 400
page = max(1, request.args.get('page', 1, type=int))
per_page = min(request.args.get('per_page', 30, type=int), 100)
offset = (page - 1) * per_page
session = Session()
try:
total = session.execute(text("""
SELECT COUNT(*)
FROM vehicle_parts vp
JOIN parts p ON p.id_part = vp.part_id
WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id
"""), {'mye_id': mye_id, 'group_id': group_id}).scalar()
rows = session.execute(text("""
SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es,
p.description, p.description_es, p.image_url
FROM vehicle_parts vp
JOIN parts p ON p.id_part = vp.part_id
WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id
ORDER BY p.name_part
LIMIT :limit OFFSET :offset
"""), {'mye_id': mye_id, 'group_id': group_id, 'limit': per_page, 'offset': offset}).mappings().all()
items = [{
'id_part': r['id_part'],
'oem_part_number': r['oem_part_number'],
'name': r['name_es'] or r['name_part'],
'description': r['description_es'] or r['description'],
'image_url': r['image_url'],
} for r in rows]
total_pages = max(1, (total + per_page - 1) // per_page)
return jsonify({'data': items, 'pagination': {
'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages
}})
finally:
session.close()
@app.route('/api/catalog/part/<int:part_id>')
def api_catalog_part_detail(part_id):
session = Session()
try:
row = session.execute(text("""
SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es,
p.description, p.description_es, p.image_url,
COALESCE(pg.name_es, pg.name_part_group) AS group_name,
COALESCE(pc.name_es, pc.name_part_category) AS category_name
FROM parts p
LEFT JOIN part_groups pg ON pg.id_part_group = p.group_id
LEFT JOIN part_categories pc ON pc.id_part_category = pg.category_id
WHERE p.id_part = :part_id
"""), {'part_id': part_id}).mappings().first()
if not row:
return jsonify({'error': 'Part not found'}), 404
part = {
'id_part': row['id_part'],
'oem_part_number': row['oem_part_number'],
'name': row['name_es'] or row['name_part'],
'description': row['description_es'] or row['description'],
'image_url': row['image_url'],
'group_name': row['group_name'],
'category_name': row['category_name'],
}
# Cross-references
xrefs = session.execute(text("""
SELECT pcr.cross_reference_number, pcr.source_ref
FROM part_cross_references pcr
WHERE pcr.part_id = :pid
LIMIT 50
"""), {'pid': part_id}).mappings().all()
# Aftermarket alternatives
afters = session.execute(text("""
SELECT ap.part_number, m.name_manufacture,
COALESCE(ap.name_es, ap.name_aftermarket_parts) AS name
FROM aftermarket_parts ap
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
WHERE ap.oem_part_id = :pid
LIMIT 50
"""), {'pid': part_id}).mappings().all()
alternatives = []
for x in xrefs:
alternatives.append({
'part_number': x['cross_reference_number'],
'manufacturer': x['source_ref'] or 'OEM Cross-Ref',
'name': None,
'type': 'cross_reference',
})
for a in afters:
alternatives.append({
'part_number': a['part_number'],
'manufacturer': a['name_manufacture'],
'name': a['name'],
'type': 'aftermarket',
})
# Bodegas
bodegas_rows = session.execute(text("""
SELECT u.business_name, wi.stock_quantity, wi.warehouse_location
FROM warehouse_inventory wi
JOIN users u ON u.id_user = wi.user_id
WHERE wi.part_id = :pid AND wi.stock_quantity > 0
ORDER BY wi.stock_quantity DESC
LIMIT 20
"""), {'pid': part_id}).mappings().all()
bodegas = [{'business_name': b['business_name'], 'stock': b['stock_quantity'],
'location': b['warehouse_location']} for b in bodegas_rows]
return jsonify({'part': part, 'alternatives': alternatives, 'bodegas': bodegas})
finally:
session.close()
@app.route('/api/catalog/search')
def api_catalog_search():
q = request.args.get('q', '').strip()
if not q or len(q) < 2:
return jsonify([])
limit = min(request.args.get('limit', 50, type=int), 100)
session = Session()
try:
is_part_number = bool(re.search(r'\d.*[-/]|[-/].*\d|\d{3,}', q))
if is_part_number:
clean_q = q.replace(' ', '').upper()
rows = session.execute(text("""
SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es,
p.image_url
FROM parts p
WHERE REPLACE(UPPER(p.oem_part_number), ' ', '') LIKE :q
ORDER BY p.oem_part_number
LIMIT :limit
"""), {'q': f'%{clean_q}%', 'limit': limit}).mappings().all()
else:
tsquery = ' & '.join(q.split())
rows = session.execute(text("""
SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es,
p.image_url
FROM parts p
WHERE p.search_vector @@ to_tsquery('spanish', :tsq)
OR p.name_part ILIKE :like
OR p.name_es ILIKE :like
ORDER BY
CASE WHEN p.search_vector @@ to_tsquery('spanish', :tsq)
THEN 0 ELSE 1 END,
p.name_part
LIMIT :limit
"""), {'tsq': tsquery, 'like': f'%{q}%', 'limit': limit}).mappings().all()
if not rows:
return jsonify([])
part_ids = [r['id_part'] for r in rows]
# Get one vehicle per part for context
vrows = session.execute(text("""
SELECT DISTINCT ON (vp.part_id)
vp.part_id, b.name_brand, m.name_model, y.year_car
FROM vehicle_parts vp
JOIN model_year_engine mye ON mye.id_mye = vp.model_year_engine_id
JOIN models m ON m.id_model = mye.model_id
JOIN brands b ON b.id_brand = m.brand_id
JOIN years y ON y.id_year = mye.year_id
WHERE vp.part_id = ANY(:pids)
ORDER BY vp.part_id, y.year_car DESC
"""), {'pids': part_ids}).mappings().all()
vmap = {v['part_id']: f"{v['name_brand']} {v['name_model']} {v['year_car']}" for v in vrows}
results = []
for r in rows:
results.append({
'id_part': r['id_part'],
'oem_part_number': r['oem_part_number'],
'name': r['name_es'] or r['name_part'],
'image_url': r['image_url'],
'vehicle_info': vmap.get(r['id_part'], ''),
})
return jsonify(results)
finally:
session.close()
# ============================================================================
# Core API Endpoints
# ============================================================================
@@ -2364,17 +2679,7 @@ def api_admin_delete_hotspot(hotspot_id):
# Captura (Data Entry) Endpoints
# ============================================================================
@app.route('/captura')
def captura_page():
return send_from_directory('.', 'captura.html')
@app.route('/captura.js')
def captura_js():
return send_from_directory('.', 'captura.js')
@app.route('/captura.css')
def captura_css():
return send_from_directory('.', 'captura.css')
# Captura page routes removed — APIs below kept for compatibility
@app.route('/api/captura/vehicles/pending')
@@ -2715,29 +3020,7 @@ def api_captura_part_aftermarket(part_id):
# POS (Point of Sale) Endpoints
# ============================================================================
@app.route('/pos')
def pos_page():
return send_from_directory('.', 'pos.html')
@app.route('/pos.js')
def pos_js():
return send_from_directory('.', 'pos.js')
@app.route('/pos.css')
def pos_css():
return send_from_directory('.', 'pos.css')
@app.route('/cuentas')
def cuentas_page():
return send_from_directory('.', 'cuentas.html')
@app.route('/cuentas.js')
def cuentas_js():
return send_from_directory('.', 'cuentas.js')
@app.route('/cuentas.css')
def cuentas_css():
return send_from_directory('.', 'cuentas.css')
# POS/cuentas page routes removed — served by POS app on its own port
# ---- Customers ----
@@ -3132,50 +3415,8 @@ def api_pos_search_parts():
# Store Dashboard Endpoints
# ============================================================================
@app.route('/demo')
def demo_page():
return send_from_directory('.', 'demo.html')
@app.route('/bodega')
def bodega_page():
return send_from_directory('.', 'bodega.html')
@app.route('/bodega.js')
def bodega_js():
return send_from_directory('.', 'bodega.js')
@app.route('/bodega.css')
def bodega_css():
return send_from_directory('.', 'bodega.css')
@app.route('/pitch')
def pitch_deck():
return send_from_directory('../pitch', 'deck.html')
@app.route('/login.html')
def login_page():
return send_from_directory('.', 'login.html')
@app.route('/login.js')
def login_js():
return send_from_directory('.', 'login.js')
@app.route('/login.css')
def login_css():
return send_from_directory('.', 'login.css')
@app.route('/tienda')
def tienda_page():
return send_from_directory('.', 'tienda.html')
@app.route('/tienda.js')
def tienda_js():
return send_from_directory('.', 'tienda.js')
@app.route('/tienda.css')
def tienda_css():
return send_from_directory('.', 'tienda.css')
# Old page routes removed (demo, bodega, pitch, login, tienda)
# APIs below are kept for backward compatibility
@app.route('/api/tienda/stats')