diff options
| -rw-r--r-- | crates/server/src/billing.rs | 17 | ||||
| -rw-r--r-- | crates/server/src/cosmetics.rs | 22 | ||||
| -rw-r--r-- | web/src/screens/storeScreen.js | 17 | ||||
| -rw-r--r-- | web/src/styles.css | 145 | ||||
| -rw-r--r-- | web/tests/helpers.py | 26 | ||||
| -rw-r--r-- | web/tests/test_account_and_store.py | 49 |
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" |