About

Hi everyone, my name is Zak, the owner of Spoolify Filament. We’re proud to be Perth’s first distributor for Siddament, and we’re really excited to meet everyone and become part of the local 3D printing community.

We’ve just received our first range of filament, with around 650kg of material now in stock. This marks the beginning of a completely new business journey for me. I’ve been 3D printing for around 7 years, and it’s always been a goal of mine to start supplying filament to others who share the same passion.

As we’re just getting started, our opening hours may be a bit limited at first, but we’re committed to growing and expanding as quickly as possible. With your support, we’ll be able to increase our stock, improve availability, and extend our hours over time.

Every time you purchase a kilogram of filament, you’re directly supporting this business and helping us continue to grow. We really appreciate it and are excited to see where this journey takes us.

Looking forward to meeting you all!

Thanks Zak!

SHOP NOW
import React, { useState, useEffect, useMemo, useRef } from 'react'; import Papa from 'papaparse'; import { LayoutDashboard, Package, Barcode, Ticket, Settings as SettingsIcon, Lock, Search, Plus, Upload, Download, Printer, X, ChevronRight, CheckCircle, Clock, AlertCircle, Tag, Store, Users, LogOut, Trash2, Filter, MessageSquare, TrendingUp, DollarSign, ArrowRight, Eye, EyeOff, Menu, Bug, ShoppingBag, Boxes, Pencil, LifeBuoy, Send, Sparkles, Layers, RefreshCw, Wrench, ListChecks } from 'lucide-react'; import { ResponsiveContainer, PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts'; /* ============================================================ Code 128 barcode encoder (Code Set B). This is the symbology Shopify uses by default for label barcodes. Patterns below are the standard Code 128 module-width table (index 0..106). ============================================================ */ const CODE128_PATTERNS = [ '212222','222122','222221','121223','121322','131222','122213','122312','132212','221213', '221312','231212','112232','122132','122231','113222','123122','123221','223211','221132', '221231','213212','223112','312131','311222','321122','321221','312212','322112','322211', '212123','212321','232121','111323','131123','131321','112313','132113','132311','211313', '231113','231311','112133','112331','132131','113123','113321','133121','313121','211331', '231131','213113','213311','213131','311123','311321','331121','312113','312311','332111', '314111','221411','431111','111224','111422','121124','121421','141122','141221','112214', '112412','122114','122411','142112','142211','241211','221114','413111','241112','134111', '111242','121142','121241','114212','124112','124211','411212','421112','421211','212141', '214121','412121','111143','111341','131141','114113','114311','411113','411311','113141', '114131','311141','411131','211412','211214','211232','2331112' ]; function encodeCode128B(data) { if (!data) return ''; const START_B = 104, STOP = 106; let sum = START_B; const values = [START_B]; for (let i = 0; i < data.length; i++) { let code = data.charCodeAt(i); if (code < 32 || code > 126) code = 63; // '?' for unsupported chars const val = code - 32; values.push(val); sum += val * (i + 1); } values.push(sum % 103); values.push(STOP); let modules = ''; for (const v of values) modules += CODE128_PATTERNS[v]; return modules; } /* ---- EAN-13 / UPC-A encoder (retail symbology; Shopify retail barcodes are frequently 12-digit UPC-A or 13-digit EAN-13) ---- */ const EAN_L = { '0':'0001101','1':'0011001','2':'0010011','3':'0111101','4':'0100011','5':'0110001','6':'0101111','7':'0111011','8':'0110111','9':'0001011' }; const EAN_G = { '0':'0100111','1':'0110011','2':'0011011','3':'0100001','4':'0011101','5':'0111001','6':'0000101','7':'0010001','8':'0001001','9':'0010111' }; const EAN_R = { '0':'1110010','1':'1100110','2':'1101100','3':'1000010','4':'1011100','5':'1001110','6':'1010000','7':'1000100','8':'1001000','9':'1110100' }; const EAN_PARITY = { '0':'LLLLLL','1':'LLGLGG','2':'LLGGLG','3':'LLGGGL','4':'LGLLGG','5':'LGGLLG','6':'LGGGLL','7':'LGLGLG','8':'LGLGGL','9':'LGGLGL' }; function eanCheckDigit(d12) { let s = 0; for (let i = 0; i < 12; i++) s += (+d12[i]) * (i % 2 === 0 ? 1 : 3); return (10 - (s % 10)) % 10; } // Returns { bits, digits, display } or null. Accepts a COMPLETE 12-digit UPC-A // (its 12th digit is the check) or a complete 13-digit EAN-13. function encodeEAN13(value) { const raw = String(value || '').replace(/\D/g, ''); let d, display; if (raw.length === 12) { d = '0' + raw; display = raw; } // UPC-A -> EAN-13 with leading zero else if (raw.length === 13) { d = raw; display = raw; } else return null; if (eanCheckDigit(d.slice(0, 12)) !== (+d[12])) return null; // validate existing check digit const parity = EAN_PARITY[d[0]]; let bits = '101'; for (let i = 1; i <= 6; i++) bits += (parity[i - 1] === 'L' ? EAN_L : EAN_G)[d[i]]; bits += '01010'; for (let i = 7; i <= 12; i++) bits += EAN_R[d[i]]; bits += '101'; return { bits, digits: d, display }; } function bitsToRects(bits, moduleWidth, quiet) { let x = quiet * moduleWidth; const rects = []; let i = 0; while (i < bits.length) { let j = i; while (j < bits.length && bits[j] === bits[i]) j++; const w = (j - i) * moduleWidth; if (bits[i] === '1') rects.push({ x, w }); x += w; i = j; } return { rects, width: x + quiet * moduleWidth }; } // Decide which symbology to actually render with. function resolveSymbology(value, pref = 'auto') { if (pref === 'code128') return 'code128'; const raw = String(value || '').trim(); const digits = raw.replace(/\D/g, ''); if (pref === 'ean13') return 'ean13'; // auto: use EAN/UPC only when the whole value is numeric AND validates as a real code if (digits === raw && (digits.length === 12 || digits.length === 13) && encodeEAN13(digits)) return 'ean13'; return 'code128'; } function computeBarcode(value, { moduleWidth = 2, height = 56, symbology = 'auto' } = {}) { const str = String(value || ''); const sym = resolveSymbology(str, symbology); if (sym === 'ean13') { const enc = encodeEAN13(str); if (enc) { const { rects, width } = bitsToRects(enc.bits, moduleWidth, 9); return { rects, width, height, text: enc.display, symbology: 'ean13' }; } // requested/auto EAN but invalid -> fall through to Code 128 so it still scans } const modules = encodeCode128B(str); if (!modules) return null; const quiet = 10; let x = quiet * moduleWidth; const rects = []; let isBar = true; for (const ch of modules) { const w = parseInt(ch, 10) * moduleWidth; if (isBar) rects.push({ x, w }); x += w; isBar = !isBar; } return { rects, width: x + quiet * moduleWidth, height, text: str, symbology: 'code128' }; } function barcodeSvgString(value, opts = {}) { const data = computeBarcode(value, opts); if (!data) return ''; const textH = opts.showText === false ? 0 : 20; const total = data.height + textH; const bars = data.rects .map((r) => ``) .join(''); const text = textH ? `${data.text || value}` : ''; return `${bars}${text}`; } function BarcodeSVG({ value, moduleWidth = 2, height = 56, showText = true, symbology = 'auto', className = '' }) { const data = useMemo(() => computeBarcode(value, { moduleWidth, height, symbology }), [value, moduleWidth, height, symbology]); if (!data) return Invalid code; const textH = showText ? 20 : 0; const total = height + textH; return ( {data.rects.map((r, i) => ( ))} {showText && ( {data.text || value} )} ); } /* ============================================================ Helpers + constants ============================================================ */ function triggerDownload(filename, content, type) { const blob = new Blob([content], { type }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } const uid = () => Math.random().toString(36).slice(2, 10); const money = (n) => '$' + (Number(n) || 0).toFixed(2); /* ============================================================ Shopify Storefront API sync. The Storefront API is CORS-enabled and made for browser use: with a Storefront access token that has "read products" + "read inventory" scopes it returns titles, SKUs, barcodes, prices and live quantityAvailable. Quantities require the inventory scope on the token. Real call below — degrades gracefully if the network/preview blocks it. ============================================================ */ const SHOPIFY_API_VERSION = '2025-07'; async function fetchShopifyProducts({ domain, token, max = 250 }) { if (!domain || !token) return { ok: false, error: 'Add your store domain and Storefront API token in Settings first.' }; const clean = String(domain).replace(/^https?:\/\//, '').replace(/\/+$/, '').trim(); const url = `https://${clean}/api/${SHOPIFY_API_VERSION}/graphql.json`; const query = `query Products($cursor: String) { products(first: 100, after: $cursor) { pageInfo { hasNextPage endCursor } edges { node { title productType vendor variants(first: 100) { edges { node { title sku barcode quantityAvailable price { amount } } } } } } } }`; const out = []; let cursor = null; try { while (out.length < max) { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Shopify-Storefront-Access-Token': token }, body: JSON.stringify({ query, variables: { cursor } }), }); if (res.status === 401 || res.status === 403) return { ok: false, error: 'Shopify rejected the token. Make sure it is a Storefront API token with read access to products and inventory.' }; if (!res.ok) return { ok: false, error: `Shopify returned HTTP ${res.status}. Double-check the store domain.` }; const json = await res.json(); if (json.errors && json.errors.length) return { ok: false, error: json.errors[0].message || 'Shopify GraphQL error.' }; const conn = json.data && json.data.products; if (!conn) return { ok: false, error: 'Unexpected response from Shopify.' }; for (const e of conn.edges) { const node = e.node; const variants = node.variants.edges; for (const v of variants) { const vn = v.node; const multi = variants.length > 1; out.push({ id: uid(), title: multi && vn.title && vn.title !== 'Default Title' ? `${node.title} — ${vn.title}` : node.title, sku: vn.sku || '', barcode: vn.barcode || '', price: vn.price && vn.price.amount ? String(vn.price.amount) : '0', qty: typeof vn.quantityAvailable === 'number' ? vn.quantityAvailable : 0, type: node.productType || 'Uncategorized', vendor: node.vendor || '', }); if (out.length >= max) break; } if (out.length >= max) break; } if (!conn.pageInfo.hasNextPage) break; cursor = conn.pageInfo.endCursor; } return { ok: true, products: out }; } catch (err) { return { ok: false, error: 'Could not reach Shopify from the browser — your network or this preview may be blocking the request. The CSV import always works as a fallback.' }; } } // Merge synced/imported products into existing state: update by SKU, add the rest. function mergeProducts(prev, incoming) { const bySku = new Map(); incoming.forEach((p) => { if (p.sku) bySku.set(p.sku, p); }); const seen = new Set(); const updated = prev.map((p) => { if (p.sku && bySku.has(p.sku)) { seen.add(p.sku); const np = bySku.get(p.sku); return { ...p, title: np.title, barcode: np.barcode || p.barcode, price: np.price, qty: np.qty, type: np.type || p.type, vendor: np.vendor || p.vendor }; } return p; }); const added = incoming.filter((p) => !p.sku || !seen.has(p.sku)); return { list: [...added, ...updated], added: added.length, updated: seen.size }; } const fmtDate = (t) => new Date(t).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); const fmtTime = (t) => new Date(t).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); const timeAgo = (t) => { const s = Math.max(0, Math.floor((Date.now() - t) / 1000)); if (s < 60) return 'just now'; const m = Math.floor(s / 60); if (m < 60) return `${m}m ago`; const h = Math.floor(m / 60); if (h < 24) return `${h}h ago`; const d = Math.floor(h / 24); if (d < 30) return `${d}d ago`; const mo = Math.floor(d / 30); if (mo < 12) return `${mo}mo ago`; return `${Math.floor(mo / 12)}y ago`; }; const CANNED_REPLIES = [ { label: 'Acknowledge', text: "Thanks for reaching out — we've received your request and are looking into it now." }, { label: 'Need info', text: 'Could you share a bit more detail (and a photo if relevant) so we can pinpoint the issue?' }, { label: 'Reprint label', text: "We've reprinted that barcode at a higher density and will get it sent over shortly." }, { label: 'Resolved', text: "We believe this is now resolved. We'll mark it as done, but just reply here if anything still looks off." }, ]; const COLUMNS = [ { id: 'todo', title: 'To Do', tone: 'slate' }, { id: 'inprogress', title: 'In Progress', tone: 'sky' }, { id: 'inreview', title: 'In Review', tone: 'violet' }, { id: 'done', title: 'Done', tone: 'emerald' }, ]; const TONE = { slate: { bg: 'bg-zinc-100', text: 'text-zinc-600', dot: 'bg-zinc-400', bar: 'bg-zinc-400' }, sky: { bg: 'bg-zinc-200', text: 'text-zinc-700', dot: 'bg-zinc-500', bar: 'bg-zinc-500' }, violet: { bg: 'bg-zinc-200', text: 'text-zinc-800', dot: 'bg-zinc-700', bar: 'bg-zinc-700' }, emerald: { bg: 'bg-zinc-900', text: 'text-white', dot: 'bg-zinc-900', bar: 'bg-zinc-900' }, amber: { bg: 'bg-zinc-100', text: 'text-zinc-700', dot: 'bg-zinc-500', bar: 'bg-zinc-500' }, rose: { bg: 'bg-zinc-300', text: 'text-zinc-900', dot: 'bg-zinc-900', bar: 'bg-zinc-900' }, orange: { bg: 'bg-zinc-200', text: 'text-zinc-900', dot: 'bg-zinc-900', bar: 'bg-zinc-900' }, }; const PRIORITY = { Highest: 'rose', High: 'amber', Medium: 'sky', Low: 'slate', }; const PRIORITIES = ['Highest', 'High', 'Medium', 'Low']; const TYPE_META = { Bug: { tone: 'rose', icon: Bug }, Support: { tone: 'sky', icon: LifeBuoy }, Task: { tone: 'violet', icon: ListChecks }, Feature: { tone: 'emerald', icon: Sparkles }, }; const TYPES = ['Support', 'Bug', 'Task', 'Feature']; const AGENTS = ['Unassigned', 'You', 'Priya R.', 'Marcus L.']; const HELP_TOPICS = [ { label: 'Order & shipping', type: 'Support', icon: Package, blurb: 'Track, change or ask about an order' }, { label: 'Returns & refunds', type: 'Support', icon: RefreshCw, blurb: 'Start a return or check a refund' }, { label: 'Product question', type: 'Support', icon: LifeBuoy, blurb: 'Sizing, stock, how things work' }, { label: 'Report a problem', type: 'Bug', icon: Bug, blurb: 'Something looks broken or wrong' }, ]; const PORTAL_FAQ = [ { q: 'Where is my order?', a: "Once your order ships you'll get an email with tracking. If it's been more than a few days with no update, open a request below with your order number and we'll chase it up." }, { q: 'How do I return or exchange something?', a: 'Most items can be returned within 30 days in their original condition. Submit a request with your order number and what you\'d like to do, and we\'ll send a prepaid label or arrange the exchange.' }, { q: 'Can I change or cancel my order?', a: "If it hasn't shipped yet, we can usually update the address or items. Send us a request as soon as possible with your order number." }, { q: 'A product or barcode issue at checkout?', a: "Let us know the item and what happened — a quick photo helps. We'll fix the listing or reissue the label right away." }, ]; const CHART_COLORS = ['#18181b', '#52525b', '#71717a', '#a1a1aa', '#d4d4d8', '#3f3f46']; /* Seed data so the dashboard + board look alive on first run */ const SAMPLE_PRODUCTS = [ { id: uid(), title: 'PLA Filament — Black 1.75mm 1kg', sku: 'PLA-BLK-175', barcode: '012345678905', price: '28.99', qty: 42, type: 'PLA', vendor: 'Spoolify' }, { id: uid(), title: 'PLA Filament — White 1.75mm 1kg', sku: 'PLA-WHT-175', barcode: '712345678904', price: '28.99', qty: 8, type: 'PLA', vendor: 'Spoolify' }, { id: uid(), title: 'PETG Filament — Clear 1.75mm 1kg', sku: 'PETG-CLR-175', barcode: '852345678907', price: '32.00', qty: 64, type: 'PETG', vendor: 'Spoolify' }, { id: uid(), title: 'TPU Flexible — Black 0.5kg', sku: 'TPU-BLK-05', barcode: '', price: '34.50', qty: 5, type: 'TPU', vendor: 'Spoolify' }, { id: uid(), title: 'ABS Filament — Grey 1.75mm 1kg', sku: 'ABS-GRY-175', barcode: '406009123458', price: '27.00', qty: 31, type: 'ABS', vendor: 'Spoolify' }, { id: uid(), title: 'PLA+ Silk — Gold 1.75mm 1kg', sku: 'PLA-SILK-GLD', barcode: '', price: '31.00', qty: 3, type: 'PLA+', vendor: 'Spoolify' }, { id: uid(), title: 'Empty Spools — 10 pack', sku: 'ACC-SPOOL-10', barcode: '036000291452', price: '12.99', qty: 27, type: 'Accessories', vendor: 'Spoolify' }, ]; const now = Date.now(); const day = 86400000; const SAMPLE_TICKETS = [ { id: uid(), key: 'SUP-101', subject: 'Spool barcode will not scan at register', type: 'Bug', priority: 'High', status: 'inprogress', assignee: 'You', requester: { name: 'Dana Ortiz', email: 'dana@shopmail.com' }, createdAt: now - 2 * day, comments: [{ author: 'Dana Ortiz', role: 'customer', text: 'The label for SKU PLA-WHT-175 will not scan on our handheld.', at: now - 2 * day }, { author: 'You', role: 'agent', text: 'Thanks — reprinting at higher density and testing now.', at: now - 1 * day }] }, { id: uid(), key: 'SUP-102', subject: 'Bulk import failed for 200 SKUs', type: 'Support', priority: 'Highest', status: 'todo', assignee: 'Unassigned', requester: { name: 'Theo Park', email: 'theo@parkgoods.co' }, createdAt: now - 6 * 3600000, comments: [{ author: 'Theo Park', role: 'customer', text: 'CSV upload stops at row 47. Can you take a look?', at: now - 6 * 3600000 }] }, { id: uid(), key: 'SUP-103', subject: 'Add EAN-13 label option', type: 'Feature', priority: 'Medium', status: 'inreview', assignee: 'Priya R.', requester: { name: 'In-house', email: 'ops@spoolify.com' }, createdAt: now - 4 * day, comments: [{ author: 'Priya R.', role: 'agent', text: 'Prototype ready for review.', at: now - 12 * 3600000 }] }, { id: uid(), key: 'SUP-104', subject: 'Label sheet prints with margins', type: 'Bug', priority: 'Low', status: 'done', assignee: 'Marcus L.', requester: { name: 'Lena Fox', email: 'lena@foxstudio.com' }, createdAt: now - 9 * day, comments: [{ author: 'Marcus L.', role: 'agent', text: 'Fixed print CSS — shipped.', at: now - 5 * day }] }, { id: uid(), key: 'SUP-105', subject: 'Sync inventory counts from Shopify', type: 'Task', priority: 'High', status: 'todo', assignee: 'You', requester: { name: 'In-house', email: 'ops@spoolify.com' }, createdAt: now - 1 * day, comments: [] }, { id: uid(), key: 'SUP-106', subject: 'Reprint labels for damaged stock', type: 'Support', priority: 'Medium', status: 'inprogress', assignee: 'Priya R.', requester: { name: 'Dana Ortiz', email: 'dana@shopmail.com' }, createdAt: now - 3 * day, comments: [{ author: 'Dana Ortiz', role: 'customer', text: 'Need 30 labels reprinted for the PETG spools.', at: now - 3 * day }] }, ]; const DEFAULT_SETTINGS = { settingsVersion: 2, brand: 'Spoolify Filament', adminPassword: 'Spoolifyfilament', shopDomain: '', shopToken: '', printerName: 'Default printer', labelTemplate: '30up', lowStockThreshold: 10, }; /* Tiny persistent-storage wrapper (artifact storage). Falls back to in-memory only if storage isn't available, so it never crashes. */ const store = { async get(key) { try { if (typeof window === 'undefined' || !window.storage) return null; const r = await window.storage.get(key); return r ? JSON.parse(r.value) : null; } catch { return null; } }, async set(key, val) { try { if (typeof window === 'undefined' || !window.storage) return; await window.storage.set(key, JSON.stringify(val)); } catch { /* ignore */ } }, }; /* ============================================================ Small UI primitives ============================================================ */ function Badge({ tone = 'slate', children, className = '' }) { const t = TONE[tone] || TONE.slate; return ( {children} ); } function PriorityBadge({ value }) { const tone = PRIORITY[value] || 'slate'; const t = TONE[tone]; return ( {value} ); } function TypeBadge({ value }) { const meta = TYPE_META[value] || TYPE_META.Task; const t = TONE[meta.tone]; const Icon = meta.icon; return ( {value} ); } function StatCard({ icon: Icon, label, value, sub, tone = 'orange' }) { const t = TONE[tone]; return (

{label}

{value}

{sub &&

{sub}

}
); } function Field({ label, children, hint }) { return ( ); } const inputCls = 'w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 outline-none focus:border-zinc-900 focus:ring-2 focus:ring-zinc-300'; function Modal({ open, onClose, children, wide }) { if (!open) return null; return (
e.stopPropagation()}> {children}
); } /* ============================================================ Dashboard ============================================================ */ function Dashboard({ products, tickets, activity, brand, lowStockThreshold = 10 }) { const openTickets = tickets.filter((t) => t.status !== 'done').length; const lowStock = products.filter((p) => Number(p.qty) <= lowStockThreshold); const inventoryValue = products.reduce((s, p) => s + (Number(p.price) || 0) * (Number(p.qty) || 0), 0); const statusData = COLUMNS.map((c) => ({ name: c.title, value: tickets.filter((t) => t.status === c.id).length })); const typeCount = {}; products.forEach((p) => { const k = p.type || 'Other'; typeCount[k] = (typeCount[k] || 0) + 1; }); const typeData = Object.entries(typeCount).map(([name, value]) => ({ name, value })); const prioData = PRIORITIES.map((p) => ({ name: p, value: tickets.filter((t) => t.priority === p && t.status !== 'done').length })); return (

Overview

A quick pulse on inventory, labels, and support for {brand}.

s + (Number(p.qty) || 0), 0)} units in stock`} tone="orange" />

Tickets by status

{statusData.map((e, i) => )}
{statusData.map((s, i) => ( {s.name} ({s.value}) ))}

Products by category

Low stock

{lowStock.length === 0 &&

Everything's well stocked.

} {lowStock.map((p) => (
{p.title} {p.qty} left
))}

Open tickets by priority

{prioData.map((p) => { const max = Math.max(1, ...prioData.map((x) => x.value)); const tone = TONE[PRIORITY[p.name]]; return (
{p.name} {p.value}
); })}

Recent activity

{activity.length === 0 &&

No activity yet.

} {activity.slice(0, 6).map((a, i) => (

{a.text}

{fmtTime(a.at)}

))}
); } /* ============================================================ Products + CSV import ============================================================ */ const SAMPLE_CSV = `Title,Variant SKU,Variant Barcode,Variant Price,Variant Inventory Qty,Type,Vendor PLA Filament — Red 1.75mm 1kg,PLA-RED-175,,28.99,24,PLA,Spoolify PETG Filament — Black 1.75mm 1kg,PETG-BLK-175,,32.00,18,PETG,Spoolify Nylon Filament — Natural 0.75kg,NYL-NAT-075,,41.00,6,Nylon,Spoolify`; function pick(row, names) { const keys = Object.keys(row); for (const n of names) { const k = keys.find((kk) => kk.trim().toLowerCase() === n.toLowerCase()); if (k && row[k] != null && String(row[k]).trim() !== '') return String(row[k]).trim(); } return ''; } function ProductsView({ products, setProducts, log, lowStockThreshold = 10, onSync, shopConnected }) { const [query, setQuery] = useState(''); const [showAdd, setShowAdd] = useState(false); const [form, setForm] = useState({ title: '', sku: '', barcode: '', price: '', qty: '', type: '', vendor: '' }); const [importMsg, setImportMsg] = useState(null); const [syncing, setSyncing] = useState(false); const [syncMsg, setSyncMsg] = useState(null); // { ok, text } const fileRef = useRef(null); async function runSync() { setSyncing(true); setSyncMsg(null); const r = await onSync(); setSyncing(false); if (r.ok) setSyncMsg({ ok: true, text: `Synced from Shopify — ${r.added} added, ${r.updated} updated.` }); else setSyncMsg({ ok: false, text: r.error }); } function adjustStock(id, delta) { setProducts((prev) => prev.map((x) => x.id === id ? { ...x, qty: Math.max(0, Number(x.qty || 0) + delta) } : x)); } const filtered = products.filter((p) => [p.title, p.sku, p.barcode, p.type, p.vendor].join(' ').toLowerCase().includes(query.toLowerCase()) ); function handleFile(e) { const file = e.target.files?.[0]; if (!file) return; Papa.parse(file, { header: true, skipEmptyLines: true, complete: (res) => { const seen = new Set(products.map((p) => p.sku)); const added = []; for (const row of res.data) { const title = pick(row, ['Title', 'Name', 'Product', 'Product Title']); const sku = pick(row, ['Variant SKU', 'SKU', 'Sku']); if (!title && !sku) continue; if (sku && seen.has(sku)) continue; if (sku) seen.add(sku); added.push({ id: uid(), title: title || sku, sku, barcode: pick(row, ['Variant Barcode', 'Barcode', 'UPC', 'EAN']), price: pick(row, ['Variant Price', 'Price']) || '0', qty: Number(pick(row, ['Variant Inventory Qty', 'Inventory', 'Qty', 'Quantity', 'Stock'])) || 0, type: pick(row, ['Type', 'Product Type', 'Category']) || 'Uncategorized', vendor: pick(row, ['Vendor', 'Brand']) || '', }); } if (added.length) { setProducts((prev) => [...added, ...prev]); log(`Imported ${added.length} product${added.length > 1 ? 's' : ''} from CSV`); } setImportMsg(added.length ? `Imported ${added.length} product(s).` : 'No new products found in that file.'); if (fileRef.current) fileRef.current.value = ''; }, error: () => setImportMsg('Could not read that file. Make sure it\'s a valid CSV.'), }); } function addProduct() { if (!form.title.trim()) return; const p = { id: uid(), ...form, qty: Number(form.qty) || 0, type: form.type || 'Uncategorized' }; setProducts((prev) => [p, ...prev]); log(`Added product “${form.title}”`); setForm({ title: '', sku: '', barcode: '', price: '', qty: '', type: '', vendor: '' }); setShowAdd(false); } function exportCsv() { const rows = products.map((p) => ({ Title: p.title, 'Variant SKU': p.sku, 'Variant Barcode': p.barcode, 'Variant Price': p.price, 'Variant Inventory Qty': p.qty, Type: p.type, Vendor: p.vendor, })); triggerDownload('products-export.csv', Papa.unparse(rows), 'text/csv'); } return (

Products

{products.length} products · import from a Shopify CSV or add them by hand.

{importMsg && (
{importMsg}
)} {syncMsg && (
{syncMsg.text}
)} {!shopConnected && !syncMsg && (
To pull your live Shopify catalog, add your store domain and a Storefront API token in Settings. Or import a Shopify product CSV export — that always works.
)}
setQuery(e.target.value)} />
{filtered.map((p) => ( ))} {filtered.length === 0 && ( )}
Product SKU Barcode Price Stock Category
{p.title}
{p.vendor &&
{p.vendor}
}
{p.sku || '—'} {p.barcode || from SKU} {money(p.price)}
{p.qty}
{p.type}
No products match your search.
setShowAdd(false)}>

Add a product

setForm({ ...form, title: e.target.value })} placeholder="e.g. Classic Cotton Tee" />
setForm({ ...form, sku: e.target.value })} placeholder="PLA-BLK-175" /> setForm({ ...form, barcode: e.target.value })} placeholder="optional" /> setForm({ ...form, price: e.target.value })} placeholder="24.99" /> setForm({ ...form, qty: e.target.value })} placeholder="0" /> setForm({ ...form, type: e.target.value })} placeholder="Apparel" /> setForm({ ...form, vendor: e.target.value })} placeholder="optional" />
{form.barcode || form.sku ? (

Preview

) : null}
); } /* ============================================================ Barcodes + printing ============================================================ */ const LABEL_TEMPLATES = { // Sheet labels — printed as a grid on a normal letter/A4 page '30up': { name: 'Avery 5160 — 30 per sheet', kind: 'sheet', cols: 3 }, '12up': { name: 'Avery 5164 — 12 per sheet', kind: 'sheet', cols: 2 }, // Roll / die-cut media for dedicated label printers — one label per page 'dymo30252': { name: 'Dymo 30252 Address — 89 × 28 mm', kind: 'roll', wmm: 89, hmm: 28 }, 'dymo30336': { name: 'Dymo 30336 Multipurpose — 54 × 25 mm', kind: 'roll', wmm: 54, hmm: 25 }, 'brotherDK1201': { name: 'Brother DK-1201 — 90 × 29 mm', kind: 'roll', wmm: 90, hmm: 29 }, 'zebra2x1': { name: 'Zebra 2" × 1" — 51 × 25 mm', kind: 'roll', wmm: 51, hmm: 25 }, 'zebra4x6': { name: 'Zebra 4" × 6" shipping — 102 × 152 mm', kind: 'roll', wmm: 102, hmm: 152 }, }; function BarcodeView({ products, settings }) { const [query, setQuery] = useState(''); const [selected, setSelected] = useState(() => new Set()); const [template, setTemplate] = useState(settings.labelTemplate || '30up'); const [moduleWidth, setModuleWidth] = useState(2); const [copies, setCopies] = useState(1); const [symbology, setSymbology] = useState('auto'); const [showTips, setShowTips] = useState(false); const filtered = products.filter((p) => [p.title, p.sku, p.barcode].join(' ').toLowerCase().includes(query.toLowerCase()) ); const toPrint = products.filter((p) => selected.has(p.id)); const tpl = LABEL_TEMPLATES[template]; const isRoll = tpl.kind === 'roll'; const cols = tpl.cols || 1; const gridCls = cols === 3 ? 'grid-cols-3' : cols === 2 ? 'grid-cols-2' : 'grid-cols-1'; // Expand the selection by the requested number of copies for the print run. const printList = []; toPrint.forEach((p) => { for (let i = 0; i < Math.max(1, copies); i++) printList.push(p); }); // Print CSS is template-specific: roll media print one label per page at exact // size; sheet media print a grid on a normal page. const printCss = isRoll ? `@media print { @page { size: ${tpl.wmm}mm ${tpl.hmm}mm; margin: 0; } .printable { padding: 0 !important; } .lbl-roll { width: ${tpl.wmm}mm; height: ${tpl.hmm}mm; box-sizing: border-box; padding: 1.5mm; page-break-after: always; break-after: page; display: flex; flex-direction: column; align-items: center; justify-content: center; overflow: hidden; } .lbl-roll:last-child { page-break-after: auto; break-after: auto; } .lbl-roll .bc { width: 100%; flex: 1 1 auto; min-height: 0; display: flex; align-items: center; justify-content: center; } .lbl-roll .bc svg { width: 100%; height: 100%; } .lbl-roll .meta { width: 100%; text-align: center; font-size: 7pt; line-height: 1.15; } }` : `@media print { @page { size: auto; margin: 10mm; } .printable { padding: 0 !important; } }`; function toggle(id) { setSelected((prev) => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; }); } const allSelected = filtered.length > 0 && filtered.every((p) => selected.has(p.id)); function toggleAll() { setSelected((prev) => { const n = new Set(prev); if (allSelected) filtered.forEach((p) => n.delete(p.id)); else filtered.forEach((p) => n.add(p.id)); return n; }); } function downloadOne(p) { triggerDownload(`barcode-${p.sku || p.barcode || 'label'}.svg`, barcodeSvgString(p.barcode || p.sku, { moduleWidth, symbology }), 'image/svg+xml'); } const totalLabels = printList.length; return (

Barcodes & labels

Scannable Code 128 + EAN-13/UPC — select items, then print to your label printer or download.

setQuery(e.target.value)} />
{showTips && (
  1. Install your printer's driver/app (Brother iPrint, Dymo Connect, or Zebra Setup) so it appears as a normal printer.
  2. Pick the matching roll size above (e.g. Dymo 30252), select your items, then hit Print.
  3. In the browser print dialog, choose your label printer as the Destination.
  4. Set Margins → None and Scale → 100%, and make sure paper size matches the roll. Each label prints on its own page.

Sheet templates (Avery) print as a grid on a normal letter/A4 page instead.

)}
{filtered.map((p) => { const active = selected.has(p.id); return (

{p.title}

{p.barcode || p.sku || '—'}

{money(p.price)}
); })} {filtered.length === 0 && (
No products yet — add some on the Products page.
)}
{/* Printable area — hidden on screen, shown only when printing */}
{isRoll ? ( printList.map((p, i) => (
{p.title}
{money(p.price)}
)) ) : (
{printList.map((p, i) => (
{p.title}
{money(p.price)}
))}
)}
); } /* ============================================================ Ticket detail modal (shared by staff + portal) ============================================================ */ function TicketModal({ ticket, open, onClose, onUpdate, isAgent }) { const [comment, setComment] = useState(''); if (!open || !ticket) return null; function addComment() { if (!comment.trim()) return; const c = { author: isAgent ? 'You' : ticket.requester.name, role: isAgent ? 'agent' : 'customer', text: comment.trim(), at: Date.now() }; onUpdate({ ...ticket, comments: [...ticket.comments, c] }); setComment(''); } return (
{ticket.key}

{ticket.subject}

{ticket.description && (

Description

{ticket.description}

)}

Conversation

{ticket.comments.length === 0 &&

No messages yet.

} {ticket.comments.map((c, i) => (
{c.author}{c.role === 'agent' && STAFF} {fmtTime(c.at)}

{c.text}

))}
{isAgent && (
{CANNED_REPLIES.map((r) => ( ))}
)}

Status

{isAgent ? ( ) : (
c.id === ticket.status)?.tone}>{COLUMNS.find((c) => c.id === ticket.status)?.title}
)}

Priority

{isAgent ? ( ) :
}
{isAgent && (

Assignee

)}

Requester

{ticket.requester.name}

{ticket.requester.email}

Opened

{fmtDate(ticket.createdAt)}

{timeAgo(ticket.createdAt)}

); } /* ============================================================ Staff ticket board (Jira-style kanban) ============================================================ */ function TicketBoard({ tickets, setTickets, log }) { const [active, setActive] = useState(null); const [dragId, setDragId] = useState(null); const [overCol, setOverCol] = useState(null); const [query, setQuery] = useState(''); const [prioFilter, setPrioFilter] = useState('All'); const [showNew, setShowNew] = useState(false); const [nf, setNf] = useState({ subject: '', description: '', type: 'Support', priority: 'Medium', assignee: 'Unassigned', name: '', email: '' }); const filtered = tickets.filter((t) => { const q = [t.key, t.subject, t.requester.name].join(' ').toLowerCase().includes(query.toLowerCase()); const pr = prioFilter === 'All' || t.priority === prioFilter; return q && pr; }); function update(updated) { setTickets((prev) => prev.map((t) => (t.id === updated.id ? updated : t))); setActive((a) => (a && a.id === updated.id ? updated : a)); } function moveTo(id, status) { setTickets((prev) => prev.map((t) => (t.id === id ? { ...t, status } : t))); } function nextKey() { const nums = tickets.map((t) => parseInt((t.key || '').replace(/\D/g, ''), 10)).filter((n) => !isNaN(n)); return 'SUP-' + ((nums.length ? Math.max(...nums) : 100) + 1); } function createTicket() { if (!nf.subject.trim()) return; const t = { id: uid(), key: nextKey(), subject: nf.subject, description: nf.description, type: nf.type, priority: nf.priority, status: 'todo', assignee: nf.assignee, requester: { name: nf.name || 'In-house', email: nf.email || 'ops@internal' }, createdAt: Date.now(), comments: [], }; setTickets((prev) => [t, ...prev]); log(`Created ticket ${t.key}`); setNf({ subject: '', description: '', type: 'Support', priority: 'Medium', assignee: 'Unassigned', name: '', email: '' }); setShowNew(false); } return (

Support board

Drag tickets across columns. Staff-only — customers see the portal.

setQuery(e.target.value)} />
{COLUMNS.map((col) => { const cards = filtered.filter((t) => t.status === col.id); const t = TONE[col.tone]; return (
{ e.preventDefault(); setOverCol(col.id); }} onDragLeave={() => setOverCol((c) => (c === col.id ? null : c))} onDrop={() => { if (dragId) { moveTo(dragId, col.id); setDragId(null); } setOverCol(null); }} className={`flex flex-col rounded-2xl border bg-zinc-50 p-3 transition ${overCol === col.id ? 'border-zinc-400 bg-zinc-100' : 'border-zinc-200'}`} >
{col.title}
{cards.length}
{cards.map((tk) => (
setDragId(tk.id)} onDragEnd={() => setDragId(null)} onClick={() => setActive(tk)} className={`cursor-pointer rounded-xl border border-zinc-200 bg-white p-3 shadow-sm transition hover:shadow-md ${dragId === tk.id ? 'opacity-40' : ''}`} >
{tk.key}

{tk.subject}

{timeAgo(tk.createdAt)}

{tk.comments.length > 0 && {tk.comments.length}} {tk.assignee === 'Unassigned' ? '–' : tk.assignee.split(' ').map((w) => w[0]).join('').slice(0, 2)}
))} {cards.length === 0 &&
Drop here
}
); })}
setActive(null)} onUpdate={update} isAgent /> setShowNew(false)}>

New ticket

setNf({ ...nf, subject: e.target.value })} />
setNf({ ...nf, name: e.target.value })} placeholder="optional" /> setNf({ ...nf, email: e.target.value })} placeholder="optional" />
); } /* ============================================================ Customer support portal (public) ============================================================ */ function CustomerPortal({ tickets, setTickets, brand, log, onStaff }) { const [tab, setTab] = useState('new'); const [form, setForm] = useState({ name: '', email: '', orderNo: '', subject: '', description: '', type: 'Support', priority: 'Medium' }); const [lookup, setLookup] = useState(''); const [submitted, setSubmitted] = useState(null); const [active, setActive] = useState(null); const [faqOpen, setFaqOpen] = useState(null); function pickTopic(t) { setForm((f) => ({ ...f, type: t.type, subject: f.subject || t.label })); setTab('new'); setSubmitted(null); if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' }); } function nextKey() { const nums = tickets.map((t) => parseInt((t.key || '').replace(/\D/g, ''), 10)).filter((n) => !isNaN(n)); return 'SUP-' + ((nums.length ? Math.max(...nums) : 100) + 1); } function submit() { if (!form.subject.trim() || !form.email.trim()) return; const desc = (form.orderNo.trim() ? `Order #${form.orderNo.trim()}\n\n` : '') + form.description; const t = { id: uid(), key: nextKey(), subject: form.subject, description: desc, type: form.type, priority: form.priority, status: 'todo', assignee: 'Unassigned', requester: { name: form.name || form.email, email: form.email }, createdAt: Date.now(), comments: [], }; setTickets((prev) => [t, ...prev]); log(`Customer ${t.requester.name} opened ${t.key}`); setSubmitted(t); setLookup(form.email); setForm({ name: '', email: '', orderNo: '', subject: '', description: '', type: 'Support', priority: 'Medium' }); } const mine = tickets.filter((t) => lookup && t.requester.email.toLowerCase() === lookup.toLowerCase()); function update(updated) { setTickets((prev) => prev.map((t) => (t.id === updated.id ? updated : t))); setActive(updated); } // Hidden staff entry — customers never see a login button. Type the word // "admin" anywhere (outside a text field), or press Ctrl/Cmd + Shift + A, // to bring up the password screen. useEffect(() => { let buf = ''; let timer; function onKey(e) { if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'A' || e.key === 'a')) { e.preventDefault(); onStaff(); return; } const el = e.target; const typing = el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || el.isContentEditable); if (typing) return; if (e.key && e.key.length === 1) { buf = (buf + e.key.toLowerCase()).slice(-6); clearTimeout(timer); timer = setTimeout(() => { buf = ''; }, 1500); if (buf.endsWith('admin')) { buf = ''; onStaff(); } } } window.addEventListener('keydown', onKey); return () => { window.removeEventListener('keydown', onKey); clearTimeout(timer); }; }, [onStaff]); return (

{brand} Help Center

We usually reply within a few hours

How can we help?

Browse common questions, open a request, or check the status of one you've already sent.

{HELP_TOPICS.map((t) => { const Icon = t.icon; return ( ); })}
{tab === 'new' && (
{submitted ? (

Request received

Your ticket is {submitted.key}. We've emailed a copy to you and will reply soon.

) : (
setForm({ ...form, name: e.target.value })} /> setForm({ ...form, email: e.target.value })} placeholder="you@email.com" />
setForm({ ...form, orderNo: e.target.value })} placeholder="#1234" />
setForm({ ...form, subject: e.target.value })} placeholder="Briefly, what's going on?" />

We typically reply within a few hours during business days.

)}
)} {tab === 'track' && (
setLookup(e.target.value)} placeholder="you@email.com" />
{!lookup &&

Enter the email you used to submit a request.

} {lookup && mine.length === 0 &&

No requests found for that email yet.

} {mine.map((t) => ( ))}
)}

Frequently asked

{PORTAL_FAQ.map((f, i) => (
{faqOpen === i &&

{f.a}

}
))}
setActive(null)} onUpdate={update} isAgent={false} />
); } /* ============================================================ Settings ============================================================ */ function SettingsView({ settings, setSettings, onReset, log, onSync }) { const [local, setLocal] = useState(settings); const [pw, setPw] = useState(''); const [pw2, setPw2] = useState(''); const [saved, setSaved] = useState(false); const [syncing, setSyncing] = useState(false); const [syncMsg, setSyncMsg] = useState(null); async function runSync() { setSettings({ ...local }); // persist creds first so the sync uses them setSyncing(true); setSyncMsg(null); const r = await onSync({ domain: local.shopDomain, token: local.shopToken }); setSyncing(false); setSyncMsg(r.ok ? { ok: true, text: `Synced — ${r.added} added, ${r.updated} updated (${r.total} variants).` } : { ok: false, text: r.error }); } function save() { setSettings({ ...local }); setSaved(true); setTimeout(() => setSaved(false), 1800); } function changePw() { if (pw.length < 4 || pw !== pw2) return; setSettings({ ...settings, adminPassword: pw }); setPw(''); setPw2(''); log('Staff password updated'); setSaved(true); setTimeout(() => setSaved(false), 1800); } return (

Settings

Branding, Shopify connection, printing, and staff access.

Branding

setLocal({ ...local, brand: e.target.value })} />

Inventory

setLocal({ ...local, lowStockThreshold: Math.max(0, Number(e.target.value) || 0) })} />

Shopify connection

Pulls your live catalog — titles, SKUs, barcodes, prices and stock — straight from Shopify.

setLocal({ ...local, shopDomain: e.target.value })} placeholder="my-shop.myshopify.com" /> setLocal({ ...local, shopToken: e.target.value })} placeholder="Storefront access token" />
{syncMsg && {syncMsg.text}}
How to get a Storefront API token
  1. Shopify admin → Settings → Apps and sales channels → Develop apps.
  2. Create an app, open Configuration → Storefront API, and enable read access to products and inventory.
  3. Install the app, then copy the Storefront API access token into the field above.

Live quantities need the inventory scope. If the sync is blocked here, it's browser security on this preview — running your own copy of the app, or using the CSV export, both work.

Printing

setLocal({ ...local, printerName: e.target.value })} />
{saved && Saved ✓}

Staff password

Gate for the staff area. Demo-grade only — real multi-user security needs server-side auth.

setPw(e.target.value)} /> setPw2(e.target.value)} />

Reset data

Clears all products and tickets and restores the sample data.

); } /* ============================================================ Staff login gate ============================================================ */ function AdminLogin({ onUnlock, settings, onPortal }) { const [pw, setPw] = useState(''); const [show, setShow] = useState(false); const [err, setErr] = useState(false); function tryLogin() { if (pw === settings.adminPassword) onUnlock(); else { setErr(true); setTimeout(() => setErr(false), 1500); } } return (

{settings.brand} Staff

This area is private. Enter your staff password.

setPw(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && tryLogin()} autoFocus />
{err &&

Incorrect password.

}
); } /* ============================================================ Root app ============================================================ */ const NAV = [ { id: 'dashboard', label: 'Overview', icon: LayoutDashboard }, { id: 'products', label: 'Products', icon: Package }, { id: 'barcodes', label: 'Barcodes', icon: Barcode }, { id: 'tickets', label: 'Support', icon: Ticket }, { id: 'settings', label: 'Settings', icon: SettingsIcon }, ]; export default function App() { const [loaded, setLoaded] = useState(false); const [products, setProducts] = useState([]); const [tickets, setTickets] = useState([]); const [activity, setActivity] = useState([]); const [settings, setSettings] = useState(DEFAULT_SETTINGS); const [mode, setMode] = useState('portal'); // 'portal' (customer) | 'admin' (staff) const [authed, setAuthed] = useState(false); const [view, setView] = useState('dashboard'); const [navOpen, setNavOpen] = useState(false); const autoSyncedRef = useRef(false); // Load persisted data on mount useEffect(() => { (async () => { const [p, t, a, s] = await Promise.all([ store.get('scanly-products'), store.get('scanly-tickets'), store.get('scanly-activity'), store.get('scanly-settings'), ]); setProducts(p ?? SAMPLE_PRODUCTS); setTickets(t ?? SAMPLE_TICKETS); setActivity(a ?? []); setSettings(s && s.settingsVersion === DEFAULT_SETTINGS.settingsVersion ? { ...DEFAULT_SETTINGS, ...s } : DEFAULT_SETTINGS); setLoaded(true); })(); }, []); // Persist on change (after initial load) useEffect(() => { if (loaded) store.set('scanly-products', products); }, [products, loaded]); useEffect(() => { if (loaded) store.set('scanly-tickets', tickets); }, [tickets, loaded]); useEffect(() => { if (loaded) store.set('scanly-activity', activity); }, [activity, loaded]); useEffect(() => { if (loaded) store.set('scanly-settings', settings); }, [settings, loaded]); const log = (text) => setActivity((prev) => [{ text, at: Date.now() }, ...prev].slice(0, 30)); async function syncFromShopify(override) { const domain = (override && override.domain) || settings.shopDomain; const token = (override && override.token) || settings.shopToken; const r = await fetchShopifyProducts({ domain, token }); if (!r.ok) return r; let summary = { added: 0, updated: 0 }; setProducts((prev) => { const m = mergeProducts(prev, r.products); summary = m; return m.list; }); log(`Shopify sync: ${summary.added} added, ${summary.updated} updated`); return { ok: true, added: summary.added, updated: summary.updated, total: r.products.length }; } // Auto-link to Shopify: when staff open the admin and a token is saved, // pull the latest catalog automatically (errors stay silent here; the // manual Sync button surfaces any issues). useEffect(() => { if (loaded && mode === 'admin' && authed && settings.shopDomain && settings.shopToken && !autoSyncedRef.current) { autoSyncedRef.current = true; syncFromShopify().catch(() => {}); } }, [loaded, mode, authed, settings.shopDomain, settings.shopToken]); function resetData() { setProducts(SAMPLE_PRODUCTS); setTickets(SAMPLE_TICKETS); setActivity([{ text: 'Reset to sample data', at: Date.now() }]); setView('dashboard'); } const fontStyle = { fontFamily: "'Archivo', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif" }; if (!loaded) { return (
Loading {settings.brand}…
); } // Customer-facing portal if (mode === 'portal') { return (
setMode('admin')} />
); } // Staff area, gated if (!authed) { return (
setAuthed(true)} settings={settings} onPortal={() => setMode('portal')} />
); } return (
{/* Sidebar */} {navOpen &&
setNavOpen(false)} />} {/* Main */}
Staff {NAV.find((n) => n.id === view)?.label}
Synced
You
{view === 'dashboard' && } {view === 'products' && } {view === 'barcodes' && } {view === 'tickets' && } {view === 'settings' && }
); }