srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--crates/server/src/billing.rs17
-rw-r--r--crates/server/src/cosmetics.rs22
-rw-r--r--web/src/screens/storeScreen.js17
-rw-r--r--web/src/styles.css145
-rw-r--r--web/tests/helpers.py26
-rw-r--r--web/tests/test_account_and_store.py49
6 files changed, 239 insertions, 37 deletions
diff --git a/crates/server/src/billing.rs b/crates/server/src/billing.rs
index fda42c7..2852b0d 100644
--- a/crates/server/src/billing.rs
+++ b/crates/server/src/billing.rs
@@ -80,6 +80,15 @@ async fn create_session(
amount_cents: i32,
) -> Result<String, AppError> {
let stripe = require_stripe(state)?;
+
+ // Nothing is ever sold for nothing. A zero here would mean a price that
+ // failed to decode or a catalogue row that was never given one, and the
+ // result would be a session that grants the item without charging.
+ if amount_cents <= 0 {
+ tracing::error!("refusing to create a checkout session for {name} at {amount_cents} cents");
+ return Err(AppError::Internal(anyhow::anyhow!("could not start checkout")));
+ }
+
let purchase_id = uuid::Uuid::new_v4().to_string();
let success = format!("{}/?purchase=done", state.frontend_origin);
@@ -161,8 +170,8 @@ async fn checkout_cosmetic(
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)?;
- let name: String = row.try_get("name").unwrap_or_default();
- let price: i32 = row.try_get("price_cents").unwrap_or(0);
+ let name: String = row.try_get("name")?;
+ let price: i32 = row.try_get("price_cents")?;
// Already owned: charging again would be taking money for nothing.
let owned = sqlx::query("SELECT 1 FROM user_cosmetics WHERE user_id = $1 AND cosmetic_id = $2")
@@ -190,8 +199,8 @@ async fn checkout_bundle(
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)?;
- let name: String = row.try_get("name").unwrap_or_default();
- let price: i32 = row.try_get("price_cents").unwrap_or(0);
+ let name: String = row.try_get("name")?;
+ let price: i32 = row.try_get("price_cents")?;
// A bundle whose every item is already owned has nothing to sell.
let remaining: i64 = sqlx::query_scalar(
diff --git a/crates/server/src/cosmetics.rs b/crates/server/src/cosmetics.rs
index b4532a8..de70950 100644
--- a/crates/server/src/cosmetics.rs
+++ b/crates/server/src/cosmetics.rs
@@ -25,7 +25,7 @@ struct Cosmetic {
id: String,
name: String,
category: String,
- price_cents: i64,
+ price_cents: i32,
value: String,
}
@@ -33,16 +33,22 @@ async fn list_catalog(State(state): State<Arc<AppState>>) -> Result<impl IntoRes
let rows = sqlx::query("SELECT id, name, category, price_cents, value FROM cosmetics ORDER BY category, price_cents")
.fetch_all(&state.db)
.await?;
+ // Decode errors are returned, not defaulted away. price_cents was typed
+ // i64 against an INTEGER column, so sqlx refused every decode and
+ // unwrap_or_default turned the whole catalogue into $0.00 with nothing
+ // logged. A wrong price is worse than an error page.
let items: Vec<Cosmetic> = rows
.into_iter()
- .map(|row| Cosmetic {
- id: row.try_get("id").unwrap_or_default(),
- name: row.try_get("name").unwrap_or_default(),
- category: row.try_get("category").unwrap_or_default(),
- price_cents: row.try_get("price_cents").unwrap_or_default(),
- value: row.try_get("value").unwrap_or_default(),
+ .map(|row| {
+ Ok(Cosmetic {
+ id: row.try_get("id")?,
+ name: row.try_get("name")?,
+ category: row.try_get("category")?,
+ price_cents: row.try_get("price_cents")?,
+ value: row.try_get("value")?,
+ })
})
- .collect();
+ .collect::<Result<_, sqlx::Error>>()?;
Ok(Json(items))
}
diff --git a/web/src/screens/storeScreen.js b/web/src/screens/storeScreen.js
index 34b0b2e..7c9a047 100644
--- a/web/src/screens/storeScreen.js
+++ b/web/src/screens/storeScreen.js
@@ -10,6 +10,10 @@ function formatPrice(cents) {
return `$${(cents / 100).toFixed(2)}`;
}
+// Enough to show what kind of thing is in a bundle without making one card
+// far taller than the others beside it.
+const MAX_PREVIEWS = 10;
+
function categoryLabel(category) {
if (category === 'caret') return 'Caret Colours';
if (category === 'sprite') return 'Race Sprites';
@@ -70,12 +74,15 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
const saving = b.full_price_cents - b.price_cents;
// The contents are shown as their own swatches: a bundle you cannot
- // see the inside of is a bundle nobody buys.
- const previews = items
- .map(id => catalog.find(c => c.id === id))
- .filter(Boolean)
+ // see the inside of is a bundle nobody buys. Capped, because The Lot
+ // holds every item and its 26 swatches made the card three rows
+ // taller than the one beside it, which then sat in dead space.
+ const resolved = items.map(id => catalog.find(c => c.id === id)).filter(Boolean);
+ const shown = resolved.slice(0, MAX_PREVIEWS);
+ const hidden = resolved.length - shown.length;
+ const previews = shown
.map(item => `<span class="bundle-preview-item${isOwned(item.id) ? ' owned' : ''}" data-tooltip="${escapeHtml(item.name)}${isOwned(item.id) ? ' (owned)' : ''}">${swatchFor(item)}</span>`)
- .join('');
+ .join('') + (hidden > 0 ? `<span class="bundle-preview-more">+${hidden}</span>` : '');
return `
<div class="bundle-card">
diff --git a/web/src/styles.css b/web/src/styles.css
index b53e99c..376c074 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -376,6 +376,12 @@ body {
margin-bottom: 0.5rem;
}
+/* No width of its own, so it shrink-wrapped to 498px inside the 636px
+ content column and the table sat narrower than everything above it. */
+.leaderboard-results {
+ width: 100%;
+}
+
.leaderboard-list {
width: 100%;
display: flex;
@@ -406,7 +412,13 @@ body {
border: none;
color: inherit;
font: inherit;
- padding: 0;
+ /* Vertical padding rather than none: at 21px tall this was under the
+ 24x24 minimum target size, and it is the only way to reach a player's
+ profile. The negative inline margin keeps the text where it was so the
+ column still lines up with the rows above and below. */
+ padding: 0.25rem 0.35rem;
+ margin-left: -0.35rem;
+ border-radius: var(--radius);
cursor: pointer;
}
@@ -2729,10 +2741,16 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
.store-signin-note {
display: flex;
align-items: center;
- justify-content: center;
+ /* Was shrink-to-fit at 415px inside a 700px panel whose item list is
+ 636px, so the page had three different content widths stacked on top of
+ each other and this box read as misplaced. It matches the list now. */
+ width: 100%;
+ box-sizing: border-box;
+ justify-content: space-between;
+ text-align: left;
gap: 1rem;
flex-wrap: wrap;
- margin-bottom: 1rem;
+ margin-bottom: 1.5rem;
padding: 0.75rem 1rem;
border: 1px solid color-mix(in srgb, var(--primary-color) 35%, transparent);
border-radius: var(--radius-panel);
@@ -2740,9 +2758,47 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
color: var(--sub-color);
}
+/* Store rows. These reuse .leaderboard-row for its border and spacing, but a
+ store row is a swatch, a name, a price and a control, not four columns of
+ text, so the parts that differ are set here.
+
+ The row was 93px tall for one line of content: .menu-button carries a
+ vertical margin meant for a stacked menu, and it applied here too. Twenty
+ six items came to roughly 2400px of scrolling. */
+.store-item-row {
+ padding: 0.45rem 1rem;
+}
+
+.store-item-row .menu-button.small {
+ margin: 0;
+ padding: 0.35rem 0.9rem;
+ font-size: 0.8rem;
+}
+
+/* Fixed width so the prices form a column instead of drifting with the length
+ of each number. */
+.store-price {
+ min-width: 4.5rem;
+ text-align: right;
+ color: var(--sub-color);
+ font-size: 0.85rem;
+}
+
+.store-item-row .leaderboard-name {
+ font-size: 0.9rem;
+}
+
+/* Equip and Buy sit in the same place in every row, so the control column has
+ one width whichever state a row is in. */
+.store-item-row .menu-button {
+ min-width: 5.5rem;
+}
+
/* The supporter offer, above the catalogue. It is the one thing in the store
that is not cosmetic, so it does not sit in a category with the rest. */
.store-supporter {
+ width: 100%;
+ box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
@@ -2777,9 +2833,16 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
font-size: 0.8rem;
}
+.store-supporter .menu-button.small,
+.store-signin-note .menu-button.small {
+ margin: 0;
+}
+
/* Bundles. A card rather than a row: a bundle has to show what is inside it,
which a one-line row has no space for. */
.bundle-grid {
+ width: 100%;
+ box-sizing: border-box;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: 0.75rem;
@@ -2834,13 +2897,30 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
opacity: 0.35;
}
+/* How many more the card did not have room to draw. */
+.bundle-preview-more {
+ display: flex;
+ align-items: center;
+ color: var(--sub-color);
+ font-size: 0.75rem;
+}
+
.bundle-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
+ /* Pushed to the bottom so the buy control lines up across a row of cards
+ whose contents are different heights. The Lot holds 26 swatches and its
+ neighbour holds six. */
margin-top: auto;
- padding-top: 0.25rem;
+ padding-top: 0.5rem;
+}
+
+.bundle-foot .menu-button.small {
+ margin: 0;
+ padding: 0.35rem 0.9rem;
+ font-size: 0.8rem;
}
.bundle-prices {
@@ -2872,7 +2952,11 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
wordmark and Escape already did, and the least reachable of the three. */
.screen-close {
position: absolute;
- top: 0;
+ /* The top rail is fixed to the viewport's top-right and is wide enough to
+ reach into the content column on any viewport narrower than about
+ 1350px, which is most of them. At top: 0 the close button sat directly
+ underneath the rail's buttons. It starts below the rail instead. */
+ top: 1.5rem;
right: 0;
width: 2.25rem;
height: 2.25rem;
@@ -3239,6 +3323,57 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
min-height: 180px;
}
+
+/* The wordmark and the top rail are both fixed to the top of the viewport and
+ neither knew about the other. Measured at 390px: the wordmark ran to x=142
+ and the rail started at x=67, so they overlapped by 75px. Both give ground
+ here, and the close button drops below the rail rather than under it. */
+@media (max-width: 700px) {
+ .logo {
+ font-size: 0.95rem;
+ letter-spacing: 1px;
+ }
+
+ .top-rail {
+ gap: 0.35rem;
+ }
+
+ .top-rail-link {
+ font-size: 0.7rem;
+ padding: 0.5rem 0.2rem;
+ }
+
+ /* Sign Up goes. Sign In reaches the same screen, which offers both, so
+ the second link only costs width. */
+ .top-rail-link.accent {
+ display: none;
+ }
+
+ .screen-close {
+ top: 2.75rem;
+ }
+}
+
+
+/* The leaderboard row's columns all have fixed widths, which added up to a
+ hard minimum of 461px. Measured at 390px the row ran from x=-36 to x=426
+ and the date column was cut off the edge of the screen entirely. The date
+ is the least useful of the six, so it goes first and the rest fit. */
+@media (max-width: 700px) {
+ .leaderboard-date {
+ display: none;
+ }
+
+ .leaderboard-row {
+ gap: 0.6rem;
+ padding: 0.6rem 0.75rem;
+ }
+
+ .leaderboard-rank {
+ min-width: 1.75rem;
+ }
+}
+
@media (max-height: 600px) {
.graph-container {
min-height: 150px;
diff --git a/web/tests/helpers.py b/web/tests/helpers.py
index 8aca3f6..285b769 100644
--- a/web/tests/helpers.py
+++ b/web/tests/helpers.py
@@ -1,11 +1,37 @@
"""Shared helpers for the Playwright test suite. See README.md for how to
run these tests and what needs to already be running.
"""
+import os
import random
import string
+import subprocess
FRONTEND_URL = "http://localhost:4173"
+# Buying is a real payment now, so a test cannot get an item by clicking Buy.
+# Ownership is granted straight in the database instead, which is what a paid
+# webhook would have done.
+DATABASE_URL = os.environ.get(
+ "TYPERPUNK_TEST_DATABASE_URL",
+ "postgresql://typerpunk:typerpunk@localhost/typerpunk",
+)
+
+
+def grant_cosmetics(username, cosmetic_ids):
+ """Gives an account the named cosmetics without going through checkout."""
+ ids = ", ".join(f"'{cid}'" for cid in cosmetic_ids)
+ sql = (
+ "INSERT INTO user_cosmetics (user_id, cosmetic_id, acquired_at) "
+ "SELECT u.id, c.id, '2026-01-01T00:00:00Z' "
+ "FROM users u CROSS JOIN cosmetics c "
+ f"WHERE u.username = '{username}' AND c.id IN ({ids}) "
+ "ON CONFLICT (user_id, cosmetic_id) DO NOTHING"
+ )
+ subprocess.run(
+ ["psql", DATABASE_URL, "-v", "ON_ERROR_STOP=1", "-c", sql],
+ check=True, capture_output=True,
+ )
+
def random_username(prefix="test"):
suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=8))
diff --git a/web/tests/test_account_and_store.py b/web/tests/test_account_and_store.py
index f7c219c..9252cdb 100644
--- a/web/tests/test_account_and_store.py
+++ b/web/tests/test_account_and_store.py
@@ -1,9 +1,13 @@
-"""Account registration, and the cosmetics store's buy/equip flow --
-including that an equipped caret color and flair icon actually show up,
-not just that the API calls succeed. See README.md to run.
+"""Account registration, and the cosmetics store's buy and equip flow.
+
+Buying is a real payment now, so clicking Buy leaves for the processor rather
+than granting anything. The test asserts that it does not grant, then gives
+the account its items the way a paid webhook would, and checks that an
+equipped caret colour and flair icon actually show up rather than that the
+API calls returned 200. See README.md to run.
"""
from playwright.sync_api import sync_playwright
-from helpers import FRONTEND_URL, random_username, register_and_login
+from helpers import FRONTEND_URL, grant_cosmetics, random_username, register_and_login
def run():
@@ -20,18 +24,33 @@ def run():
rows = page.locator(".store-item-row")
assert rows.count() > 0, "store catalog did not render"
- def buy_and_equip(item_name):
- row = page.locator(".store-item-row", has=page.locator(".leaderboard-name", has_text=item_name))
- buy_btn = row.locator('[data-action="buy"]')
- if buy_btn.count() > 0:
- buy_btn.click()
- page.wait_for_timeout(500)
- row = page.locator(".store-item-row", has=page.locator(".leaderboard-name", has_text=item_name))
- row.locator('[data-action="equip"]').click(timeout=10000)
- page.wait_for_timeout(500)
+ def row_for(item_name):
+ return page.locator(
+ ".store-item-row",
+ has=page.locator(".leaderboard-name", has_text=item_name),
+ )
- buy_and_equip("Cyan Caret")
- buy_and_equip("Bolt")
+ # Buy must not grant. Before this was a real payment the endpoint
+ # handed the item over for nothing, so anyone signed in could take
+ # the catalogue. Clicking Buy leaves for the processor, or reports
+ # that payments are unconfigured; either way nothing is owned and
+ # no Equip control appears.
+ row_for("Cyan Caret").locator('[data-action="buy"]').click()
+ page.wait_for_timeout(800)
+ assert row_for("Cyan Caret").locator('[data-action="equip"]').count() == 0, \
+ "Buy granted the item without a payment"
+
+ # Granted the way a signature-verified webhook would.
+ grant_cosmetics(username, ["caret-cyan", "flair-bolt"])
+ # A reload drops back to the menu, so the store has to be reopened.
+ page.reload()
+ page.wait_for_timeout(600)
+ page.click('[data-action="store"]')
+ page.wait_for_selector(".store-item-row", timeout=10000)
+
+ for item_name in ("Cyan Caret", "Bolt"):
+ row_for(item_name).locator('[data-action="equip"]').click(timeout=10000)
+ page.wait_for_timeout(500)
equipped_rows = page.locator(".store-item-row", has=page.get_by_text("EQUIPPED"))
assert equipped_rows.count() >= 2, "expected caret and flair to both show as equipped"