Compare commits

...

No commits in common. "fa0274e33625921618c3a73e38bae8026d595c8e" and "70465d7afb3be53da07fb1aa2cd992ef21e6c357" have entirely different histories.

28 changed files with 261 additions and 0 deletions

43
.github/workflows/static.yml vendored Normal file
View File

@ -0,0 +1,43 @@
# Simple workflow for deploying static content to GitHub Pages
name: Deploy static content to Pages
on:
# Runs on pushes targeting the default branch
push:
branches: ["main"]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
# Single deploy job since we're just deploying
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
# Upload entire repository
path: '.'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

1
CNAME Normal file
View File

@ -0,0 +1 @@
www.oblistudios.com

1
README.md Normal file
View File

@ -0,0 +1 @@
# obli-studios-website

50
auth.js Normal file
View File

@ -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;

17
client.js Normal file
View File

@ -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

23
discord.js Normal file
View File

@ -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
}]
})
});
}

BIN
img/BestWCoast.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
img/LargeMap.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

BIN
img/Logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
img/MediumMap.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

BIN
img/MutatedCretures.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

BIN
img/SP1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
img/SP2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
img/SP3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
img/SP4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
img/SP5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
img/SP6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
img/ServerSlotX30.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1014 KiB

BIN
img/SmallMap.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

BIN
img/StarterPack.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
img/Xlogo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 854 KiB

BIN
img/game-icon-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
img/mainmenu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

49
inventory.js Normal file
View File

@ -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;

77
payments.js Normal file
View File

@ -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;