Il contesto
Un cliente rivenditore di terminali POS aveva bisogno di un sito vetrina con e-commerce integrato. I requisiti erano chiari: nessun payment gateway da integrare (checkout con link di pagamento già pronti o estremi per bonifico), un catalogo di circa sette prodotti con varianti tariffarie selezionabili, e la possibilità che i propri venditori inserissero ordini direttamente dal sito — con il nome del commerciale salvato automaticamente sull'ordine, letto dall'identità già autenticata nel loro pannello gestionale interno.
La scelta di non usare piattaforme e-commerce preconfezionate era voluta: serviva qualcosa di completamente su misura, integrabile con l'ecosistema Firebase già in uso per il gestionale, e controllabile nei minimi dettagli. Stack risultante: HTML/CSS/JS vanilla, Firebase Realtime Database per catalogo e ordini, Firebase Storage per immagini e PDF, Netlify per l'hosting e le funzioni serverless.
The context
A POS terminal reseller needed a showcase site with an integrated e-commerce. The requirements were clear: no payment gateway to integrate (checkout with ready-made payment links or bank transfer details), a catalog of around seven products with selectable pricing variants, and the ability for their sales team to place orders directly from the site — with the salesperson's name automatically saved on each order, read from their identity already authenticated in the internal management panel.
The decision to skip off-the-shelf e-commerce platforms was deliberate: something fully custom was needed, integrable with the Firebase ecosystem already in use for the management panel, and controllable in every detail. Resulting stack: vanilla HTML/CSS/JS, Firebase Realtime Database for catalog and orders, Firebase Storage for images and PDFs, Netlify for hosting and serverless functions.
Architettura: due Firebase separati
La prima decisione importante è stata tenere il database dell'e-commerce completamente separato da quello del pannello gestionale interno. Con circa quaranta ordini al giorno (circa quindicimila l'anno), unirli nello stesso progetto Firebase avrebbe creato rischio di contaminazione tra dati commerciali e dati amministrativi (stipendi, budget, anagrafiche).
La struttura del Realtime Database dell'e-commerce:
Architecture: two separate Firebase projects
The first key decision was keeping the e-commerce database completely separate from the internal management panel's database. With around forty orders per day (about fifteen thousand a year), merging them into the same Firebase project would have created risk of contamination between commercial data and administrative data (salaries, budgets, employee records).
The e-commerce Realtime Database structure:
{
"products": { // lettura pubblica, scrittura solo admin auth
"{id}": {
"name": "[Prodotto A]",
"price": 0,
"image": "https://...",
"activeTariffs": ["tariff_id_1", "tariff_id_2"]
}
},
"tariffs": { // archivio globale tariffe, lettura pubblica
"{id}": {
"name": "[Piano A]", "code": "[CODICE-TARIFFA]",
"canone": "€0/mese", "commissioni": "1,00%",
"pdfUrl": "https://firebasestorage..."
}
},
"orders": { // lettura/scrittura solo utenti autenticati
"{pushKey}": {
"number": 1, "vendor": "[Venditore]",
"customer": { "name": "...", "email": "..." },
"items": [{ "product": "[Prodotto A]", "tariff": "[Piano A]" }]
}
},
"counters": { // non leggibile dal client, solo transazioni
"lastOrderNumber": 42
}
}
Varianti tariffarie: archivio globale + assegnazione per prodotto
Il requisito più interessante dal punto di vista del modello dati era la gestione delle tariffe. Ogni terminale POS può essere abbinato a più piani tariffari ([Piano A], [Piano B], [Piano C], ecc.), ma le stesse tariffe si ripetono su prodotti diversi. Incorporare le tariffe dentro ogni prodotto avrebbe significato duplicare i dati e aggiornare più nodi ogni volta che una tariffa cambia.
La soluzione è stata un archivio globale in /tariffs con tutte le tariffe definite una volta sola, e per ogni prodotto un array activeTariffs con gli ID delle tariffe applicabili. L'admin può spuntare o togliere tariffe da un prodotto con una checkbox, senza toccare i dati della tariffa stessa. I prodotti senza tariffe (es. una stampante) usano invece il flag noTariffs: true.
Nel frontend, quando l'utente seleziona una tariffa dalla scheda prodotto, un riquadro si aggiorna in tempo reale con tutti i dettagli: commissioni, canone mensile, conto e carta abbinati, condizioni economiche, servizi inclusi e — in evidenza con font monospace — il codice tariffa [Provider] da comunicare al cliente.
Pricing variants: global archive + per-product assignment
The most interesting data modeling challenge was handling pricing plans. Each POS terminal can be paired with multiple pricing tiers ([Piano A], [Piano B], [Piano C], etc.), but the same plans repeat across different products. Embedding tariffs inside each product would mean duplicating data and updating multiple nodes every time a plan changes.
The solution was a global archive at /tariffs with all plans defined once, and per product an activeTariffs array holding the IDs of applicable plans. The admin can check or uncheck plans per product with a checkbox, without touching the plan data itself. Products without plans (e.g. a printer) use the noTariffs: true flag instead.
On the frontend, when the user selects a plan from the product sheet, a detail box updates in real time showing all information: commissions, monthly fee, linked account and card, economic conditions, included services, and — highlighted in monospace — the [Provider] plan code to communicate to the customer.
Numerazione ordini: transazione atomica Firebase
La numerazione progressiva degli ordini sembra banale, ma con più venditori che inseriscono ordini contemporaneamente diventa un problema classico di race condition. Se due venditori premono "Conferma" nello stesso istante, entrambi leggono lastOrderNumber = 41 e scrivono 42: un ordine sparisce.
Firebase Realtime Database risolve questo con le transazioni atomiche: la funzione runTransaction() tenta l'aggiornamento, e se il valore è cambiato nel frattempo sul server, ritenta automaticamente finché non riesce con il valore corretto.
Order numbering: Firebase atomic transaction
Progressive order numbering sounds trivial, but with multiple vendors placing orders concurrently it becomes a classic race condition problem. If two vendors press "Confirm" at the same instant, both read lastOrderNumber = 41 and write 42 — one order vanishes.
Firebase Realtime Database solves this with atomic transactions: the runTransaction() function attempts the update, and if the value changed on the server in the meantime, it automatically retries until it succeeds with the correct value.
// Incremento atomico del contatore — zero duplicati anche con più venditori
async function submitOrder(orderData) {
const counterRef = ref(db, 'counters/lastOrderNumber');
const { snapshot } = await runTransaction(counterRef, currentValue => {
// currentValue è null al primo ordine
return (currentValue || 0) + 1;
});
const orderNumber = snapshot.val(); // garantito unico
const ordersRef = ref(db, 'orders');
await push(ordersRef, {
number: orderNumber,
vendor: orderData.vendorName || 'Anonimo',
customer: orderData.customer,
items: orderData.items,
method: orderData.paymentMethod,
ts: Date.now()
});
}
Il nodo /counters nelle regole Firebase ha ".read": false — non è leggibile dal client. Solo la transazione scrive su quel nodo, il numero arriva nella risposta della transazione stessa.
Admin panel: PBKDF2 senza password nel codice
Il pannello admin permette di gestire prodotti (nome, prezzo, immagini, tariffe assegnate) e l'archivio globale delle tariffe (codice [Provider], canone, condizioni, PDF dei T&C). Deve essere accessibile solo a chi conosce la password, ma senza un backend dedicato.
Mettere la password in chiaro nel codice sorgente è ovviamente fuori questione — chiunque veda il file HTML la legge. Ma con un hash non è abbastanza sicuro neanche usare MD5 o SHA-256 semplici: sono veloci da brute-force. La soluzione è PBKDF2 (Password-Based Key Derivation Function 2), che applica la funzione hash migliaia di volte rendendola computazionalmente costosa da attaccare.
La Web Crypto API del browser espone PBKDF2 nativamente. Al primo setup si genera l'hash fuori dal sito (o con un piccolo script Node) e si mette nel codice solo hash e salt — mai la password originale.
Admin panel: PBKDF2 with no password in the source
The admin panel lets you manage products (name, price, images, assigned plans) and the global pricing archive ([Provider] codes, fees, conditions, T&C PDFs). It must be accessible only to those who know the password, but without a dedicated backend.
Storing the password in plain text in the source code is obviously out of the question — anyone reading the HTML file sees it. But even hashing isn't enough with plain MD5 or SHA-256: they're fast to brute-force. The solution is PBKDF2 (Password-Based Key Derivation Function 2), which applies the hash function thousands of times making it computationally expensive to attack.
The browser's Web Crypto API exposes PBKDF2 natively. At first setup, the hash is generated outside the site (or with a small Node script) and only the hash and salt are put in the code — never the original password.
// Nel codice: solo salt e hash. La password non c'è mai.
const STORED_SALT = "a7f3..."; // generato casualmente al setup
const STORED_HASH = "e9b2..."; // PBKDF2 della password reale
async function verifyPassword(input) {
const enc = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw', enc.encode(input), 'PBKDF2', false, ['deriveBits']
);
const derived = await crypto.subtle.deriveBits({
name: 'PBKDF2',
salt: hexToBuffer(STORED_SALT),
iterations: 310000, // costoso per il brute-force
hash: 'SHA-256'
}, keyMaterial, 256);
return bufferToHex(derived) === STORED_HASH;
}
Dopo la verifica positiva, l'admin fa un sign-in anonimo Firebase in background: questo permette alle Security Rules del database di riconoscere l'utente come autenticato (auth != null) e di autorizzare le scritture su prodotti e tariffe. Il catalogo rimane leggibile pubblicamente, gli ordini sono protetti.
After a successful verification, the admin performs an anonymous Firebase sign-in in the background: this allows the database Security Rules to recognize the user as authenticated (auth != null) and authorize writes on products and plans. The catalog remains publicly readable, orders are protected.
La trappola del valore zero in JavaScript
Durante lo sviluppo ho incontrato un bug sottile che vale la pena documentare: il prezzo di un terminale era zero (incluso nel canone), ma il sito continuava a mostrare "prezzo da definire" anche dopo averlo salvato correttamente.
Il problema era la falsy evaluation di JavaScript. In due punti del codice usavo pattern del tipo:
The zero-value trap in JavaScript
During development I encountered a subtle bug worth documenting: a terminal's price was zero (included in the monthly fee), but the site kept showing "price to be defined" even after saving it correctly.
The problem was JavaScript's falsy evaluation. In two places in the code I was using patterns like:
// ❌ SBAGLIATO: 0 è falsy, viene trattato come assente
const price = p.price || null; // 0 → null → Firebase cancella il campo
const display = p.price ? ... : 'prezzo da definire'; // 0 → ramo else
// Nel template HTML dell'admin:
`value="${p.price || ''}"` // 0 || '' → '', campo appare vuoto al reload
// ✅ CORRETTO: != null è true per qualsiasi numero, incluso lo zero
const price = p.price != null ? p.price : null;
const display = p.price != null ? ... : 'prezzo da definire';
`value="${p.price != null ? p.price : ''}"`
Il bug si manifestava in tre posti diversi con effetti diversi: nel salvataggio (il campo veniva cancellato da Firebase perché riceveva null), nel template admin (il campo appariva vuoto al reload e al salvataggio successivo scriveva di nuovo null), e nella visualizzazione frontend (mostrava il fallback invece del prezzo). La correzione è sempre la stessa: usare != null invece di || o dell'operatore ternario senza guard.
The bug manifested in three different places with different effects: during save (the field was deleted by Firebase because it received null), in the admin template (the field appeared empty on reload and the next save wrote null again), and in the frontend display (showing the fallback instead of the price). The fix is always the same: use != null instead of || or a ternary without a guard.
Content Security Policy e Firebase: sottodomini dinamici
Aggiungere una Content Security Policy è una buona pratica di sicurezza, ma Firebase Realtime Database usa il long-polling come canale di fallback rispetto ai WebSocket, e i sottodomini che genera sono dinamici e imprevedibili (es. s-gke-euw1-nssi1-1.europe-west1.firebasedatabase.app). Una CSP nel tag <meta> HTML non supporta i wildcard per i sottodomini, quindi Firebase veniva bloccato silenziosamente.
La soluzione è spostare la CSP dagli header HTML agli header HTTP di Netlify nel file netlify.toml, dove i wildcard funzionano correttamente:
Content Security Policy and Firebase: dynamic subdomains
Adding a Content Security Policy is good security practice, but Firebase Realtime Database uses long-polling as a fallback channel alongside WebSockets, and the subdomains it generates are dynamic and unpredictable (e.g. s-gke-euw1-nssi1-1.europe-west1.firebasedatabase.app). A CSP in an HTML <meta> tag doesn't support subdomain wildcards, so Firebase was silently blocked.
The solution is moving the CSP from HTML to Netlify's HTTP headers in netlify.toml, where wildcards work correctly:
[[headers]]
for = "/*"
[headers.values]
Content-Security-Policy = """
default-src 'self';
script-src 'self' 'unsafe-inline'
https://www.gstatic.com
https://cdnjs.cloudflare.com;
connect-src 'self'
https://*.firebasedatabase.app
wss://*.firebasedatabase.app
https://*.googleapis.com
https://firebasestorage.googleapis.com;
img-src 'self' data: blob:
https://firebasestorage.googleapis.com;
"""
X-Frame-Options = "DENY"
X-Content-Type-Options = "nosniff"
Strict-Transport-Security = "max-age=31536000; includeSubDomains"
I tag <meta http-equiv="Content-Security-Policy"> non supportano i wildcard di sottodominio in script-src. Per Firebase (e in generale per qualsiasi servizio che usa sottodomini dinamici) la CSP va sempre configurata negli header HTTP del server — su Netlify tramite netlify.toml.
UI: drawer laterale e slideshow immagini
Il catalogo mostra card con immagine a piena copertura (object-fit: contain per i terminali — verticali e sottili — invece di cover che li avrebbe ritagliati), nome e prezzo. Al click si apre un pannello laterale con immagine grande, descrizione, bottoni tariffa e riepilogo dettagliato.
Ogni prodotto può avere fino a tre immagini (principale + due supplementari caricate su Firebase Storage). Se presenti, nel pannello laterale si attiva automaticamente uno slideshow con avanzamento automatico ogni tre secondi, frecce di navigazione manuale e indicatori pallino. Il timer si azzerava correttamente alla chiusura del pannello per evitare che continuasse a girare in background.
UI: side drawer and image slideshow
The catalog shows cards with full-coverage image (object-fit: contain for terminals — tall and narrow — rather than cover which would crop them), name and price. On click, a side panel opens with a large image, description, plan buttons and detailed summary.
Each product can have up to three images (main + two supplementary uploaded to Firebase Storage). If present, the side panel automatically activates a slideshow with auto-advance every three seconds, manual navigation arrows and dot indicators. The timer is properly cleared when the panel closes to prevent it continuing in the background.
Sicurezza: regole Firebase e architettura
La protezione reale non sta nel nascondere bottoni in JavaScript, ma nelle Security Rules di Firebase. La struttura definitiva:
Security: Firebase rules and architecture
Real protection isn't about hiding buttons in JavaScript, but in Firebase Security Rules. The final structure:
{
"rules": {
"products": {
".read": true,
".write": "auth != null" // solo admin autenticato
},
"tariffs": {
".read": true,
".write": "auth != null"
},
"orders": {
".read": "auth != null", // solo venditori autenticati
".write": "auth != null"
},
"counters": {
".read": false, // mai leggibile dal client
".write": "auth != null"
}
}
}
Il passo successivo — non ancora completato in questa sessione — è sostituire il generico auth != null sulla sezione ordini con un custom claim Firebase (role: 'venditore') assegnato tramite la Netlify Function che valida il token del pannello gestionale. In questo modo solo i venditori con token valido emesso dal panel avranno accesso agli ordini, non qualsiasi utente autenticato.
The next step — not yet completed in this session — is replacing the generic auth != null on the orders section with a Firebase custom claim (role: 'venditore') assigned by the Netlify Function that validates the management panel token. This way only vendors with a valid panel-issued token will have access to orders, not just any authenticated user.
Il risultato
Un e-commerce completamente su misura, senza piattaforme preconfezionate, costruito su uno stack già familiare: Firebase per i dati, Netlify per il deployment, vanilla JS per tutto il resto. Il venditore apre il sito dal pannello gestionale con un token nell'URL, viene riconosciuto automaticamente, seleziona il terminale e la tariffa, compila i dati del cliente, e l'ordine finisce su Firebase con numero progressivo garantito univoco e il suo nome abbinato. Il cliente riceverà la mail con il PDF dei T&C della tariffa scelta — non appena la Netlify Function per le email sarà collegata.
La parte più istruttiva del progetto non è stata la complessità tecnica, ma quante decisioni architetturali dipendono da requisiti apparentemente semplici: separare i database, tener le tariffe globali, usare transazioni invece di semplici write, spostare la CSP dagli header HTML a quelli HTTP. Ogni scelta ha evitato un problema che si sarebbe manifestato in produzione.
The result
A fully custom e-commerce, without off-the-shelf platforms, built on a familiar stack: Firebase for data, Netlify for deployment, vanilla JS for everything else. The vendor opens the site from the management panel with a token in the URL, gets recognized automatically, selects the terminal and plan, fills in customer details, and the order lands on Firebase with a guaranteed-unique sequential number and their name attached. The customer will receive an email with the T&C PDF for the chosen plan — as soon as the Netlify Function for emails is wired up.
The most instructive part of the project wasn't the technical complexity, but how many architectural decisions depend on seemingly simple requirements: separating databases, keeping plans global, using transactions instead of simple writes, moving the CSP from HTML headers to HTTP headers. Each choice prevented a problem that would have surfaced in production.