gigafibre-fsm/services/targo-hub/lib/store.js
louispaulb 87949f933d Boutique matériel — page modèle /store (staging, self-contained Vue 3)
Vitrine fluide inspirée des meilleurs carts : grille produits, visualisation de
variantes (swatches couleur + pills longueur/modèle, prix delta live, rupture barrée),
panier optimistic (drawer slide, steppers, badge animé, toast, taxes QC, total).
Données démo (caméras/boosters/câbles/TP-Link/bundles). Branchement ERPNext (catalog
+ Product Bundle + pricing) = étape suivante. Validé live au navigateur.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 20:09:47 -04:00

382 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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 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>⚙️ <b>Page modèle</b> — données démo. Branchement au catalogue ERPNext (Items + bundles + prix) à l'étape suivante.</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"><span class=from>dès </span>{{ money(fromPrice(p)) }}</template>
<template v-else>{{ 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>{{ 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,_t:0,_b:0}},
computed:{
cats(){return ['Tous'].concat([...new Set(DEMO.map(p=>p.cat))])},
shown(){return this.cat==='Tous'?DEMO:DEMO.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;
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:{
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>`
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)
}
// À venir : GET /store/catalog → Items + Product Bundle d'ERPNext
return json(res, 404, { error: 'not found' })
}
module.exports = { handle }