Add files via upload
This commit is contained in:
parent
f05cdb0217
commit
074b96ed78
118
ASAshop.html
118
ASAshop.html
|
|
@ -5,6 +5,14 @@
|
||||||
<title>Obli Studios Shop</title>
|
<title>Obli Studios Shop</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<style>
|
<style>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
:root {
|
:root {
|
||||||
--bg: #0b1224;
|
--bg: #0b1224;
|
||||||
--card: #121b34;
|
--card: #121b34;
|
||||||
|
|
@ -497,14 +505,23 @@
|
||||||
function loadCart() { try { return JSON.parse(localStorage.getItem('obli.cart') || '{}'); } catch { return {}; } }
|
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 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); }
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Render ===== */
|
/* ===== Render ===== */
|
||||||
function cardHtml(p) {
|
function cardHtml(p) {
|
||||||
const inCart = state.cart[p.id] || 0;
|
const inCart = state.cart[p.id] || 0;
|
||||||
let infoBtn = '';
|
let infoBtn = '';
|
||||||
if (p.name === 'Server Class 1') infoBtn = `<button class="btn btn-secondary" data-info="server-class-1">Info</button>`;
|
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>`;
|
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>`;
|
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">
|
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="img">${p.img ? `<img src="${p.img}" alt="${p.name}">` : `<canvas data-id="${p.id}" width="320" height="150"></canvas>`}</div>
|
||||||
|
|
@ -572,11 +589,38 @@
|
||||||
const add = e.target.closest('[data-add]');
|
const add = e.target.closest('[data-add]');
|
||||||
const inc = e.target.closest('[data-inc]');
|
const inc = e.target.closest('[data-inc]');
|
||||||
const dec = e.target.closest('[data-dec]');
|
const dec = e.target.closest('[data-dec]');
|
||||||
if (add) { const id = add.getAttribute('data-add'); state.cart[id] = (state.cart[id] || 0) + 1; saveCart(); render(); }
|
|
||||||
if (inc) { const id = inc.getAttribute('data-inc'); state.cart[id] = (state.cart[id] || 0) + 1; saveCart(); updateCartUi(); }
|
if (add) {
|
||||||
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(); }
|
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) ===== */
|
/* ===== Modals (single global handlers) ===== */
|
||||||
const modals = {
|
const modals = {
|
||||||
'server-class-1': document.getElementById('infoModal'),
|
'server-class-1': document.getElementById('infoModal'),
|
||||||
|
|
@ -627,32 +671,36 @@
|
||||||
const p = products.find(p => p.id === id);
|
const p = products.find(p => p.id === id);
|
||||||
return { id, name: p?.name || id, qty, price: p?.price || 0 };
|
return { id, name: p?.name || id, qty, price: p?.price || 0 };
|
||||||
});
|
});
|
||||||
const total = items.reduce((s, i) => s + i.price * i.qty, 0);
|
|
||||||
|
|
||||||
// notify your backend -> Discord webhook
|
// 1) Try to reserve on the server
|
||||||
try {
|
const reserve = await fetch('/api/reserve', {
|
||||||
await fetch('/api/notify-webhook', {
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||||
method: 'POST',
|
body: JSON.stringify({ items })
|
||||||
headers: { 'Content-Type': 'application/json' },
|
}).then(r => r.json()).catch(() => ({ ok: false }));
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({ items, total })
|
|
||||||
});
|
|
||||||
} catch (e) { console.warn('Webhook notify failed', e); }
|
|
||||||
|
|
||||||
// payment behavior
|
if (!reserve?.ok) {
|
||||||
if (entries.length === 1) {
|
const rem = typeof reserve?.remaining === 'number' ? reserve.remaining : remaining;
|
||||||
const [id] = entries[0];
|
alert(`Sold out. Remaining: ${rem}. Adjust your cart.`);
|
||||||
const p = products.find(p => p.id === id);
|
await refreshRemaining(); // sync the UI
|
||||||
if (p && p.payment) { window.open(p.payment, '_blank'); return; }
|
return;
|
||||||
}
|
}
|
||||||
if (GENERIC_CHECKOUT && GENERIC_CHECKOUT.startsWith('http')) {
|
|
||||||
window.open(GENERIC_CHECKOUT, '_blank');
|
// 2) Create a Stripe Checkout session
|
||||||
} else {
|
const session = await fetch('/api/create-checkout', {
|
||||||
const lines = items.map(i => `${i.qty} x ${i.name}`).join('%0A');
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||||
window.location.href = `mailto:sales@oblistudios.com?subject=Order%20Request&body=${lines}`;
|
body: JSON.stringify({ items })
|
||||||
|
}).then(r => r.json());
|
||||||
|
|
||||||
|
if (!session?.url) {
|
||||||
|
alert('Could not start checkout. Try again.'); return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.location.href = session.url;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* ===== Boot ===== */
|
/* ===== Boot ===== */
|
||||||
document.getElementById('year').textContent = new Date().getFullYear();
|
document.getElementById('year').textContent = new Date().getFullYear();
|
||||||
render();
|
render();
|
||||||
|
|
@ -672,6 +720,24 @@
|
||||||
ctx.fillText(products.find(p => p.id === id)?.name || id, 12, cv.height - 12);
|
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/inventory'); const j = await r.json();
|
||||||
|
remaining = j.remaining;
|
||||||
|
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 { }
|
||||||
|
}
|
||||||
|
refreshRemaining();
|
||||||
|
setInterval(refreshRemaining, 30_000); // stay “24/7” up-to-date
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
// auth.js
|
||||||
|
import express from "express";
|
||||||
|
import fetch from "node-fetch";
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const CLIENT_ID = process.env.DISCORD_CLIENT_ID;
|
||||||
|
const CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET;
|
||||||
|
const REDIRECT_URI = process.env.DISCORD_REDIRECT_URI; // e.g. https://your.site/api/auth/discord/callback
|
||||||
|
const SCOPES = ["identify"]; // you only need the user's handle/id
|
||||||
|
|
||||||
|
router.get("/auth/discord", (req, res) => {
|
||||||
|
const url = new URL("https://discord.com/oauth2/authorize");
|
||||||
|
url.searchParams.set("client_id", CLIENT_ID);
|
||||||
|
url.searchParams.set("response_type", "code");
|
||||||
|
url.searchParams.set("scope", SCOPES.join(" "));
|
||||||
|
url.searchParams.set("redirect_uri", REDIRECT_URI);
|
||||||
|
res.redirect(url.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/auth/discord/callback", async (req, res) => {
|
||||||
|
const code = req.query.code;
|
||||||
|
const token = await fetch("https://discord.com/api/oauth2/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
client_secret: CLIENT_SECRET,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code, redirect_uri: REDIRECT_URI
|
||||||
|
})
|
||||||
|
}).then(r => r.json());
|
||||||
|
|
||||||
|
const me = await fetch("https://discord.com/api/users/@me", {
|
||||||
|
headers: { Authorization: `Bearer ${token.access_token}` }
|
||||||
|
}).then(r => r.json());
|
||||||
|
|
||||||
|
req.session.user = {
|
||||||
|
id: me.id,
|
||||||
|
username: me.username,
|
||||||
|
global_name: me.global_name || null
|
||||||
|
};
|
||||||
|
res.redirect("/"); // back to storefront
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/api/whoami", (req, res) => {
|
||||||
|
if (!req.session.user) return res.sendStatus(401);
|
||||||
|
res.json(req.session.user);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
// client.js (in your existing <script>)
|
||||||
|
let remaining = 12;
|
||||||
|
async function refreshRemaining() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/inventory'); const j = await r.json();
|
||||||
|
remaining = j.remaining;
|
||||||
|
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 { }
|
||||||
|
}
|
||||||
|
refreshRemaining();
|
||||||
|
setInterval(refreshRemaining, 30_000); // stay “24/7” up-to-date
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
// discord.js
|
||||||
|
import fetch from "node-fetch";
|
||||||
|
const WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL;
|
||||||
|
|
||||||
|
export async function sendDiscordOrder({ user, items, total, refund = false }) {
|
||||||
|
const lines = items.map(i => `• ${i.qty} × ${i.name} ($${i.price.toFixed(2)})`).join("\n");
|
||||||
|
const title = refund ? "REFUND processed" : "New order paid";
|
||||||
|
const buyer = user?.discord_username || "unknown";
|
||||||
|
|
||||||
|
await fetch(WEBHOOK_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: "Shop Bot",
|
||||||
|
embeds: [{
|
||||||
|
title,
|
||||||
|
description: `${lines}\n\nTotal: **$${total?.toFixed(2) ?? "—"}**`,
|
||||||
|
footer: { text: `Buyer: ${buyer} (ID ${user?.discord_user_id ?? "?"})` },
|
||||||
|
color: refund ? 0xcc3333 : 0x33cc66
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
// inventory.js
|
||||||
|
import express from "express";
|
||||||
|
import { pool } from "./db.js"; // pg pool
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Calculate how many server units are in this cart
|
||||||
|
const serverIds = new Set(["server1", "server2", "server3"]);
|
||||||
|
|
||||||
|
router.post("/api/reserve", async (req, res) => {
|
||||||
|
const { items } = req.body; // [{id, qty, price}]
|
||||||
|
const want = items.filter(i => serverIds.has(i.id))
|
||||||
|
.reduce((a, i) => a + i.qty, 0);
|
||||||
|
|
||||||
|
if (want === 0) return res.json({ ok: true, remaining: await currentRemaining() });
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
const { rows } = await client.query("SELECT value_int FROM inventory_state WHERE key='server_slots_remaining' FOR UPDATE");
|
||||||
|
const remaining = rows[0].value_int;
|
||||||
|
|
||||||
|
if (remaining < want) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
return res.status(409).json({ ok: false, reason: "sold_out", remaining });
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
"UPDATE inventory_state SET value_int = value_int - $1 WHERE key='server_slots_remaining'",
|
||||||
|
[want]
|
||||||
|
);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
res.json({ ok: true, remaining: remaining - want });
|
||||||
|
} catch (e) {
|
||||||
|
await client.query("ROLLBACK"); throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function currentRemaining() {
|
||||||
|
const { rows } = await pool.query("SELECT value_int FROM inventory_state WHERE key='server_slots_remaining'");
|
||||||
|
return rows[0].value_int;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/api/inventory", async (req, res) => {
|
||||||
|
res.json({ remaining: await currentRemaining() });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
// payments.js
|
||||||
|
import express from "express";
|
||||||
|
import Stripe from "stripe";
|
||||||
|
import { pool } from "./db.js";
|
||||||
|
import { sendDiscordOrder } from "./discord.js";
|
||||||
|
const router = express.Router();
|
||||||
|
const stripe = new Stripe(process.env.STRIPE_SECRET);
|
||||||
|
|
||||||
|
router.post("/api/create-checkout", async (req, res) => {
|
||||||
|
const user = req.session.user;
|
||||||
|
if (!user) return res.sendStatus(401);
|
||||||
|
|
||||||
|
const { items } = req.body;
|
||||||
|
// (Optional) enforce again that requested server qty <= currently reserved
|
||||||
|
const line_items = items.map(i => ({
|
||||||
|
quantity: i.qty,
|
||||||
|
price_data: {
|
||||||
|
currency: "usd",
|
||||||
|
product_data: { name: i.name },
|
||||||
|
unit_amount: Math.round(i.price * 100)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const session = await stripe.checkout.sessions.create({
|
||||||
|
mode: "payment",
|
||||||
|
line_items,
|
||||||
|
success_url: process.env.SUCCESS_URL,
|
||||||
|
cancel_url: process.env.CANCEL_URL,
|
||||||
|
metadata: {
|
||||||
|
discord_user_id: user.id,
|
||||||
|
discord_username: user.username,
|
||||||
|
items: JSON.stringify(items)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ url: session.url });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Webhook: payment succeeded / refunded
|
||||||
|
router.post("/api/stripe/webhook", express.raw({ type: "application/json" }), async (req, res) => {
|
||||||
|
const sig = req.headers["stripe-signature"];
|
||||||
|
let evt;
|
||||||
|
try {
|
||||||
|
evt = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(400).send(`Webhook Error: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (evt.type === "checkout.session.completed") {
|
||||||
|
const s = evt.data.object;
|
||||||
|
const items = JSON.parse(s.metadata.items);
|
||||||
|
const total = items.reduce((a, i) => a + i.price * i.qty, 0);
|
||||||
|
await pool.query(
|
||||||
|
"INSERT INTO orders(id, discord_user_id, discord_username, line_items, total_cents, stripe_payment_intent, status) VALUES (gen_random_uuid(), $1,$2,$3,$4,$5,'paid')",
|
||||||
|
[s.metadata.discord_user_id, s.metadata.discord_username, JSON.stringify(items), Math.round(total * 100), s.payment_intent]
|
||||||
|
);
|
||||||
|
await sendDiscordOrder({ user: s.metadata, items, total });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (evt.type === "charge.refunded") {
|
||||||
|
const pi = evt.data.object.payment_intent;
|
||||||
|
// Find the order and mark refunded
|
||||||
|
const { rows } = await pool.query("UPDATE orders SET status='refunded' WHERE stripe_payment_intent=$1 RETURNING line_items", [pi]);
|
||||||
|
if (rows.length) {
|
||||||
|
const items = rows[0].line_items;
|
||||||
|
const returned = items.filter(i => serverIds.has(i.id)).reduce((a, i) => a + i.qty, 0);
|
||||||
|
if (returned > 0) {
|
||||||
|
await pool.query("UPDATE inventory_state SET value_int = value_int + $1 WHERE key='server_slots_remaining'", [returned]);
|
||||||
|
}
|
||||||
|
await sendDiscordOrder({ refund: true, items });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ received: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
Loading…
Reference in New Issue