Affiché barré (gris) si > prix de vente (Item Price), sur produits simples + variantes (les bundles gardent barré=somme des composants). Catalogue lit le champ; page rend <s>. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
476 lines
27 KiB
JavaScript
476 lines
27 KiB
JavaScript
'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 = `<!doctype html><html lang=fr><head>
|
||
<meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1">
|
||
<title>Boutique Gigafibre</title>
|
||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||
<style>
|
||
:root{--g:#019547;--g2:#01733a;--ink:#1a1a1a;--mut:#6b7280;--line:#e6e9ee;--bg:#f5f7fa}
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--ink);line-height:1.5}
|
||
a{color:inherit}
|
||
.top{position:sticky;top:0;z-index:30;background:rgba(255,255,255,.86);backdrop-filter:saturate(180%) blur(10px);border-bottom:1px solid var(--line)}
|
||
.top .in{max-width:1080px;margin:0 auto;padding:12px 18px;display:flex;align-items:center;gap:14px}
|
||
.brand{font-weight:800;font-size:18px}.brand b{color:var(--g)}
|
||
.brand .s{color:var(--mut);font-weight:500;font-size:13px;margin-left:6px}
|
||
.cartbtn{margin-left:auto;position:relative;border:1px solid var(--line);background:#fff;border-radius:12px;padding:9px 14px;font-weight:700;cursor:pointer;display:flex;align-items:center;gap:8px;font-size:14px;transition:.15s}
|
||
.cartbtn:hover{border-color:var(--g);box-shadow:0 2px 10px rgba(1,149,71,.12)}
|
||
.badge{position:absolute;top:-7px;right:-7px;min-width:20px;height:20px;border-radius:10px;background:#e11d48;color:#fff;font-size:11px;font-weight:800;display:flex;align-items:center;justify-content:center;padding:0 5px;transition:transform .2s}
|
||
.badge.bump{transform:scale(1.45)}
|
||
.wrap{max-width:1080px;margin:0 auto;padding:18px}
|
||
.cats{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:18px}
|
||
.cat{border:1px solid var(--line);background:#fff;border-radius:999px;padding:7px 15px;font-size:13px;font-weight:600;color:var(--mut);cursor:pointer;transition:.15s}
|
||
.cat:hover{border-color:#cbd5e1}
|
||
.cat.on{background:var(--g);border-color:var(--g);color:#fff}
|
||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:16px}
|
||
.card{background:#fff;border:1px solid var(--line);border-radius:16px;overflow:hidden;cursor:pointer;transition:transform .18s,box-shadow .18s;display:flex;flex-direction:column}
|
||
.card:hover{transform:translateY(-4px);box-shadow:0 12px 28px rgba(15,23,42,.10)}
|
||
.tile{height:140px;display:flex;align-items:center;justify-content:center;font-size:52px;position:relative;transition:background .25s}
|
||
.tile .bdg{position:absolute;top:10px;left:10px;background:rgba(255,255,255,.92);color:var(--g2);font-size:11px;font-weight:800;border-radius:8px;padding:3px 8px}
|
||
.tile .dots{position:absolute;bottom:10px;left:10px;display:flex;gap:4px}
|
||
.tile .dots i{width:14px;height:14px;border-radius:50%;border:1.5px solid rgba(255,255,255,.9);display:inline-block}
|
||
.cbody{padding:12px 14px;display:flex;flex-direction:column;gap:3px;flex:1}
|
||
.ccat{font-size:11px;color:var(--mut);text-transform:uppercase;letter-spacing:.4px;font-weight:700}
|
||
.cname{font-weight:700;font-size:14.5px;line-height:1.3}
|
||
.cprice{margin-top:auto;font-weight:800;color:var(--g);font-size:16px;padding-top:6px}
|
||
.cprice .from{color:var(--mut);font-weight:500;font-size:11px}
|
||
.cprice s{color:#9ca3af;font-weight:500;font-size:13px;margin-right:5px}
|
||
/* overlay + modal */
|
||
.ov{position:fixed;inset:0;background:rgba(15,23,42,.5);z-index:40;display:flex;align-items:center;justify-content:center;padding:16px}
|
||
.modal{background:#fff;border-radius:20px;max-width:760px;width:100%;max-height:92vh;overflow:auto;display:grid;grid-template-columns:1fr 1fr}
|
||
@media(max-width:640px){.modal{grid-template-columns:1fr}}
|
||
.mtile{min-height:300px;display:flex;align-items:center;justify-content:center;font-size:120px;transition:background .25s}
|
||
.mbody{padding:22px 24px;display:flex;flex-direction:column}
|
||
.mcat{font-size:11px;color:var(--mut);text-transform:uppercase;letter-spacing:.5px;font-weight:700}
|
||
.mname{font-size:22px;font-weight:800;margin:2px 0 4px}
|
||
.mdesc{font-size:13px;color:var(--mut);margin-bottom:14px}
|
||
.mprice{font-size:26px;font-weight:800;color:var(--g);margin-bottom:2px}
|
||
.mprice s{color:#9ca3af;font-weight:500;font-size:18px;margin-right:8px}
|
||
.stock{font-size:12.5px;font-weight:600;margin-bottom:16px}
|
||
.stock.ok{color:var(--g)}.stock.low{color:#d97706}.stock.no{color:#e11d48}
|
||
.opt{margin-bottom:16px}
|
||
.opt .lab{font-size:12.5px;font-weight:700;color:#374151;margin-bottom:7px}
|
||
.opt .lab span{color:var(--mut);font-weight:500}
|
||
.sw{display:flex;gap:10px;flex-wrap:wrap}
|
||
.swatch{width:34px;height:34px;border-radius:50%;cursor:pointer;border:2.5px solid transparent;box-shadow:0 0 0 1px var(--line) inset;position:relative;transition:.15s}
|
||
.swatch:hover{transform:scale(1.08)}
|
||
.swatch.on{border-color:var(--g);box-shadow:0 0 0 1px var(--g)}
|
||
.swatch.on::after{content:"✓";position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:800;color:#fff;text-shadow:0 0 3px rgba(0,0,0,.45)}
|
||
.pills{display:flex;gap:8px;flex-wrap:wrap}
|
||
.pill{border:1.5px solid var(--line);background:#fff;border-radius:10px;padding:8px 13px;font-size:13px;font-weight:600;cursor:pointer;transition:.15s;min-width:46px;text-align:center}
|
||
.pill:hover{border-color:#cbd5e1}
|
||
.pill.on{background:var(--g);border-color:var(--g);color:#fff}
|
||
.pill.dis{color:#cbd5e1;text-decoration:line-through;cursor:not-allowed;background:#fafbfc}
|
||
.pill .add{display:block;font-size:10px;font-weight:600;color:var(--mut);margin-top:1px}
|
||
.pill.on .add{color:rgba(255,255,255,.85)}
|
||
.qtyrow{display:flex;align-items:center;gap:14px;margin:6px 0 18px}
|
||
.qty{display:flex;align-items:center;border:1.5px solid var(--line);border-radius:12px;overflow:hidden}
|
||
.qty button{width:38px;height:40px;border:none;background:#fff;font-size:18px;cursor:pointer;color:var(--g);font-weight:700}
|
||
.qty button:hover{background:#f3fbf6}
|
||
.qty span{width:42px;text-align:center;font-weight:700}
|
||
.add-btn{margin-top:auto;border:none;background:var(--g);color:#fff;font-weight:800;font-size:15.5px;border-radius:14px;padding:15px;cursor:pointer;transition:.15s;display:flex;align-items:center;justify-content:center;gap:8px}
|
||
.add-btn:hover{background:var(--g2)}
|
||
.add-btn:disabled{background:#cbd5e1;cursor:not-allowed}
|
||
.add-btn.done{background:var(--g2)}
|
||
.closex{position:absolute;top:14px;right:16px;font-size:26px;color:#fff;cursor:pointer;text-shadow:0 1px 4px rgba(0,0,0,.4);z-index:2;line-height:1}
|
||
/* bundle */
|
||
.bundle{background:linear-gradient(135deg,#f3fbf6,#eefbf3)}
|
||
.comp{font-size:12.5px;color:#374151;display:flex;gap:6px;align-items:center;margin:3px 0}
|
||
.comp i{color:var(--g)}
|
||
/* drawer */
|
||
.drawer{position:fixed;top:0;right:0;height:100%;width:400px;max-width:92vw;background:#fff;z-index:50;box-shadow:-8px 0 30px rgba(15,23,42,.18);display:flex;flex-direction:column}
|
||
.dhead{padding:18px 20px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:10px}
|
||
.dhead h3{font-size:17px;font-weight:800}
|
||
.dhead .x{margin-left:auto;font-size:24px;color:var(--mut);cursor:pointer;line-height:1}
|
||
.ditems{flex:1;overflow:auto;padding:8px 16px}
|
||
.line{display:flex;gap:12px;padding:14px 4px;border-bottom:1px solid #f1f5f9}
|
||
.lthumb{width:54px;height:54px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:26px;flex-shrink:0}
|
||
.lmid{flex:1;min-width:0}
|
||
.lname{font-weight:700;font-size:13.5px}
|
||
.lopt{font-size:11.5px;color:var(--mut)}
|
||
.lrem{font-size:11.5px;color:#e11d48;cursor:pointer;font-weight:600;margin-top:3px;display:inline-block}
|
||
.lright{text-align:right;display:flex;flex-direction:column;align-items:flex-end;gap:6px}
|
||
.lprice{font-weight:800;font-size:14px}
|
||
.miniqty{display:flex;align-items:center;border:1px solid var(--line);border-radius:9px;overflow:hidden}
|
||
.miniqty button{width:26px;height:26px;border:none;background:#fff;cursor:pointer;color:var(--g);font-weight:800}
|
||
.miniqty span{width:26px;text-align:center;font-size:12.5px;font-weight:700}
|
||
.empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--mut);gap:10px;padding:40px}
|
||
.empty .big{font-size:52px;opacity:.5}
|
||
.dfoot{padding:18px 20px;border-top:1px solid var(--line)}
|
||
.sub{display:flex;justify-content:space-between;font-size:14px;margin-bottom:4px}
|
||
.sub.tot{font-size:18px;font-weight:800}
|
||
.sub .mut{color:var(--mut)}
|
||
.cta{width:100%;border:none;background:var(--g);color:#fff;font-weight:800;font-size:15.5px;border-radius:14px;padding:15px;cursor:pointer;margin-top:12px;transition:.15s}
|
||
.cta:hover{background:var(--g2)}
|
||
.note{font-size:11px;color:var(--mut);text-align:center;margin-top:8px}
|
||
/* toast */
|
||
.toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);background:var(--ink);color:#fff;padding:13px 20px;border-radius:14px;font-weight:700;font-size:14px;z-index:60;box-shadow:0 10px 30px rgba(0,0,0,.25);display:flex;align-items:center;gap:9px}
|
||
.toast .c{width:22px;height:22px;border-radius:50%;background:var(--g);display:flex;align-items:center;justify-content:center;font-size:13px}
|
||
.demo{background:#fffbeb;border:1px solid #fde68a;color:#92400e;font-size:12px;border-radius:10px;padding:8px 12px;margin-bottom:16px}
|
||
/* transitions */
|
||
.fade-enter-active,.fade-leave-active{transition:opacity .2s}
|
||
.fade-enter-from,.fade-leave-to{opacity:0}
|
||
.pop-enter-active{transition:transform .22s cubic-bezier(.2,1.3,.4,1),opacity .2s}
|
||
.pop-leave-active{transition:transform .15s,opacity .15s}
|
||
.pop-enter-from,.pop-leave-to{transform:scale(.92);opacity:0}
|
||
.slide-enter-active,.slide-leave-active{transition:transform .26s cubic-bezier(.3,.8,.3,1)}
|
||
.slide-enter-from,.slide-leave-to{transform:translateX(100%)}
|
||
.tup-enter-active{transition:transform .26s cubic-bezier(.2,1.2,.4,1),opacity .2s}
|
||
.tup-leave-active{transition:opacity .2s}
|
||
.tup-enter-from{transform:translateX(-50%) translateY(20px);opacity:0}
|
||
.tup-leave-to{opacity:0}
|
||
</style></head><body>
|
||
<div id=app>
|
||
<div class=top><div class=in>
|
||
<div class=brand>Giga<b>fibre</b><span class=s>Boutique</span></div>
|
||
<button class=cartbtn @click="cartOpen=true">
|
||
🛒 Panier
|
||
<span v-if="count" class=badge :class="{bump}">{{ count }}</span>
|
||
</button>
|
||
</div></div>
|
||
|
||
<div class=wrap>
|
||
<div class=demo v-if="!live">⚙️ <b>Page modèle</b> — données démo (catalogue ERPNext vide ou indisponible). Cochez « Afficher dans la boutique » sur des Items pour les voir ici.</div>
|
||
<div class=demo v-else style="background:#f0fdf4;border-color:#bbf7d0;color:#15803d">✓ <b>Catalogue ERPNext en direct</b> — produits cochés « Afficher dans la boutique » · prix Item Price · stock live.</div>
|
||
<div class=cats>
|
||
<div class=cat v-for="c in cats" :class="{on:cat===c}" @click="cat=c">{{ c }}</div>
|
||
</div>
|
||
<div class=grid>
|
||
<div class=card v-for="p in shown" :key="p.id" @click="open(p)">
|
||
<div class=tile :style="{background:tileBg(p)}">
|
||
<span>{{ p.icon }}</span>
|
||
<span v-if="p.bundle" class=bdg>BUNDLE</span>
|
||
<div v-if="colorOf(p)" class=dots>
|
||
<i v-for="v in colorOf(p).values" :key="v.v" :style="{background:v.hex}"></i>
|
||
</div>
|
||
</div>
|
||
<div class=cbody>
|
||
<div class=ccat>{{ p.cat }}</div>
|
||
<div class=cname>{{ p.name }}</div>
|
||
<div class=cprice>
|
||
<template v-if="p.bundle"><s>{{ money(p.sum) }}</s>{{ money(p.price) }}</template>
|
||
<template v-else-if="p.options"><s v-if="p.reg">{{ money(p.reg) }}</s> <span class=from>dès </span>{{ money(fromPrice(p)) }}</template>
|
||
<template v-else><s v-if="p.reg">{{ money(p.reg) }}</s> {{ money(p.base) }}</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- DETAIL MODAL -->
|
||
<transition name=fade>
|
||
<div class=ov v-if="sel" @click.self="sel=null">
|
||
<transition name=pop appear>
|
||
<div class=modal>
|
||
<div class=mtile :style="{background:tileBg(sel,true)}">
|
||
<span class=closex @click="sel=null">×</span>
|
||
<span>{{ sel.icon }}</span>
|
||
</div>
|
||
<div class=mbody>
|
||
<div class=mcat>{{ sel.cat }}</div>
|
||
<div class=mname>{{ sel.name }}</div>
|
||
<div class=mdesc>{{ sel.desc }}</div>
|
||
|
||
<div class=mprice>
|
||
<s v-if="sel.bundle">{{ money(sel.sum) }}</s><s v-else-if="sel.reg && sel.reg > curPrice">{{ money(sel.reg) }}</s> {{ money(curPrice) }}
|
||
</div>
|
||
<div class=stock :class="stockClass">{{ stockLabel }}</div>
|
||
|
||
<!-- bundle components -->
|
||
<div v-if="sel.bundle" class=opt>
|
||
<div class=lab>Inclus dans le kit</div>
|
||
<div class=comp v-for="c in sel.items" :key="c.n"><i>✓</i> {{ c.q }}× {{ c.n }}</div>
|
||
</div>
|
||
|
||
<!-- variant options -->
|
||
<div class=opt v-for="o in (sel.options||[])" :key="o.name">
|
||
<div class=lab>{{ o.name }} <span v-if="o.type==='color'">· {{ picked[o.name] }}</span></div>
|
||
<div v-if="o.type==='color'" class=sw>
|
||
<div class=swatch v-for="v in o.values" :key="v.v"
|
||
:class="{on:picked[o.name]===v.v}" :style="{background:v.hex}"
|
||
:title="v.v" @click="pick(o,v)"></div>
|
||
</div>
|
||
<div v-else class=pills>
|
||
<div class=pill v-for="v in o.values" :key="v.v"
|
||
:class="{on:picked[o.name]===v.v, dis:v.stock===0}"
|
||
@click="v.stock!==0 && pick(o,v)">
|
||
{{ v.v }}
|
||
<span v-if="v.add" class=add>+{{ money(v.add) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class=qtyrow>
|
||
<div class=qty>
|
||
<button @click="qty=Math.max(1,qty-1)">−</button>
|
||
<span>{{ qty }}</span>
|
||
<button @click="qty++">+</button>
|
||
</div>
|
||
<div style="color:var(--mut);font-size:13px">Total : <b style="color:var(--ink)">{{ money(curPrice*qty) }}</b></div>
|
||
</div>
|
||
|
||
<button class=add-btn :class="{done:added}" :disabled="curStock===0" @click="addSel">
|
||
<span v-if="added">✓ Ajouté</span>
|
||
<span v-else-if="curStock===0">Rupture de stock</span>
|
||
<span v-else>Ajouter au panier · {{ money(curPrice*qty) }}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
</div>
|
||
</transition>
|
||
|
||
<!-- CART DRAWER -->
|
||
<transition name=fade>
|
||
<div class=ov v-if="cartOpen" @click.self="cartOpen=false" style="justify-content:flex-end;padding:0"></div>
|
||
</transition>
|
||
<transition name=slide>
|
||
<div class=drawer v-if="cartOpen">
|
||
<div class=dhead>
|
||
<h3>Votre panier</h3>
|
||
<span v-if="count" style="color:var(--mut);font-weight:600;font-size:13px">{{ count }} article{{ count>1?'s':'' }}</span>
|
||
<span class=x @click="cartOpen=false">×</span>
|
||
</div>
|
||
<div v-if="!cart.length" class=empty>
|
||
<div class=big>🛒</div>
|
||
<div>Votre panier est vide</div>
|
||
<button class=cat @click="cartOpen=false">Continuer mes achats</button>
|
||
</div>
|
||
<div v-else class=ditems>
|
||
<div class=line v-for="(l,i) in cart" :key="l.key">
|
||
<div class=lthumb :style="{background:l.bg}">{{ l.icon }}</div>
|
||
<div class=lmid>
|
||
<div class=lname>{{ l.name }}</div>
|
||
<div class=lopt v-if="l.opt">{{ l.opt }}</div>
|
||
<span class=lrem @click="remove(i)">Retirer</span>
|
||
</div>
|
||
<div class=lright>
|
||
<div class=lprice>{{ money(l.price*l.qty) }}</div>
|
||
<div class=miniqty>
|
||
<button @click="l.qty>1?l.qty--:remove(i)">−</button>
|
||
<span>{{ l.qty }}</span>
|
||
<button @click="l.qty++">+</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="cart.length" class=dfoot>
|
||
<div class="sub"><span class=mut>Sous-total</span><span>{{ money(total) }}</span></div>
|
||
<div class="sub"><span class=mut>Taxes (TPS+TVQ ~14,975 %)</span><span>{{ money(total*0.14975) }}</span></div>
|
||
<div class="sub tot"><span>Total</span><span>{{ money(total*1.14975) }}</span></div>
|
||
<button class=cta @click="checkout">Passer à la caisse →</button>
|
||
<div class=note>Paiement sécurisé Stripe · livraison ou ramassage</div>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
|
||
<transition name=tup>
|
||
<div class=toast v-if="toast"><span class=c>✓</span>{{ toast }}</div>
|
||
</transition>
|
||
</div>
|
||
|
||
<script>
|
||
const CAT_ACCENT={'Caméras':'#eef0ff','Boosters WiFi':'#e6f6ff','Câbles réseau':'#e7f8f0','TP-Link':'#fff4e0','Bundles':'#eafaf1','Accessoires':'#f1f5f9'};
|
||
const DEMO=[
|
||
{id:'cam4k',cat:'Caméras',icon:'📷',name:'Caméra extérieure 4K',desc:'Vision nocturne, étanche IP67, détection IA.',base:129.99,
|
||
options:[
|
||
{name:'Couleur',type:'color',values:[{v:'Blanc',hex:'#f3f4f6'},{v:'Noir',hex:'#1f2937'}]},
|
||
{name:'Résolution',type:'pill',values:[{v:'2K',add:0,stock:14},{v:'4K',add:40,stock:9}]}
|
||
]},
|
||
{id:'camint',cat:'Caméras',icon:'🎥',name:'Caméra intérieure 2K',desc:'Pan/tilt 360°, audio bidirectionnel.',base:79.99,
|
||
options:[{name:'Couleur',type:'color',values:[{v:'Blanc',hex:'#f3f4f6'},{v:'Noir',hex:'#1f2937'}]}]},
|
||
{id:'mesh',cat:'Boosters WiFi',icon:'📶',name:'Booster WiFi Mesh',desc:'Couverture sans coupure, Wi-Fi 6.',base:89.99,
|
||
options:[{name:'Nœuds',type:'pill',values:[{v:'1',add:0,stock:20},{v:'2',add:75,stock:11},{v:'3',add:140,stock:6}]}]},
|
||
{id:'cat6',cat:'Câbles réseau',icon:'🔌',name:'Câble Ethernet Cat6',desc:'Blindé, 10 Gbps, gaine LSZH.',base:9.99,
|
||
options:[
|
||
{name:'Couleur',type:'color',values:[{v:'Gris',hex:'#9ca3af'},{v:'Bleu',hex:'#2563eb'},{v:'Noir',hex:'#1f2937'}]},
|
||
{name:'Longueur',type:'pill',values:[{v:'1 m',add:0,stock:50},{v:'3 m',add:5,stock:40},{v:'5 m',add:9,stock:22},{v:'10 m',add:18,stock:0}]}
|
||
]},
|
||
{id:'router',cat:'TP-Link',icon:'📡',name:'Routeur TP-Link Wi-Fi 6',desc:'MU-MIMO, OFDMA, 4 ports Gigabit.',base:99.99,
|
||
options:[{name:'Modèle',type:'pill',values:[{v:'AX1500',add:0,stock:15},{v:'AX3000',add:60,stock:8},{v:'AX6000',add:160,stock:3}]}]},
|
||
{id:'poe',cat:'Accessoires',icon:'⚡',name:'Adaptateur PoE Gigabit',desc:'Alimente une caméra via le câble réseau.',base:24.99},
|
||
{id:'kitcam',cat:'Bundles',icon:'📦',bundle:true,name:'Kit Sécurité 4 caméras + NVR',desc:'Tout pour sécuriser la maison.',price:549.99,sum:719.95,
|
||
items:[{q:4,n:'Caméra extérieure 4K'},{q:1,n:'Enregistreur NVR 2 To'},{q:4,n:'Câble Cat6 5 m'}]},
|
||
{id:'kitmesh',cat:'Bundles',icon:'🧰',bundle:true,name:'Mesh WiFi 3-pack + routeur',desc:'Couverture totale, prêt à brancher.',price:299.99,sum:389.96,
|
||
items:[{q:3,n:'Booster Mesh Wi-Fi 6'},{q:1,n:'Routeur TP-Link AX3000'}]}
|
||
];
|
||
|
||
Vue.createApp({
|
||
data(){return{cat:'Tous',sel:null,picked:{},qty:1,cart:[],cartOpen:false,toast:null,added:false,bump:false,items:DEMO,live:false,_t:0,_b:0}},
|
||
mounted(){this.load()},
|
||
computed:{
|
||
cats(){return ['Tous'].concat([...new Set(this.items.map(p=>p.cat))])},
|
||
shown(){return this.cat==='Tous'?this.items:this.items.filter(p=>p.cat===this.cat)},
|
||
count(){return this.cart.reduce((s,l)=>s+l.qty,0)},
|
||
total(){return this.cart.reduce((s,l)=>s+l.price*l.qty,0)},
|
||
curPrice(){
|
||
if(!this.sel)return 0;
|
||
if(this.sel.bundle)return this.sel.price;
|
||
let p=this.sel.base;(this.sel.options||[]).forEach(o=>{const v=this.valOf(o);if(v&&v.add)p+=v.add});return p;
|
||
},
|
||
curStock(){
|
||
if(!this.sel)return 1;if(this.sel.bundle)return 5;
|
||
if(!this.sel.options)return this.sel.stock!==undefined?this.sel.stock:99;
|
||
let st=99;(this.sel.options||[]).forEach(o=>{const v=this.valOf(o);if(v&&v.stock!==undefined)st=Math.min(st,v.stock)});return st;
|
||
},
|
||
stockClass(){const s=this.curStock;return s===0?'no':(s<=6?'low':'ok')},
|
||
stockLabel(){const s=this.curStock;return s===0?'Rupture de stock':(s<=6?('Plus que '+s+' en stock'):'En stock')}
|
||
},
|
||
methods:{
|
||
load(){fetch('/store/catalog').then(r=>r.json()).then(d=>{if(d&&d.products&&d.products.length){this.items=d.products;this.live=true}}).catch(()=>{})},
|
||
money(n){return (Math.round(n*100)/100).toFixed(2).replace('.',',')+' $'},
|
||
colorOf(p){return (p.options||[]).find(o=>o.type==='color')},
|
||
valOf(o){return o.values.find(v=>v.v===this.picked[o.name])},
|
||
tileBg(p,big){
|
||
const c=this.colorOf(p);
|
||
if(c){const sel=big&&this.sel&&this.sel.id===p.id?this.picked[c.name]:c.values[0].v;const v=c.values.find(x=>x.v===sel)||c.values[0];
|
||
return 'radial-gradient(circle at 50% 38%, '+v.hex+'33, '+(CAT_ACCENT[p.cat]||'#f1f5f9')+')';}
|
||
return CAT_ACCENT[p.cat]||'#f1f5f9';
|
||
},
|
||
fromPrice(p){let m=p.base;(p.options||[]).forEach(o=>{const a=Math.min(...o.values.map(v=>v.add||0));m+=a});return m},
|
||
open(p){
|
||
this.sel=p;this.qty=1;this.added=false;const pk={};
|
||
(p.options||[]).forEach(o=>{const first=o.values.find(v=>v.stock!==0)||o.values[0];pk[o.name]=first.v});
|
||
this.picked=pk;
|
||
},
|
||
pick(o,v){this.picked[o.name]=v.v},
|
||
bgFor(p){const c=this.colorOf(p);if(c){const v=c.values.find(x=>x.v===this.picked[c.name])||c.values[0];return 'radial-gradient(circle at 50% 40%, '+v.hex+'33, '+(CAT_ACCENT[p.cat]||'#f1f5f9')+')'}return CAT_ACCENT[p.cat]||'#f1f5f9'},
|
||
addSel(){
|
||
if(this.curStock===0)return;
|
||
const opt=(this.sel.options||[]).map(o=>this.picked[o.name]).join(' · ');
|
||
const key=this.sel.id+'|'+opt;
|
||
const ex=this.cart.find(l=>l.key===key);
|
||
if(ex)ex.qty+=this.qty;
|
||
else this.cart.push({key,name:this.sel.name,opt,price:this.curPrice,qty:this.qty,icon:this.sel.icon,bg:this.bgFor(this.sel)});
|
||
this.fire('Ajouté au panier');this.added=true;
|
||
clearTimeout(this._a);this._a=setTimeout(()=>{this.added=false;this.sel=null;this.cartOpen=true},650);
|
||
},
|
||
remove(i){this.cart.splice(i,1)},
|
||
fire(msg){
|
||
this.toast=msg;this.bump=true;
|
||
clearTimeout(this._t);this._t=setTimeout(()=>this.toast=null,1800);
|
||
clearTimeout(this._b);this._b=setTimeout(()=>this.bump=false,220);
|
||
},
|
||
checkout(){this.fire('Checkout Stripe — à brancher (P0.2)')}
|
||
}
|
||
}).mount('#app');
|
||
</script>
|
||
</body></html>`
|
||
|
||
// ── 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', 'store_regular_price'],
|
||
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 rg = round2(it.store_regular_price); if (rg > baseP) p.reg = rg
|
||
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)
|
||
const rg = round2(it.store_regular_price); if (rg > p.base) p.reg = rg
|
||
}
|
||
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 }
|