DEV Community

Cover image for SPACESHOP
Gift Trust
Gift Trust

Posted on

SPACESHOP

*<!DOCTYPE html>




SPACESHOP — Dashboard

:root{
--ink:#0D0E14; --panel:#12141C; --card:rgba(255,255,255,0.03); --card-b:rgba(255,255,255,0.07);
--line:rgba(255,255,255,0.08); --white:#F7F5F0;
--orange:#FF6A3D; --gold:#F0B429; --mint:#3DDC8A; --blue:#5B8CFF;
--muted:rgba(255,255,255,0.45); --muted2:rgba(255,255,255,0.28);
}
*{box-sizing:border-box;}
body{margin:0;background:var(--ink);color:var(--white);font-family:'Inter',-apple-system,'Segoe UI',Roboto,system-ui,sans-serif;-webkit-font-smoothing:antialiased;}
h1,h2,.brand{font-family:'Space Grotesk','Inter',-apple-system,sans-serif;}
.mono{font-family:'JetBrains Mono','SFMono-Regular',Consolas,monospace;}
button{font-family:inherit;cursor:pointer;border:none;background:none;color:inherit;}
input,textarea{font-family:inherit;color:inherit;}
::selection{background:var(--orange);color:var(--ink);}

/* ---- layout shells ---- */
.screen{min-height:100vh;}
.hidden{display:none !important;}

/* ---- onboarding ---- */
.onboard{display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px;}
.onboard-box{width:100%;max-width:380px;}
.brand-row{display:flex;align-items:center;justify-content:center;gap:10px;margin-bottom:32px;}
.brand-mark{width:36px;height:36px;border-radius:10px;background:var(--orange);display:flex;align-items:center;justify-content:center;font-weight:700;color:var(--ink);font-size:16px;}
.brand-name{font-weight:700;font-size:20px;letter-spacing:-0.02em;}
.onboard h1{font-size:26px;font-weight:700;text-align:center;line-height:1.2;margin:0;}
.onboard p.sub{color:var(--muted);font-size:14px;text-align:center;margin:10px 0 0;line-height:1.5;}
.field{margin-top:14px;}
.field label{display:block;color:rgba(255,255,255,0.55);font-size:12px;font-weight:500;margin-bottom:6px;}
.field input, .field textarea{width:100%;background:rgba(255,255,255,0.05);border:1px solid var(--line);border-radius:12px;padding:12px 14px;font-size:14px;outline:none;transition:border-color .15s;}
.field input:focus, .field textarea:focus{border-color:rgba(255,106,61,0.6);}
.slug-preview{color:var(--muted2);font-size:12px;margin-top:6px;}
.btn-primary{width:100%;margin-top:20px;background:var(--orange);color:var(--ink);font-weight:700;padding:14px;border-radius:12px;font-size:15px;display:flex;align-items:center;justify-content:center;gap:6px;transition:filter .15s, transform .1s;}
.btn-primary:hover{filter:brightness(1.06);}
.btn-primary:active{transform:scale(0.99);}
.hint{color:var(--muted2);font-size:12px;text-align:center;margin-top:14px;}

/* ---- dashboard top bar ---- */
.topbar{position:sticky;top:0;z-index:20;background:rgba(13,14,20,0.9);backdrop-filter:blur(8px);border-bottom:1px solid var(--line);padding:14px 20px;display:flex;align-items:center;justify-content:space-between;}
.topbar .brand-row{margin:0;}
.btn-ghost{display:flex;align-items:center;gap:6px;background:rgba(255,255,255,0.06);border:1px solid var(--line);color:var(--white);font-size:13px;font-weight:500;padding:9px 15px;border-radius:999px;transition:background .15s;}
.btn-ghost:hover{background:rgba(255,255,255,0.1);}

.wrap{max-width:960px;margin:0 auto;padding:24px 20px 60px;}

/* ---- shop identity ---- */
.shop-id{display:flex;align-items:center;gap:12px;margin-bottom:22px;}
.shop-avatar{width:48px;height:48px;border-radius:16px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:18px;color:var(--ink);flex-shrink:0;}
.shop-name{font-weight:700;font-size:20px;line-height:1.2;}
.shop-link{display:flex;align-items:center;gap:5px;color:var(--muted);font-size:13px;background:none;border:none;padding:0;margin-top:2px;}
.shop-link:hover{color:rgba(255,255,255,0.7);}

/* ---- stat cards ---- */
.stats{display:flex;gap:12px;overflow-x:auto;margin-bottom:22px;padding-bottom:2px;}
.stat-card{flex:1;min-width:130px;background:var(--card);border:1px solid var(--card-b);border-radius:16px;padding:14px 16px;}
.stat-top{display:flex;align-items:center;justify-content:space-between;}
.stat-label{color:var(--muted);font-size:12px;font-weight:500;}
.stat-value{font-size:22px;font-weight:700;margin-top:6px;}

/* ---- toolbar ---- */
.toolbar{display:flex;gap:8px;margin-bottom:16px;}
.search-wrap{position:relative;flex:1;}
.search-wrap svg{position:absolute;left:14px;top:50%;transform:translateY(-50%);opacity:0.35;}
.search-wrap input{width:100%;background:rgba(255,255,255,0.04);border:1px solid var(--line);border-radius:12px;padding:11px 14px 11px 38px;font-size:14px;outline:none;}
.search-wrap input:focus{border-color:rgba(255,255,255,0.25);}
.btn-add{display:flex;align-items:center;gap:6px;background:var(--orange);color:var(--ink);font-weight:600;font-size:14px;padding:11px 18px;border-radius:12px;flex-shrink:0;transition:filter .15s, transform .1s;}
.btn-add:hover{filter:brightness(1.06);}
.btn-add:active{transform:scale(0.98);}

/* ---- category pills ---- */
.pills{display:flex;gap:8px;overflow-x:auto;margin-bottom:16px;padding-bottom:2px;}
.pill{display:flex;align-items:center;gap:6px;padding:7px 13px;border-radius:999px;font-size:13px;font-weight:500;border:1px solid var(--line);color:rgba(255,255,255,0.6);white-space:nowrap;flex-shrink:0;transition:all .15s;}
.pill:hover{border-color:rgba(255,255,255,0.25);color:rgba(255,255,255,0.9);}
.pill.active-all{background:var(--white);color:var(--ink);border-color:var(--white);}

/* ---- listings panel ---- */
.panel{background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.06);border-radius:18px;padding:0 16px;}
.empty{text-align:center;padding:56px 20px;color:var(--muted);font-size:14px;}
.row{display:flex;align-items:center;gap:12px;padding:14px 0;border-bottom:1px solid rgba(255,255,255,0.06);}
.row:last-child{border-bottom:none;}
.beacon{width:5px;align-self:stretch;border-radius:4px;flex-shrink:0;min-height:40px;}
.row-body{flex:1;min-width:0;}
.row-title-line{display:flex;align-items:center;gap:8px;}
.row-title{font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.badge-sold{font-size:10px;font-weight:700;text-transform:uppercase;color:rgba(255,255,255,0.4);background:rgba(255,255,255,0.06);padding:2px 6px;border-radius:5px;flex-shrink:0;}
.row-meta{display:flex;align-items:center;gap:12px;font-size:12px;color:var(--muted);margin-top:3px;flex-wrap:wrap;}
.row-price{font-size:14px;font-weight:600;flex-shrink:0;}
.row-actions{display:flex;gap:2px;flex-shrink:0;}
.icon-btn{padding:8px;border-radius:8px;color:rgba(255,255,255,0.45);}
.icon-btn:hover{background:rgba(255,255,255,0.08);color:#fff;}
.icon-btn.danger:hover{background:rgba(239,68,68,0.12);color:#f87171;}

/* ---- modals ---- */
.overlay{position:fixed;inset:0;background:rgba(0,0,0,0.7);backdrop-filter:blur(4px);z-index:50;display:flex;align-items:flex-end;justify-content:center;}
@media(min-width:640px){.overlay{align-items:center;}}
.modal{width:100%;max-width:420px;background:#181A24;border:1px solid var(--line);border-radius:24px 24px 0 0;padding:20px 20px 32px;max-height:90vh;overflow-y:auto;animation:slideup .2s ease;}
@media(min-width:640px){.modal{border-radius:24px;padding-bottom:24px;}}
@keyframes slideup{from{transform:translateY(20px);opacity:0;}to{transform:translateY(0);opacity:1;}}
.modal-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
.modal-head h2{font-size:18px;font-weight:700;margin:0;}
.close-btn{color:rgba(255,255,255,0.4);padding:4px;}
.close-btn:hover{color:#fff;}
.cat-grid{display:flex;gap:8px;flex-wrap:wrap;}
.cat-btn{display:flex;align-items:center;gap:6px;padding:9px 13px;border-radius:12px;font-size:13px;font-weight:500;border:1px solid var(--line);color:rgba(255,255,255,0.6);}
.cat-btn.active{color:var(--ink);}
.two-col{display:grid;grid-template-columns:1fr 1fr;gap:12px;}
.modal .field textarea{resize:none;}

/* ---- share sheet ---- */
.share-link-box{display:flex;align-items:center;gap:8px;background:rgba(255,255,255,0.05);border:1px solid var(--line);border-radius:12px;padding:10px 12px;margin-top:14px;}
.share-link-box span{flex:1;font-size:13px;color:rgba(255,255,255,0.7);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.channels{display:flex;gap:8px;margin-top:14px;}
.channel{flex:1;display:flex;flex-direction:column;align-items:center;gap:6px;padding:14px 8px;border-radius:12px;background:rgba(255,255,255,0.05);border:1px solid var(--line);transition:background .15s;}
.channel:hover{background:rgba(255,255,255,0.09);}
.channel span{font-size:11px;color:rgba(255,255,255,0.6);}

/* ---- toast ---- */
.toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:60;display:flex;align-items:center;gap:8px;padding:11px 18px;border-radius:999px;font-size:14px;font-weight:500;background:var(--panel);color:#fff;border:1px solid rgba(255,255,255,0.1);box-shadow:0 10px 30px rgba(0,0,0,0.4);animation:fadeup .2s ease;}
@keyframes fadeup{from{transform:translate(-50%,8px);opacity:0;}to{transform:translate(-50%,0);opacity:1;}}

/* ---- storefront (public view) ---- */
.store-header{border-bottom:1px solid var(--line);padding:32px 20px 22px;}
.store-inner{max-width:720px;margin:0 auto;}
.store-search{position:relative;margin-top:18px;}
.store-search svg{position:absolute;left:14px;top:50%;transform:translateY(-50%);opacity:0.3;}
.store-search input{width:100%;background:rgba(255,255,255,0.05);border:1px solid var(--line);border-radius:16px;padding:13px 16px 13px 40px;font-size:14px;outline:none;}
.store-grid{display:grid;grid-template-columns:1fr;gap:12px;max-width:720px;margin:24px auto 0;padding:0 20px;}
@media(min-width:640px){.store-grid{grid-template-columns:1fr 1fr;}}
.listing-card{display:flex;gap:10px;background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:16px;padding:12px;text-align:left;transition:background .15s;}
.listing-card:hover{background:rgba(255,255,255,0.06);}
.lc-title-line{display:flex;justify-content:space-between;gap:8px;}
.lc-title{font-size:15px;font-weight:600;line-height:1.3;}
.lc-desc{color:var(--muted);font-size:13px;margin-top:3px;line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}
.lc-bottom{display:flex;justify-content:space-between;align-items:center;margin-top:10px;}
.lc-price{font-size:15px;font-weight:600;}
.lc-views{color:rgba(255,255,255,0.3);font-size:11px;display:flex;align-items:center;gap:4px;}
.footer-tag{text-align:center;padding:32px 0;color:rgba(255,255,255,0.25);font-size:12px;}
.empty-shop{display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center;padding:24px;}

.wa-btn{margin-top:18px;display:flex;align-items:center;justify-content:center;gap:8px;width:100%;background:#25D366;color:#0b1a10;font-weight:700;padding:14px;border-radius:16px;}
.wa-btn:hover{filter:brightness(0.97);}

/* ===================================================================
SPACESHOP — single-file dashboard + storefront.
All data lives in-memory (window.STATE) for this demo file.
To make it permanent/shareable for real, this same logic drops
into a backend + real domain — see the note at the bottom of chat.
=================================================================== */

const CATEGORIES = {
clothes: { label: "Clothes", color: "#FF6A3D", icon: "shirt" },
food: { label: "Food", color: "#F0B429", icon: "food" },
houses: { label: "Houses", color: "#3DDC8A", icon: "house" },
appliances: { label: "Appliances", color: "#5B8CFF", icon: "bolt" },
};

const ICONS = {
shirt: '<path d="M16 4l4 3-2 3-2-1.5V20H8V8.5L6 10 4 7l4-3 2 2h4z"/>',
food: '<path d="M6 2v6a3 3 0 003 3v11M6 2v6M10 2v6M6 8h4M18 2c-2 2-3 4-3 7 0 2 1 3 3 3v9"/>',
house: '<path d="M3 11l9-8 9 8M5 10v10h14V10"/>',
bolt: '<path d="M13 2L4 14h6l-1 8 9-12h-6l1-8z"/>',
search: '<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>',
eye: '<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
x: '<path d="M18 6L6 18M6 6l12 12"/>',
edit: '<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/>',
trash: '<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/>',
share: '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 13.5l6.8 4M15.4 6.5l-6.8 4"/>',
copy: '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>',
check: '<path d="M20 6L9 17l-5-5"/>',
pkg: '<path d="M21 8l-9-5-9 5v8l9 5 9-5V8z"/><path d="M3 8l9 5 9-5M12 13v8"/>',
chart: '<path d="M3 3v18h18M18 9l-5 5-3-3-4 4"/>',
store: '<path d="M3 9l1-5h16l1 5M4 9v11h16V9M4 9h16M9 20v-6h6v6"/>',
chevron: '<path d="M9 18l6-6-6-6"/>',
sparkle: '<path d="M12 2l1.5 6.5L20 10l-6.5 1.5L12 18l-1.5-6.5L4 10l6.5-1.5L12 2z"/>',
wa: '<path d="M17 3a10 10 0 00-14.7 13L2 21l5.2-1.4A10 10 0 1017 3z"/><path d="M8.5 8.5c0 4 3 7 7 7"/>',
twitter: '<path d="M22 4.5c-.8.4-1.6.6-2.5.8a4.3 4.3 0 001.9-2.4c-.9.5-1.8.9-2.8 1.1A4.3 4.3 0 0012 7c0 .3 0 .7.1 1A12.2 12.2 0 013 3.3a4.3 4.3 0 001.3 5.7c-.7 0-1.4-.2-2-.5v.1c0 2.1 1.5 3.8 3.4 4.2-.6.2-1.3.2-1.9.1a4.3 4.3 0 004 3A8.6 8.6 0 012 18a12.1 12.1 0 006.6 1.9c7.9 0 12.2-6.6 12.2-12.2v-.6c.8-.6 1.5-1.4 2.2-2.2z"/>',
fb: '<path d="M18 2h-3a5 5 0 00-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 011-1h3z"/>',
alert: '<circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/>',
imgoff: '<path d="M3 3l18 18M10.5 10.5a2 2 0 002.9 2.9M21 15V5a2 2 0 00-2-2H8m-5 4v12a2 2 0 002 2h12"/>',
};

function icon(name, size=15, extra=""){
return &lt;svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="${extra}"&gt;${ICONS[name]}&lt;/svg&gt;;
}

function uid(){ return Math.random().toString(36).slice(2,10); }
function slugify(s){
return s.toLowerCase().trim().replace(/[^a-z0-9\s-]/g,"").replace(/\s+/g,"-").replace(/-+/g,"-").slice(0,40) || "shop";
}
function formatNaira(n){ return "₦" + Number(n).toLocaleString("en-NG"); }
function timeAgo(ts){
const s=(Date.now()-ts)/1000;
if(s<60) return "just now";
if(s<3600) return Math.floor(s/60)+"m ago";
if(s<86400) return Math.floor(s/3600)+"h ago";
return Math.floor(s/86400)+"d ago";
}
function escapeHtml(str){
return String(str).replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[c]));
}

/* ---- in-memory "database" (per-browser-tab demo store) ---- */
const DB = { shops: {}, slugs: {} };

async function reserveUniqueSlug(base){
let candidate = slugify(base), n = 1;
while (DB.slugs[candidate]) { n++; candidate = slugify(base) + "-" + n; }
return candidate;
}

const SEED = () => ([
{ id: uid(), name: "Ankara Wrap Dress", category: "clothes", price: 15000, desc: "Handmade wrap dress, true to size, machine washable.", stock: 8, views: 142, createdAt: Date.now() - 86400000*2 },
{ id: uid(), name: "Jollof Rice (Party Pack)", category: "food", price: 8000, desc: "Serves 5. Smoky party jollof, made fresh on order.", stock: 20, views: 310, createdAt: Date.now() - 3600000*5 },
{ id: uid(), name: "2 Bedroom Flat, Lekki", category: "houses", price: 1800000, desc: "Newly renovated, self-compound, 24/7 light.", stock: 1, views: 502, createdAt: Date.now() - 86400000*6 },
{ id: uid(), name: "Standing Fan 18-inch", category: "appliances", price: 22000, desc: "Brand new, 1 year warranty, 3-speed.", stock: 12, views: 88, createdAt: Date.now() - 86400000 },
]);

function rankListings(listings, query, categoryFilter){
const q = query.trim().toLowerCase();
const now = Date.now();
return listings
.filter(l => categoryFilter === "all" || l.category === categoryFilter)
.map(l => {
let score = 0;
if (q) {
const name = l.name.toLowerCase();
const cat = CATEGORIES[l.category].label.toLowerCase();
const desc = (l.desc||"").toLowerCase();
if (name === q) score += 100;
else if (name.startsWith(q)) score += 60;
else if (name.includes(q)) score += 40;
if (cat.includes(q)) score += 20;
if (desc.includes(q)) score += 10;
if (score === 0) return null;
}
const ageDays = (now - l.createdAt) / 86400000;
score += Math.max(0, 6 - ageDays);
score += l.stock > 0 ? 3 : -50;
return Object.assign({}, l, { _score: score });
})
.filter(Boolean)
.sort((a,b) => b._score - a._score);
}

/* ---------------- Toast ---------------- */
function showToast(msg, kind="ok"){
const old = document.querySelector(".toast");
if (old) old.remove();
const t = document.createElement("div");
t.className = "toast";
t.innerHTML = (kind==="ok" ? icon("check",14) : icon("alert",14)) + &lt;span&gt;${escapeHtml(msg)}&lt;/span&gt;;
document.body.appendChild(t);
setTimeout(()=>t.remove(), 2600);
}

/* ---------------- App state / router ---------------- */
const App = {
view: "onboard", // onboard | dashboard | storefront
shopId: null,
storeQuery: "",
storeCat: "all",
dashQuery: "",
dashCat: "all",
activeListing: null,
};

const root = document.getElementById("root");

function render(){
if (App.view === "onboard") root.innerHTML = renderOnboard();
else if (App.view === "dashboard") root.innerHTML = renderDashboard();
else if (App.view === "storefront") root.innerHTML = renderStorefront();
bindEvents();
}

/* ================= ONBOARDING ================= */
function renderOnboard(){
return
&lt;div class="screen onboard"&gt;
&lt;div class="onboard-box"&gt;
&lt;div class="brand-row"&gt;
&lt;div class="brand-mark"&gt;${icon("sparkle",17)}&lt;/div&gt;
&lt;div class="brand-name"&gt;SPACESHOP&lt;/div&gt;
&lt;/div&gt;
&lt;h1&gt;Open your shop&lt;/h1&gt;
&lt;p class="sub"&gt;Sell clothes, food, houses, or appliances. Get one link that opens anywhere — WhatsApp, Instagram, Facebook, X.&lt;/p&gt;
&lt;div class="field"&gt;
&lt;label&gt;Shop name&lt;/label&gt;
&lt;input id="in-name" placeholder="e.g. Chidera's Closet" autofocus /&gt;
&lt;div class="slug-preview" id="slug-preview"&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;div class="field"&gt;
&lt;label&gt;WhatsApp number (for orders)&lt;/label&gt;
&lt;input id="in-wa" placeholder="e.g. 2348012345678" /&gt;
&lt;/div&gt;
&lt;button class="btn-primary" id="btn-create"&gt;Create my shop ${icon("chevron",16)}&lt;/button&gt;
&lt;p class="hint"&gt;We'll drop in a few sample listings to start you off.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
;
}

/* ================= DASHBOARD ================= */
function renderDashboard(){
const shop = DB.shops[App.shopId];
const listings = shop.listings;
const q = App.dashQuery.trim().toLowerCase();
const filtered = listings
.filter(l => App.dashCat === "all" || l.category === App.dashCat)
.filter(l => !q || l.name.toLowerCase().includes(q))
.sort((a,b) => b.createdAt - a.createdAt);

const totalViews = listings.reduce((s,l)=>s+(l.views||0),0);
const inStock = listings.filter(l=>l.stock>0).length;
const totalValue = listings.reduce((s,l)=>s+l.price*l.stock,0);
const counts = {};
listings.forEach(l => counts[l.category] = (counts[l.category]||0)+1);

return `
<div class="screen">
<div class="topbar">
<div class="brand-row"><div class="brand-mark">${icon("sparkle",15)}</div><span class="brand-name" style="font-size:16px;">SPACESHOP</span></div>
<button class="btn-ghost" id="btn-share">${icon("share",14)} Share shop</button>
</div>
<div class="wrap">
<div class="shop-id">
<div class="shop-avatar" style="background:${shop.color}">${escapeHtml(shop.name[0].toUpperCase())}</div>
<div>
<div class="shop-name">${escapeHtml(shop.name)}</div>
<button class="shop-link" id="btn-copy-link">/${escapeHtml(shop.slug)} ${icon("copy",11)}</button>
</div>
</div>

  &lt;div class="stats"&gt;
    &lt;div class="stat-card"&gt;&lt;div class="stat-top"&gt;&lt;span class="stat-label"&gt;Listings&lt;/span&gt;${icon("pkg",14,"color:#FF6A3D")}&lt;/div&gt;&lt;div class="stat-value mono"&gt;${listings.length}&lt;/div&gt;&lt;/div&gt;
    &lt;div class="stat-card"&gt;&lt;div class="stat-top"&gt;&lt;span class="stat-label"&gt;Total views&lt;/span&gt;${icon("eye",14,"color:#5B8CFF")}&lt;/div&gt;&lt;div class="stat-value mono"&gt;${totalViews}&lt;/div&gt;&lt;/div&gt;
    &lt;div class="stat-card"&gt;&lt;div class="stat-top"&gt;&lt;span class="stat-label"&gt;In stock&lt;/span&gt;${icon("chart",14,"color:#3DDC8A")}&lt;/div&gt;&lt;div class="stat-value mono"&gt;${inStock}&lt;/div&gt;&lt;/div&gt;
    &lt;div class="stat-card"&gt;&lt;div class="stat-top"&gt;&lt;span class="stat-label"&gt;Inventory value&lt;/span&gt;${icon("store",14,"color:#F0B429")}&lt;/div&gt;&lt;div class="stat-value mono"&gt;${formatNaira(totalValue)}&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;

  &lt;div class="toolbar"&gt;
    &lt;div class="search-wrap"&gt;${icon("search",15)}&lt;input id="dash-search" placeholder="Search your listings…" value="${escapeHtml(App.dashQuery)}" /&gt;&lt;/div&gt;
    &lt;button class="btn-add" id="btn-add-listing"&gt;${icon("plus",16)} Add listing&lt;/button&gt;
  &lt;/div&gt;

  &lt;div class="pills"&gt;
    &lt;button class="pill ${App.dashCat==='all'?'active-all':''}" data-cat="all"&gt;All · ${listings.length}&lt;/button&gt;
    ${Object.entries(CATEGORIES).map(([k,c])=&gt;`
      &lt;button class="pill" data-cat="${k}" style="${App.dashCat===k?`background:${c.color};border-color:${c.color};color:#12141C`:''}"&gt;
        ${icon(c.icon,13)} ${c.label} · ${counts[k]||0}
      &lt;/button&gt;`).join("")}
  &lt;/div&gt;

  &lt;div class="panel"&gt;
    ${filtered.length === 0 ? `
      &lt;div class="empty"&gt;${icon("pkg",28,"opacity:.25;margin-bottom:10px")}&lt;div&gt;${listings.length===0 ? "No listings yet — add your first one" : "Nothing matches that search"}&lt;/div&gt;&lt;/div&gt;
    ` : filtered.map(l =&gt; renderRow(l)).join("")}
  &lt;/div&gt;
&lt;/div&gt;
Enter fullscreen mode Exit fullscreen mode

</div>`;
}

function renderRow(l){
const c = CATEGORIES[l.category];
return
&lt;div class="row" data-id="${l.id}"&gt;
&lt;div class="beacon" style="background:${c.color}"&gt;&lt;/div&gt;
&lt;div class="row-body"&gt;
&lt;div class="row-title-line"&gt;
&lt;div class="row-title"&gt;${escapeHtml(l.name)}&lt;/div&gt;
${l.stock&lt;=0 ? '&lt;span class="badge-sold"&gt;Sold out&lt;/span&gt;' : ''}
&lt;/div&gt;
&lt;div class="row-meta"&gt;
&lt;span style="color:${c.color};font-weight:500"&gt;${c.label}&lt;/span&gt;
&lt;span&gt;${l.stock} in stock&lt;/span&gt;
&lt;span style="display:flex;align-items:center;gap:4px"&gt;${icon("eye",11)}${l.views}&lt;/span&gt;
&lt;span&gt;${timeAgo(l.createdAt)}&lt;/span&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div class="row-price mono"&gt;${formatNaira(l.price)}&lt;/div&gt;
&lt;div class="row-actions"&gt;
&lt;button class="icon-btn btn-edit" data-id="${l.id}"&gt;${icon("edit",14)}&lt;/button&gt;
&lt;button class="icon-btn danger btn-delete" data-id="${l.id}"&gt;${icon("trash",14)}&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
;
}

/* ================= LISTING FORM MODAL ================= */
function openListingModal(listing){
const isEdit = !!listing;
const f = listing || { name:"", category:"clothes", price:"", desc:"", stock:1 };
const modal = document.createElement("div");
modal.className = "overlay";
modal.innerHTML =
&lt;div class="modal"&gt;
&lt;div class="modal-head"&gt;&lt;h2&gt;${isEdit?"Edit listing":"New listing"}&lt;/h2&gt;&lt;button class="close-btn" id="modal-close"&gt;${icon("x",18)}&lt;/button&gt;&lt;/div&gt;
&lt;div class="field"&gt;&lt;label&gt;Item name&lt;/label&gt;&lt;input id="f-name" placeholder="e.g. Leather Sneakers" value="${escapeHtml(f.name)}" /&gt;&lt;/div&gt;
&lt;div class="field"&gt;&lt;label&gt;Category&lt;/label&gt;
&lt;div class="cat-grid"&gt;
${Object.entries(CATEGORIES).map(([k,c])=&gt;

<button class="cat-btn" data-cat="${k}" style="${f.category===k?background:${c.color};border-color:${c.color};color:#12141C:''}">${icon(c.icon,13)} ${c.label}</button>
).join("")}
&lt;/div&gt;
&lt;/div&gt;
&lt;div class="two-col"&gt;
&lt;div class="field"&gt;&lt;label&gt;Price (₦)&lt;/label&gt;&lt;input id="f-price" type="number" placeholder="15000" class="mono" value="${f.price}" /&gt;&lt;/div&gt;
&lt;div class="field"&gt;&lt;label&gt;Stock&lt;/label&gt;&lt;input id="f-stock" type="number" placeholder="10" class="mono" value="${f.stock}" /&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;div class="field"&gt;&lt;label&gt;Description&lt;/label&gt;&lt;textarea id="f-desc" rows="3" placeholder="What buyers should know…"&gt;${escapeHtml(f.desc)}&lt;/textarea&gt;&lt;/div&gt;
&lt;button class="btn-primary" id="modal-save"&gt;${isEdit?"Save changes":"Add listing"}&lt;/button&gt;
&lt;/div&gt;
;
document.body.appendChild(modal);

let selectedCat = f.category;
modal.querySelectorAll(".cat-btn").forEach(btn => {
btn.addEventListener("click", () => {
selectedCat = btn.dataset.cat;
modal.querySelectorAll(".cat-btn").forEach(b=>{
const c = CATEGORIES[b.dataset.cat];
if (b.dataset.cat === selectedCat) b.style.cssText = background:${c.color};border-color:${c.color};color:#12141C;
else b.style.cssText = "";
});
});
});

modal.addEventListener("click", (e) => { if (e.target === modal) modal.remove(); });
modal.querySelector("#modal-close").addEventListener("click", () => modal.remove());
modal.querySelector("#modal-save").addEventListener("click", () => {
const name = modal.querySelector("#f-name").value.trim();
const price = Number(modal.querySelector("#f-price").value);
if (!name || !price) { showToast("Add a name and price first", "err"); return; }
const shop = DB.shops[App.shopId];
const newListing = {
id: listing ? listing.id : uid(),
name, category: selectedCat,
price, stock: Number(modal.querySelector("#f-stock").value) || 0,
desc: modal.querySelector("#f-desc").value.trim(),
views: listing ? listing.views : 0,
createdAt: listing ? listing.createdAt : Date.now(),
};
if (listing) {
shop.listings = shop.listings.map(l => l.id === listing.id ? newListing : l);
showToast("Listing updated");
} else {
shop.listings = [newListing, ...shop.listings];
showToast("Listing added");
}
modal.remove();
render();
});
}

/* ================= SHARE MODAL ================= */
function openShareModal(){
const shop = DB.shops[App.shopId];
const link = window.location.origin + window.location.pathname + "#/s/" + shop.slug;
const shareMsg = encodeURIComponent(Check out ${shop.name} on SPACESHOP 🛍️ ${link});
const modal = document.createElement("div");
modal.className = "overlay";
modal.innerHTML =
&lt;div class="modal"&gt;
&lt;div class="modal-head"&gt;&lt;h2&gt;Share your shop&lt;/h2&gt;&lt;button class="close-btn" id="modal-close"&gt;${icon("x",18)}&lt;/button&gt;&lt;/div&gt;
&lt;p style="color:rgba(255,255,255,0.4);font-size:13px;margin:0 0 4px;"&gt;Anyone who opens this link sees your storefront.&lt;/p&gt;
&lt;div class="share-link-box"&gt;&lt;span&gt;${escapeHtml(link)}&lt;/span&gt;&lt;button id="btn-copy2"&gt;${icon("copy",15)}&lt;/button&gt;&lt;/div&gt;
&lt;div class="channels"&gt;
&lt;a class="channel" target="_blank" href="https://wa.me/?text=${shareMsg}"&gt;${icon("wa",18,"color:#25D366")}&lt;span&gt;WhatsApp&lt;/span&gt;&lt;/a&gt;
&lt;a class="channel" target="_blank" href="https://twitter.com/intent/tweet?text=${shareMsg}"&gt;${icon("twitter",18)}&lt;span&gt;X / Twitter&lt;/span&gt;&lt;/a&gt;
&lt;a class="channel" target="_blank" href="https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(link)}"&gt;${icon("fb",18,"color:#1877F2")}&lt;span&gt;Facebook&lt;/span&gt;&lt;/a&gt;
&lt;/div&gt;
&lt;p style="color:rgba(255,255,255,0.25);font-size:11px;text-align:center;margin-top:14px;"&gt;Instagram doesn't allow direct share links — paste the link into your bio or story instead.&lt;/p&gt;
&lt;/div&gt;
;
document.body.appendChild(modal);
modal.addEventListener("click", (e)=>{ if (e.target===modal) modal.remove(); });
modal.querySelector("#modal-close").addEventListener("click", ()=>modal.remove());
modal.querySelector("#btn-copy2").addEventListener("click", async ()=>{
try { await navigator.clipboard.writeText(link); showToast("Link copied"); }
catch { showToast("Couldn't copy — select manually", "err"); }
});
}

/* ================= STOREFRONT (public buyer view) ================= */
function renderStorefront(){
const shop = DB.shops[App.shopId];
if (!shop) {
return
&lt;div class="empty-shop"&gt;
&lt;div&gt;
&lt;div style="width:56px;height:56px;border-radius:16px;background:rgba(255,255,255,0.05);display:flex;align-items:center;justify-content:center;margin:0 auto 16px;"&gt;${icon("imgoff",22,"opacity:.3")}&lt;/div&gt;
&lt;h1 style="font-size:18px;font-weight:700;margin:0;"&gt;This shop link doesn't exist&lt;/h1&gt;
&lt;p style="color:rgba(255,255,255,0.4);font-size:14px;margin-top:6px;"&gt;It may have been removed, or the link was typed wrong.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
;
}
const ranked = rankListings(shop.listings, App.storeQuery, App.storeCat);
const counts = {};
shop.listings.forEach(l => counts[l.category] = (counts[l.category]||0)+1);

return
&lt;div class="screen"&gt;
&lt;div class="store-header"&gt;
&lt;div class="store-inner"&gt;
&lt;div class="shop-id" style="margin-bottom:0;"&gt;
&lt;div class="shop-avatar" style="background:${shop.color}"&gt;${escapeHtml(shop.name[0].toUpperCase())}&lt;/div&gt;
&lt;div&gt;
&lt;div class="shop-name"&gt;${escapeHtml(shop.name)}&lt;/div&gt;
&lt;div style="color:rgba(255,255,255,0.4);font-size:13px;"&gt;Powered by SPACESHOP&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div class="store-search"&gt;${icon("search",16)}&lt;input id="store-search" placeholder="Search this shop…" value="${escapeHtml(App.storeQuery)}" /&gt;&lt;/div&gt;
&lt;div class="pills" style="margin-top:14px;"&gt;
&lt;button class="pill ${App.storeCat==='all'?'active-all':''}" data-cat="all"&gt;All · ${shop.listings.length}&lt;/button&gt;
${Object.entries(CATEGORIES).map(([k,c])=&gt;

<button class="pill" data-cat="${k}" style="${App.storeCat===k?background:${c.color};border-color:${c.color};color:#12141C:''}">${icon(c.icon,13)} ${c.label} · ${counts[k]||0}</button>
).join("")}
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div class="store-grid"&gt;
${ranked.length === 0 ?
<div class="empty" style="grid-column:1/-1;">${App.storeQuery ? No results for "${escapeHtml(App.storeQuery)}" : "No listings yet in this category"}</div>:
ranked.map(l =&gt; renderListingCard(l)).join("")}
&lt;/div&gt;
&lt;div class="footer-tag"&gt;Built with &lt;strong style="color:rgba(255,255,255,0.4)"&gt;SPACESHOP&lt;/strong&gt; — a shop that opens with just a link&lt;/div&gt;
&lt;/div&gt;
;
}

function renderListingCard(l){
const c = CATEGORIES[l.category];
return
&lt;button class="listing-card" data-id="${l.id}"&gt;
&lt;div class="beacon" style="background:${c.color}"&gt;&lt;/div&gt;
&lt;div style="flex:1;min-width:0;"&gt;
&lt;div class="lc-title-line"&gt;
&lt;div class="lc-title"&gt;${escapeHtml(l.name)}&lt;/div&gt;
${l.stock&lt;=0 ? '&lt;span class="badge-sold"&gt;Sold out&lt;/span&gt;' : ''}
&lt;/div&gt;
&lt;div class="lc-desc"&gt;${escapeHtml(l.desc)}&lt;/div&gt;
&lt;div class="lc-bottom"&gt;
&lt;span class="lc-price mono" style="color:${c.color}"&gt;${formatNaira(l.price)}&lt;/span&gt;
&lt;span class="lc-views"&gt;${icon("eye",11)} ${l.views}&lt;/span&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/button&gt;
;
}

function openListingSheet(listing){
const shop = DB.shops[App.shopId];
const c = CATEGORIES[listing.category];
listing.views = (listing.views||0) + 1;
const waNumber = (shop.whatsapp||"").replace(/[^0-9]/g,"");
const msg = encodeURIComponent(Hi ${shop.name}! I'd like to order:\n\n${listing.name} — ${formatNaira(listing.price)}\n\nIs it available?);
const waLink = waNumber ? https://wa.me/${waNumber}?text=${msg} : null;

const modal = document.createElement("div");
modal.className = "overlay";
modal.innerHTML =
&lt;div class="modal"&gt;
&lt;div class="modal-head"&gt;
&lt;span style="font-size:11px;font-weight:700;text-transform:uppercase;padding:5px 10px;border-radius:999px;background:${c.color}22;color:${c.color}"&gt;${c.label}&lt;/span&gt;
&lt;button class="close-btn" id="modal-close"&gt;${icon("x",18)}&lt;/button&gt;
&lt;/div&gt;
&lt;h2 style="font-size:20px;font-weight:700;margin:10px 0 0;line-height:1.25;"&gt;${escapeHtml(listing.name)}&lt;/h2&gt;
&lt;p style="color:rgba(255,255,255,0.5);font-size:14px;margin-top:8px;line-height:1.5;"&gt;${escapeHtml(listing.desc)}&lt;/p&gt;
&lt;div style="display:flex;justify-content:space-between;align-items:center;margin-top:16px;"&gt;
&lt;span class="mono" style="font-size:24px;font-weight:700;color:${c.color}"&gt;${formatNaira(listing.price)}&lt;/span&gt;
&lt;span style="font-size:12px;font-weight:500;color:${listing.stock&gt;0?'#3DDC8A':'rgba(255,255,255,0.3)'}"&gt;${listing.stock&gt;0 ? listing.stock+" available" : "Out of stock"}&lt;/span&gt;
&lt;/div&gt;
${waLink ?
<a class="wa-btn" target="_blank" href="${waLink}">${icon("wa",18)} Order on WhatsApp</a>
:
<div style="margin-top:18px;text-align:center;color:rgba(255,255,255,0.4);font-size:14px;padding:14px;border:1px solid var(--line);border-radius:16px;">Seller hasn't added a WhatsApp number yet</div>}
&lt;/div&gt;
;
document.body.appendChild(modal);
modal.addEventListener("click", (e)=>{ if (e.target===modal) { modal.remove(); render(); } });
modal.querySelector("#modal-close").addEventListener("click", ()=>{ modal.remove(); render(); });
}

/* ================= EVENT BINDING ================= */
function bindEvents(){
if (App.view === "onboard") {
const nameInput = document.getElementById("in-name");
nameInput.addEventListener("input", () => {
document.getElementById("slug-preview").textContent = nameInput.value ? "yoursite.com/#/s/" + slugify(nameInput.value) : "";
});
document.getElementById("btn-create").addEventListener("click", async () => {
const name = nameInput.value.trim();
const wa = document.getElementById("in-wa").value.trim();
if (!name) { showToast("Give your shop a name first", "err"); return; }
const slug = await reserveUniqueSlug(name);
const id = uid();
const shop = { id, name, whatsapp: wa, slug, color: "#FF6A3D", listings: SEED(), createdAt: Date.now() };
DB.shops[id] = shop;
DB.slugs[slug] = id;
App.shopId = id;
App.view = "dashboard";
window.location.hash = "#/dash/" + id;
render();
});
return;
}

if (App.view === "dashboard") {
document.getElementById("dash-search").addEventListener("input", (e) => { App.dashQuery = e.target.value; render(); });
document.querySelectorAll(".pill").forEach(p => p.addEventListener("click", () => { App.dashCat = p.dataset.cat; render(); }));
document.getElementById("btn-add-listing").addEventListener("click", () => openListingModal(null));
document.getElementById("btn-share").addEventListener("click", openShareModal);
document.getElementById("btn-copy-link").addEventListener("click", async () => {
const shop = DB.shops[App.shopId];
const link = window.location.origin + window.location.pathname + "#/s/" + shop.slug;
try { await navigator.clipboard.writeText(link); showToast("Link copied"); } catch { showToast("Couldn't copy", "err"); }
});
document.querySelectorAll(".btn-edit").forEach(b => b.addEventListener("click", () => {
const shop = DB.shops[App.shopId];
const listing = shop.listings.find(l => l.id === b.dataset.id);
openListingModal(listing);
}));
document.querySelectorAll(".btn-delete").forEach(b => b.addEventListener("click", () => {
const shop = DB.shops[App.shopId];
shop.listings = shop.listings.filter(l => l.id !== b.dataset.id);
showToast("Listing removed");
render();
}));
// preserve focus/cursor on search re-render
const s = document.getElementById("dash-search");
if (s && document.activeElement !== s && App.dashQuery) { /* no-op, cursor UX best-effort in vanilla JS */ }
return;
}

if (App.view === "storefront") {
const search = document.getElementById("store-search");
if (search) search.addEventListener("input", (e) => { App.storeQuery = e.target.value; render(); document.getElementById("store-search").focus(); });
document.querySelectorAll(".pill").forEach(p => p.addEventListener("click", () => { App.storeCat = p.dataset.cat; render(); }));
document.querySelectorAll(".listing-card").forEach(card => card.addEventListener("click", () => {
const shop = DB.shops[App.shopId];
const listing = shop.listings.find(l => l.id === card.dataset.id);
openListingSheet(listing);
}));
}
}

/* ================= ROUTER ================= */
function handleRoute(){
const hash = window.location.hash;
const storeMatch = hash.match(/^#\/s\/(.+)$/);
const dashMatch = hash.match(/^#\/dash\/(.+)$/);

if (storeMatch) {
const slug = storeMatch[1];
App.shopId = DB.slugs[slug] || null;
App.view = "storefront";
} else if (dashMatch && DB.shops[dashMatch[1]]) {
App.shopId = dashMatch[1];
App.view = "dashboard";
} else if (!App.shopId) {
App.view = "onboard";
}
render();
}

window.addEventListener("hashchange", handleRoute);
handleRoute();



is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry.*

Top comments (0)