'use strict' /** * store.js — Boutique matériel (PAGE MODÈLE, staging, servie par le hub). * Objectif : valider l'EXPÉRIENCE (panier optimistic + visualisation de variantes) * avant tout branchement. Données démo en dur pour l'instant. * * Prochaine étape (P0.2) : remplacer DEMO_CATALOG par GET /store/catalog * (Items + Product Bundle d'ERPNext) et prix via apply_pricing_rule. * * Routes publiques : * GET /store → page modèle (Vue 3 via CDN, self-contained) * GET /store/catalog → (à venir) catalogue réel ERPNext * * IMPORTANT : ce fichier est un template literal. NE PAS utiliser la séquence * dollar-accolade dans le HTML/JS interne (Vue utilise les doubles accolades). */ const { json } = require('./helpers') const erp = require('./erp') const PAGE = ` Boutique Gigafibre
GigafibreBoutique
⚙️ Page modèle — données démo (catalogue ERPNext vide ou indisponible). Cochez « Afficher dans la boutique » sur des Items pour les voir ici.
Catalogue ERPNext en direct — produits cochés « Afficher dans la boutique » · prix Item Price · stock live.
{{ c }}
{{ p.icon }} BUNDLE
{{ p.cat }}
{{ p.name }}

Votre panier

{{ count }} article{{ count>1?'s':'' }} ×
🛒
Votre panier est vide
{{ l.icon }}
{{ l.name }}
{{ l.opt }}
Retirer
{{ money(l.price*l.qty) }}
{{ l.qty }}
Sous-total{{ money(total) }}
Taxes (TPS+TVQ ~14,975 %){{ money(total*0.14975) }}
Total{{ money(total*1.14975) }}
Paiement sécurisé Stripe · livraison ou ramassage
{{ toast }}
` // ── Catalogue live depuis ERPNext ────────────────────────────────────────── // Curation = champ Item.show_in_store (case à cocher). Catégorie = item_group. // Prix = Item Price (Standard Selling). Stock = somme Bin.actual_qty. Bundles = // Product Bundle. Variantes mono-attribut → options additives (add + stock/valeur). const ERP_HOST = 'https://erp.gigafibre.ca' const PRICE_LIST = 'Standard Selling' const COLOR_MAP = { blanc: '#f3f4f6', noir: '#1f2937', gris: '#9ca3af', bleu: '#2563eb', rouge: '#dc2626', vert: '#16a34a', jaune: '#eab308', orange: '#f97316', argent: '#cbd5e1' } const GROUP_ICON = { 'Caméras': '📷', 'Boosters WiFi': '📶', 'Câbles réseau': '🔌', 'TP-Link': '📡', 'Accessoires': '⚡', 'Bundles': '📦' } const round2 = n => Math.round((Number(n) || 0) * 100) / 100 const stripHtml = s => (s || '').replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\s+/g, ' ').trim() const imgUrl = img => (img && img.startsWith('/files')) ? ERP_HOST + img : '' async function priceOf (code) { const rows = await erp.list('Item Price', { filters: [['item_code', '=', code], ['price_list', '=', PRICE_LIST], ['selling', '=', 1]], fields: ['price_list_rate'], limit: 1 }) return rows && rows[0] ? round2(rows[0].price_list_rate) : 0 } async function stockOf (code) { const bins = await erp.list('Bin', { filters: [['item_code', '=', code]], fields: ['actual_qty'], limit: 50 }) return Math.round((bins || []).reduce((s, b) => s + (Number(b.actual_qty) || 0), 0)) } async function nameOf (code) { const it = await erp.get('Item', code, { fields: ['item_name'] }) return (it && it.item_name) || code } async function buildCatalog () { const items = await erp.list('Item', { filters: [['show_in_store', '=', 1], ['disabled', '=', 0]], fields: ['name', 'item_name', 'item_group', 'image', 'description', 'standard_rate', 'has_variants', 'variant_of'], limit: 200, }) const bundles = await erp.list('Product Bundle', { fields: ['name'], limit: 200 }) const bundleSet = new Set((bundles || []).map(b => b.name)) const out = [] for (const it of (items || [])) { if (it.variant_of) continue // les variantes enfant sont exposées via leur template const p = { id: it.name, cat: it.item_group || 'Boutique', name: it.item_name || it.name, desc: stripHtml(it.description), icon: GROUP_ICON[it.item_group] || '📦', image: imgUrl(it.image), } if (bundleSet.has(it.name)) { const pb = await erp.get('Product Bundle', it.name) const comps = (pb && pb.items) || [] let sum = 0; const list = [] for (const c of (comps || [])) { const q = Number(c.qty) || 1; sum += (await priceOf(c.item_code)) * q list.push({ q, n: await nameOf(c.item_code) }) } p.bundle = true; p.sum = round2(sum); p.price = (await priceOf(it.name)) || p.sum; p.items = list } else if (it.has_variants) { const vs = await erp.list('Item', { filters: [['variant_of', '=', it.name], ['disabled', '=', 0]], fields: ['name'], limit: 100 }) const info = [] for (const v of (vs || [])) { const vd = await erp.get('Item', v.name) const attrs = (vd && vd.attributes) || [] info.push({ attrs, price: await priceOf(v.name), stock: await stockOf(v.name) }) } const prices = info.map(v => v.price).filter(n => n > 0) const baseP = prices.length ? Math.min(...prices) : (round2(it.standard_rate) || 0) p.base = baseP const attrNames = [...new Set(info.flatMap(v => v.attrs.map(a => a.attribute)))] p.options = attrNames.map(an => { const isColor = /couleur|color/i.test(an) const vals = [...new Set(info.flatMap(v => v.attrs.filter(a => a.attribute === an).map(a => a.attribute_value)))] return { name: an, type: isColor ? 'color' : 'pill', values: vals.map(val => { const match = info.filter(v => v.attrs.some(a => a.attribute === an && a.attribute_value === val)) const mp = match.map(v => v.price).filter(n => n > 0) const o = { v: val, add: round2((mp.length ? Math.min(...mp) : baseP) - baseP), stock: match.reduce((s, v) => s + v.stock, 0) } if (isColor) o.hex = COLOR_MAP[String(val).toLowerCase()] || '#9ca3af' return o }), } }) } else { p.base = (await priceOf(it.name)) || round2(it.standard_rate) || 0 p.stock = await stockOf(it.name) } out.push(p) } return out } async function handle (req, res, method, path) { if (path === '/store' && method === 'GET') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) return res.end(PAGE) } if (path === '/store/catalog' && method === 'GET') { try { return json(res, 200, { products: await buildCatalog() }) } catch (e) { return json(res, 200, { products: [], error: e.message }) } } return json(res, 404, { error: 'not found' }) } module.exports = { handle }