order button

This commit is contained in:
boxswapper
2026-11-12 19:41:10 -07:00
parent c8c5e658e2
commit 3be92f55fb
7 changed files with 471 additions and 10 deletions

View File

@@ -19,10 +19,12 @@ const state = {
user: null,
inventory: { search: "", category: "", sort: "name" },
available: { search: "", category: "", sort: "updated" },
orders: { search: "", category: "", status: "", sort: "updated" },
};
let authMode = "login";
let pendingView = null;
let currentDetailClass = null;
const els = {};
@@ -38,6 +40,8 @@ function cacheElements() {
"available-view", "avail-search", "avail-category", "avail-sort",
"avail-summary", "avail-empty", "avail-table-wrap", "avail-tbody",
"tab-available",
"orders-view", "order-search", "order-category", "order-status", "order-sort",
"order-summary", "order-empty", "order-table-wrap", "order-tbody", "tab-orders",
"auth-backdrop", "auth-close", "auth-title", "auth-form",
"auth-username", "auth-password", "auth-error", "auth-submit",
"auth-toggle", "toast",
@@ -198,6 +202,11 @@ async function submitAuth(event) {
pendingView = null;
switchToMine();
}
// The detail view was opened while logged out: re-render it so the
// order form becomes available in place.
if (currentDetailClass && !els["modal-backdrop"].classList.contains("hidden")) {
openModal(currentDetailClass);
}
} catch (err) {
els["auth-error"].textContent = err.message;
} finally {
@@ -222,6 +231,8 @@ async function logout() {
state.category = "";
loadItems();
}
// Orders stay public, but the owner-only action buttons must disappear.
if (state.view === "orders") loadOrders();
}
/* ---------- catalog list ---------- */
@@ -338,6 +349,7 @@ function renderPorts(ports) {
}
async function openModal(class_name) {
currentDetailClass = class_name;
els["modal-body"].innerHTML = '<p class="hint">Loading…</p>';
els["modal-backdrop"].classList.remove("hidden");
try {
@@ -369,6 +381,27 @@ async function openModal(class_name) {
${state.user ? "Add to my inventory" : "Log in to add"}
</button>
</div>
<h3>Request this item</h3>
${state.user ? `
<p class="hint">Set up an order so every player can see what you are looking for.</p>
<div class="add-row">
<label for="order-qty">Qty</label>
<input type="number" id="order-qty" min="1" max="1000000" value="1">
${item.quality_applicable ? `
<label for="order-quality">Quality</label>
<input type="number" id="order-quality" min="1" max="1000" placeholder="—">
` : ""}
<input type="text" id="order-notes" class="order-notes-input"
maxlength="1000" placeholder="Notes for other pilots (optional)">
<button type="button" id="create-order" class="btn-primary">Set up order</button>
</div>
<p id="order-error" class="auth-error"></p>
` : `
<div class="add-row">
<span>Log in to set up an order other players can see.</span>
<button type="button" id="order-login" class="btn-primary">Log in to request</button>
</div>
`}
`;
document.getElementById("add-to-inventory").addEventListener("click", () => {
const qty = Math.max(1, Number(document.getElementById("add-qty").value) || 1);
@@ -383,12 +416,27 @@ async function openModal(class_name) {
}
addToInventory(item.class_name, qty, quality);
});
const createOrderButton = document.getElementById("create-order");
if (createOrderButton) {
createOrderButton.addEventListener("click", () =>
submitDetailOrder(item, createOrderButton)
);
} else {
const loginButton = document.getElementById("order-login");
if (loginButton) {
loginButton.addEventListener("click", () =>
openAuth("login", "Log in to set up an order.")
);
}
}
} catch (err) {
els["modal-body"].innerHTML = `<p class="modal-desc">Failed to load item: ${escapeHtml(err.message)}</p>`;
}
}
function closeModal() {
currentDetailClass = null;
els["modal-backdrop"].classList.add("hidden");
}
@@ -424,6 +472,7 @@ async function switchToMine() {
state.view = "mine";
els["catalog-view"].classList.add("hidden");
els["available-view"].classList.add("hidden");
els["orders-view"].classList.add("hidden");
els["inventory-view"].classList.remove("hidden");
await loadInventory();
}
@@ -432,6 +481,7 @@ function showCatalogView() {
state.view = "catalog";
els["inventory-view"].classList.add("hidden");
els["available-view"].classList.add("hidden");
els["orders-view"].classList.add("hidden");
els["catalog-view"].classList.remove("hidden");
}
@@ -727,6 +777,7 @@ async function switchToAvailable() {
state.view = "available";
els["catalog-view"].classList.add("hidden");
els["inventory-view"].classList.add("hidden");
els["orders-view"].classList.add("hidden");
els["available-view"].classList.remove("hidden");
await loadAvailable();
}
@@ -795,6 +846,190 @@ function renderAvailable(listing) {
}
}
/* ---------- orders (public board) ---------- */
async function switchToOrders() {
setActiveTab(els["tab-orders"]);
state.view = "orders";
els["catalog-view"].classList.add("hidden");
els["inventory-view"].classList.add("hidden");
els["available-view"].classList.add("hidden");
els["orders-view"].classList.remove("hidden");
await loadOrders();
}
async function submitDetailOrder(item, button) {
const qtyInput = document.getElementById("order-qty");
const qualityInput = document.getElementById("order-quality");
const notesInput = document.getElementById("order-notes");
const error = document.getElementById("order-error");
const quantity = Number(qtyInput.value);
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 1000000) {
error.textContent = "Quantity must be between 1 and 1,000,000";
return;
}
let quality = null;
if (item.quality_applicable && qualityInput) {
const raw = qualityInput.value.trim();
if (raw !== "") {
quality = Number(raw);
if (!Number.isInteger(quality) || quality < 1 || quality > 1000) {
error.textContent = "Quality must be between 1 and 1000";
return;
}
}
}
const body = {
class_name: item.class_name,
quantity,
notes: notesInput.value.trim(),
};
if (quality !== null) body.quality = quality;
button.disabled = true;
error.textContent = "";
try {
await apiFetch("/api/v1/orders", { method: "POST", body });
showToast("Order set up — visible to all pilots");
if (state.view === "orders") loadOrders();
} catch (err) {
if (!handleUnauthorized(err)) error.textContent = err.message;
} finally {
button.disabled = false;
}
}
async function loadOrders() {
const params = new URLSearchParams();
if (state.orders.search) params.set("search", state.orders.search);
if (state.orders.category) params.set("category", state.orders.category);
if (state.orders.status) params.set("status", state.orders.status);
params.set("sort", state.orders.sort);
params.set("page_size", String(INVENTORY_PAGE_SIZE));
els["order-summary"].textContent = "Loading…";
try {
const listing = await apiFetch(`/api/v1/orders?${params}`);
renderOrders(listing);
} catch (err) {
els["order-summary"].textContent = "";
showToast(err.message, true);
}
}
function renderOrders(listing) {
const tbody = els["order-tbody"];
tbody.innerHTML = "";
els["order-summary"].textContent =
`${listing.total} order${listing.total === 1 ? "" : "s"}`;
els["order-empty"].classList.toggle("hidden", listing.total !== 0);
els["order-table-wrap"].classList.toggle("hidden", listing.total === 0);
for (const entry of listing.items) {
const row = document.createElement("tr");
const nameCell = itemNameCell(entry);
const categoryCell = document.createElement("td");
categoryCell.innerHTML = entry.category
? categoryBadge(entry.category)
: '<span class="badge size">—</span>';
const qtyCell = document.createElement("td");
qtyCell.textContent = String(entry.quantity);
const notesCell = document.createElement("td");
notesCell.className = "order-notes";
notesCell.textContent = entry.notes || "—";
const userCell = document.createElement("td");
userCell.className = "avail-user";
userCell.textContent = entry.username;
const statusCell = document.createElement("td");
const status = document.createElement("span");
status.className = `order-status ${entry.status}`;
status.textContent = entry.status === "fulfilled" ? "Fulfilled" : "Open";
statusCell.appendChild(status);
const updatedCell = document.createElement("td");
updatedCell.className = "avail-updated";
updatedCell.textContent = formatDate(entry.updated_at);
const actionCell = document.createElement("td");
actionCell.className = "order-actions";
if (state.user && entry.username === state.user.username) {
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "visibility-toggle";
const next = entry.status === "fulfilled" ? "open" : "fulfilled";
toggle.textContent = next === "fulfilled" ? "Fulfil" : "Reopen";
toggle.dataset.status = next;
toggle.addEventListener("click", () =>
patchOrder(entry, { status: next }, toggle)
);
const remove = document.createElement("button");
remove.type = "button";
remove.className = "inv-remove";
remove.textContent = "Remove";
remove.addEventListener("click", () => deleteOrder(entry));
actionCell.append(toggle, remove);
}
row.append(
nameCell,
categoryCell,
qtyCell,
qualityCell(entry, false),
notesCell,
userCell,
statusCell,
updatedCell,
actionCell
);
tbody.appendChild(row);
}
}
async function patchOrder(entry, payload, button = null) {
if (button) button.disabled = true;
try {
const updated = await apiFetch(`/api/v1/orders/${entry.id}`, {
method: "PATCH",
body: payload,
});
showToast(
updated.status === "fulfilled" ? "Order marked fulfilled" : "Order reopened"
);
await loadOrders();
} catch (err) {
if (!handleUnauthorized(err)) showToast(err.message, true);
await loadOrders();
} finally {
if (button) button.disabled = false;
}
}
async function deleteOrder(entry) {
const confirmed = await confirmAction(
`Remove your order for "${entry.name}"? It will disappear for everyone.`,
"Remove"
);
if (!confirmed) return;
try {
await apiFetch(`/api/v1/orders/${entry.id}`, { method: "DELETE" });
showToast("Order removed");
await loadOrders();
} catch (err) {
if (!handleUnauthorized(err)) showToast(err.message, true);
}
}
/* ---------- events ---------- */
function debounce(fn, wait) {
@@ -827,6 +1062,10 @@ function bindEvents() {
switchToAvailable();
return;
}
if (tab.dataset.view === "orders") {
switchToOrders();
return;
}
setActiveTab(tab);
showCatalogView();
state.category = tab.dataset.category;
@@ -949,6 +1188,28 @@ function bindAvailableEvents() {
});
}
function bindOrderEvents() {
els["order-search"].addEventListener(
"input",
debounce(() => {
state.orders.search = els["order-search"].value.trim();
loadOrders();
}, 300)
);
els["order-category"].addEventListener("change", () => {
state.orders.category = els["order-category"].value;
loadOrders();
});
els["order-status"].addEventListener("change", () => {
state.orders.status = els["order-status"].value;
loadOrders();
});
els["order-sort"].addEventListener("change", () => {
state.orders.sort = els["order-sort"].value;
loadOrders();
});
}
/* ---------- boot ---------- */
document.addEventListener("DOMContentLoaded", async () => {
@@ -957,6 +1218,7 @@ document.addEventListener("DOMContentLoaded", async () => {
bindAuthEvents();
bindInventoryEvents();
bindAvailableEvents();
bindOrderEvents();
initHeader();
await initAuth();
loadItems();

View File

@@ -31,6 +31,7 @@
<button class="tab" data-view="catalog" data-category="commodities">Commodities</button>
<button class="tab tab-mine" id="tab-mine" data-view="mine">My Inventory</button>
<button class="tab" id="tab-available" data-view="available">Available</button>
<button class="tab" id="tab-orders" data-view="orders">Orders</button>
</nav>
<div id="catalog-view">
@@ -140,6 +141,53 @@
</div>
</section>
<section id="orders-view" class="hidden">
<div class="inventory-controls">
<input type="search" id="order-search" placeholder="Search item or pilot…" autocomplete="off">
<select id="order-category">
<option value="">All categories</option>
<option value="ships">Ships</option>
<option value="ship-items">Ship Equipment</option>
<option value="fps-items">FPS Items</option>
<option value="commodities">Commodities</option>
</select>
<select id="order-status" aria-label="Filter by order status">
<option value="">All statuses</option>
<option value="open">Open</option>
<option value="fulfilled">Fulfilled</option>
</select>
<select id="order-sort" aria-label="Sort orders">
<option value="updated">Sort: Recently updated</option>
<option value="name">Sort: Name</option>
<option value="category">Sort: Category</option>
<option value="quantity">Sort: Quantity</option>
</select>
</div>
<div id="order-summary" class="inv-summary"></div>
<div id="order-empty" class="empty-state hidden">
<p>No orders yet.</p>
<p class="hint">Log in, open any catalog item and use “Request this item” to set it up here.</p>
</div>
<div id="order-table-wrap" class="inv-table-wrap hidden">
<table class="inv-table">
<thead>
<tr>
<th>Item</th>
<th>Category</th>
<th>Qty</th>
<th>Quality</th>
<th>Notes</th>
<th>Requested by</th>
<th>Status</th>
<th>Updated</th>
<th></th>
</tr>
</thead>
<tbody id="order-tbody"></tbody>
</table>
</div>
</section>
<footer class="site-footer">
<p>Data source:
<a href="https://github.com/richardthombs/scunpacked" target="_blank" rel="noopener noreferrer">scunpacked</a>

View File

@@ -480,6 +480,40 @@ main {
.avail-updated { color: var(--text-dim); font-size: 0.8rem; white-space: nowrap; }
.unavailable-note { color: var(--accent-warm); font-size: 0.75rem; }
/* Orders board */
.order-notes {
color: var(--text-dim);
font-size: 0.82rem;
min-width: 10rem;
max-width: 18rem;
white-space: normal;
}
.order-status {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
padding: 0.2rem 0.6rem;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--text-dim);
white-space: nowrap;
}
.order-status.open {
color: var(--accent);
border-color: var(--accent);
}
.order-status.fulfilled {
color: var(--success);
border-color: var(--success);
background: rgba(34, 197, 94, 0.12);
}
.order-actions {
display: flex;
gap: 0.35rem;
white-space: nowrap;
}
/* Auth modal */
.modal-narrow { max-width: 400px; }
.auth-form {
@@ -532,6 +566,11 @@ main {
padding: 0.5rem 0.6rem;
}
.add-row input:focus { outline: none; border-color: var(--accent); }
.add-row input.order-notes-input {
width: auto;
flex: 1;
min-width: 10rem;
}
/* Toast */
.toast {