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>
This commit is contained in:
parent
37f4d5a941
commit
87949f933d
381
services/targo-hub/lib/store.js
Normal file
381
services/targo-hub/lib/store.js
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
'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 }
|
||||
|
|
@ -132,6 +132,8 @@ const server = http.createServer(async (req, res) => {
|
|||
if (path === '/book' || path.startsWith('/book/')) return require('./lib/roster').handlePublicBooking(req, res, method, path, url)
|
||||
// Portail self-service d'abonnement (staging) — page + submit, PUBLIC.
|
||||
if (path === '/signup' || path.startsWith('/signup/')) return require('./lib/signup').handle(req, res, method, path)
|
||||
// Boutique matériel (page modèle, staging) — page + (à venir) catalogue ERPNext, PUBLIC.
|
||||
if (path === '/store' || path.startsWith('/store/')) return require('./lib/store').handle(req, res, method, path)
|
||||
if (path.startsWith('/campaigns')) return require('./lib/campaigns').handle(req, res, method, path)
|
||||
// Gift redirect wrapper — short public URLs in campaign emails that
|
||||
// 302 to the underlying Giftbit shortlink (subject to our expiry/revoke).
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user