Compare commits
No commits in common. "main" and "11fab4f8f65d4ae5a2dafc74c017878f2a226609" have entirely different histories.
main
...
11fab4f8f6
|
|
@ -343,7 +343,6 @@
|
|||
<div class="spacer"></div>
|
||||
<nav class="links" aria-label="Primary">
|
||||
<a href="https://www.oblistudios.com">Home</a>
|
||||
<a href="https://www.oblistudios.com/shardwalkershop.html">Shardwalker Shop</a>
|
||||
</nav>
|
||||
<a href="#" class="cart-btn" id="openCart">🛒 Cart <span id="cartCount">0</span></a>
|
||||
</div>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 4.0 MiB |
|
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 2.8 MiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
50
auth.js
|
|
@ -1,50 +0,0 @@
|
|||
// 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;
|
||||
17
client.js
|
|
@ -1,17 +0,0 @@
|
|||
// 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
|
||||
23
discord.js
|
|
@ -1,23 +0,0 @@
|
|||
// 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
|
||||
}]
|
||||
})
|
||||
});
|
||||
}
|
||||
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
BIN
img/SP1.png
|
Before Width: | Height: | Size: 1.3 MiB |
BIN
img/SP2.png
|
Before Width: | Height: | Size: 1.3 MiB |
BIN
img/SP3.png
|
Before Width: | Height: | Size: 1.3 MiB |
BIN
img/SP4.png
|
Before Width: | Height: | Size: 1.5 MiB |
BIN
img/SP5.png
|
Before Width: | Height: | Size: 1.4 MiB |
BIN
img/SP6.png
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1014 KiB |
BIN
img/SmallMap.png
|
Before Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 1.3 MiB |
BIN
img/Xlogo.png
|
Before Width: | Height: | Size: 854 KiB |
BIN
img/mainmenu.png
|
Before Width: | Height: | Size: 1.1 MiB |
|
|
@ -427,9 +427,8 @@
|
|||
<li><a href="#game">Games</a></li>
|
||||
<li><a href="#about">About</a></li>
|
||||
<li><a href="#contact">Contact</a></li>
|
||||
<li><a href="https://www.oblistudios.com/roadmap.html">Roadmap</a></li>
|
||||
<li><a href="https://www.oblistudios.com/ASAshop.html"> ASA Shop</a></li>
|
||||
<li><a herf="https://www.oblistudios.com/shardwalkershop.html">Shardwalker Shop</a></li>
|
||||
<a href="https://www.oblistudios.com/roadmap.html">Roadmap</a>
|
||||
<a href="https://www.oblistudios.com/ASAshop.html"> ASA Shop</a>
|
||||
|
||||
</ul>
|
||||
</nav>
|
||||
|
|
|
|||
49
inventory.js
|
|
@ -1,49 +0,0 @@
|
|||
// 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;
|
||||
77
payments.js
|
|
@ -1,77 +0,0 @@
|
|||
// 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;
|
||||
|
|
@ -1,333 +0,0 @@
|
|||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Shardwalker Shop</title>
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b1220;
|
||||
--card: #0f172a;
|
||||
--ink: #e5e7eb;
|
||||
--muted: #94a3b8;
|
||||
--accent: #7c3aed;
|
||||
--ok: #22c55e;
|
||||
--bad: #ef4444
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Inter,system-ui,Segoe UI,Roboto,Ubuntu,sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--ink)
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 28px
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 18px
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .2px
|
||||
}
|
||||
|
||||
nav a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
margin-left: 16px;
|
||||
font-size: 14px
|
||||
}
|
||||
|
||||
nav a:hover {
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.health {
|
||||
font-size: 13px;
|
||||
color: var(--muted)
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid currentColor
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: var(--ok)
|
||||
}
|
||||
|
||||
.bad {
|
||||
color: var(--bad)
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
grid-template-columns: repeat(auto-fit,minmax(260px,1fr))
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column
|
||||
}
|
||||
|
||||
.hero {
|
||||
aspect-ratio: 16/9;
|
||||
background: #111827;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 800;
|
||||
color: #c084fc
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 800;
|
||||
font-size: 16px
|
||||
}
|
||||
|
||||
.desc {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
min-height: 34px
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px
|
||||
}
|
||||
|
||||
.price {
|
||||
font-weight: 800
|
||||
}
|
||||
|
||||
.qty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px
|
||||
}
|
||||
|
||||
.qty button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #334155;
|
||||
background: #111827;
|
||||
color: var(--ink);
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
.qty input {
|
||||
width: 52px;
|
||||
text-align: center;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
background: #0b1220;
|
||||
color: var(--ink);
|
||||
padding: 6px
|
||||
}
|
||||
|
||||
.add {
|
||||
margin-top: 8px;
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
color: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
.add:hover {
|
||||
filter: brightness(1.1)
|
||||
}
|
||||
|
||||
.bar {
|
||||
position: sticky;
|
||||
bottom: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end
|
||||
}
|
||||
|
||||
.checkout {
|
||||
background: linear-gradient(135deg,#7c3aed,#22d3ee);
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
padding: 12px 18px;
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
.ghost {
|
||||
background: #111827;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 14px;
|
||||
color: var(--ink);
|
||||
padding: 12px 14px
|
||||
}
|
||||
|
||||
.cart-mini {
|
||||
color: var(--muted);
|
||||
font-size: 13px
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div style="display:flex;align-items:center;gap:16px;">
|
||||
<h1>Shardwalker Shop</h1>
|
||||
<nav>
|
||||
<a href="https://www.oblistudios.com/index.html">Home</a>
|
||||
<a href="https://www.oblistudios.com/roadmap.html">Roadmap</a>
|
||||
<a href="https://www.oblistudios.com/ASAshop.html">ASA Shop</a>
|
||||
|
||||
</nav>
|
||||
</div>
|
||||
<div class="health">Shop API: <span id="apiPill" class="pill">checking…</span></div>
|
||||
</header>
|
||||
|
||||
<div class="grid" id="grid"></div>
|
||||
|
||||
<div class="bar">
|
||||
<div class="ghost cart-mini" id="cartInfo">Cart is empty</div>
|
||||
<button class="checkout" id="checkoutBtn">Checkout</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* --- Configure your Stripe prices (same mode as server key) --- */
|
||||
const CATALOG = [
|
||||
{ sku: 'sw_base', name: 'Shardwalker – Base Game', priceId: 'price_LIVE_BASE', cents: 1500, hero: 'BASE' },
|
||||
{ sku: 'sw_deluxe', name: 'Shardwalker – Deluxe Edition', priceId: 'price_LIVE_DELUXE', cents: 2999, hero: 'DELUXE' },
|
||||
{ sku: 'sw_cosm1', name: 'Cosmetic Pack: Voidglass', priceId: 'price_LIVE_COSM1', cents: 199, hero: 'VOID' },
|
||||
{ sku: 'sw_cosm2', name: 'Cosmetic Pack: Astral Weave', priceId: 'price_LIVE_COSM2', cents: 199, hero: 'ASTRAL' },
|
||||
{ sku: 'sw_sound', name: 'Official Soundtrack (MP3/FLAC)', priceId: 'price_LIVE_SOUND', cents: 299, hero: 'OST' },
|
||||
{ sku: 'sw_founder', name: 'Founder Supporter Bundle', priceId: 'price_LIVE_FOUNDER', cents: 3599, hero: 'FDR' },
|
||||
];
|
||||
|
||||
const API_BASE = 'https://pay.oblistudios.com';
|
||||
const CHECKOUT_ENDPOINT = API_BASE + '/create-checkout-session';
|
||||
|
||||
const grid = document.getElementById('grid');
|
||||
const cartInfo = document.getElementById('cartInfo');
|
||||
const btn = document.getElementById('checkoutBtn');
|
||||
const pill = document.getElementById('apiPill');
|
||||
|
||||
const cart = new Map(); // priceId -> quantity
|
||||
const dollars = (cents) => '$' + (cents / 100).toFixed(2);
|
||||
const setPill = (ok) => { pill.className = 'pill ' + (ok ? 'ok' : 'bad'); pill.textContent = ok ? 'ONLINE' : 'OFFLINE'; };
|
||||
|
||||
async function health() {
|
||||
try {
|
||||
const r = await fetch(API_BASE + '/healthz', { cache: 'no-store' });
|
||||
setPill(r.ok);
|
||||
} catch { setPill(false); }
|
||||
}
|
||||
health(); setInterval(health, 15000);
|
||||
|
||||
// render cards
|
||||
for (const p of CATALOG) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'card';
|
||||
el.innerHTML = `
|
||||
<div class="hero">${p.hero}</div>
|
||||
<div class="body">
|
||||
<div class="name">${p.name}</div>
|
||||
<div class="desc">Secure Stripe checkout. Instant Discord fulfillment.</div>
|
||||
<div class="row">
|
||||
<div class="price">${dollars(p.cents)}</div>
|
||||
<div class="qty">
|
||||
<button data-act="dec" aria-label="decrease">−</button>
|
||||
<input data-qty type="number" min="0" value="0" step="1" inputmode="numeric" aria-label="quantity">
|
||||
<button data-act="inc" aria-label="increase">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="add">Add to Cart</button>
|
||||
</div>`;
|
||||
const dec = el.querySelector('[data-act="dec"]');
|
||||
const inc = el.querySelector('[data-act="inc"]');
|
||||
const qty = el.querySelector('[data-qty]'); /* fixed selector */
|
||||
const add = el.querySelector('.add');
|
||||
|
||||
dec.onclick = () => qty.value = Math.max(0, (+qty.value || 0) - 1);
|
||||
inc.onclick = () => qty.value = (+qty.value || 0) + 1;
|
||||
add.onclick = () => {
|
||||
const q = Math.max(0, Math.floor(+qty.value || 0));
|
||||
if (q === 0) cart.delete(p.priceId); else cart.set(p.priceId, q);
|
||||
renderCart();
|
||||
};
|
||||
grid.appendChild(el);
|
||||
}
|
||||
|
||||
function renderCart() {
|
||||
if (cart.size === 0) { cartInfo.textContent = 'Cart is empty'; return; }
|
||||
let items = 0, total = 0;
|
||||
for (const [priceId, q] of cart) {
|
||||
const prod = CATALOG.find(x => x.priceId === priceId);
|
||||
if (!prod) continue;
|
||||
items += q; total += q * prod.cents;
|
||||
}
|
||||
cartInfo.textContent = `${items} item${items > 1 ? 's' : ''} • ${dollars(total)}`;
|
||||
}
|
||||
|
||||
btn.onclick = async () => {
|
||||
if (cart.size === 0) { alert('Your cart is empty.'); return; }
|
||||
btn.disabled = true; btn.textContent = 'Creating session…';
|
||||
try {
|
||||
const line_items = Array.from(cart.entries()).map(([price, quantity]) => ({ price, quantity }));
|
||||
const r = await fetch(CHECKOUT_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'accept': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
line_items,
|
||||
mode: 'payment',
|
||||
// optional: success/cancel redirects
|
||||
success_url: 'https://www.oblistudios.com/shardwalkershop.html?ok=true',
|
||||
cancel_url: 'https://www.oblistudios.com/shardwalkershop.html?cancel=true'
|
||||
})
|
||||
});
|
||||
if (!r.ok) {
|
||||
const txt = await r.text();
|
||||
alert('Checkout failed:\n' + txt.slice(0, 300));
|
||||
btn.disabled = false; btn.textContent = 'Checkout'; return;
|
||||
}
|
||||
const j = await r.json();
|
||||
if (j && j.url) window.location = j.url;
|
||||
else { alert('Missing session URL from server.'); btn.disabled = false; btn.textContent = 'Checkout'; }
|
||||
} catch (e) {
|
||||
alert('Network error: ' + (e?.message || e));
|
||||
btn.disabled = false; btn.textContent = 'Checkout';
|
||||
}
|
||||
};
|
||||
</script>
|
||||