srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-02-28 01:17:00 +0200
committersrdusr <[email protected]>2026-02-28 01:17:00 +0200
commit5af72f55bb7fb620af740c9da394e093d7f1280a (patch)
tree81fb6ae1830886a25b0f81912a1a12636493ba51
parentd7c1c7b35e9d0995eed41d437404d5255529cf17 (diff)
downloadtyperpunk-5af72f55bb7fb620af740c9da394e093d7f1280a.tar.gz
typerpunk-5af72f55bb7fb620af740c9da394e093d7f1280a.zip
Sell merchandise, put the sprites on the track, and add the page margins
Merchandise. Shirts, mugs and deskmats, sold through the same hosted checkout. Physical goods differ from cosmetics in three ways the schema had to carry: they have a size to choose, they need an address, and payment unlocks nothing. What a paid order produces is an obligation to pack and post something. The address is collected by Stripe on its own page and arrives here on the webhook, so no postal detail is ever entered on this site. Postage is a separate line item rather than folded into the price. A size is checked against the sizes the item actually comes in, on the server as well as in the browser, so a request naming anything else is refused rather than quietly posted as a medium. /api/admin/orders lists what has been paid for and not yet sent, and the Contribute screen shows an administrator the same list with the address and a button to mark each one posted. Without that the list of what is owed lives only in the processor's dashboard. Shipping is limited to 21 countries, which is a list of places somebody is willing to post to, not a technical limit. The race view. The sprite sat still at the left of a 6px bar, which made the one thing a player owns and can see the least visible part of the race. It rides the track now, moving with progress, on a dashed line that reads as road ahead with the trail behind it. The field is 1040px wide rather than 680, the sprites are 30px rather than 18, and each row carries its own percentage as well as its speed. Advertising. Two 160x600 rails in the page margins, shown only above 1424px, which is the width at which they fit beside the widest content column without crowding it. Below that they do not exist. They follow the same rules as the banner: never while typing, never for a supporter. --border-color was used in five places and defined in none. An undefined custom property invalidates the whole declaration at computed-value time, so every one of those borders fell back to currentColor: bundle cards, merch cards, the size buttons and the settings checkbox all had borders that were either invisible or faint text-coloured lines. It is defined now, derived from each theme's neutral so it tracks the palette. .settings-hint caps itself at 340px, which is right under a heading and wrong for a line introducing a full-width grid. Verified against a real database: the catalogue endpoint, a shirt refused without a size, a shirt refused with a size it does not come in, a valid size and a no-variant mug both reaching checkout, 404 on an unknown item, 401 unauthenticated, and 401 on the admin orders list without the role. 24 Rust tests, 3 browser tests, and all 21 screen and viewport combinations clean.
-rwxr-xr-xREADME.md18
-rw-r--r--crates/server/migrations/0017_merch.sql47
-rw-r--r--crates/server/src/admin.rs95
-rw-r--r--crates/server/src/billing.rs189
-rw-r--r--crates/server/src/cosmetics.rs29
-rw-r--r--web/src/adSlot.js30
-rw-r--r--web/src/app.js4
-rw-r--r--web/src/screens/contributeScreen.js44
-rw-r--r--web/src/screens/multiplayerScreen.js17
-rw-r--r--web/src/screens/storeScreen.js81
-rw-r--r--web/src/styles.css243
11 files changed, 765 insertions, 32 deletions
diff --git a/README.md b/README.md
index 595b0e6..fbda605 100755
--- a/README.md
+++ b/README.md
@@ -174,8 +174,22 @@ payment succeeds, the server verifies the signature, and only then does the
item appear. Set both Stripe variables. With either one missing, the store
still displays but every checkout answers 501.
-Prices come from the `cosmetics` and `bundles` tables. The client sends an
-item id, never an amount.
+Prices come from the `cosmetics`, `bundles` and `merch` tables. The client
+sends an item id, never an amount.
+
+Shirts, mugs and deskmats are sold from the same store. These need an address,
+which Stripe collects on its own checkout page, so no postal detail is entered
+on this site. Postage is charged as a separate line so the buyer can see it.
+
+A physical order unlocks nothing. It records an obligation to pack and post
+something. `/api/admin/orders` lists what has been paid for and not yet sent,
+with the address, and the Contribute screen shows the same list to an
+administrator. Mark an order posted there once it goes out. Nothing emails
+the buyer; that is still a manual step.
+
+Shipping is limited to the countries in `SHIPPING_COUNTRIES` in
+`crates/server/src/billing.rs`. Add to that list only where you are willing to
+post and handle returns.
## Deployment
diff --git a/crates/server/migrations/0017_merch.sql b/crates/server/migrations/0017_merch.sql
new file mode 100644
index 0000000..9aba955
--- /dev/null
+++ b/crates/server/migrations/0017_merch.sql
@@ -0,0 +1,47 @@
+- Physical goods: shirts, mugs, deskmats.
+--
+- These differ from cosmetics in three ways that the schema has to carry. They
+- have a size or colour to choose. They need a shipping address. And nothing
+- is granted on payment: an order is recorded for someone to pack and post.
+CREATE TABLE merch (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ description TEXT,
+ price_cents INTEGER NOT NULL,
+ - 'shirt', 'mug', 'deskmat'. Groups the store display.
+ kind TEXT NOT NULL,
+ - Sizes or colours. Empty means the item has only one form.
+ variants TEXT[] NOT NULL DEFAULT '{}',
+ - Postage, charged once per order rather than per item.
+ shipping_cents INTEGER NOT NULL DEFAULT 0,
+ available BOOLEAN NOT NULL DEFAULT TRUE,
+ sort_order INTEGER NOT NULL DEFAULT 0
+);
+
+INSERT INTO merch (id, name, description, price_cents, kind, variants, shipping_cents, sort_order) VALUES
+ ('shirt-mark', 'Mark T-Shirt', 'The TyperPunk mark, printed small on the left chest.', 2600, 'shirt',
+ ARRAY['S','M','L','XL','2XL'], 600, 1),
+ ('shirt-layout', 'Home Row T-Shirt', 'ASDF JKL; across the chest, set in the same face the app types in.', 2800, 'shirt',
+ ARRAY['S','M','L','XL','2XL'], 600, 2),
+ ('mug-prompt', 'Prompt Mug', 'The mark on one side, a blinking block cursor on the other. 11oz.', 1600, 'mug',
+ '{}', 700, 3),
+ ('mug-wpm', 'WPM Mug', 'Reads "measured in words per minute, drunk in cups per hour". 11oz.', 1600, 'mug',
+ '{}', 700, 4),
+ ('deskmat-track', 'Race Deskmat', 'The race track across 900x400mm, stitched edge, rubber base.', 3800, 'deskmat',
+ '{}', 900, 5),
+ ('deskmat-mark', 'Mark Deskmat', 'The mark bottom-right on plain black. 900x400mm, stitched edge.', 3600, 'deskmat',
+ '{}', 900, 6);
+
+- Orders. A purchase row already records payment; this records what has to be
+- packed and where it goes. The address comes back from the processor, which
+- collected it during checkout, so it is never typed into this site.
+ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_kind_check;
+ALTER TABLE purchases ADD CONSTRAINT purchases_kind_check
+ CHECK (kind IN ('cosmetic', 'supporter', 'bundle', 'merch'));
+ALTER TABLE purchases ADD COLUMN merch_id TEXT REFERENCES merch(id) ON DELETE SET NULL;
+ALTER TABLE purchases ADD COLUMN merch_variant TEXT;
+- Set once the item is posted. NULL means it is still waiting to be packed.
+ALTER TABLE purchases ADD COLUMN shipped_at TEXT;
+ALTER TABLE purchases ADD COLUMN shipping_address TEXT;
+
+CREATE INDEX idx_purchases_unshipped ON purchases(kind, status, shipped_at);
diff --git a/crates/server/src/admin.rs b/crates/server/src/admin.rs
index 4b9ec51..805c2d2 100644
--- a/crates/server/src/admin.rs
+++ b/crates/server/src/admin.rs
@@ -22,6 +22,8 @@ pub fn router() -> Router<Arc<AppState>> {
Router::new()
.route("/api/admin/users", get(list_users))
.route("/api/admin/users/:username/role", post(set_role))
+ .route("/api/admin/orders", get(list_orders))
+ .route("/api/admin/orders/:id/shipped", post(mark_shipped))
}
#[derive(Debug, Serialize)]
@@ -152,3 +154,96 @@ async fn set_role(
);
Ok(Json(serde_json::json!({ "username": username, "moderator": body.moderator })))
}
+
+
+/// Paid merchandise that has not been posted yet.
+///
+/// Selling a physical object creates an obligation that no amount of code
+/// discharges: somebody has to pack it and take it to a post office. This is
+/// the list of what is owed, which without it lives only in the processor's
+/// dashboard.
+#[derive(Debug, Serialize)]
+struct OrderView {
+ id: String,
+ username: String,
+ item: String,
+ variant: Option<String>,
+ amount_cents: i32,
+ paid_at: Option<String>,
+ shipping_address: Option<String>,
+ shipped_at: Option<String>,
+}
+
+#[derive(Debug, Deserialize)]
+struct OrderQuery {
+ /// Include orders already posted. Off by default, because the useful
+ /// question is what still has to go out.
+ #[serde(default)]
+ all: bool,
+}
+
+async fn list_orders(
+ State(state): State<Arc<AppState>>,
+ jar: CookieJar,
+ headers: HeaderMap,
+ Query(q): Query<OrderQuery>,
+) -> Result<impl IntoResponse, AppError> {
+ require_admin(&state, &jar, &headers).await?;
+
+ let rows = sqlx::query(
+ "SELECT p.id, u.username, m.name AS item, p.merch_variant, p.amount_cents,
+ p.paid_at, p.shipping_address, p.shipped_at
+ FROM purchases p
+ JOIN users u ON u.id = p.user_id
+ LEFT JOIN merch m ON m.id = p.merch_id
+ WHERE p.kind = 'merch' AND p.status = 'paid'
+ AND ($1 OR p.shipped_at IS NULL)
+ ORDER BY p.paid_at ASC
+ LIMIT 200",
+ )
+ .bind(q.all)
+ .fetch_all(&state.db)
+ .await?;
+
+ let orders: Vec<OrderView> = rows
+ .iter()
+ .map(|r| OrderView {
+ id: r.try_get("id").unwrap_or_default(),
+ username: r.try_get("username").unwrap_or_default(),
+ item: r
+ .try_get::<Option<String>, _>("item")
+ .unwrap_or(None)
+ .unwrap_or_else(|| "(item removed)".to_string()),
+ variant: r.try_get("merch_variant").unwrap_or(None),
+ amount_cents: r.try_get("amount_cents").unwrap_or(0),
+ paid_at: r.try_get("paid_at").unwrap_or(None),
+ shipping_address: r.try_get("shipping_address").unwrap_or(None),
+ shipped_at: r.try_get("shipped_at").unwrap_or(None),
+ })
+ .collect();
+
+ Ok(Json(orders))
+}
+
+async fn mark_shipped(
+ State(state): State<Arc<AppState>>,
+ jar: CookieJar,
+ headers: HeaderMap,
+ Path(id): Path<String>,
+) -> Result<impl IntoResponse, AppError> {
+ require_admin(&state, &jar, &headers).await?;
+
+ let done = sqlx::query(
+ "UPDATE purchases SET shipped_at = $1
+ WHERE id = $2 AND kind = 'merch' AND status = 'paid' AND shipped_at IS NULL",
+ )
+ .bind(crate::auth::format_timestamp(time::OffsetDateTime::now_utc()))
+ .bind(&id)
+ .execute(&state.db)
+ .await?;
+
+ if done.rows_affected() == 0 {
+ return Err(AppError::NotFound);
+ }
+ Ok(axum::http::StatusCode::NO_CONTENT)
+}
diff --git a/crates/server/src/billing.rs b/crates/server/src/billing.rs
index 2852b0d..7ae3207 100644
--- a/crates/server/src/billing.rs
+++ b/crates/server/src/billing.rs
@@ -31,6 +31,13 @@ use std::sync::Arc;
use subtle::ConstantTimeEq;
use time::{Duration as TimeDuration, OffsetDateTime};
+/// Where physical orders can be sent. Kept short deliberately: every country
+/// added is one somebody has to be willing to post to and handle returns for.
+const SHIPPING_COUNTRIES: &[&str] = &[
+ "US", "CA", "GB", "IE", "AU", "NZ", "DE", "FR", "NL", "BE", "ES", "IT",
+ "SE", "NO", "DK", "FI", "PL", "PT", "AT", "CH", "ZA",
+];
+
/// What the supporter subscription costs, and how long it lasts.
const SUPPORTER_PRICE_CENTS: i32 = 300;
const SUPPORTER_DAYS: i64 = 30;
@@ -43,6 +50,7 @@ pub fn router() -> Router<Arc<AppState>> {
Router::new()
.route("/api/billing/checkout/:cosmetic_id", post(checkout_cosmetic))
.route("/api/billing/bundle/:bundle_id", post(checkout_bundle))
+ .route("/api/billing/merch/:merch_id", post(checkout_merch))
.route("/api/billing/supporter", post(checkout_supporter))
.route("/api/billing/webhook", post(webhook))
}
@@ -76,6 +84,7 @@ async fn create_session(
kind: &str,
cosmetic_id: Option<&str>,
bundle_id: Option<&str>,
+ merch: Option<(&str, Option<&str>, i32)>,
name: &str,
amount_cents: i32,
) -> Result<String, AppError> {
@@ -113,6 +122,35 @@ async fn create_session(
if let Some(id) = cosmetic_id {
form.push(("metadata[cosmetic_id]".into(), id.to_string()));
}
+ if let Some((merch_id, variant, shipping_cents)) = merch {
+ form.push(("metadata[merch_id]".into(), merch_id.to_string()));
+ if let Some(v) = variant {
+ form.push(("metadata[merch_variant]".into(), v.to_string()));
+ }
+ // Stripe collects the address on its own page, so no postal detail is
+ // ever entered on this site or held by it before an order exists.
+ for (i, country) in SHIPPING_COUNTRIES.iter().enumerate() {
+ form.push((
+ format!("shipping_address_collection[allowed_countries][{i}]"),
+ (*country).to_string(),
+ ));
+ }
+ form.push(("phone_number_collection[enabled]".into(), "false".into()));
+ // Postage as its own line, so the buyer sees what it costs rather
+ // than finding it folded into the price.
+ if shipping_cents > 0 {
+ form.push(("line_items[1][quantity]".into(), "1".into()));
+ form.push(("line_items[1][price_data][currency]".into(), "usd".into()));
+ form.push((
+ "line_items[1][price_data][unit_amount]".into(),
+ shipping_cents.to_string(),
+ ));
+ form.push((
+ "line_items[1][price_data][product_data][name]".into(),
+ "Shipping".into(),
+ ));
+ }
+ }
let res = state
.http
@@ -140,15 +178,19 @@ async fn create_session(
let session: Session = res.json().await.map_err(|e| AppError::Internal(e.into()))?;
sqlx::query(
- "INSERT INTO purchases (id, user_id, kind, cosmetic_id, bundle_id, amount_cents, currency, status, session_id, created_at)
- VALUES ($1, $2, $3, $4, $5, $6, 'usd', 'pending', $7, $8)",
+ "INSERT INTO purchases (id, user_id, kind, cosmetic_id, bundle_id, merch_id, merch_variant,
+ amount_cents, currency, status, session_id, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'usd', 'pending', $9, $10)",
)
.bind(&purchase_id)
.bind(user_id)
.bind(kind)
.bind(cosmetic_id)
.bind(bundle_id)
- .bind(amount_cents)
+ .bind(merch.map(|(id, _, _)| id))
+ .bind(merch.and_then(|(_, v, _)| v))
+ // The recorded amount includes postage, because that is what was charged.
+ .bind(amount_cents + merch.map_or(0, |(_, _, s)| s))
.bind(&session.id)
.bind(format_timestamp(OffsetDateTime::now_utc()))
.execute(&state.db)
@@ -183,7 +225,7 @@ async fn checkout_cosmetic(
return Err(AppError::InvalidInput("you already own that".into()));
}
- let url = create_session(&state, &user.id, "cosmetic", Some(&cosmetic_id), None, &name, price).await?;
+ let url = create_session(&state, &user.id, "cosmetic", Some(&cosmetic_id), None, None, &name, price).await?;
Ok(Json(serde_json::json!({ "url": url })))
}
@@ -217,7 +259,69 @@ async fn checkout_bundle(
return Err(AppError::InvalidInput("you already own everything in that bundle".into()));
}
- let url = create_session(&state, &user.id, "bundle", None, Some(&bundle_id), &name, price).await?;
+ let url = create_session(&state, &user.id, "bundle", None, Some(&bundle_id), None, &name, price).await?;
+ Ok(Json(serde_json::json!({ "url": url })))
+}
+
+#[derive(Deserialize)]
+struct MerchRequest {
+ #[serde(default)]
+ variant: Option<String>,
+}
+
+async fn checkout_merch(
+ State(state): State<Arc<AppState>>,
+ jar: CookieJar,
+ Path(merch_id): Path<String>,
+ Json(body): Json<MerchRequest>,
+) -> Result<impl IntoResponse, AppError> {
+ let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?;
+
+ let row = sqlx::query(
+ "SELECT name, price_cents, shipping_cents, variants, available FROM merch WHERE id = $1",
+ )
+ .bind(&merch_id)
+ .fetch_optional(&state.db)
+ .await?
+ .ok_or(AppError::NotFound)?;
+
+ let available: bool = row.try_get("available")?;
+ if !available {
+ return Err(AppError::InvalidInput("that item is not for sale".into()));
+ }
+
+ let name: String = row.try_get("name")?;
+ let price: i32 = row.try_get("price_cents")?;
+ let shipping: i32 = row.try_get("shipping_cents")?;
+ let variants: Vec<String> = row.try_get("variants").unwrap_or_default();
+
+ // The variant has to be one this item actually comes in. A request naming
+ // anything else is rejected rather than quietly posted as a medium.
+ let variant = match (&body.variant, variants.is_empty()) {
+ (_, true) => None,
+ (Some(v), false) if variants.iter().any(|allowed| allowed == v) => Some(v.clone()),
+ _ => {
+ return Err(AppError::InvalidInput(
+ "choose a size before buying".into(),
+ ))
+ }
+ };
+
+ let label = match &variant {
+ Some(v) => format!("{name} ({v})"),
+ None => name,
+ };
+ let url = create_session(
+ &state,
+ &user.id,
+ "merch",
+ None,
+ None,
+ Some((&merch_id, variant.as_deref(), shipping)),
+ &label,
+ price,
+ )
+ .await?;
Ok(Json(serde_json::json!({ "url": url })))
}
@@ -232,6 +336,7 @@ async fn checkout_supporter(
"supporter",
None,
None,
+ None,
"TyperPunk supporter, 30 days",
SUPPORTER_PRICE_CENTS,
)
@@ -304,6 +409,58 @@ struct WebhookData {
#[derive(Deserialize)]
struct WebhookObject {
id: String,
+ /// Present on a physical order. Stripe collected it on its own page, so
+ /// this is the first time the address reaches this server.
+ #[serde(default)]
+ shipping_details: Option<ShippingDetails>,
+}
+
+#[derive(Deserialize)]
+struct ShippingDetails {
+ #[serde(default)]
+ name: Option<String>,
+ #[serde(default)]
+ address: Option<ShippingAddress>,
+}
+
+#[derive(Deserialize)]
+struct ShippingAddress {
+ #[serde(default)]
+ line1: Option<String>,
+ #[serde(default)]
+ line2: Option<String>,
+ #[serde(default)]
+ city: Option<String>,
+ #[serde(default)]
+ state: Option<String>,
+ #[serde(default)]
+ postal_code: Option<String>,
+ #[serde(default)]
+ country: Option<String>,
+}
+
+impl ShippingDetails {
+ /// One readable block for whoever packs the parcel. Stored as text rather
+ /// than as columns because nothing queries the parts of an address, and
+ /// splitting it would only invite assumptions about how addresses work in
+ /// countries that do not work that way.
+ fn to_label(&self) -> String {
+ let a = self.address.as_ref();
+ [
+ self.name.clone(),
+ a.and_then(|a| a.line1.clone()),
+ a.and_then(|a| a.line2.clone()),
+ a.and_then(|a| a.city.clone()),
+ a.and_then(|a| a.state.clone()),
+ a.and_then(|a| a.postal_code.clone()),
+ a.and_then(|a| a.country.clone()),
+ ]
+ .into_iter()
+ .flatten()
+ .filter(|part| !part.trim().is_empty())
+ .collect::<Vec<_>>()
+ .join("\n")
+ }
}
async fn webhook(
@@ -394,6 +551,28 @@ async fn webhook(
.await?;
}
}
+ "merch" => {
+ // Nothing is unlocked by buying a mug. What the payment produces
+ // is an order for somebody to pack, so the address is stored and
+ // shipped_at is left null until it goes out.
+ let address = event
+ .data
+ .object
+ .shipping_details
+ .as_ref()
+ .map(|d| d.to_label())
+ .filter(|a| !a.is_empty());
+ if address.is_none() {
+ // Worth knowing about: it means an order was paid for with
+ // nowhere to send it, which needs a human either way.
+ tracing::error!("paid merch order {purchase_id} arrived with no shipping address");
+ }
+ sqlx::query("UPDATE purchases SET shipping_address = $1 WHERE id = $2")
+ .bind(address)
+ .bind(&purchase_id)
+ .execute(&mut *tx)
+ .await?;
+ }
"supporter" => {
// Extends from whichever is later, so renewing early does not
// throw away the time already paid for.
diff --git a/crates/server/src/cosmetics.rs b/crates/server/src/cosmetics.rs
index de70950..ee9a50d 100644
--- a/crates/server/src/cosmetics.rs
+++ b/crates/server/src/cosmetics.rs
@@ -16,6 +16,7 @@ pub fn router() -> Router<Arc<AppState>> {
.route("/api/cosmetics", get(list_catalog))
.route("/api/cosmetics/me", get(my_cosmetics))
.route("/api/cosmetics/bundles", get(list_bundles))
+ .route("/api/merch", get(list_merch))
.route("/api/cosmetics/:id/equip", post(equip))
.route("/api/cosmetics/unequip", post(unequip))
}
@@ -61,6 +62,34 @@ struct MyCosmetics {
is_supporter: bool,
}
+/// What is for sale in the physical store.
+async fn list_merch(State(state): State<Arc<AppState>>) -> Result<impl IntoResponse, AppError> {
+ let rows = sqlx::query(
+ "SELECT id, name, description, price_cents, kind, variants, shipping_cents
+ FROM merch WHERE available ORDER BY sort_order",
+ )
+ .fetch_all(&state.db)
+ .await?;
+
+ let items: Vec<serde_json::Value> = rows
+ .iter()
+ .map(|r| {
+ let variants: Vec<String> = r.try_get("variants").unwrap_or_default();
+ serde_json::json!({
+ "id": r.try_get::<String, _>("id").unwrap_or_default(),
+ "name": r.try_get::<String, _>("name").unwrap_or_default(),
+ "description": r.try_get::<Option<String>, _>("description").unwrap_or(None),
+ "price_cents": r.try_get::<i32, _>("price_cents").unwrap_or(0),
+ "shipping_cents": r.try_get::<i32, _>("shipping_cents").unwrap_or(0),
+ "kind": r.try_get::<String, _>("kind").unwrap_or_default(),
+ "variants": variants,
+ })
+ })
+ .collect();
+
+ Ok(Json(items))
+}
+
/// Bundles, with the items each contains so the store can show what is in one
/// and what the buyer already owns.
async fn list_bundles(State(state): State<Arc<AppState>>) -> Result<impl IntoResponse, AppError> {
diff --git a/web/src/adSlot.js b/web/src/adSlot.js
index 6dfb5b5..b87ae4f 100644
--- a/web/src/adSlot.js
+++ b/web/src/adSlot.js
@@ -31,6 +31,8 @@ import { getUser, onAuthChange } from './auth.js';
const SIZES = {
// Roughly a leaderboard unit, and a mobile banner below the breakpoint.
banner: { w: 728, h: 90, mobileW: 320, mobileH: 100 },
+ // A wide skyscraper, the usual unit for a page margin.
+ skyscraper: { w: 160, h: 600, mobileW: 160, mobileH: 600 },
};
export function shouldShowAds() {
@@ -65,6 +67,34 @@ export function renderAdSlot(root, { size = 'banner', label = 'Advertisement' }
}
+// The two page margins. They appear only where there is genuinely room for
+// them beside the content, which the stylesheet decides, and they are the
+// first thing dropped on a narrow window.
+export function mountSideAdRails() {
+ const rails = ['left', 'right'].map(side => {
+ const rail = document.createElement('div');
+ rail.className = `ad-rail ad-rail-${side}`;
+ document.body.appendChild(rail);
+ return { rail, cleanup: renderAdSlot(rail, { size: 'skyscraper' }) };
+ });
+
+ let screenName = 'menu';
+ function paint() {
+ const show = shouldShowAds() && !HIDDEN_ON.has(screenName);
+ for (const { rail } of rails) rail.hidden = !show;
+ }
+ paint();
+ const unsubscribe = onAuthChange(paint);
+
+ return {
+ setScreen(name) { screenName = name; paint(); },
+ cleanup() {
+ unsubscribe();
+ for (const { rail, cleanup } of rails) { cleanup(); rail.remove(); }
+ },
+ };
+}
+
// Screens the banner stays off. Typing and passive reading are both somebody
// working through a text, and the race lobby leads straight into typing.
const HIDDEN_ON = new Set(['typing', 'passive', 'multiplayer']);
diff --git a/web/src/app.js b/web/src/app.js
index 4ef6c2e..b306c66 100644
--- a/web/src/app.js
+++ b/web/src/app.js
@@ -18,7 +18,7 @@ import { saveDocument, setPosition, getDocument, listDocuments, removeDocument }
import { getSettings } from './settings.js';
import { generateWordStream, generateWeakKeyStream, wordCountForDuration } from './wordGenerator.js';
import { getWeakChars } from './keyStats.js';
-import { mountTopAdBanner } from './adSlot.js';
+import { mountTopAdBanner, mountSideAdRails } from './adSlot.js';
const FALLBACK_TEXT = { category: 'general', content: 'The quick brown fox jumps over the lazy dog.', attribution: 'Traditional pangram' };
@@ -98,6 +98,7 @@ export function startApp(root, localTexts) {
let game = null;
let cleanupScreen = null;
const adBanner = mountTopAdBanner();
+ const adRails = mountSideAdRails();
// Approved community submissions, merged on top of the bundled dataset.
// Best-effort: the app is fully usable on the packs it ships with, so a
@@ -146,6 +147,7 @@ export function startApp(root, localTexts) {
currentScreenName = name;
escapeIsCostly = costly;
adBanner.setScreen(name);
+ adRails.setScreen(name);
}
function handleGlobalEscape(e) {
diff --git a/web/src/screens/contributeScreen.js b/web/src/screens/contributeScreen.js
index b84ee40..b5eb262 100644
--- a/web/src/screens/contributeScreen.js
+++ b/web/src/screens/contributeScreen.js
@@ -28,6 +28,7 @@ export function renderContributeScreen(root, { onBack, onShowStats, onShowPlaceh
let isAdmin = false;
let roleHolders = [];
let userSearch = [];
+ let orders = [];
let cleanupInner = null;
function statusLabel(s) {
@@ -47,6 +48,14 @@ export function renderContributeScreen(root, { onBack, onShowStats, onShowPlaceh
// ordinary case for almost everyone.
try { roleHolders = await api.get('/api/admin/users'); isAdmin = true; }
catch { roleHolders = []; isAdmin = false; }
+ // Paid orders waiting to be posted. Only an administrator can see
+ // them, and they carry a postal address, so this is the one screen in
+ // the app that shows one.
+ if (isAdmin) {
+ try { orders = await api.get('/api/admin/orders'); } catch { orders = []; }
+ } else {
+ orders = [];
+ }
}
function submissionRow(s, moderating) {
@@ -116,6 +125,30 @@ export function renderContributeScreen(root, { onBack, onShowStats, onShowPlaceh
</div>
` : ''}
+ ${isAdmin ? `
+ <h3>Orders to post${orders.length ? ` (${orders.length})` : ''}</h3>
+ <div class="settings-hint">Paid and not yet posted. Marking one sent does not notify the buyer.</div>
+ ${orders.length ? `
+ <div class="submission-list">
+ ${orders.map(o => `
+ <div class="submission-row order-row" data-order="${escapeHtml(o.id)}">
+ <div class="submission-head">
+ <span class="submission-category">${escapeHtml(o.item)}${o.variant ? ` &middot; ${escapeHtml(o.variant)}` : ''}</span>
+ <span class="submission-by">for ${escapeHtml(o.username)}</span>
+ <span class="submission-status">$${(o.amount_cents / 100).toFixed(2)}</span>
+ </div>
+ <div class="order-address">${o.shipping_address
+ ? escapeHtml(o.shipping_address).replace(/\n/g, '<br>')
+ : 'No address recorded. Check the payment processor before posting.'}</div>
+ <div class="submission-actions">
+ <button class="menu-button small primary" data-action="mark-shipped" data-order="${escapeHtml(o.id)}">Mark posted</button>
+ </div>
+ </div>
+ `).join('')}
+ </div>
+ ` : '<div class="stats-empty">Nothing waiting to go out.</div>'}
+ ` : ''}
+
${isModerator ? `
<h3>Review queue${queue.length ? ` (${queue.length})` : ''}</h3>
${queue.length
@@ -185,7 +218,16 @@ export function renderContributeScreen(root, { onBack, onShowStats, onShowPlaceh
});
});
- root.querySelectorAll('.submission-actions button').forEach(btn => {
+ root.querySelectorAll('[data-action="mark-shipped"]').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ try { await api.post(`/api/admin/orders/${encodeURIComponent(btn.dataset.order)}/shipped`); }
+ catch { /* reloading below shows what actually happened */ }
+ await load();
+ render();
+ });
+ });
+
+ root.querySelectorAll('.submission-actions button:not([data-action="mark-shipped"])').forEach(btn => {
btn.addEventListener('click', async () => {
const row = btn.closest('.submission-row');
const decision = btn.dataset.action === 'approve' ? 'approve' : 'reject';
diff --git a/web/src/screens/multiplayerScreen.js b/web/src/screens/multiplayerScreen.js
index 878f659..053a461 100644
--- a/web/src/screens/multiplayerScreen.js
+++ b/web/src/screens/multiplayerScreen.js
@@ -50,6 +50,12 @@ function applyInlineStyles(container) {
container.querySelectorAll('[data-percent]').forEach(el => {
el.style.width = `${el.dataset.percent}%`;
});
+ // How far along the track a sprite has travelled, as a fraction. The
+ // stylesheet turns it into a position; a percentage on its own would put
+ // the sprite's left edge at the finish rather than the sprite itself.
+ container.querySelectorAll('[data-progress]').forEach(el => {
+ el.style.setProperty('--p', String(Math.max(0, Math.min(1, Number(el.dataset.progress) / 100)) || 0));
+ });
}
// No account required - a quick race with friends shouldn't need signing
@@ -281,11 +287,18 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o
opponents.innerHTML = players.map(p => {
const me = p.id === myId;
const prog = progressById[p.id] || {};
+ const percent = Math.round(prog.percent || 0);
+ // The sprite rides the track rather than sitting beside it.
+ // A static icon next to a thin bar made the one thing a
+ // player owns and can see the least visible part of the race.
return `
<div class="mp-racer-row${me ? ' me' : ''}" data-racer-color="${colors[p.id]}">
- <span class="mp-racer-sprite">${RACER_SPRITES[sprites[p.id]]}</span>
<span class="mp-racer-name">${escapeHtml(p.name)}${me ? ' (you)' : ''}</span>
- <div class="mp-racer-bar"><div class="mp-racer-bar-fill" data-percent="${prog.percent || 0}"></div></div>
+ <div class="mp-racer-track">
+ <div class="mp-racer-trail" data-percent="${percent}"></div>
+ <span class="mp-racer-sprite" data-progress="${percent}">${RACER_SPRITES[sprites[p.id]]}</span>
+ </div>
+ <span class="mp-racer-percent">${percent}%</span>
<span class="mp-racer-wpm">${Math.round(prog.wpm || 0)}</span>
</div>`;
}).join('');
diff --git a/web/src/screens/storeScreen.js b/web/src/screens/storeScreen.js
index 5299582..26cbade 100644
--- a/web/src/screens/storeScreen.js
+++ b/web/src/screens/storeScreen.js
@@ -15,6 +15,13 @@ function formatPrice(cents) {
// far taller than the others beside it.
const MAX_PREVIEWS = 10;
+function merchKindLabel(kind) {
+ if (kind === 'shirt') return 'Shirts';
+ if (kind === 'mug') return 'Mugs';
+ if (kind === 'deskmat') return 'Deskmats';
+ return 'Other';
+}
+
function categoryLabel(category) {
if (category === 'caret') return 'Caret Colours';
if (category === 'sprite') return 'Race Sprites';
@@ -28,6 +35,9 @@ function categoryLabel(category) {
export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer }) {
let catalog = [];
let bundles = [];
+ let merch = [];
+ // Size picked per item, so a shirt cannot be bought without one.
+ const chosenVariant = {};
let mine = { owned: [], equipped_caret: null, equipped_flair: null, equipped_sprite: null, is_supporter: false };
let status = 'loading';
let message = '';
@@ -106,6 +116,35 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
</div>`;
}
+ function merchMarkup(item) {
+ const signedOut = status === 'signed-out';
+ const needsVariant = (item.variants || []).length > 0;
+ const picked = chosenVariant[item.id];
+ return `
+ <div class="merch-card">
+ <div class="merch-head">
+ <div class="merch-name">${escapeHtml(item.name)}</div>
+ <div class="merch-price">${formatPrice(item.price_cents)}</div>
+ </div>
+ ${item.description ? `<div class="merch-description">${escapeHtml(item.description)}</div>` : ''}
+ ${needsVariant ? `
+ <div class="merch-variants">
+ ${item.variants.map(v => `
+ <button class="merch-variant${picked === v ? ' active' : ''}" data-action="pick-variant"
+ data-id="${escapeHtml(item.id)}" data-variant="${escapeHtml(v)}">${escapeHtml(v)}</button>
+ `).join('')}
+ </div>` : ''}
+ <div class="merch-foot">
+ <span class="merch-shipping">${item.shipping_cents ? `plus ${formatPrice(item.shipping_cents)} postage` : 'postage included'}</span>
+ <button class="menu-button small${signedOut ? ' quiet' : ' primary'}"
+ data-action="${signedOut ? 'go-account' : 'buy-merch'}"
+ data-id="${escapeHtml(item.id)}"
+ ${signedOut ? ' data-tooltip="Sign in to order"' : ''}>Order</button>
+ </div>
+ <div class="merch-error" data-error-for="${escapeHtml(item.id)}"></div>
+ </div>`;
+ }
+
function supporterMarkup() {
if (status === 'signed-out') return '';
if (mine.is_supporter) {
@@ -139,6 +178,18 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
? `<h3>Bundles</h3><div class="bundle-grid">${bundles.map(bundleMarkup).join('')}</div>`
: '';
+ const merchKinds = ['shirt', 'mug', 'deskmat'];
+ const merchSections = merch.length
+ ? `<h3>Merch</h3>
+ <div class="settings-hint merch-note">Posted to 21 countries. Your address is entered on the payment page and never on this site.</div>`
+ + merchKinds.map(kind => {
+ const items = merch.filter(m => m.kind === kind);
+ if (!items.length) return '';
+ return `<h4 class="merch-kind">${merchKindLabel(kind)}</h4>
+ <div class="merch-grid">${items.map(merchMarkup).join('')}</div>`;
+ }).join('')
+ : '';
+
const categories = ['caret', 'flair', 'sprite'];
const itemSections = categories.map(cat => {
const items = catalog.filter(i => i.category === cat);
@@ -146,16 +197,16 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
return `<h3>${categoryLabel(cat)}</h3><div class="leaderboard-list">${items.map(itemMarkup).join('')}</div>`;
}).join('');
- return banner + supporterMarkup() + bundleSection + itemSections
+ return banner + supporterMarkup() + bundleSection + itemSections + merchSections
+ `<div class="custom-error store-error"></div>`;
}
/// Starts a checkout and follows the redirect the server returns. A server
/// with no processor keys answers 501, which is reported rather than
/// leaving the button looking broken.
- async function startCheckout(path, errorEl) {
+ async function startCheckout(path, errorEl, body) {
try {
- const data = await api.post(path);
+ const data = await api.post(path, body);
if (data && data.url) window.location.assign(data.url);
} catch (err) {
if (!errorEl) return;
@@ -196,6 +247,29 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
root.querySelector('[data-action="buy-supporter"]')
?.addEventListener('click', () => startCheckout('/api/billing/supporter', errorEl));
+ root.querySelectorAll('[data-action="pick-variant"]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ chosenVariant[btn.dataset.id] = btn.dataset.variant;
+ rerender();
+ });
+ });
+ root.querySelectorAll('[data-action="buy-merch"]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const id = btn.dataset.id;
+ const item = merch.find(m => m.id === id);
+ const cardError = root.querySelector(`[data-error-for="${CSS.escape(id)}"]`);
+ // Checked here as well as on the server, so choosing a size is
+ // a click rather than a failed checkout.
+ if (item && (item.variants || []).length && !chosenVariant[id]) {
+ if (cardError) cardError.textContent = 'Choose a size first.';
+ return;
+ }
+ if (cardError) cardError.textContent = '';
+ startCheckout(`/api/billing/merch/${encodeURIComponent(id)}`, cardError || errorEl,
+ { variant: chosenVariant[id] || null });
+ });
+ });
+
root.querySelectorAll('[data-action="equip"]').forEach(btn => {
btn.addEventListener('click', async () => {
await api.post(`/api/cosmetics/${btn.dataset.id}/equip`).catch(() => {});
@@ -233,6 +307,7 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
// A store with no bundles is still a store, so this failing is not an
// error worth replacing the page with.
try { bundles = await api.get('/api/cosmetics/bundles'); } catch { bundles = []; }
+ try { merch = await api.get('/api/merch'); } catch { merch = []; }
}
async function loadMine() {
diff --git a/web/src/styles.css b/web/src/styles.css
index f51bb65..ee132d9 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -8,6 +8,9 @@
--neutral-color: #646669;
--caret-color: #00ff9d;
--sub-color: #8b8e92;
+ /* Panel and control outlines. Derived from the neutral so it tracks each
+ theme, rather than being a sixth colour to keep in step by hand. */
+ --border-color: color-mix(in srgb, var(--neutral-color) 55%, transparent);
/* Unified top offset below fixed header/toggle */
/* Reserved height for the top advertising banner, and the amount the
header moves down when one is shown. --ad-banner-h is set on the root
@@ -1945,21 +1948,23 @@ body {
left out the one bar you most need to see: your own. */
.mp-racers {
position: fixed;
- top: 58%;
+ top: 56%;
left: 50%;
transform: translateX(-50%);
z-index: 90;
display: flex;
flex-direction: column;
- gap: 0.5rem;
- width: min(680px, 80vw);
+ gap: 0.35rem;
+ /* Wider than it was. The field is the thing you look up at between
+ glances at the passage, so it gets the room. */
+ width: min(1040px, 94vw);
}
.mp-racer-row {
display: flex;
align-items: center;
- gap: 0.75rem;
- font-size: 0.8rem;
+ gap: 0.85rem;
+ font-size: 0.9rem;
color: var(--racer-color, var(--sub-color));
}
@@ -1970,32 +1975,61 @@ body {
}
.mp-racer-name {
- min-width: 7rem;
- max-width: 7rem;
+ min-width: 9rem;
+ max-width: 9rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
}
-.mp-racer-bar {
+/* The track the sprite travels along. Tall enough to hold the sprite, with
+ the trail drawn behind it. */
+.mp-racer-track {
+ position: relative;
flex: 1 1 auto;
- height: 0.4rem;
- background: color-mix(in srgb, var(--neutral-color) 45%, transparent);
- border-radius: var(--radius-pill);
- overflow: hidden;
+ height: 2rem;
+ border-bottom: var(--border-width) dashed color-mix(in srgb, var(--neutral-color) 45%, transparent);
}
-.mp-racer-bar-fill {
- height: 100%;
+/* How far this racer has come. Sits at the foot of the track, on the dashed
+ line, so the line reads as road ahead and the trail as road behind. */
+.mp-racer-trail {
+ position: absolute;
+ left: 0;
+ bottom: -1px;
+ height: 2px;
background: var(--racer-color, var(--primary-color));
transition: width 0.15s ease;
}
+/* The sprite rides the track. The position is the fraction of the way along,
+ scaled by the room left after the sprite's own width, so at 100 percent the
+ sprite sits at the finish rather than overhanging it. */
+.mp-racer-sprite {
+ position: absolute;
+ bottom: 0.15rem;
+ left: calc(var(--p, 0) * (100% - 1.9rem));
+ width: 1.9rem;
+ height: 1.9rem;
+ transition: left 0.15s ease;
+}
+
+.mp-racer-row.me .mp-racer-sprite {
+ filter: drop-shadow(0 0 4px var(--racer-color, var(--primary-color)));
+}
+
+.mp-racer-percent,
.mp-racer-wpm {
- min-width: 2.5rem;
- text-align: left;
+ min-width: 3rem;
+ text-align: right;
font-family: 'JetBrains Mono', monospace;
+ font-variant-numeric: tabular-nums;
+}
+
+.mp-racer-percent {
+ color: var(--sub-color);
+ font-size: 0.8rem;
}
/* The one genuinely time-critical state in the app: seconds left to get your
@@ -3050,6 +3084,138 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
min-width: 5.5rem;
}
+/* A postal address, shown to an administrator so an order can be packed. */
+.order-address {
+ margin: 0.4rem 0;
+ padding: 0.5rem 0.75rem;
+ border-left: 2px solid var(--border-color);
+ color: var(--text-color);
+ font-size: 0.8rem;
+ line-height: 1.5;
+ white-space: normal;
+}
+
+/* Physical goods. A card, like a bundle, because each one has a description,
+ possibly a size to pick, and a postage line. */
+.merch-note {
+ text-align: left;
+ width: 100%;
+ max-width: none;
+ margin-bottom: 0.75rem;
+}
+
+.merch-kind {
+ width: 100%;
+ text-align: left;
+ color: var(--sub-color);
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ margin: 0.75rem 0 0.4rem 0;
+}
+
+.merch-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
+ gap: 0.75rem;
+ width: 100%;
+ margin-bottom: 0.75rem;
+ box-sizing: border-box;
+}
+
+.merch-card {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ padding: 0.9rem 1rem;
+ border: var(--border-width) solid var(--border-color);
+ border-radius: var(--radius-panel);
+ text-align: left;
+}
+
+.merch-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.merch-name {
+ color: var(--text-color);
+ font-size: 0.95rem;
+ font-weight: 600;
+}
+
+.merch-price {
+ color: var(--text-color);
+ font-size: 1rem;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.merch-description {
+ color: var(--sub-color);
+ font-size: 0.8rem;
+ line-height: 1.45;
+}
+
+.merch-variants {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+}
+
+.merch-variant {
+ padding: 0.3rem 0.6rem;
+ border: var(--border-width) solid var(--border-color);
+ border-radius: var(--radius);
+ background: none;
+ color: var(--sub-color);
+ font-family: inherit;
+ font-size: 0.75rem;
+ cursor: pointer;
+}
+
+.merch-variant:hover {
+ border-color: var(--primary-color);
+ color: var(--text-color);
+}
+
+.merch-variant.active {
+ border-color: var(--primary-color);
+ background: var(--primary-color);
+ color: var(--background-color);
+}
+
+.merch-foot {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ margin-top: auto;
+ padding-top: 0.25rem;
+}
+
+.merch-foot .menu-button.small {
+ margin: 0;
+ padding: 0.35rem 0.9rem;
+ font-size: 0.8rem;
+}
+
+.merch-shipping {
+ color: var(--sub-color);
+ font-size: 0.72rem;
+}
+
+.merch-error:empty {
+ display: none;
+}
+
+.merch-error {
+ color: var(--error-color, var(--incorrect-color));
+ font-size: 0.75rem;
+}
+
/* 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 {
@@ -3673,12 +3839,18 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
display: inline-flex;
align-items: center;
justify-content: center;
- width: 1.15rem;
- height: 1.15rem;
flex-shrink: 0;
color: var(--racer-color, var(--primary-color));
}
+/* Beside a name in a list, rather than on a track. */
+.leaderboard-name .mp-racer-sprite,
+.mp-standings-name .mp-racer-sprite {
+ position: static;
+ width: 1.15rem;
+ height: 1.15rem;
+}
+
.mp-racer-sprite svg {
width: 100%;
height: 100%;
@@ -3754,6 +3926,41 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
box-sizing: border-box;
}
+/* The page margins. Hidden by default and shown only on a window wide enough
+ to hold them beside the content without crowding it: the widest content
+ column is 1040px, and two 160px rails with gaps need 1424px before they fit
+ without pushing anything. Below that they simply do not exist. */
+.ad-rail {
+ display: none;
+}
+
+.ad-rail[hidden] {
+ display: none;
+}
+
+@media (min-width: 1424px) {
+ .ad-rail:not([hidden]) {
+ display: flex;
+ position: fixed;
+ top: 50%;
+ transform: translateY(-50%);
+ z-index: 80;
+ align-items: center;
+ }
+
+ .ad-rail-left:not([hidden]) {
+ left: 1.5rem;
+ }
+
+ .ad-rail-right:not([hidden]) {
+ right: 1.5rem;
+ }
+
+ .ad-rail .ad-slot {
+ margin: 0;
+ }
+}
+
/* Dashed and unfilled, which is the one place that convention is right: it is
a placeholder, and it should not be mistaken for content. */
.ad-slot-label {