2025-10-02 17:16:06 -07:00
<!doctype html>
2025-10-01 01:43:48 -07:00
< html lang = "en" >
< head >
2025-10-02 17:16:06 -07:00
< meta charset = "utf-8" >
< title > Obli Studios Shop< / title >
2025-10-01 01:43:48 -07:00
< meta name = "viewport" content = "width=device-width, initial-scale=1" / >
< style >
2025-10-03 19:14:35 -07:00
[data-add="server1"]:disabled, [data-add="server2"]:disabled, [data-add="server3"]:disabled {
opacity: .6;
cursor: not-allowed
}
article.card:has([data-add="server1"]:disabled), article.card:has([data-add="server2"]:disabled), article.card:has([data-add="server3"]:disabled) {
filter: grayscale(0.2);
}
2025-10-01 01:43:48 -07:00
:root {
2025-10-02 17:16:06 -07:00
--bg: #0b1224;
--card: #121b34;
--stroke: #243053;
--accent: #4aa3ff;
--text: #e9f1ff;
--muted: #9bb3d9;
--chip: #1a2a50;
--btn: #1e3566;
--btn2: #244272;
2025-10-01 01:43:48 -07:00
}
* {
box-sizing: border-box
}
body {
margin: 0;
2025-10-02 17:16:06 -07:00
font: 14px/1.5 system-ui,Segoe UI,Inter,Arial;
background: var(--bg);
color: var(--text)
2025-10-01 01:43:48 -07:00
}
2025-10-02 17:16:06 -07:00
header, footer {
border-bottom: 1px solid var(--stroke);
background: #0c1530
2025-10-01 01:43:48 -07:00
}
2025-10-02 17:16:06 -07:00
header .wrap, main .wrap, footer .wrap {
max-width: 1100px;
margin: 0 auto;
padding: 14px 16px
2025-10-01 01:43:48 -07:00
}
2025-10-02 17:16:06 -07:00
header .row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap
2025-10-01 01:43:48 -07:00
}
2025-10-02 17:16:06 -07:00
h1 {
font-size: 20px;
margin: 0 8px 0 0
2025-10-01 01:43:48 -07:00
}
2025-10-02 17:16:06 -07:00
.spacer {
flex: 1
2025-10-01 01:43:48 -07:00
}
2025-10-02 17:16:06 -07:00
input, select {
background: #0c1733;
color: var(--text);
border: 1px solid var(--stroke);
border-radius: 8px;
padding: 8px;
outline: none
2025-10-01 01:43:48 -07:00
}
2025-10-02 17:16:06 -07:00
.cart-btn, .btn {
background: var(--btn);
2025-10-01 01:43:48 -07:00
color: var(--text);
2025-10-02 17:16:06 -07:00
border: 1px solid var(--stroke);
padding: 8px 12px;
2025-10-01 01:43:48 -07:00
border-radius: 10px;
2025-10-02 17:16:06 -07:00
text-decoration: none;
cursor: pointer
2025-10-01 01:43:48 -07:00
}
2025-10-02 17:16:06 -07:00
.btn-secondary {
background: var(--btn2)
}
2025-10-01 01:43:48 -07:00
.grid {
display: grid;
2025-10-02 17:16:06 -07:00
gap: 16px;
grid-template-columns: repeat(auto-fill,minmax(240px,1fr));
padding: 16px
2025-10-01 01:43:48 -07:00
}
.card {
2025-10-02 17:16:06 -07:00
background: var(--card);
border: 1px solid var(--stroke);
border-radius: 16px;
2025-10-01 01:43:48 -07:00
overflow: hidden;
display: flex;
flex-direction: column
}
2025-10-02 17:16:06 -07:00
.card .footer {
background: #0d1b3a; /* dark blue footer */
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
margin-top: auto;
color: #fff;
}
.card .footer h3 {
margin: 0;
font-size: 16px;
color: #fff;
}
.card .footer .meta {
color: #cbd6f2;
font-size: 13px;
}
.card .footer .price {
font-weight: 700;
color: #fff;
}
.card .footer .chip {
background: #1e3566;
color: #fff;
padding: 3px 8px;
border-radius: 999px;
font-size: 12px;
display: inline-block;
}
.card .footer .actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.card .footer .btn {
background: #1e3566;
border: 1px solid #243053;
color: #fff;
}
2025-10-01 01:43:48 -07:00
.card .img {
2025-10-02 17:16:06 -07:00
background: #0c1630;
2025-10-01 01:43:48 -07:00
display: flex;
align-items: center;
2025-10-02 17:16:06 -07:00
justify-content: center;
padding: 8px;
2025-10-01 01:43:48 -07:00
}
2025-10-02 17:16:06 -07:00
.card .img img {
width: 100%;
height: auto;
object-fit: contain;
display: block;
2025-10-01 01:43:48 -07:00
}
2025-10-02 17:16:06 -07:00
.card .body {
padding: 12px
2025-10-01 01:43:48 -07:00
}
.price {
font-weight: 700
}
.chip {
2025-10-02 17:16:06 -07:00
background: var(--chip);
color: #bcd0ff;
2025-10-01 01:43:48 -07:00
padding: 3px 8px;
border-radius: 999px;
2025-10-02 17:16:06 -07:00
display: inline-block;
margin: 6px 0
2025-10-01 01:43:48 -07:00
}
2025-10-02 17:16:06 -07:00
.meta {
color: var(--muted);
font-size: 12px
}
2025-10-01 01:43:48 -07:00
2025-10-02 17:16:06 -07:00
.toolbar {
display: flex;
gap: 8px;
flex-wrap: wrap;
padding: 0 16px 8px
}
/* Drawer */
#drawer {
2025-10-01 01:43:48 -07:00
position: fixed;
2025-10-02 17:16:06 -07:00
inset: 0 0 0 auto;
2025-10-01 01:43:48 -07:00
width: 380px;
2025-10-02 17:16:06 -07:00
background: var(--card);
border-left: 1px solid var(--stroke);
transform: translateX(100%);
transition: .25s;
z-index: 50;
2025-10-01 01:43:48 -07:00
display: flex;
flex-direction: column
}
2025-10-02 17:16:06 -07:00
#drawer.open {
transform: none
2025-10-01 01:43:48 -07:00
}
2025-10-02 17:16:06 -07:00
#drawer header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--stroke)
2025-10-01 01:43:48 -07:00
}
2025-10-02 17:16:06 -07:00
#drawer .body {
padding: 12px;
overflow: auto;
flex: 1
2025-10-01 01:43:48 -07:00
}
2025-10-02 17:16:06 -07:00
#drawer .row {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 10px;
align-items: center;
padding: 8px 0;
border-bottom: 1px dashed var(--stroke)
}
#drawer .qty {
2025-10-01 01:43:48 -07:00
display: flex;
2025-10-02 17:16:06 -07:00
gap: 6px;
align-items: center
2025-10-01 01:43:48 -07:00
}
2025-10-02 17:16:06 -07:00
#drawer .qty button {
width: 28px;
height: 28px;
border-radius: 8px;
border: 1px solid var(--stroke);
background: var(--btn)
}
2025-10-01 01:43:48 -07:00
2025-10-02 17:16:06 -07:00
#exitCart {
background: #8b0c0c;
border-color: #5e0909;
color: #fff;
padding: 4px 8px;
border-radius: 8px;
font-size: 12px
}
2025-10-01 01:43:48 -07:00
2025-10-02 17:16:06 -07:00
.total {
padding: 12px;
border-top: 1px solid var(--stroke)
}
/* Modals */
.modal {
position: fixed;
inset: 0;
background: rgba(0,0,0,.5);
display: none;
2025-10-01 01:43:48 -07:00
align-items: center;
2025-10-02 17:16:06 -07:00
justify-content: center;
z-index: 60
2025-10-01 01:43:48 -07:00
}
2025-10-02 17:16:06 -07:00
.modal.open {
display: flex
2025-10-01 01:43:48 -07:00
}
2025-10-02 17:16:06 -07:00
.modal__dialog {
background: var(--card);
border: 1px solid var(--stroke);
border-radius: 16px;
max-width: 720px;
width: 92%;
overflow: hidden
2025-10-01 01:43:48 -07:00
}
2025-10-02 17:16:06 -07:00
.modal__head {
2025-10-01 01:43:48 -07:00
display: flex;
2025-10-02 17:16:06 -07:00
align-items: center;
2025-10-01 01:43:48 -07:00
justify-content: space-between;
2025-10-02 17:16:06 -07:00
padding: 12px 16px;
border-bottom: 1px solid var(--stroke)
2025-10-01 01:43:48 -07:00
}
2025-10-02 17:16:06 -07:00
.modal__body {
padding: 16px
}
.modal__close {
background: #24365e;
color: #fff;
border: 1px solid var(--stroke);
2025-10-01 01:43:48 -07:00
border-radius: 10px;
2025-10-02 17:16:06 -07:00
padding: 6px 10px;
2025-10-01 01:43:48 -07:00
cursor: pointer
}
footer {
2025-10-02 17:16:06 -07:00
border-top: 1px solid var(--stroke)
}
.muted {
2025-10-01 01:43:48 -07:00
color: var(--muted)
}
2025-10-02 17:16:06 -07:00
nav.links {
display: flex;
gap: 16px;
}
2025-10-01 01:43:48 -07:00
2025-10-02 17:16:06 -07:00
nav.links a {
color: #e9f1ff;
text-decoration: none;
font-weight: 500;
2025-10-01 01:43:48 -07:00
}
2025-10-02 17:16:06 -07:00
nav.links a:hover {
color: #4aa3ff;
}
2025-10-01 01:43:48 -07:00
< / style >
< / head >
< body >
2025-10-04 18:18:34 -07:00
2025-10-01 01:43:48 -07:00
< header >
2025-10-02 17:16:06 -07:00
< div class = "wrap" >
< div class = "row" >
< h1 > BESTWCOAST Shop< / h1 >
< a class = "cart-btn" href = "/auth/discord" id = "loginDiscord" > Login with Discord< / a >
< span id = "whoami" class = "muted" > < / span >
< div class = "spacer" > < / div >
< nav class = "links" aria-label = "Primary" >
< a href = "https://www.oblistudios.com" > Home< / a >
< a href = "https://www.oblistudios.com/ASAservers.html" > Servers< / a >
< / nav >
< a href = "#" class = "cart-btn" id = "openCart" > 🛒 Cart < span id = "cartCount" > 0< / span > < / a >
2025-10-01 01:43:48 -07:00
< / div >
2025-10-02 17:16:06 -07:00
< div class = "toolbar" >
< input id = "search" placeholder = "Search products…" / >
< select id = "category" title = "Category" >
< option value = "all" > All categories< / option >
< option value = "servers" > Servers< / option >
< option value = "perks" > Server Perks< / option >
< option value = "maps" > Custom Maps< / option >
< option value = "bundles" > Bundles< / option >
< option value = "vip" > VIP / Subscriptions< / option >
< / select >
< select id = "sort" title = "Sort" >
< option value = "featured" > Featured< / option >
< option value = "price-asc" > Price ↑< / option >
< option value = "price-desc" > Price ↓< / option >
< option value = "name" > Name A→Z< / option >
< / select >
< / div >
< / div >
2025-10-01 01:43:48 -07:00
< / header >
< main >
2025-10-02 17:16:06 -07:00
< div class = "wrap" >
< section id = "grid" class = "grid" > < / section >
2025-10-01 01:43:48 -07:00
< / div >
< / main >
2025-10-02 17:16:06 -07:00
<!-- Drawer (Cart) -->
< aside id = "drawer" aria-hidden = "true" >
< header >
< strong > Your Cart< / strong >
< button id = "exitCart" title = "Close cart" > Exit< / button >
2025-10-01 01:43:48 -07:00
< / header >
2025-10-02 17:16:06 -07:00
< div class = "body" id = "cartItems" > < / div >
< div class = "total" >
< div style = "display:flex; align-items:center; justify-content:space-between" >
2025-10-01 01:43:48 -07:00
< div > Total< / div >
2025-10-02 17:16:06 -07:00
< div id = "cartTotal" > $0.00< / div >
2025-10-01 23:42:17 -07:00
< / div >
2025-10-02 17:16:06 -07:00
< button class = "btn" id = "checkoutBtn" style = "width:100%; margin-top:10px" > Checkout< / button >
2025-10-01 01:43:48 -07:00
< / div >
< / aside >
< footer >
2025-10-02 17:16:06 -07:00
< div class = "wrap" >
< div style = "display:flex; align-items:center; gap:8px; flex-wrap:wrap" >
< div class = "muted" > © < span id = "year" > < / span > Obli Studios< / div >
< div class = "muted" > — Build anywhere, don’ t block obelisks. Be kind.< / div >
< / div >
< / div >
2025-10-01 01:43:48 -07:00
< / footer >
2025-10-02 17:16:06 -07:00
<!-- Info Modal: Server Class 1 -->
< div id = "infoModal" class = "modal" aria-hidden = "true" >
< div class = "modal__dialog" >
< header class = "modal__head" >
< h3 style = "margin:0" > 🔒 Private Server Tier (Class 1)< / h3 >
< button class = "modal__close" data-close = "infoModal" > × < / button >
< / header >
< div class = "modal__body" >
< p > < strong > Exclusive to you and your tribe.< / strong > < / p >
< p > 🏝 < strong > Server Setup:< / strong > Mirrors main cluster (PVP, breeding, rates). < strong > 20 slots< / strong > , password-secured.< / p >
< p > ⚙️ < strong > Tools & Perks:< / strong > Creature Management Tool + < strong > 10× Mutation Potions< / strong > per tier.< / p >
< p > 🎁 < strong > Starting Bonus:< / strong > < strong > 100,000 points< / strong > at the start of each wipe.< / p >
< p > ⏳ < strong > Setup:< / strong > Usually < strong > ≤ 2 business days< / strong > . Never wipes while subscribed.< / p >
< p > 🏗 < strong > Building:< / strong > All standard rules; don’ t block obelisks; otherwise build anywhere.< / p >
< p > 📜 < strong > Ownership:< / strong > Tribe-only access. No outsider storage/griefing. Maintain a base on at least one main server.< / p >
< p > 🌐 < strong > Clustering:< / strong > Linked within < strong > 1 hour< / strong > of any wipe.< / p >
< p > 💬 < strong > Support:< / strong > After purchase, open a Discord ticket for setup.< / p >
< / div >
< / div >
< / div >
2025-10-01 23:42:17 -07:00
2025-10-02 17:16:06 -07:00
<!-- Info Modal: Server Class 2 -->
< div id = "infoModal2" class = "modal" aria-hidden = "true" >
< div class = "modal__dialog" >
< header class = "modal__head" >
< h3 style = "margin:0" > 🔒 Private Server Tier (Class 2)< / h3 >
< button class = "modal__close" data-close = "infoModal2" > × < / button >
< / header >
< div class = "modal__body" >
< p > < strong > Exclusive to you and your tribe.< / strong > < / p >
< p > 🏝 < strong > Server Setup:< / strong > Mirrors main cluster (PVP, breeding, rates). < strong > 30 slots< / strong > , password-secured.< / p >
< p > ⚙️ < strong > Tools & Perks:< / strong > Creature Management Tool + < strong > 10× Mutation Potions< / strong > per tier.< / p >
< p > 🎁 < strong > Starting Bonus:< / strong > < strong > 200,000 points< / strong > at the start of each wipe.< / p >
< p > ⏳ < strong > Setup:< / strong > Usually < strong > ≤ 2 business days< / strong > . Never wipes while subscribed.< / p >
< p > 🏗 < strong > Building:< / strong > All standard rules; don’ t block obelisks; otherwise build anywhere.< / p >
< p > 📜 < strong > Ownership:< / strong > Tribe-only access. No outsider storage/griefing. Maintain a base on at least one main server.< / p >
< p > 🌐 < strong > Clustering:< / strong > Linked within < strong > 1 hour< / strong > of any wipe.< / p >
< p > 💬 < strong > Support:< / strong > After purchase, open a Discord ticket for setup.< / p >
< / div >
< / div >
< / div >
2025-10-01 23:42:17 -07:00
2025-10-02 17:16:06 -07:00
<!-- Info Modal: Server Class 3 -->
< div id = "infoModal3" class = "modal" aria-hidden = "true" >
< div class = "modal__dialog" >
< header class = "modal__head" >
< h3 style = "margin:0" > 🔒 Private Server Tier (Class 3)< / h3 >
< button class = "modal__close" data-close = "infoModal3" > × < / button >
< / header >
< div class = "modal__body" >
< p > < strong > Exclusive to you and your tribe.< / strong > < / p >
< p > 🏝 < strong > Server Setup:< / strong > Mirrors main cluster (PVP, breeding, rates). < strong > 50 slots< / strong > , password-secured.< / p >
< p > ⚙️ < strong > Tools & Perks:< / strong > Creature Management Tool + < strong > 10× Mutation Potions< / strong > per tier.< / p >
< p > 🎁 < strong > Starting Bonus:< / strong > < strong > 300,000 points< / strong > at the start of each wipe.< / p >
< p > ⏳ < strong > Setup:< / strong > Usually < strong > ≤ 2 business days< / strong > . Never wipes while subscribed.< / p >
< p > 🏗 < strong > Building:< / strong > All standard rules; don’ t block obelisks; otherwise build anywhere.< / p >
< p > 📜 < strong > Ownership:< / strong > Tribe-only access. No outsider storage/griefing. Maintain a base on at least one main server.< / p >
< p > 🌐 < strong > Clustering:< / strong > Linked within < strong > 1 hour< / strong > of any wipe.< / p >
< p > 💬 < strong > Support:< / strong > After purchase, open a Discord ticket for setup.< / p >
< / div >
< / div >
< / div >
< script >
2025-10-04 18:18:34 -07:00
const API = "https://affiliated-lets-automatic-oak.trycloudflare.com";
2025-10-02 17:16:06 -07:00
2025-10-01 23:42:17 -07:00
2025-10-04 18:18:34 -07:00
/* ===== Login banner (whoami) ===== */
async function getWhoAmI() {
try {
const r = await fetch(`${API}/api/inventory`, { credentials: 'include' })
if (!r.ok) return null;
return await r.json();
} catch { return null; }
}
getWhoAmI().then(u => {
if (u) document.getElementById('whoami').textContent =
`Signed in as ${u.global_name || u.username}`;
});
/* ===== Products (unique IDs) ===== */
const products = [
{ id: 'server1', name: 'Server Class 1', price: 25.00, category: 'servers', tag: 'Server monthly', img: 'img/PrivateServerCLASS01.png'},
{ id: 'server2', name: 'Server Class 2', price: 50.00, category: 'servers', tag: 'Server monthly', img: 'img/PrivateServerCLASS02.png'},
{ id: 'server3', name: 'Server Class 3', price: 75.00, category: 'servers', tag: 'Server monthly', img: 'img/PrivateServerCLASS03.png' },
{ id: 'slot30', name: 'ASA Server Slot x30 days', price: 5.00, category: 'perks', tag: 'Server Perk monthly', img: 'img/ServerSlotX30.png' },
{ id: 'mutants', name: 'Mutated Creatures', price: 1.50, category: 'bundles', tag: 'Dino Pack One Time Payment 10 non-breedable', img: 'img/MutatedCretures.png'},
{ id: 'starter', name: 'Starter Pack', price: 1.50, category: 'bundles', tag: 'Starter Pack One Time Payment', img: 'img/StarterPack.png'},
{ id: 'supporter1', name: 'ASA Server Supporter Pack 1', price: 5.00, category: 'vip', tag: 'Server Perk monthly < br > ✨ Perks Included ✨ 💎 50,000 Points every week🎁 50,000 Bonus Points at the start of each wipe 🏅 Exclusive VIP Role on Discord 🔒 Access to the Donator - Only Chat', img: 'img/SP1.png' },
{ id: 'supporter2', name: 'ASA Server Supporter Pack 2', price: 10.00, category: 'vip', tag: 'Server Perk monthly < br > ✨ Perks Included ✨ 💎 100,000 Points every week🎁 100,000 Bonus Points at the start of each wipe 🏅 Exclusive VIP Role on Discord 🔒 Access to the Donator - Only Chat', img: 'img/SP2.png' },
{ id: 'supporter3', name: 'ASA Server Supporter Pack 3', price: 15.00, category: 'vip', tag: 'Server Perk monthly < br > ✨ Perks Included ✨ 💎 150,000 Points every week🎁 150,000 Bonus Points at the start of each wipe 🏅 Exclusive VIP Role on Discord 🔒 Access to the Donator - Only Chat', img: 'img/SP3.png'},
{ id: 'supporter4', name: 'ASA Server Supporter Pack 4', price: 20.00, category: 'vip', tag: 'Server Perk monthly < br > ✨ Perks Included ✨ 💎 500,000 Points every week🎁 500,000 Bonus Points at the start of each wipe 🏅 Exclusive MVP Role on Discord 🔒 Access to the Donator - Only Chat', img: 'img/SP4.png'},
{ id: 'supporter5', name: 'ASA Server Supporter Pack 5', price: 25.00, category: 'vip', tag: 'Server Perk monthly < br > ✨ Perks Included ✨ 💎 1,000,000 Points every week🎁 1,000,000 Bonus Points at the start of each wipe 🏅 Exclusive MVP Role on Discord 🔒 Access to the Donator - Only Chat', img: 'img/SP5.png'},
{ id: 'supporter6', name: 'ASA Server Supporter Pack 6', price: 35.00, category: 'vip', tag: 'Server Perk monthly < br > ✨ Perks Included ✨ 💎 1,500,000 Points every week🎁 1,500,000 Bonus Points at the start of each wipe 🏅 Exclusive MVP Role on Discord 🔒 Access to the Donator - Only Chat', img: 'img/SP6.png'},
{ id: 'map-small', name: 'Small Map manipulation', price: 5.00, category: 'maps', tag: 'Small Map manipulation Pack One Time Payment', img: 'img/SmallMap.png'},
{ id: 'map-medium', name: 'Medium Map manipulation', price: 10.00, category: 'maps', tag: 'Medium Map manipulation Pack One Time Payment', img: 'img/MediumMap.png' },
{ id: 'map-large', name: 'Large Map manipulation', price: 15.00, category: 'maps', tag: 'Large Map manipulation Pack One Time Payment', img: 'img/LargeMap.png' },
];
/* ===== State / helpers ===== */
const state = { q: '', cat: 'all', sort: 'featured', cart: loadCart() };
const $ = s => document.querySelector(s);
const fmt = n => `$${n.toFixed(2)}`;
function saveCart() { localStorage.setItem('obli.cart', JSON.stringify(state.cart)); }
function loadCart() { try { return JSON.parse(localStorage.getItem('obli.cart') || '{}'); } catch { return {}; } }
function cartCount() { return Object.values(state.cart).reduce((a, b) => a + b, 0); }
function cartTotal() { return Object.entries(state.cart).reduce((s, [id, q]) => { const p = products.find(p => p.id === id); return s + (p ? p.price * q : 0) }, 0); }
const isServer = id => id === 'server1' || id === 'server2' || id === 'server3';
function serversInCart() {
return Object.entries(state.cart)
.filter(([id]) => isServer(id))
.reduce((a, [, q]) => a + q, 0);
}
function canAddServers(qtyToAdd) {
return serversInCart() + qtyToAdd < = remaining;
}
2025-10-02 17:16:06 -07:00
2025-10-04 18:18:34 -07:00
/* ===== Render ===== */
function cardHtml(p) {
const inCart = state.cart[p.id] || 0;
let infoBtn = '';
if (p.name === 'Server Class 1') infoBtn = `< button class = "btn btn-secondary" data-info = "server-class-1" > Info< / button > < span class = "chip" data-remaining > Remaining: < span class = "remN" > ?< / span > /12< / span > `;
if (p.name === 'Server Class 2') infoBtn = `< button class = "btn btn-secondary" data-info = "server-class-2" > Info< / button > < span class = "chip" data-remaining > Remaining: < span class = "remN" > ?< / span > /12< / span > `;
if (p.name === 'Server Class 3') infoBtn = `< button class = "btn btn-secondary" data-info = "server-class-3" > Info< / button > < span class = "chip" data-remaining > Remaining: < span class = "remN" > ?< / span > /12< / span > `;
return `< article class = "card" >
< div class = "img" > ${p.img ? `< img src = "${p.img}" alt = "${p.name}" > ` : `< canvas data-id = "${p.id}" width = "320" height = "150" > < / canvas > `}< / div >
< div class = "footer" >
< h3 > ${p.name}< / h3 >
< div class = "meta" > ${p.tag || ''}< / div >
< div class = "price" > ${fmt(p.price)}< / div >
< div class = "chip" > ${p.category}< / div >
< div class = "actions" >
< button class = "btn" data-add = "${p.id}" > ${inCart ? 'Add another' : 'Add to cart'}< / button >
${infoBtn}
< / div >
< / div >
< / article > `;
}
2025-10-02 17:16:06 -07:00
2025-10-01 23:42:17 -07:00
2025-10-04 18:18:34 -07:00
function render() {
const grid = $('#grid');
let list = products.filter(p => state.cat === 'all' || p.category === state.cat);
if (state.q) {
const q = state.q.toLowerCase();
list = list.filter(p =>
p.name.toLowerCase().includes(q) ||
(p.tag || '').toLowerCase().includes(q) ||
(p.category || '').toLowerCase().includes(q)
);
}
switch (state.sort) {
case 'price-asc': list.sort((a, b) => a.price - b.price); break;
case 'price-desc': list.sort((a, b) => b.price - a.price); break;
case 'name': list.sort((a, b) => a.name.localeCompare(b.name)); break;
}
grid.innerHTML = list.map(cardHtml).join('');
updateCartUi();
2025-10-03 19:14:35 -07:00
}
2025-10-04 18:18:34 -07:00
/* ===== Cart UI ===== */
function rowHtml(id, qty) {
const p = products.find(x => x.id === id);
return `< div class = "row" >
< div class = "title" > < div style = "font-weight:600" > ${p.name}< / div > < div class = "meta" > ${fmt(p.price)} each< / div > < / div >
< div class = "qty" >
< button data-dec = "${id}" > − < / button >
< div > ${qty}< / div >
< button data-inc = "${id}" > +< / button >
< / div >
< div style = "width:72px; text-align:right" > ${fmt(p.price * qty)}< / div >
< / div > `;
}
function updateCartUi() {
$('#cartCount').textContent = cartCount();
$('#cartTotal').textContent = fmt(cartTotal());
const rows = Object.entries(state.cart);
$('#cartItems').innerHTML = rows.length ? rows.map(([id, qty]) => rowHtml(id, qty)).join('') : '< div class = "muted" > Cart is empty.< / div > ';
2025-10-03 19:14:35 -07:00
}
2025-10-04 18:18:34 -07:00
/* ===== Events ===== */
$('#search').addEventListener('input', e => { state.q = e.target.value; render(); });
$('#category').addEventListener('change', e => { state.cat = e.target.value; render(); });
$('#sort').addEventListener('change', e => { state.sort = e.target.value; render(); });
document.body.addEventListener('click', e => {
const add = e.target.closest('[data-add]');
const inc = e.target.closest('[data-inc]');
const dec = e.target.closest('[data-dec]');
if (add) {
const id = add.getAttribute('data-add');
const qtyToAdd = 1;
if (isServer(id) & & !canAddServers(qtyToAdd)) {
alert(`Sold out or limit reached. Remaining: ${Math.max(0, remaining - serversInCart())}.`);
return;
}
state.cart[id] = (state.cart[id] || 0) + 1;
saveCart(); render();
}
if (inc) {
const id = inc.getAttribute('data-inc');
const qtyToAdd = 1;
if (isServer(id) & & !canAddServers(qtyToAdd)) {
alert(`Sold out or limit reached. Remaining: ${Math.max(0, remaining - serversInCart())}.`);
return;
}
state.cart[id] = (state.cart[id] || 0) + 1;
saveCart(); updateCartUi();
}
if (dec) {
const id = dec.getAttribute('data-dec');
state.cart[id] = Math.max(0, (state.cart[id] || 0) - 1);
if (state.cart[id] === 0) delete state.cart[id];
saveCart(); render();
}
});
/* ===== Modals (single global handlers) ===== */
const modals = {
'server-class-1': document.getElementById('infoModal'),
'server-class-2': document.getElementById('infoModal2'),
'server-class-3': document.getElementById('infoModal3'),
};
document.body.addEventListener('click', (e) => {
const infoBtn = e.target.closest('[data-info]');
if (infoBtn) {
const k = infoBtn.getAttribute('data-info');
const m = modals[k];
if (m) { m.classList.add('open'); m.setAttribute('aria-hidden', 'false'); }
}
const closeBtn = e.target.closest('.modal__close');
if (closeBtn) {
const id = closeBtn.getAttribute('data-close');
const m = id ? document.getElementById(id) : closeBtn.closest('.modal');
if (m) { m.classList.remove('open'); m.setAttribute('aria-hidden', 'true'); }
}
});
Object.values(modals).forEach(m => {
if (!m) return;
m.addEventListener('click', (e) => { if (e.target === m) { m.classList.remove('open'); m.setAttribute('aria-hidden', 'true'); } });
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { Object.values(modals).forEach(m => m?.classList.remove('open')); }
});
/* ===== Drawer ===== */
const drawer = document.getElementById('drawer');
document.getElementById('openCart').addEventListener('click', e => {
e.preventDefault(); drawer.classList.add('open'); drawer.setAttribute('aria-hidden', 'false'); updateCartUi();
});
document.getElementById('exitCart').addEventListener('click', () => {
drawer.classList.remove('open'); drawer.setAttribute('aria-hidden', 'true');
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') { drawer.classList.remove('open'); drawer.setAttribute('aria-hidden', 'true'); }
});
/* ===== Checkout ===== */
const API = "https://affiliated-lets-automatic-oak.trycloudflare.com";
async function checkout(cart) {
// 1) reserve
const r1 = await fetch(`${API}/api/reserve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: cart })
});
const j1 = await r1.json();
if (!j1.ok) { alert(j1.error || 'reserve failed'); return; }
// 2) create checkout
const r2 = await fetch(`${API}/api/create-checkout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
resKey: j1.resKey,
items: [],
success_url: location.origin + location.pathname + '?ok=1',
cancel_url: location.origin + location.pathname + '?cancel=1'
})
2025-10-02 17:16:06 -07:00
});
2025-10-04 18:18:34 -07:00
const j2 = await r2.json();
if (!j2.url) { alert(j2.error || 'create-checkout failed'); return; }
location.href = j2.url; // redirect to Stripe
}
2025-10-02 17:16:06 -07:00
2025-10-03 19:14:35 -07:00
2025-10-04 18:18:34 -07:00
/* ===== Boot ===== */
document.getElementById('year').textContent = new Date().getFullYear();
render();
paintPlaceholders();
/* ===== Placeholder art for items without images ===== */
function paintPlaceholders() {
document.querySelectorAll('canvas[data-id]').forEach(cv => {
const id = cv.getAttribute('data-id');
const ctx = cv.getContext('2d');
const g = ctx.createLinearGradient(0, 0, cv.width, cv.height);
g.addColorStop(0, '#0f1b3a'); g.addColorStop(1, '#1b2f5d');
ctx.fillStyle = g; ctx.fillRect(0, 0, cv.width, cv.height);
ctx.strokeStyle = 'rgba(255,255,255,.12)';
for (let i = 0; i < 7 ; i + + ) { ctx . beginPath ( ) ; ctx . moveTo ( 0 , 22 * i ) ; ctx . lineTo ( cv . width , 22 * i ) ; ctx . stroke ( ) ; }
ctx.fillStyle = '#aad0ff'; ctx.font = 'bold 16px Inter,system-ui';
ctx.fillText(products.find(p => p.id === id)?.name || id, 12, cv.height - 12);
});
}
// client.js (in your existing
< script >
)
let remaining = 12;
async function refreshRemaining() {
try {
const r = await fetch(`${API}/api/inventory`, { credentials: 'include' })
document.querySelectorAll('[data-remaining] .remN').forEach(s => s.textContent = remaining);
// Disable all three server buttons if none left:
if (remaining < = 0) {
document.querySelectorAll('[data-add="server1"],[data-add="server2"],[data-add="server3"]').forEach(btn => {
btn.disabled = true; btn.textContent = 'Sold out';
});
}
} catch { }
2025-10-03 19:14:35 -07:00
}
2025-10-04 18:18:34 -07:00
refreshRemaining();
setInterval(refreshRemaining, 30_000); // stay “24/7” up-to-date
2025-10-03 19:14:35 -07:00
2025-10-01 01:43:48 -07:00
< / script >
< / body >
< / html >