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

@@ -9,10 +9,12 @@ RMC, construction materials, …) are browsable with search, manufacturer / type
size filters, pagination, and a detail view per item. size filters, pagination, and a detail view per item.
Users can optionally create an account and build a **personal inventory**: items Users can optionally create an account and build a **personal inventory**: items
from the catalog are added to their own list with a quantity and notes. The from the catalog are added to their own list with a quantity and notes. Logged-in
catalog itself stays immutable read-only reference data; personal inventories users can also place **orders** for any catalog item; orders go onto a public
live in PostgreSQL and reference catalog items by `class_name` only, so the board that every visitor can see. The catalog itself stays immutable read-only
dataset can be replaced wholesale without touching user data. reference data; personal inventories and orders live in PostgreSQL and reference
catalog items by `class_name` only, so the dataset can be replaced wholesale
without touching user data.
## Stack ## Stack
@@ -144,8 +146,18 @@ item's catalog details, quantity, quality (when applicable), owner username and
last update. Private rows last update. Private rows
never appear there and inventory notes are never exposed. never appear there and inventory notes are never exposed.
Clicking an item name in either **My Inventory** or **Available** opens the same The **Orders** tab (backed by `GET /api/v1/orders`) is also public. A logged-in
catalog detail view used by the catalog grid. user clicks any item to open its detail view, then fills in the **Request this
item** form there with a quantity, optional notes, and — for mineable
commodities — an optional quality. Setting up the order publishes the request
on the public board.
Orders show the item, quantity, quality, notes, requesting username, status
(`open`/`fulfilled`) and last update. Notes on orders are public by design;
inventory notes remain private. Only the owner can edit, mark fulfilled/reopen,
or remove their own order.
Clicking an item name in **My Inventory**, **Available** or **Orders** opens the
same catalog detail view used by the catalog grid.
### Security: read before exposing this publicly ### Security: read before exposing this publicly
@@ -186,7 +198,7 @@ curl -fsS -b cookies.txt http://localhost:8080/api/v1/me/inventory
cd trade-hub/backend cd trade-hub/backend
python3 -m venv .venv python3 -m venv .venv
.venv/bin/pip install -r requirements.txt -r requirements-dev.txt .venv/bin/pip install -r requirements.txt -r requirements-dev.txt
.venv/bin/python -m pytest tests/ -q # 65 tests, no database needed .venv/bin/python -m pytest tests/ -q # 83 tests, no database needed
DATA_DIR=./data/samples .venv/bin/uvicorn main:app --port 8000 DATA_DIR=./data/samples .venv/bin/uvicorn main:app --port 8000
``` ```
@@ -229,12 +241,19 @@ PATCH /api/v1/me/inventory/{class_name} {quantity?, notes?, visibility?, qual
DELETE /api/v1/me/inventory/{class_name} DELETE /api/v1/me/inventory/{class_name}
GET /api/v1/available?category=&search=&sort=&page=&page_size= GET /api/v1/available?category=&search=&sort=&page=&page_size=
GET /api/v1/orders?category=&search=&status=&sort=&page=&page_size=
POST /api/v1/orders {class_name, quantity?, notes?, quality?}
PATCH /api/v1/orders/{id} {quantity?, notes?, status?, quality?}
DELETE /api/v1/orders/{id}
``` ```
`/api/v1/me/*` requires a valid session cookie and returns `401` otherwise. `/api/v1/me/*` requires a valid session cookie and returns `401` otherwise.
`/api/v1/available` is public and read-only; it accepts `sort=name|category|quantity|updated` `/api/v1/available` and `GET /api/v1/orders` are public and read-only. Both
(default `updated`). Catalog `category` values are `ships`, `ship-items`, accept `sort=name|category|quantity|updated` (default `updated`); orders also
`fps-items` and `commodities`. accept `status=open|fulfilled`. Creating or changing an order requires a
session; only its owner may update or delete it. Catalog `category` values are
`ships`, `ship-items`, `fps-items` and `commodities`.
### Configuration ### Configuration

View File

@@ -15,6 +15,7 @@ from app.available import router as available_router
from app.catalog import store from app.catalog import store
from app.db import db_healthy, init_db from app.db import db_healthy, init_db
from app.inventory import router as inventory_router from app.inventory import router as inventory_router
from app.orders import router as orders_router
@asynccontextmanager @asynccontextmanager
@@ -47,6 +48,7 @@ if config.CORS_ORIGINS:
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(inventory_router) app.include_router(inventory_router)
app.include_router(available_router) app.include_router(available_router)
app.include_router(orders_router)
VALID_CATEGORIES = {"ships", "ship-items", "fps-items", "commodities"} VALID_CATEGORIES = {"ships", "ship-items", "fps-items", "commodities"}

View File

@@ -49,6 +49,9 @@ class User(Base):
inventory: Mapped[list["InventoryEntry"]] = relationship( inventory: Mapped[list["InventoryEntry"]] = relationship(
back_populates="user", cascade="all, delete-orphan" back_populates="user", cascade="all, delete-orphan"
) )
orders: Mapped[list["Order"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
class UserSession(Base): class UserSession(Base):
@@ -109,3 +112,46 @@ class InventoryEntry(Base):
) )
user: Mapped[User] = relationship(back_populates="inventory") user: Mapped[User] = relationship(back_populates="inventory")
class Order(Base):
"""A public request for a catalog item placed by a logged-in user.
Orders are intentionally visible to everyone (see app/orders.py). They
reference catalog items by `class_name` only, exactly like inventory rows,
so a catalog refresh cannot break them.
"""
__tablename__ = "orders"
__table_args__ = (
CheckConstraint("quantity >= 1", name="ck_orders_quantity_positive"),
CheckConstraint(
"quality IS NULL OR (quality >= 1 AND quality <= 1000)",
name="ck_orders_quality_range",
),
CheckConstraint(
"status IN ('open', 'fulfilled')",
name="ck_orders_status",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
class_name: Mapped[str] = mapped_column(String(255), nullable=False)
quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
# Mining/refining quality (1-1000); only valid for quality-applicable ores.
quality: Mapped[int | None] = mapped_column(Integer, nullable=True)
notes: Mapped[str] = mapped_column(Text, nullable=False, default="")
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="open", server_default="open"
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=utcnow
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow
)
user: Mapped[User] = relationship(back_populates="orders")

View File

@@ -109,3 +109,48 @@ class AvailablePage(BaseModel):
class InventoryAddResponse(BaseModel): class InventoryAddResponse(BaseModel):
entry: InventoryEntryOut entry: InventoryEntryOut
created: bool created: bool
class OrderCreateRequest(BaseModel):
class_name: str = Field(min_length=1, max_length=255)
quantity: int = Field(default=1, ge=1, le=config.MAX_QUANTITY)
notes: str = Field(default="", max_length=config.MAX_NOTES_LENGTH)
quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY)
class OrderPatchRequest(BaseModel):
quantity: int | None = Field(default=None, ge=1, le=config.MAX_QUANTITY)
notes: str | None = Field(default=None, max_length=config.MAX_NOTES_LENGTH)
status: Literal["open", "fulfilled"] | None = None
# Explicit null clears the quality; omitting the field leaves it unchanged.
quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY)
class OrderOut(BaseModel):
id: int
class_name: str
name: str
category: str | None
type: str | None
size: int | None
grade: Any | None = None
manufacturer: dict | None
quantity: int
notes: str
quality: int | None = None
quality_applicable: bool = False
# Public listing: the requesting user's display name.
username: str
status: Literal["open", "fulfilled"]
# False when the catalog no longer contains the referenced item.
available: bool
created_at: datetime
updated_at: datetime
class OrderPage(BaseModel):
total: int
page: int
page_size: int
total_pages: int
items: list[OrderOut]

View File

@@ -19,10 +19,12 @@ const state = {
user: null, user: null,
inventory: { search: "", category: "", sort: "name" }, inventory: { search: "", category: "", sort: "name" },
available: { search: "", category: "", sort: "updated" }, available: { search: "", category: "", sort: "updated" },
orders: { search: "", category: "", status: "", sort: "updated" },
}; };
let authMode = "login"; let authMode = "login";
let pendingView = null; let pendingView = null;
let currentDetailClass = null;
const els = {}; const els = {};
@@ -38,6 +40,8 @@ function cacheElements() {
"available-view", "avail-search", "avail-category", "avail-sort", "available-view", "avail-search", "avail-category", "avail-sort",
"avail-summary", "avail-empty", "avail-table-wrap", "avail-tbody", "avail-summary", "avail-empty", "avail-table-wrap", "avail-tbody",
"tab-available", "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-backdrop", "auth-close", "auth-title", "auth-form",
"auth-username", "auth-password", "auth-error", "auth-submit", "auth-username", "auth-password", "auth-error", "auth-submit",
"auth-toggle", "toast", "auth-toggle", "toast",
@@ -198,6 +202,11 @@ async function submitAuth(event) {
pendingView = null; pendingView = null;
switchToMine(); 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) { } catch (err) {
els["auth-error"].textContent = err.message; els["auth-error"].textContent = err.message;
} finally { } finally {
@@ -222,6 +231,8 @@ async function logout() {
state.category = ""; state.category = "";
loadItems(); loadItems();
} }
// Orders stay public, but the owner-only action buttons must disappear.
if (state.view === "orders") loadOrders();
} }
/* ---------- catalog list ---------- */ /* ---------- catalog list ---------- */
@@ -338,6 +349,7 @@ function renderPorts(ports) {
} }
async function openModal(class_name) { async function openModal(class_name) {
currentDetailClass = class_name;
els["modal-body"].innerHTML = '<p class="hint">Loading…</p>'; els["modal-body"].innerHTML = '<p class="hint">Loading…</p>';
els["modal-backdrop"].classList.remove("hidden"); els["modal-backdrop"].classList.remove("hidden");
try { try {
@@ -369,6 +381,27 @@ async function openModal(class_name) {
${state.user ? "Add to my inventory" : "Log in to add"} ${state.user ? "Add to my inventory" : "Log in to add"}
</button> </button>
</div> </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", () => { document.getElementById("add-to-inventory").addEventListener("click", () => {
const qty = Math.max(1, Number(document.getElementById("add-qty").value) || 1); 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); 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) { } catch (err) {
els["modal-body"].innerHTML = `<p class="modal-desc">Failed to load item: ${escapeHtml(err.message)}</p>`; els["modal-body"].innerHTML = `<p class="modal-desc">Failed to load item: ${escapeHtml(err.message)}</p>`;
} }
} }
function closeModal() { function closeModal() {
currentDetailClass = null;
els["modal-backdrop"].classList.add("hidden"); els["modal-backdrop"].classList.add("hidden");
} }
@@ -424,6 +472,7 @@ async function switchToMine() {
state.view = "mine"; state.view = "mine";
els["catalog-view"].classList.add("hidden"); els["catalog-view"].classList.add("hidden");
els["available-view"].classList.add("hidden"); els["available-view"].classList.add("hidden");
els["orders-view"].classList.add("hidden");
els["inventory-view"].classList.remove("hidden"); els["inventory-view"].classList.remove("hidden");
await loadInventory(); await loadInventory();
} }
@@ -432,6 +481,7 @@ function showCatalogView() {
state.view = "catalog"; state.view = "catalog";
els["inventory-view"].classList.add("hidden"); els["inventory-view"].classList.add("hidden");
els["available-view"].classList.add("hidden"); els["available-view"].classList.add("hidden");
els["orders-view"].classList.add("hidden");
els["catalog-view"].classList.remove("hidden"); els["catalog-view"].classList.remove("hidden");
} }
@@ -727,6 +777,7 @@ async function switchToAvailable() {
state.view = "available"; state.view = "available";
els["catalog-view"].classList.add("hidden"); els["catalog-view"].classList.add("hidden");
els["inventory-view"].classList.add("hidden"); els["inventory-view"].classList.add("hidden");
els["orders-view"].classList.add("hidden");
els["available-view"].classList.remove("hidden"); els["available-view"].classList.remove("hidden");
await loadAvailable(); 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 ---------- */ /* ---------- events ---------- */
function debounce(fn, wait) { function debounce(fn, wait) {
@@ -827,6 +1062,10 @@ function bindEvents() {
switchToAvailable(); switchToAvailable();
return; return;
} }
if (tab.dataset.view === "orders") {
switchToOrders();
return;
}
setActiveTab(tab); setActiveTab(tab);
showCatalogView(); showCatalogView();
state.category = tab.dataset.category; 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 ---------- */ /* ---------- boot ---------- */
document.addEventListener("DOMContentLoaded", async () => { document.addEventListener("DOMContentLoaded", async () => {
@@ -957,6 +1218,7 @@ document.addEventListener("DOMContentLoaded", async () => {
bindAuthEvents(); bindAuthEvents();
bindInventoryEvents(); bindInventoryEvents();
bindAvailableEvents(); bindAvailableEvents();
bindOrderEvents();
initHeader(); initHeader();
await initAuth(); await initAuth();
loadItems(); loadItems();

View File

@@ -31,6 +31,7 @@
<button class="tab" data-view="catalog" data-category="commodities">Commodities</button> <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 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-available" data-view="available">Available</button>
<button class="tab" id="tab-orders" data-view="orders">Orders</button>
</nav> </nav>
<div id="catalog-view"> <div id="catalog-view">
@@ -140,6 +141,53 @@
</div> </div>
</section> </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"> <footer class="site-footer">
<p>Data source: <p>Data source:
<a href="https://github.com/richardthombs/scunpacked" target="_blank" rel="noopener noreferrer">scunpacked</a> <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; } .avail-updated { color: var(--text-dim); font-size: 0.8rem; white-space: nowrap; }
.unavailable-note { color: var(--accent-warm); font-size: 0.75rem; } .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 */ /* Auth modal */
.modal-narrow { max-width: 400px; } .modal-narrow { max-width: 400px; }
.auth-form { .auth-form {
@@ -532,6 +566,11 @@ main {
padding: 0.5rem 0.6rem; padding: 0.5rem 0.6rem;
} }
.add-row input:focus { outline: none; border-color: var(--accent); } .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 */
.toast { .toast {