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:
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user