diff options
| -rwxr-xr-x | README.md | 16 | ||||
| -rw-r--r-- | crates/core/src/stats.rs | 3 | ||||
| -rw-r--r-- | crates/server/.env.example | 15 | ||||
| -rw-r--r-- | crates/server/Cargo.toml | 3 | ||||
| -rw-r--r-- | crates/server/build.rs | 7 | ||||
| -rw-r--r-- | crates/server/migrations/0014_billing.sql | 27 | ||||
| -rw-r--r-- | crates/server/migrations/0015_pricing_bundles.sql | 78 | ||||
| -rw-r--r-- | crates/server/migrations/0016_starter_price.sql | 4 | ||||
| -rw-r--r-- | crates/server/src/auth.rs | 18 | ||||
| -rw-r--r-- | crates/server/src/billing.rs | 461 | ||||
| -rw-r--r-- | crates/server/src/cosmetics.rs | 85 | ||||
| -rw-r--r-- | crates/server/src/main.rs | 14 | ||||
| -rw-r--r-- | crates/server/src/state.rs | 3 | ||||
| -rw-r--r-- | web/src/screens/storeScreen.js | 158 | ||||
| -rw-r--r-- | web/src/styles.css | 127 |
15 files changed, 943 insertions, 76 deletions
@@ -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. */ |