srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rwxr-xr-xREADME.md16
-rw-r--r--crates/core/src/stats.rs3
-rw-r--r--crates/server/.env.example15
-rw-r--r--crates/server/Cargo.toml3
-rw-r--r--crates/server/build.rs7
-rw-r--r--crates/server/migrations/0014_billing.sql27
-rw-r--r--crates/server/migrations/0015_pricing_bundles.sql78
-rw-r--r--crates/server/migrations/0016_starter_price.sql4
-rw-r--r--crates/server/src/auth.rs18
-rw-r--r--crates/server/src/billing.rs461
-rw-r--r--crates/server/src/cosmetics.rs85
-rw-r--r--crates/server/src/main.rs14
-rw-r--r--crates/server/src/state.rs3
-rw-r--r--web/src/screens/storeScreen.js158
-rw-r--r--web/src/styles.css127
15 files changed, 943 insertions, 76 deletions
diff --git a/README.md b/README.md
index 615a2ec..595b0e6 100755
--- a/README.md
+++ b/README.md
@@ -160,6 +160,22 @@ Migrations run at startup. Configuration is by environment variable; see
| `TEXTS_JSON_PATH` | Dataset the race passages come from |
| `SPOTIFY_CLIENT_ID` | Spotify application ID, for Lyrics mode |
| `SPOTIFY_CLIENT_SECRET` | Spotify application secret |
+| `STRIPE_SECRET_KEY` | Stripe API key, for the store |
+| `STRIPE_WEBHOOK_SECRET` | Signing secret for the Stripe webhook |
+
+### Store
+
+The store sells cosmetics and a supporter subscription. Checkout is hosted by
+Stripe: the buyer is redirected there, pays there, and returns. No card
+details reach this server.
+
+Nothing is granted at checkout. Stripe calls `/api/billing/webhook` when the
+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.
## Deployment
diff --git a/crates/core/src/stats.rs b/crates/core/src/stats.rs
index fe94468..e46e54b 100644
--- a/crates/core/src/stats.rs
+++ b/crates/core/src/stats.rs
@@ -117,13 +117,12 @@ impl Stats {
let mut best_streak_local = 0;
let mut correct_chars = 0usize;
let mut incorrect_chars = 0usize;
- let mut total_words = 0usize;
let mut correct_words = 0usize;
// Tokenize by whitespace to count words
let input_words: Vec<&str> = input.split_whitespace().collect();
let target_words: Vec<&str> = target.split_whitespace().collect();
- total_words = input_words.len();
+ let total_words = input_words.len();
for (iw, tw) in input_words.iter().zip(target_words.iter()) {
if *iw == *tw { correct_words += 1; }
}
diff --git a/crates/server/.env.example b/crates/server/.env.example
index b7501a1..91416c4 100644
--- a/crates/server/.env.example
+++ b/crates/server/.env.example
@@ -40,3 +40,18 @@ COOKIE_SECURE=0
# never asks end users for their own API keys, only its own.
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
+
+# Required for the store. Register at https://dashboard.stripe.com and use a
+# test-mode key (sk_test_...) for development. Checkout is hosted by Stripe,
+# so no card details reach this server.
+#
+# STRIPE_WEBHOOK_SECRET comes from the webhook endpoint you create in the
+# Stripe dashboard, pointing at /api/billing/webhook. Nothing is granted to a
+# buyer until a webhook arrives with a valid signature, so without this the
+# store takes payments and delivers nothing. For local work, `stripe listen
+# --forward-to localhost:8787/api/billing/webhook` prints a secret to use.
+#
+# Without both, every checkout route answers 501 and the store stays visible
+# but unbuyable.
+STRIPE_SECRET_KEY=
+STRIPE_WEBHOOK_SECRET=
diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml
index d0292fc..afb7f25 100644
--- a/crates/server/Cargo.toml
+++ b/crates/server/Cargo.toml
@@ -23,6 +23,9 @@ serde_json.workspace = true
anyhow.workspace = true
thiserror.workspace = true
argon2.workspace = true
+hmac.workspace = true
+sha2.workspace = true
+subtle.workspace = true
uuid.workspace = true
time.workspace = true
dashmap.workspace = true
diff --git a/crates/server/build.rs b/crates/server/build.rs
new file mode 100644
index 0000000..35756ee
--- /dev/null
+++ b/crates/server/build.rs
@@ -0,0 +1,7 @@
+// sqlx::migrate! reads the migrations directory at compile time, but cargo
+// only watches source files. Without this, adding a migration does not make
+// cargo rebuild, so the new binary silently ships the old migration set and
+// the schema change never runs.
+fn main() {
+ println!("cargo:rerun-if-changed=migrations");
+}
diff --git a/crates/server/migrations/0014_billing.sql b/crates/server/migrations/0014_billing.sql
new file mode 100644
index 0000000..edc1bc6
--- /dev/null
+++ b/crates/server/migrations/0014_billing.sql
@@ -0,0 +1,27 @@
+- Payments.
+--
+- Checkout is hosted by the processor, so no card details ever reach this
+- server and it stays outside PCI scope. What is recorded here is only what
+- is needed to fulfil an order and to answer "did this person pay".
+CREATE TABLE purchases (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ - 'cosmetic' or 'supporter'.
+ kind TEXT NOT NULL CHECK (kind IN ('cosmetic', 'supporter')),
+ - The cosmetic bought, or NULL for a supporter subscription.
+ cosmetic_id TEXT REFERENCES cosmetics(id) ON DELETE SET NULL,
+ amount_cents INTEGER NOT NULL,
+ currency TEXT NOT NULL DEFAULT 'usd',
+ status TEXT NOT NULL CHECK (status IN ('pending', 'paid', 'failed', 'refunded')),
+ - The processor's own session id. Unique, so a webhook delivered twice
+ - cannot grant the same item twice: fulfilment keys off this row.
+ session_id TEXT NOT NULL UNIQUE,
+ created_at TEXT NOT NULL,
+ paid_at TEXT
+);
+
+CREATE INDEX idx_purchases_user ON purchases(user_id);
+CREATE INDEX idx_purchases_status ON purchases(status);
+
+- When the supporter subscription runs out. NULL means never subscribed.
+ALTER TABLE users ADD COLUMN supporter_until TEXT;
diff --git a/crates/server/migrations/0015_pricing_bundles.sql b/crates/server/migrations/0015_pricing_bundles.sql
new file mode 100644
index 0000000..a38ce80
--- /dev/null
+++ b/crates/server/migrations/0015_pricing_bundles.sql
@@ -0,0 +1,78 @@
+- Prices per item, and bundles.
+--
+- Everything was priced in three flat bands, so a plain colour swap cost the
+- same as the most elaborate sprite and nothing signalled which items were
+- worth more. Prices now vary by how much each item actually offers.
+UPDATE cosmetics SET price_cents = CASE id
+ - Plain colours: the cheapest thing in the store.
+ WHEN 'caret-magenta' THEN 149
+ WHEN 'caret-amber' THEN 149
+ WHEN 'caret-cyan' THEN 149
+ WHEN 'caret-crimson' THEN 149
+ WHEN 'caret-lime' THEN 179
+ WHEN 'caret-violet' THEN 179
+ WHEN 'caret-ice' THEN 179
+ WHEN 'caret-ember' THEN 199
+ WHEN 'caret-bone' THEN 199
+ - Deeper colours, held back as the ones worth saving for.
+ WHEN 'caret-void' THEN 349
+ WHEN 'caret-signal' THEN 349
+ - Flair, by how much drawing is in it.
+ WHEN 'flair-star' THEN 129
+ WHEN 'flair-bolt' THEN 129
+ WHEN 'flair-shard' THEN 129
+ WHEN 'flair-eye' THEN 179
+ WHEN 'flair-circuit' THEN 179
+ WHEN 'flair-skull' THEN 199
+ WHEN 'flair-crown' THEN 249
+ WHEN 'flair-moth' THEN 249
+ WHEN 'flair-reactor' THEN 299
+ - Sprites are the most visible thing you own: everyone in the race sees
+ - one, so they carry the highest single-item prices.
+ WHEN 'sprite-dart' THEN 249
+ WHEN 'sprite-signal' THEN 249
+ WHEN 'sprite-blade' THEN 299
+ WHEN 'sprite-helm' THEN 299
+ WHEN 'sprite-core' THEN 349
+ WHEN 'sprite-rocket' THEN 399
+ ELSE price_cents
+END;
+
+CREATE TABLE bundles (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ description TEXT,
+ price_cents INTEGER NOT NULL,
+ sort_order INTEGER NOT NULL DEFAULT 0
+);
+
+CREATE TABLE bundle_items (
+ bundle_id TEXT NOT NULL REFERENCES bundles(id) ON DELETE CASCADE,
+ cosmetic_id TEXT NOT NULL REFERENCES cosmetics(id) ON DELETE CASCADE,
+ PRIMARY KEY (bundle_id, cosmetic_id)
+);
+
+- A bundle is worth buying only if it is visibly cheaper than its parts, so
+- each is priced below the sum of what it contains.
+INSERT INTO bundles (id, name, description, price_cents, sort_order) VALUES
+ ('bundle-starter', 'Starter Kit', 'A caret, a flair and a sprite to make a profile your own.', 449, 1),
+ ('bundle-neon', 'Neon Set', 'The brightest carets in the store, together.', 549, 2),
+ ('bundle-racer', 'Racer Set', 'Every race sprite.', 1299, 3),
+ ('bundle-everything', 'The Lot', 'Every cosmetic currently in the store.', 3499, 4);
+
+INSERT INTO bundle_items (bundle_id, cosmetic_id) VALUES
+ ('bundle-starter', 'caret-cyan'), ('bundle-starter', 'flair-bolt'), ('bundle-starter', 'sprite-dart'),
+ ('bundle-neon', 'caret-magenta'), ('bundle-neon', 'caret-cyan'), ('bundle-neon', 'caret-lime'), ('bundle-neon', 'caret-signal'),
+ ('bundle-racer', 'sprite-dart'), ('bundle-racer', 'sprite-rocket'), ('bundle-racer', 'sprite-helm'),
+ ('bundle-racer', 'sprite-core'), ('bundle-racer', 'sprite-signal'), ('bundle-racer', 'sprite-blade');
+
+- "The Lot" is defined as everything, rather than listed by hand, so it stays
+- correct as items are added.
+INSERT INTO bundle_items (bundle_id, cosmetic_id)
+ SELECT 'bundle-everything', id FROM cosmetics;
+
+- Purchases can now be of a bundle.
+ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_kind_check;
+ALTER TABLE purchases ADD CONSTRAINT purchases_kind_check
+ CHECK (kind IN ('cosmetic', 'supporter', 'bundle'));
+ALTER TABLE purchases ADD COLUMN bundle_id TEXT REFERENCES bundles(id) ON DELETE SET NULL;
diff --git a/crates/server/migrations/0016_starter_price.sql b/crates/server/migrations/0016_starter_price.sql
new file mode 100644
index 0000000..f8bf973
--- /dev/null
+++ b/crates/server/migrations/0016_starter_price.sql
@@ -0,0 +1,4 @@
+- The Starter Kit saved $0.78 against $5.27 of parts. It is the cheapest way
+- in and the one most people will see first, so the discount has to be worth
+- reading. At 399 it saves $1.28, which is a quarter off.
+UPDATE bundles SET price_cents = 399 WHERE id = 'bundle-starter';
diff --git a/crates/server/src/auth.rs b/crates/server/src/auth.rs
index caf428b..3dbbeff 100644
--- a/crates/server/src/auth.rs
+++ b/crates/server/src/auth.rs
@@ -309,11 +309,19 @@ async fn me(State(state): State<Arc<AppState>>, jar: CookieJar) -> Result<impl I
let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?;
// Whether the ad slots are shown. Fetched here so the client has it with
// the identity rather than asking a second time.
- let is_supporter: bool = sqlx::query_scalar("SELECT is_supporter FROM users WHERE id = $1")
- .bind(&user.id)
- .fetch_optional(&state.db)
- .await?
- .unwrap_or(false);
+ // Derived from the expiry rather than read from the stored flag. A
+ // subscription that is only ever switched on never ends: nothing runs at
+ // midnight to switch it off, so the flag alone would make every supporter
+ // a supporter forever. Comparing against the expiry is self-correcting.
+ let is_supporter: bool = sqlx::query_scalar(
+ "SELECT is_supporter AND supporter_until IS NOT NULL AND supporter_until > $2
+ FROM users WHERE id = $1",
+ )
+ .bind(&user.id)
+ .bind(format_timestamp(OffsetDateTime::now_utc()))
+ .fetch_optional(&state.db)
+ .await?
+ .unwrap_or(false);
Ok(Json(serde_json::json!({
"id": user.id,
"username": user.username,
diff --git a/crates/server/src/billing.rs b/crates/server/src/billing.rs
new file mode 100644
index 0000000..fda42c7
--- /dev/null
+++ b/crates/server/src/billing.rs
@@ -0,0 +1,461 @@
+//! Payments, through Stripe Checkout.
+//!
+//! Three rules shape this module.
+//!
+//! Checkout is hosted by Stripe. The customer is redirected there, enters
+//! their card there, and comes back. No card details reach this server, which
+//! keeps it outside PCI scope entirely. That is worth more than the small
+//! amount of control a self-hosted form would buy.
+//!
+//! Price is decided here, never by the caller. The client asks to buy a
+//! cosmetic by id; the amount comes from this server's own catalogue row.
+//!
+//! Nothing is granted until Stripe says so, through a webhook whose signature
+//! is verified. A request that merely claims a payment succeeded is worthless.
+
+use crate::auth::{current_user, format_timestamp};
+use crate::error::AppError;
+use crate::state::AppState;
+use axum::body::Bytes;
+use axum::extract::{Path, State};
+use axum::http::HeaderMap;
+use axum::response::IntoResponse;
+use axum::routing::post;
+use axum::{Json, Router};
+use axum_extra::extract::CookieJar;
+use hmac::{Hmac, Mac};
+use serde::Deserialize;
+use sha2::Sha256;
+use sqlx::Row;
+use std::sync::Arc;
+use subtle::ConstantTimeEq;
+use time::{Duration as TimeDuration, OffsetDateTime};
+
+/// What the supporter subscription costs, and how long it lasts.
+const SUPPORTER_PRICE_CENTS: i32 = 300;
+const SUPPORTER_DAYS: i64 = 30;
+
+/// A Stripe timestamp older than this is not accepted, so a captured webhook
+/// cannot be replayed later.
+const WEBHOOK_TOLERANCE_SECS: i64 = 300;
+
+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/supporter", post(checkout_supporter))
+ .route("/api/billing/webhook", post(webhook))
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct StripeConfig {
+ pub secret_key: String,
+ pub webhook_secret: String,
+}
+
+impl StripeConfig {
+ pub fn is_configured(&self) -> bool {
+ !self.secret_key.is_empty() && !self.webhook_secret.is_empty()
+ }
+}
+
+fn require_stripe(state: &AppState) -> Result<&StripeConfig, AppError> {
+ if !state.stripe.is_configured() {
+ return Err(AppError::NotConfigured(
+ "payments are not configured on this server".into(),
+ ));
+ }
+ Ok(&state.stripe)
+}
+
+/// Creates a Checkout session and records it as pending. The item is not
+/// granted here; the webhook does that once Stripe confirms payment.
+async fn create_session(
+ state: &AppState,
+ user_id: &str,
+ kind: &str,
+ cosmetic_id: Option<&str>,
+ bundle_id: Option<&str>,
+ name: &str,
+ amount_cents: i32,
+) -> Result<String, AppError> {
+ let stripe = require_stripe(state)?;
+ let purchase_id = uuid::Uuid::new_v4().to_string();
+
+ let success = format!("{}/?purchase=done", state.frontend_origin);
+ let cancel = format!("{}/?purchase=cancelled", state.frontend_origin);
+ let amount = amount_cents.to_string();
+
+ // Stripe's API is form-encoded, not JSON.
+ let mut form: Vec<(String, String)> = vec![
+ ("mode".into(), "payment".to_string()),
+ ("success_url".into(), success),
+ ("cancel_url".into(), cancel),
+ ("client_reference_id".into(), purchase_id.clone()),
+ ("line_items[0][quantity]".into(), "1".into()),
+ ("line_items[0][price_data][currency]".into(), "usd".into()),
+ ("line_items[0][price_data][unit_amount]".into(), amount),
+ ("line_items[0][price_data][product_data][name]".into(), name.to_string()),
+ // Echoed back on the webhook, so fulfilment does not have to trust
+ // anything the browser sends.
+ ("metadata[purchase_id]".into(), purchase_id.clone()),
+ ("metadata[user_id]".into(), user_id.to_string()),
+ ];
+ if let Some(id) = cosmetic_id {
+ form.push(("metadata[cosmetic_id]".into(), id.to_string()));
+ }
+
+ let res = state
+ .http
+ .post("https://api.stripe.com/v1/checkout/sessions")
+ .basic_auth(&stripe.secret_key, Some(""))
+ .form(&form)
+ .send()
+ .await
+ .map_err(|e| AppError::Internal(e.into()))?;
+
+ if !res.status().is_success() {
+ let status = res.status();
+ let body = res.text().await.unwrap_or_default();
+ // Logged in full, returned as a generic failure: a processor error can
+ // carry account detail that does not belong in a browser.
+ tracing::error!("stripe checkout failed ({status}): {body}");
+ return Err(AppError::Internal(anyhow::anyhow!("could not start checkout")));
+ }
+
+ #[derive(Deserialize)]
+ struct Session {
+ id: String,
+ url: String,
+ }
+ 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)",
+ )
+ .bind(&purchase_id)
+ .bind(user_id)
+ .bind(kind)
+ .bind(cosmetic_id)
+ .bind(bundle_id)
+ .bind(amount_cents)
+ .bind(&session.id)
+ .bind(format_timestamp(OffsetDateTime::now_utc()))
+ .execute(&state.db)
+ .await?;
+
+ Ok(session.url)
+}
+
+async fn checkout_cosmetic(
+ State(state): State<Arc<AppState>>,
+ jar: CookieJar,
+ Path(cosmetic_id): Path<String>,
+) -> Result<impl IntoResponse, AppError> {
+ let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?;
+
+ // Price and name come from our own row, never from the request.
+ let row = sqlx::query("SELECT name, price_cents FROM cosmetics WHERE id = $1")
+ .bind(&cosmetic_id)
+ .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);
+
+ // 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")
+ .bind(&user.id)
+ .bind(&cosmetic_id)
+ .fetch_optional(&state.db)
+ .await?;
+ if owned.is_some() {
+ return Err(AppError::InvalidInput("you already own that".into()));
+ }
+
+ let url = create_session(&state, &user.id, "cosmetic", Some(&cosmetic_id), None, &name, price).await?;
+ Ok(Json(serde_json::json!({ "url": url })))
+}
+
+async fn checkout_bundle(
+ State(state): State<Arc<AppState>>,
+ jar: CookieJar,
+ Path(bundle_id): Path<String>,
+) -> Result<impl IntoResponse, AppError> {
+ let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?;
+
+ let row = sqlx::query("SELECT name, price_cents FROM bundles WHERE id = $1")
+ .bind(&bundle_id)
+ .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);
+
+ // A bundle whose every item is already owned has nothing to sell.
+ let remaining: i64 = sqlx::query_scalar(
+ "SELECT COUNT(*) FROM bundle_items bi
+ WHERE bi.bundle_id = $1
+ AND NOT EXISTS (SELECT 1 FROM user_cosmetics uc
+ WHERE uc.user_id = $2 AND uc.cosmetic_id = bi.cosmetic_id)",
+ )
+ .bind(&bundle_id)
+ .bind(&user.id)
+ .fetch_one(&state.db)
+ .await?;
+ if remaining == 0 {
+ 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?;
+ Ok(Json(serde_json::json!({ "url": url })))
+}
+
+async fn checkout_supporter(
+ State(state): State<Arc<AppState>>,
+ jar: CookieJar,
+) -> Result<impl IntoResponse, AppError> {
+ let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?;
+ let url = create_session(
+ &state,
+ &user.id,
+ "supporter",
+ None,
+ None,
+ "TyperPunk supporter, 30 days",
+ SUPPORTER_PRICE_CENTS,
+ )
+ .await?;
+ Ok(Json(serde_json::json!({ "url": url })))
+}
+
+/// Verifies Stripe's `Stripe-Signature` header against the raw request body.
+///
+/// This is the whole security of the payment path. Without it, anyone who
+/// knows the URL can post a message claiming a payment succeeded and be given
+/// the goods. Compared in constant time, and the timestamp is checked so a
+/// captured request cannot be replayed.
+fn verify_signature(secret: &str, header: &str, body: &[u8]) -> bool {
+ let mut timestamp = None;
+ let mut signatures = Vec::new();
+ for part in header.split(',') {
+ let Some((key, value)) = part.trim().split_once('=') else { continue };
+ match key {
+ "t" => timestamp = value.parse::<i64>().ok(),
+ "v1" => signatures.push(value),
+ _ => {}
+ }
+ }
+ let Some(timestamp) = timestamp else { return false };
+ if signatures.is_empty() {
+ return false;
+ }
+
+ let now = OffsetDateTime::now_utc().unix_timestamp();
+ if (now - timestamp).abs() > WEBHOOK_TOLERANCE_SECS {
+ return false;
+ }
+
+ let mut mac = match Hmac::<Sha256>::new_from_slice(secret.as_bytes()) {
+ Ok(m) => m,
+ Err(_) => return false,
+ };
+ mac.update(timestamp.to_string().as_bytes());
+ mac.update(b".");
+ mac.update(body);
+ let expected = mac.finalize().into_bytes();
+ let expected_hex = hex_encode(&expected);
+
+ signatures.iter().any(|candidate| {
+ candidate.as_bytes().ct_eq(expected_hex.as_bytes()).into()
+ })
+}
+
+fn hex_encode(bytes: &[u8]) -> String {
+ let mut out = String::with_capacity(bytes.len() * 2);
+ for b in bytes {
+ out.push_str(&format!("{b:02x}"));
+ }
+ out
+}
+
+#[derive(Deserialize)]
+struct WebhookEvent {
+ #[serde(rename = "type")]
+ kind: String,
+ data: WebhookData,
+}
+
+#[derive(Deserialize)]
+struct WebhookData {
+ object: WebhookObject,
+}
+
+#[derive(Deserialize)]
+struct WebhookObject {
+ id: String,
+}
+
+async fn webhook(
+ State(state): State<Arc<AppState>>,
+ headers: HeaderMap,
+ body: Bytes,
+) -> Result<impl IntoResponse, AppError> {
+ let stripe = require_stripe(&state)?;
+ let signature = headers
+ .get("stripe-signature")
+ .and_then(|v| v.to_str().ok())
+ .unwrap_or_default();
+
+ if !verify_signature(&stripe.webhook_secret, signature, &body) {
+ tracing::warn!("rejected a webhook with an invalid signature");
+ return Err(AppError::Unauthorized);
+ }
+
+ let event: WebhookEvent =
+ serde_json::from_slice(&body).map_err(|e| AppError::InvalidInput(e.to_string()))?;
+
+ if event.kind != "checkout.session.completed" {
+ // Everything else is acknowledged and ignored, so Stripe stops
+ // retrying it.
+ return Ok(axum::http::StatusCode::OK);
+ }
+
+ // Fulfilment keys off our own pending row, matched by session id. A
+ // webhook delivered twice updates a row that is already paid and grants
+ // nothing further.
+ let row = sqlx::query(
+ "SELECT id, user_id, kind, cosmetic_id, bundle_id, status FROM purchases WHERE session_id = $1",
+ )
+ .bind(&event.data.object.id)
+ .fetch_optional(&state.db)
+ .await?;
+
+ let Some(row) = row else {
+ tracing::warn!("webhook for an unknown session {}", event.data.object.id);
+ return Ok(axum::http::StatusCode::OK);
+ };
+ let status: String = row.try_get("status").unwrap_or_default();
+ if status == "paid" {
+ return Ok(axum::http::StatusCode::OK);
+ }
+
+ let purchase_id: String = row.try_get("id").unwrap_or_default();
+ let user_id: String = row.try_get("user_id").unwrap_or_default();
+ let kind: String = row.try_get("kind").unwrap_or_default();
+ let cosmetic_id: Option<String> = row.try_get("cosmetic_id").unwrap_or(None);
+ let now = OffsetDateTime::now_utc();
+
+ let mut tx = state.db.begin().await?;
+ sqlx::query("UPDATE purchases SET status = 'paid', paid_at = $1 WHERE id = $2")
+ .bind(format_timestamp(now))
+ .bind(&purchase_id)
+ .execute(&mut *tx)
+ .await?;
+
+ match kind.as_str() {
+ "cosmetic" => {
+ if let Some(cid) = cosmetic_id {
+ sqlx::query(
+ "INSERT INTO user_cosmetics (user_id, cosmetic_id, acquired_at)
+ VALUES ($1, $2, $3) ON CONFLICT (user_id, cosmetic_id) DO NOTHING",
+ )
+ .bind(&user_id)
+ .bind(&cid)
+ .bind(format_timestamp(now))
+ .execute(&mut *tx)
+ .await?;
+ }
+ }
+ "bundle" => {
+ let bundle_id: Option<String> = row.try_get("bundle_id").unwrap_or(None);
+ if let Some(bid) = bundle_id {
+ // One statement rather than a loop: the set is defined by the
+ // bundle, so it cannot drift from what was paid for.
+ sqlx::query(
+ "INSERT INTO user_cosmetics (user_id, cosmetic_id, acquired_at)
+ SELECT $1, bi.cosmetic_id, $2 FROM bundle_items bi WHERE bi.bundle_id = $3
+ ON CONFLICT (user_id, cosmetic_id) DO NOTHING",
+ )
+ .bind(&user_id)
+ .bind(format_timestamp(now))
+ .bind(&bid)
+ .execute(&mut *tx)
+ .await?;
+ }
+ }
+ "supporter" => {
+ // Extends from whichever is later, so renewing early does not
+ // throw away the time already paid for.
+ let until = now + TimeDuration::days(SUPPORTER_DAYS);
+ sqlx::query(
+ "UPDATE users SET is_supporter = TRUE,
+ supporter_until = GREATEST(COALESCE(supporter_until, $1), $1)
+ WHERE id = $2",
+ )
+ .bind(format_timestamp(until))
+ .bind(&user_id)
+ .execute(&mut *tx)
+ .await?;
+ }
+ _ => {}
+ }
+ tx.commit().await?;
+
+ tracing::info!("fulfilled {kind} purchase {purchase_id}");
+ Ok(axum::http::StatusCode::OK)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn sign(secret: &str, timestamp: i64, body: &[u8]) -> String {
+ let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
+ mac.update(timestamp.to_string().as_bytes());
+ mac.update(b".");
+ mac.update(body);
+ format!("t={timestamp},v1={}", hex_encode(&mac.finalize().into_bytes()))
+ }
+
+ #[test]
+ fn accepts_a_correct_signature() {
+ let now = OffsetDateTime::now_utc().unix_timestamp();
+ let body = br#"{"type":"checkout.session.completed"}"#;
+ assert!(verify_signature("whsec_test", &sign("whsec_test", now, body), body));
+ }
+
+ #[test]
+ fn rejects_a_forged_signature() {
+ let now = OffsetDateTime::now_utc().unix_timestamp();
+ let body = br#"{"type":"checkout.session.completed"}"#;
+ // Anyone can post this body; without the secret they cannot sign it.
+ assert!(!verify_signature("whsec_test", &format!("t={now},v1=deadbeef"), body));
+ assert!(!verify_signature("whsec_test", &sign("wrong_secret", now, body), body));
+ }
+
+ #[test]
+ fn rejects_a_replayed_request() {
+ let old = OffsetDateTime::now_utc().unix_timestamp() - (WEBHOOK_TOLERANCE_SECS + 60);
+ let body = br#"{"type":"checkout.session.completed"}"#;
+ // Correctly signed, but captured and replayed later.
+ assert!(!verify_signature("whsec_test", &sign("whsec_test", old, body), body));
+ }
+
+ #[test]
+ fn rejects_a_tampered_body() {
+ let now = OffsetDateTime::now_utc().unix_timestamp();
+ let signed = br#"{"amount":100}"#;
+ let tampered = br#"{"amount":999}"#;
+ assert!(!verify_signature("whsec_test", &sign("whsec_test", now, signed), tampered));
+ }
+
+ #[test]
+ fn rejects_a_missing_or_malformed_header() {
+ let body = br#"{}"#;
+ assert!(!verify_signature("whsec_test", "", body));
+ assert!(!verify_signature("whsec_test", "nonsense", body));
+ assert!(!verify_signature("whsec_test", "v1=abc", body));
+ }
+}
diff --git a/crates/server/src/cosmetics.rs b/crates/server/src/cosmetics.rs
index 460fc66..b4532a8 100644
--- a/crates/server/src/cosmetics.rs
+++ b/crates/server/src/cosmetics.rs
@@ -1,4 +1,5 @@
use crate::auth::{current_user, format_timestamp};
+use time::OffsetDateTime;
use crate::error::AppError;
use crate::state::AppState;
use axum::extract::{Path, State};
@@ -9,13 +10,12 @@ use axum_extra::extract::cookie::CookieJar;
use serde::{Deserialize, Serialize};
use sqlx::Row;
use std::sync::Arc;
-use time::OffsetDateTime;
pub fn router() -> Router<Arc<AppState>> {
Router::new()
.route("/api/cosmetics", get(list_catalog))
.route("/api/cosmetics/me", get(my_cosmetics))
- .route("/api/cosmetics/:id/purchase", post(purchase))
+ .route("/api/cosmetics/bundles", get(list_bundles))
.route("/api/cosmetics/:id/equip", post(equip))
.route("/api/cosmetics/unequip", post(unequip))
}
@@ -52,6 +52,43 @@ struct MyCosmetics {
equipped_caret: Option<String>,
equipped_flair: Option<String>,
equipped_sprite: Option<String>,
+ is_supporter: bool,
+}
+
+/// 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> {
+ let rows = sqlx::query(
+ "SELECT b.id, b.name, b.description, b.price_cents,
+ COALESCE(SUM(c.price_cents), 0) AS full_price,
+ COALESCE(ARRAY_AGG(c.id ORDER BY c.id) FILTER (WHERE c.id IS NOT NULL), '{}') AS items
+ FROM bundles b
+ LEFT JOIN bundle_items bi ON bi.bundle_id = b.id
+ LEFT JOIN cosmetics c ON c.id = bi.cosmetic_id
+ GROUP BY b.id, b.name, b.description, b.price_cents, b.sort_order
+ ORDER BY b.sort_order",
+ )
+ .fetch_all(&state.db)
+ .await?;
+
+ let bundles: Vec<serde_json::Value> = rows
+ .iter()
+ .map(|r| {
+ let items: Vec<String> = r.try_get("items").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),
+ // What the same items cost bought one at a time, so the store
+ // can show the saving rather than asserting one.
+ "full_price_cents": r.try_get::<i64, _>("full_price").unwrap_or(0),
+ "items": items,
+ })
+ })
+ .collect();
+
+ Ok(Json(bundles))
}
async fn my_cosmetics(State(state): State<Arc<AppState>>, jar: CookieJar) -> Result<impl IntoResponse, AppError> {
@@ -63,10 +100,17 @@ async fn my_cosmetics(State(state): State<Arc<AppState>>, jar: CookieJar) -> Res
.await?;
let owned: Vec<String> = owned_rows.into_iter().filter_map(|r| r.try_get("cosmetic_id").ok()).collect();
- let equip_row = sqlx::query("SELECT equipped_caret, equipped_flair, equipped_sprite FROM users WHERE id = $1")
- .bind(&user.id)
- .fetch_one(&state.db)
- .await?;
+ // is_supporter is computed from the expiry here for the same reason it is
+ // in /api/auth/me: the stored flag is set on payment and never cleared.
+ let equip_row = sqlx::query(
+ "SELECT equipped_caret, equipped_flair, equipped_sprite,
+ (is_supporter AND supporter_until IS NOT NULL AND supporter_until > $2) AS active_supporter
+ FROM users WHERE id = $1",
+ )
+ .bind(&user.id)
+ .bind(format_timestamp(OffsetDateTime::now_utc()))
+ .fetch_one(&state.db)
+ .await?;
Ok(Json(MyCosmetics {
owned,
@@ -79,37 +123,10 @@ async fn my_cosmetics(State(state): State<Arc<AppState>>, jar: CookieJar) -> Res
equipped_caret: equip_row.try_get::<Option<String>, _>("equipped_caret").unwrap_or(None),
equipped_sprite: equip_row.try_get::<Option<String>, _>("equipped_sprite").unwrap_or(None),
equipped_flair: equip_row.try_get::<Option<String>, _>("equipped_flair").unwrap_or(None),
+ is_supporter: equip_row.try_get::<Option<bool>, _>("active_supporter").unwrap_or(None).unwrap_or(false),
}))
}
-// Stub: grants ownership immediately with no actual charge. Wiring a real
-// payment processor (Stripe or otherwise) needs the project owner's own
-// merchant account - same situation as the Spotify integration needing its
-// own developer credentials. This endpoint is the seam a real charge would
-// slot into later without changing the ownership/equip logic around it.
-async fn purchase(State(state): State<Arc<AppState>>, jar: CookieJar, Path(cosmetic_id): Path<String>) -> Result<impl IntoResponse, AppError> {
- let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?;
-
- let exists = sqlx::query("SELECT id FROM cosmetics WHERE id = $1")
- .bind(&cosmetic_id)
- .fetch_optional(&state.db)
- .await?;
- if exists.is_none() {
- return Err(AppError::NotFound);
- }
-
- // Postgres spells SQLite's INSERT OR IGNORE as an explicit conflict
- // target; the pair is the table's primary key.
- sqlx::query("INSERT INTO user_cosmetics (user_id, cosmetic_id, acquired_at) VALUES ($1, $2, $3) ON CONFLICT (user_id, cosmetic_id) DO NOTHING")
- .bind(&user.id)
- .bind(&cosmetic_id)
- .bind(format_timestamp(OffsetDateTime::now_utc()))
- .execute(&state.db)
- .await?;
-
- Ok(axum::http::StatusCode::NO_CONTENT)
-}
-
async fn equip(State(state): State<Arc<AppState>>, jar: CookieJar, Path(cosmetic_id): Path<String>) -> Result<impl IntoResponse, AppError> {
let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?;
diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs
index b291e64..beaabef 100644
--- a/crates/server/src/main.rs
+++ b/crates/server/src/main.rs
@@ -1,5 +1,6 @@
mod admin;
mod anticheat;
+mod billing;
mod bot_results;
mod auth;
mod cosmetics;
@@ -20,7 +21,6 @@ use axum::Router;
use serde::Deserialize;
use state::{AppState, SpotifyConfig};
use std::net::SocketAddr;
-use std::str::FromStr;
use std::sync::Arc;
use tower_http::cors::CorsLayer;
use tower::ServiceBuilder;
@@ -95,6 +95,7 @@ fn build_app(app_state: Arc<AppState>) -> Router {
.merge(lyrics::router())
.merge(texts::router())
.merge(admin::router())
+ .merge(billing::router())
.with_state(app_state)
}
@@ -194,8 +195,16 @@ async fn main() -> anyhow::Result<()> {
tracing::warn!("SPOTIFY_CLIENT_ID/SECRET not set - the Lyrics mode's Spotify connection will return 501 until configured.");
}
+ let stripe_config = billing::StripeConfig {
+ secret_key: std::env::var("STRIPE_SECRET_KEY").unwrap_or_default(),
+ webhook_secret: std::env::var("STRIPE_WEBHOOK_SECRET").unwrap_or_default(),
+ };
+ if !stripe_config.is_configured() {
+ tracing::warn!("STRIPE_SECRET_KEY/STRIPE_WEBHOOK_SECRET not set - the store will return 501 on checkout until configured.");
+ }
+
let race_texts = load_race_texts();
- let app_state = Arc::new(AppState::new(db, cookie_secure, race_texts, spotify_config, frontend_origin.clone()));
+ let app_state = Arc::new(AppState::new(db, cookie_secure, race_texts, spotify_config, stripe_config, frontend_origin.clone()));
admin::bootstrap_admin(&app_state).await;
bot_results::spawn(app_state.clone());
@@ -275,6 +284,7 @@ mod tests {
attribution: None,
}],
SpotifyConfig::default(),
+ billing::StripeConfig::default(),
"http://localhost:4173".to_string(),
));
let app = build_app(app_state);
diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs
index ff583ac..8696764 100644
--- a/crates/server/src/state.rs
+++ b/crates/server/src/state.rs
@@ -51,6 +51,7 @@ pub struct AppState {
/// than per-room, since the pool itself never changes at runtime.
pub race_texts: Vec<RaceText>,
pub spotify: SpotifyConfig,
+ pub stripe: crate::billing::StripeConfig,
pub frontend_origin: String,
pub http: Client,
}
@@ -61,10 +62,12 @@ impl AppState {
cookie_secure: bool,
race_texts: Vec<RaceText>,
spotify: SpotifyConfig,
+ stripe: crate::billing::StripeConfig,
frontend_origin: String,
) -> Self {
Self {
db,
+ stripe,
auth_rate_limiter: RateLimiter::new(10, Duration::from_secs(5 * 60)),
// A genuine player finishes a test at most every several
// seconds; 60 submissions in 5 minutes is generous headroom for
diff --git a/web/src/screens/storeScreen.js b/web/src/screens/storeScreen.js
index f66e3c8..34b0b2e 100644
--- a/web/src/screens/storeScreen.js
+++ b/web/src/screens/storeScreen.js
@@ -16,39 +16,107 @@ function categoryLabel(category) {
return 'Flair';
}
-// Real purchase/equip flow against the catalog, but purchase is a stub --
-// it grants ownership immediately with no actual charge, since real payment
-// processing needs the project owner's own Stripe (or other processor)
-// account. Parked as a question rather than assumed: see nightshift
-// questions.
+// Buying sends the browser to the processor's own checkout page. Nothing is
+// granted here: the item appears once the processor's webhook reaches the
+// server. That is why every Buy button leaves the site rather than updating
+// in place.
export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer }) {
let catalog = [];
- let mine = { owned: [], equipped_caret: null, equipped_flair: null };
+ let bundles = [];
+ let mine = { owned: [], equipped_caret: null, equipped_flair: null, equipped_sprite: null, is_supporter: false };
let status = 'loading';
let message = '';
let stopped = false;
- function itemMarkup(item) {
- const signedOut = status === 'signed-out';
- const owned = !signedOut && mine.owned.includes(item.id);
- const equipped = !signedOut && (mine.equipped_caret === item.id || mine.equipped_flair === item.id);
+ function isOwned(id) {
+ return status !== 'signed-out' && mine.owned.includes(id);
+ }
+
+ function swatchFor(item) {
// A caret is a colour, so the swatch is the colour itself; flair and
// sprites are drawings, so the swatch is the drawing.
- const swatch = item.category === 'caret'
- ? `<span class="store-swatch" data-swatch-colour="${escapeHtml(item.value)}"></span>`
- : `<span class="store-swatch store-flair-swatch">${(item.category === 'sprite' ? RACER_SPRITES : FLAIR_ICONS)[item.value] || ''}</span>`;
+ if (item.category === 'caret') {
+ return `<span class="store-swatch" data-swatch-colour="${escapeHtml(item.value)}"></span>`;
+ }
+ const set = item.category === 'sprite' ? RACER_SPRITES : FLAIR_ICONS;
+ return `<span class="store-swatch store-flair-swatch">${set[item.value] || ''}</span>`;
+ }
+
+ function itemMarkup(item) {
+ const signedOut = status === 'signed-out';
+ const owned = isOwned(item.id);
+ const equipped = !signedOut && (
+ mine.equipped_caret === item.id ||
+ mine.equipped_flair === item.id ||
+ mine.equipped_sprite === item.id
+ );
return `
<div class="leaderboard-row store-item-row">
- ${swatch}
+ ${swatchFor(item)}
<div class="leaderboard-name">${escapeHtml(item.name)}</div>
- ${!owned ? `<div class="leaderboard-acc">${formatPrice(item.price_cents)}</div>` : ''}
+ ${!owned ? `<div class="leaderboard-acc store-price">${formatPrice(item.price_cents)}</div>` : ''}
${owned
- ? `<button class="menu-button small${equipped ? ' active' : ' quiet'}" data-action="${equipped ? 'unequip' : 'equip'}" data-id="${item.id}" data-category="${item.category}" data-tooltip="${equipped ? 'Unequip - back to the default look' : `Replaces whichever ${categoryLabel(item.category).toLowerCase()} you have equipped now`}">${equipped ? 'Equipped' : 'Equip'}</button>`
+ ? `<button class="menu-button small${equipped ? ' active' : ' quiet'}" data-action="${equipped ? 'unequip' : 'equip'}" data-id="${item.id}" data-category="${item.category}" data-tooltip="${equipped ? 'Unequip, back to the default look' : `Replaces whichever ${categoryLabel(item.category).toLowerCase()} you have equipped now`}">${equipped ? 'Equipped' : 'Equip'}</button>`
: `<button class="menu-button small${signedOut ? ' quiet' : ''}" data-action="${signedOut ? 'go-account' : 'buy'}" data-id="${item.id}"${signedOut ? ' data-tooltip="Sign in to buy this"' : ''}>Buy</button>`}
</div>
`;
}
+ function bundleMarkup(b) {
+ const signedOut = status === 'signed-out';
+ const items = b.items || [];
+ const ownedCount = items.filter(isOwned).length;
+ const complete = items.length > 0 && ownedCount === items.length;
+ 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)
+ .map(item => `<span class="bundle-preview-item${isOwned(item.id) ? ' owned' : ''}" data-tooltip="${escapeHtml(item.name)}${isOwned(item.id) ? ' (owned)' : ''}">${swatchFor(item)}</span>`)
+ .join('');
+
+ return `
+ <div class="bundle-card">
+ <div class="bundle-head">
+ <div class="bundle-name">${escapeHtml(b.name)}</div>
+ ${saving > 0 ? `<div class="bundle-saving">Save ${formatPrice(saving)}</div>` : ''}
+ </div>
+ ${b.description ? `<div class="bundle-description">${escapeHtml(b.description)}</div>` : ''}
+ <div class="bundle-previews">${previews}</div>
+ <div class="bundle-foot">
+ <div class="bundle-prices">
+ <span class="bundle-price">${formatPrice(b.price_cents)}</span>
+ ${saving > 0 ? `<span class="bundle-full-price">${formatPrice(b.full_price_cents)}</span>` : ''}
+ </div>
+ ${complete
+ ? `<span class="bundle-owned">You own all of these</span>`
+ : `<button class="menu-button small${signedOut ? ' quiet' : ' primary'}" data-action="${signedOut ? 'go-account' : 'buy-bundle'}" data-id="${escapeHtml(b.id)}"${signedOut ? ' data-tooltip="Sign in to buy this"' : ''}>Buy${ownedCount ? ` the other ${items.length - ownedCount}` : ''}</button>`}
+ </div>
+ ${ownedCount && !complete ? `<div class="bundle-note">You already own ${ownedCount} of these ${items.length}. The price does not change.</div>` : ''}
+ </div>`;
+ }
+
+ function supporterMarkup() {
+ if (status === 'signed-out') return '';
+ if (mine.is_supporter) {
+ return `<div class="store-supporter is-supporter">
+ <div class="store-supporter-text">
+ <strong>You are a supporter.</strong>
+ <span>Thank you. The site runs without ads for you.</span>
+ </div>
+ </div>`;
+ }
+ return `<div class="store-supporter">
+ <div class="store-supporter-text">
+ <strong>Supporter</strong>
+ <span>Removes the ad slot for 30 days and pays for the servers.</span>
+ </div>
+ <button class="menu-button small primary" data-action="buy-supporter">$3.00</button>
+ </div>`;
+ }
+
function bodyMarkup() {
if (status === 'loading') return `<div class="stats-empty">Loading...</div>`;
if (status === 'error') return `<div class="stats-empty">${escapeHtml(message)}</div>`;
@@ -58,12 +126,35 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
? `<div class="store-signin-note">Sign in to buy and equip these.
<button class="menu-button small" data-action="go-account">Sign In</button></div>`
: '';
+
+ const bundleSection = bundles.length
+ ? `<h3>Bundles</h3><div class="bundle-grid">${bundles.map(bundleMarkup).join('')}</div>`
+ : '';
+
const categories = ['caret', 'flair', 'sprite'];
- return banner + categories.map(cat => {
+ const itemSections = categories.map(cat => {
const items = catalog.filter(i => i.category === cat);
if (items.length === 0) return '';
return `<h3>${categoryLabel(cat)}</h3><div class="leaderboard-list">${items.map(itemMarkup).join('')}</div>`;
- }).join('') + `<div class="custom-error store-error"></div>`;
+ }).join('');
+
+ return banner + supporterMarkup() + bundleSection + itemSections
+ + `<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) {
+ try {
+ const data = await api.post(path);
+ if (data && data.url) window.location.assign(data.url);
+ } catch (err) {
+ if (!errorEl) return;
+ errorEl.textContent = err instanceof ApiError
+ ? err.message
+ : 'Could not start checkout. Try again.';
+ }
}
function render() {
@@ -85,21 +176,18 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
attachTooltips(root);
const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends });
- const goAccount = root.querySelector('[data-action="go-account"]');
- if (goAccount) goAccount.addEventListener('click', onShowAccount);
+ root.querySelectorAll('[data-action="go-account"]').forEach(el => el.addEventListener('click', onShowAccount));
const errorEl = root.querySelector('.store-error');
root.querySelectorAll('[data-action="buy"]').forEach(btn => {
- btn.addEventListener('click', async () => {
- try {
- await api.post(`/api/cosmetics/${btn.dataset.id}/purchase`);
- await loadMine();
- rerender();
- } catch (err) {
- if (errorEl) errorEl.textContent = err instanceof ApiError ? err.message : 'Could not complete the purchase - try again.';
- }
- });
+ btn.addEventListener('click', () => startCheckout(`/api/billing/checkout/${encodeURIComponent(btn.dataset.id)}`, errorEl));
});
+ root.querySelectorAll('[data-action="buy-bundle"]').forEach(btn => {
+ btn.addEventListener('click', () => startCheckout(`/api/billing/bundle/${encodeURIComponent(btn.dataset.id)}`, errorEl));
+ });
+ root.querySelector('[data-action="buy-supporter"]')
+ ?.addEventListener('click', () => startCheckout('/api/billing/supporter', errorEl));
+
root.querySelectorAll('[data-action="equip"]').forEach(btn => {
btn.addEventListener('click', async () => {
await api.post(`/api/cosmetics/${btn.dataset.id}/equip`).catch(() => {});
@@ -131,15 +219,19 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
catalog = await api.get('/api/cosmetics');
} catch {
status = 'error';
- message = 'Could not load the store - try again.';
+ message = 'Could not load the store. Try again.';
+ return;
}
+ // 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 = []; }
}
async function loadMine() {
if (!getUser()) {
- // The catalogue still renders - only ownership needs an account.
+ // The catalogue still renders; only ownership needs an account.
status = 'signed-out';
- mine = { owned: [], equipped_caret: null, equipped_flair: null };
+ mine = { owned: [], equipped_caret: null, equipped_flair: null, equipped_sprite: null, is_supporter: false };
return;
}
try {
@@ -147,7 +239,7 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
status = 'ok';
} catch {
status = 'error';
- message = 'Could not load your cosmetics - try again.';
+ message = 'Could not load your cosmetics. Try again.';
}
}
diff --git a/web/src/styles.css b/web/src/styles.css
index 3dae32b..b53e99c 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -2740,6 +2740,133 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
color: var(--sub-color);
}
+/* 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 {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ flex-wrap: wrap;
+ margin-bottom: 1.5rem;
+ padding: 0.9rem 1.1rem;
+ border: var(--border-width) solid color-mix(in srgb, var(--primary-color) 45%, transparent);
+ border-radius: var(--radius-panel);
+ background: color-mix(in srgb, var(--primary-color) 7%, transparent);
+ text-align: left;
+}
+
+.store-supporter.is-supporter {
+ border-color: color-mix(in srgb, var(--correct-color) 45%, transparent);
+ background: color-mix(in srgb, var(--correct-color) 7%, transparent);
+}
+
+.store-supporter-text {
+ display: flex;
+ flex-direction: column;
+ gap: 0.15rem;
+}
+
+.store-supporter-text strong {
+ color: var(--text-color);
+ font-size: 0.95rem;
+}
+
+.store-supporter-text span {
+ color: var(--sub-color);
+ font-size: 0.8rem;
+}
+
+/* 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 {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
+ gap: 0.75rem;
+ margin-bottom: 1.5rem;
+}
+
+.bundle-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;
+}
+
+.bundle-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.bundle-name {
+ color: var(--text-color);
+ font-size: 0.95rem;
+ font-weight: 600;
+}
+
+.bundle-saving {
+ color: var(--correct-color);
+ font-size: 0.75rem;
+ white-space: nowrap;
+}
+
+.bundle-description {
+ color: var(--sub-color);
+ font-size: 0.8rem;
+ line-height: 1.4;
+}
+
+.bundle-previews {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ margin: 0.15rem 0;
+}
+
+/* An item already owned is dimmed rather than hidden, so the bundle still
+ shows its full contents. */
+.bundle-preview-item.owned {
+ opacity: 0.35;
+}
+
+.bundle-foot {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ margin-top: auto;
+ padding-top: 0.25rem;
+}
+
+.bundle-prices {
+ display: flex;
+ align-items: baseline;
+ gap: 0.4rem;
+}
+
+.bundle-price {
+ color: var(--text-color);
+ font-size: 1.05rem;
+ font-weight: 600;
+}
+
+.bundle-full-price {
+ color: var(--sub-color);
+ font-size: 0.8rem;
+ text-decoration: line-through;
+}
+
+.bundle-owned,
+.bundle-note {
+ color: var(--sub-color);
+ font-size: 0.75rem;
+}
+
/* Close, at the top-right of the content. Replaces the full-width "Back"
button every screen carried at the bottom - a third way to do what the
wordmark and Escape already did, and the least reachable of the three. */