This commit is contained in:
2026-06-17 10:59:45 +02:00
parent 408e48c568
commit a2ccec4bb1
35 changed files with 2514 additions and 257 deletions
+633 -36
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -12,7 +12,7 @@ tower-sessions = "0.14"
tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] } tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] }
sqlx = { version = "0.8", default-features = false, features = [ sqlx = { version = "0.8", default-features = false, features = [
"runtime-tokio", "tls-rustls", "postgres", "uuid", "time", "macros", "runtime-tokio", "tls-rustls", "postgres", "uuid", "time", "macros", "rust_decimal",
] } ] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
@@ -32,6 +32,9 @@ thiserror = "2"
anyhow = "1" anyhow = "1"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
time = { version = "0.3", features = ["serde"] } time = { version = "0.3", features = ["serde", "serde-well-known"] }
uuid = { version = "1", features = ["v4", "serde"] } uuid = { version = "1", features = ["v4", "serde"] }
rust_decimal = { version = "1", features = ["serde-float"] }
dotenvy = "0.15" dotenvy = "0.15"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "gzip"] }
url = "2"
+53
View File
@@ -0,0 +1,53 @@
-- Phase 2: topic-based wantlists + items
-- A "list" is a topic (clothes, gear, …). An "item" is a thing the user covets,
-- usually backed by a pasted product URL. Price/metadata columns are filled by the
-- Phase 3 refetch worker (generic Shopify .json adapter, etc.) and stay NULL until then.
CREATE TABLE lists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
emoji TEXT, -- optional decorative glyph
description TEXT,
position INTEGER NOT NULL DEFAULT 0, -- manual ordering
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_lists_user ON lists(user_id);
CREATE TYPE item_status AS ENUM ('coveted', 'acquired', 'renounced');
CREATE TABLE items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
list_id UUID NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
title TEXT NOT NULL,
url TEXT, -- pasted product URL (Phase 3 tracks this)
note TEXT,
status item_status NOT NULL DEFAULT 'coveted',
target_price NUMERIC(12, 2), -- alert threshold the user sets
position INTEGER NOT NULL DEFAULT 0,
-- Filled by the Phase 3 fetcher; NULL until first successful fetch.
title_fetched TEXT,
current_price NUMERIC(12, 2),
currency TEXT, -- ISO 4217, e.g. 'EUR'
image_url TEXT,
in_stock BOOLEAN,
source TEXT, -- adapter that produced the data, e.g. 'shopify'
fetched_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_items_list ON items(list_id);
CREATE INDEX idx_items_url ON items(url) WHERE url IS NOT NULL;
CREATE TRIGGER trg_lists_updated
BEFORE UPDATE ON lists
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TRIGGER trg_items_updated
BEFORE UPDATE ON items
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+25
View File
@@ -0,0 +1,25 @@
-- Phase 3: price tracking. The refetch worker pulls product data for items that
-- carry a URL (generic Shopify .json adapter first), updates the item's metadata
-- columns, and appends a row to price_history on every successful fetch.
-- Per-item tracking control + last fetch outcome.
ALTER TABLE items
ADD COLUMN track_enabled BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN last_error TEXT,
ADD COLUMN checked_at TIMESTAMPTZ; -- last fetch attempt (success or failure)
-- Append-only price observations. One row per successful fetch.
CREATE TABLE price_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE,
price NUMERIC(12, 2) NOT NULL,
currency TEXT NOT NULL,
in_stock BOOLEAN,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_price_history_item ON price_history(item_id, fetched_at DESC);
-- Worker scan: trackable items that have a URL, cheapest checked first.
CREATE INDEX idx_items_trackable ON items(checked_at NULLS FIRST)
WHERE url IS NOT NULL AND track_enabled;
+6
View File
@@ -0,0 +1,6 @@
-- Phase 4: price-drop notifications.
-- Tracks when we last emailed the owner that an item reached its target price.
-- NULL = "armed": a future drop to/under target will notify. Stamped non-NULL
-- after sending; cleared (re-armed) when the price rises back above target.
ALTER TABLE items
ADD COLUMN notified_at TIMESTAMPTZ;
+3 -1
View File
@@ -1,4 +1,6 @@
use argon2::password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; use argon2::password_hash::{
rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString,
};
use argon2::Argon2; use argon2::Argon2;
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
+20 -10
View File
@@ -91,7 +91,11 @@ async fn register(
validate(&req)?; validate(&req)?;
let hash = hash_password(&req.password)?; let hash = hash_password(&req.password)?;
let display = req.display_name.as_deref().map(str::trim).filter(|s| !s.is_empty()); let display = req
.display_name
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let user = sqlx::query_as::<_, User>( let user = sqlx::query_as::<_, User>(
"INSERT INTO users (email, password_hash, display_name) "INSERT INTO users (email, password_hash, display_name)
@@ -190,9 +194,13 @@ async fn request_password_reset(
.fetch_optional(&state.pool) .fetch_optional(&state.pool)
.await? .await?
{ {
let token = let token = tokens::create(
tokens::create(&state.pool, user.id, TokenPurpose::PasswordReset, Duration::hours(1)) &state.pool,
.await?; user.id,
TokenPurpose::PasswordReset,
Duration::hours(1),
)
.await?;
let link = format!("{}/reset?token={}", state.config.public_app_url, token); let link = format!("{}/reset?token={}", state.config.public_app_url, token);
let _ = state let _ = state
.mailer .mailer
@@ -225,10 +233,7 @@ async fn reset_password(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
async fn me( async fn me(State(state): State<AppState>, AuthUser(user): AuthUser) -> AppResult<Json<MeResp>> {
State(state): State<AppState>,
AuthUser(user): AuthUser,
) -> AppResult<Json<MeResp>> {
let settings = sqlx::query_as::<_, UserSettings>( let settings = sqlx::query_as::<_, UserSettings>(
"SELECT user_id, locale, currency, theme, notify_email "SELECT user_id, locale, currency, theme, notify_email
FROM user_settings WHERE user_id = $1", FROM user_settings WHERE user_id = $1",
@@ -246,8 +251,13 @@ async fn me(
// ── Helpers ───────────────────────────────────────────────── // ── Helpers ─────────────────────────────────────────────────
async fn send_verification_email(state: &AppState, user: &User) -> AppResult<()> { async fn send_verification_email(state: &AppState, user: &User) -> AppResult<()> {
let token = let token = tokens::create(
tokens::create(&state.pool, user.id, TokenPurpose::VerifyEmail, Duration::hours(24)).await?; &state.pool,
user.id,
TokenPurpose::VerifyEmail,
Duration::hours(24),
)
.await?;
let link = format!("{}/verify?token={}", state.config.public_app_url, token); let link = format!("{}/verify?token={}", state.config.public_app_url, token);
state state
.mailer .mailer
+1 -3
View File
@@ -72,7 +72,5 @@ pub async fn consume(pool: &PgPool, raw: &str, purpose: TokenPurpose) -> AppResu
.fetch_optional(pool) .fetch_optional(pool)
.await?; .await?;
user_id.ok_or(AppError::BadRequest( user_id.ok_or(AppError::BadRequest("invalid or expired token".to_string()))
"invalid or expired token".to_string(),
))
} }
+9
View File
@@ -10,6 +10,12 @@ pub struct Config {
pub public_app_url: String, pub public_app_url: String,
pub cors_origins: Vec<String>, pub cors_origins: Vec<String>,
pub smtp: SmtpConfig, pub smtp: SmtpConfig,
/// Background refetch worker tick. 0 disables the worker.
pub refetch_interval_secs: u64,
/// Min age before an item is eligible for the next automatic refetch.
pub refetch_min_age_secs: i64,
/// Default ISO 4217 currency when an adapter can't determine one.
pub default_currency: String,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -58,6 +64,9 @@ impl Config {
session_secret, session_secret,
public_app_url: opt("PUBLIC_APP_URL", "http://localhost:5173"), public_app_url: opt("PUBLIC_APP_URL", "http://localhost:5173"),
cors_origins, cors_origins,
refetch_interval_secs: opt("REFETCH_INTERVAL_SECS", "300").parse()?,
refetch_min_age_secs: opt("REFETCH_MIN_AGE_SECS", "21600").parse()?,
default_currency: opt("DEFAULT_CURRENCY", "EUR").to_uppercase(),
smtp: SmtpConfig { smtp: SmtpConfig {
host: opt("SMTP_HOST", "localhost"), host: opt("SMTP_HOST", "localhost"),
port: opt("SMTP_PORT", "587").parse()?, port: opt("SMTP_PORT", "587").parse()?,
+39
View File
@@ -0,0 +1,39 @@
//! Product data adapters. The deal source is a user-pasted product URL — no
//! per-retailer scrapers. Adapters are generic platform readers; the first is
//! Shopify, whose storefronts expose a public `/products/{handle}.json` document.
use rust_decimal::Decimal;
mod shopify;
/// Normalised product snapshot produced by an adapter.
#[derive(Debug, Clone)]
pub struct FetchedProduct {
pub title: String,
pub price: Decimal,
pub currency: String,
pub image_url: Option<String>,
pub in_stock: Option<bool>,
pub source: &'static str,
}
/// A shared HTTP client tuned for storefront fetches.
pub fn http_client() -> reqwest::Client {
reqwest::Client::builder()
.user_agent("consumers-bot/0.1 (+self-hosted wantlist price watcher)")
.timeout(std::time::Duration::from_secs(15))
.build()
.expect("failed to build reqwest client")
}
/// Try every adapter in turn. Returns the first that recognises the URL.
pub async fn fetch_product(
client: &reqwest::Client,
url: &str,
default_currency: &str,
) -> anyhow::Result<FetchedProduct> {
if let Some(p) = shopify::fetch(client, url, default_currency).await? {
return Ok(p);
}
anyhow::bail!("no adapter could read this URL (only Shopify storefronts are supported for now)")
}
+185
View File
@@ -0,0 +1,185 @@
//! Generic Shopify storefront adapter.
//!
//! Every Shopify shop exposes an Ajax product document at
//! `https://{shop}/products/{handle}.js`, which carries title, image, per-variant
//! price (integer minor units) and availability. We derive that URL from the
//! pasted product URL (any path with a `/products/{handle}` segment). No shop is
//! hardcoded.
//!
//! Pricing is the subtle part. The `.js` doc reports the shop's *base* currency
//! price. Shops using Shopify Markets show visitors a converted *presentment*
//! price (e.g. a PLN shop shows EUR in the EU). That conversion is reachable
//! generically via `.js?currency=EUR`. So we fetch both and:
//! - if the converted price differs from the base price → Markets converted it,
//! and the price is genuinely in our requested currency;
//! - if they're equal → no conversion happened, so the price is in the shop's
//! base currency (read from `/meta.json`), not our requested one.
//! This stops us from mislabelling e.g. "821 EUR" when the value is 821 PLN.
use rust_decimal::Decimal;
use serde::Deserialize;
use url::{Position, Url};
use super::FetchedProduct;
#[derive(Deserialize)]
struct JsDoc {
#[serde(default)]
title: Option<String>,
#[serde(default)]
featured_image: Option<String>,
#[serde(default)]
variants: Vec<JsVariant>,
}
#[derive(Deserialize)]
struct JsVariant {
/// Price in the currency's minor units (cents), e.g. 19795 = 197.95.
price: i64,
#[serde(default)]
available: Option<bool>,
}
/// Returns `Ok(None)` when the URL isn't a Shopify product URL (so other
/// adapters could try), `Err` when it looks like one but the fetch/parse fails.
pub async fn fetch(
client: &reqwest::Client,
raw_url: &str,
default_currency: &str,
) -> anyhow::Result<Option<FetchedProduct>> {
let Some(base_url) = product_doc_url(raw_url, "js") else {
return Ok(None);
};
// Presentment price in the requested currency (Markets-converted if enabled).
let conv_url = format!("{base_url}?currency={default_currency}");
let Some(conv) = fetch_js(client, &conv_url).await? else {
return Ok(None);
};
let Some(conv_cents) = cheapest(&conv.variants) else {
anyhow::bail!("Shopify product has no readable price");
};
// Base price (no currency param) to detect whether conversion happened.
let base = fetch_js(client, &base_url).await?;
let base_cents = base.as_ref().and_then(|b| cheapest(&b.variants));
let (cents, currency) = match base_cents {
// Converted: value really is in the requested currency.
Some(b) if b != conv_cents => (conv_cents, default_currency.to_string()),
// No conversion: value is the shop's base currency.
Some(b) => (
b,
shop_currency(client, raw_url)
.await
.unwrap_or_else(|| default_currency.to_string()),
),
None => (conv_cents, default_currency.to_string()),
};
let in_stock = availability(&conv.variants);
let title = conv
.title
.clone()
.or_else(|| base.as_ref().and_then(|b| b.title.clone()))
.unwrap_or_else(|| "Untitled product".to_string());
let image_url = conv
.featured_image
.clone()
.or_else(|| base.and_then(|b| b.featured_image))
.map(normalize_image);
Ok(Some(FetchedProduct {
title,
price: Decimal::new(cents, 2),
currency,
image_url,
in_stock,
source: "shopify",
}))
}
/// GET a `.js` product doc. `Ok(None)` if it isn't a Shopify product document.
async fn fetch_js(client: &reqwest::Client, url: &str) -> anyhow::Result<Option<JsDoc>> {
let resp = client.get(url).send().await?;
if !resp.status().is_success() {
return Ok(None);
}
let body = resp.text().await?;
Ok(serde_json::from_str::<JsDoc>(&body).ok())
}
/// Cheapest available variant's price (minor units); falls back to cheapest
/// overall. `None` if there are no priced variants.
fn cheapest(variants: &[JsVariant]) -> Option<i64> {
variants
.iter()
.filter(|v| v.available == Some(true))
.map(|v| v.price)
.min()
.or_else(|| variants.iter().map(|v| v.price).min())
}
/// `Some(true/false)` if any variant reported availability, else `None`.
fn availability(variants: &[JsVariant]) -> Option<bool> {
if variants.iter().all(|v| v.available.is_none()) {
return None;
}
Some(variants.iter().any(|v| v.available == Some(true)))
}
/// The shop's base ISO 4217 currency, from `{origin}/meta.json`. Best-effort.
async fn shop_currency(client: &reqwest::Client, raw_url: &str) -> Option<String> {
#[derive(Deserialize)]
struct Meta {
currency: String,
}
let origin = origin_of(raw_url)?;
let resp = client
.get(format!("{origin}/meta.json"))
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let meta: Meta = resp.json().await.ok()?;
let c = meta.currency.trim().to_uppercase();
(c.len() == 3).then_some(c)
}
/// Shopify image URLs are often protocol-relative (`//cdn.shopify.com/...`).
fn normalize_image(src: String) -> String {
if let Some(rest) = src.strip_prefix("//") {
format!("https://{rest}")
} else {
src
}
}
fn origin_of(raw: &str) -> Option<String> {
let u = Url::parse(raw).ok()?;
if !matches!(u.scheme(), "http" | "https") {
return None;
}
Some(u[..Position::BeforePath].to_string())
}
/// Build `{origin}/products/{handle}.{ext}`, or `None` if there's no
/// `/products/{handle}` segment.
fn product_doc_url(raw: &str, ext: &str) -> Option<String> {
let u = Url::parse(raw).ok()?;
if !matches!(u.scheme(), "http" | "https") {
return None;
}
let segs: Vec<&str> = u.path_segments()?.filter(|s| !s.is_empty()).collect();
let pos = segs.iter().position(|s| *s == "products")?;
let handle = segs.get(pos + 1)?;
let handle = handle.trim_end_matches(".json").trim_end_matches(".js");
if handle.is_empty() {
return None;
}
let origin = &u[..Position::BeforePath];
Some(format!("{origin}/products/{handle}.{ext}"))
}
+2 -4
View File
@@ -26,10 +26,8 @@ impl Mailer {
.port(cfg.port); .port(cfg.port);
if !cfg.username.is_empty() { if !cfg.username.is_empty() {
builder = builder.credentials(Credentials::new( builder =
cfg.username.clone(), builder.credentials(Credentials::new(cfg.username.clone(), cfg.password.clone()));
cfg.password.clone(),
));
} }
let from: Mailbox = cfg let from: Mailbox = cfg
+6
View File
@@ -2,10 +2,13 @@ mod auth;
mod config; mod config;
mod db; mod db;
mod error; mod error;
mod fetch;
mod mail; mod mail;
mod models; mod models;
mod notify;
mod routes; mod routes;
mod state; mod state;
mod worker;
use std::sync::Arc; use std::sync::Arc;
@@ -50,8 +53,11 @@ async fn main() -> anyhow::Result<()> {
pool, pool,
config: Arc::new(config.clone()), config: Arc::new(config.clone()),
mailer, mailer,
http: fetch::http_client(),
}; };
worker::spawn(state.clone());
let api = Router::new() let api = Router::new()
.merge(routes::router()) .merge(routes::router())
.nest("/auth", auth::routes::router()); .nest("/auth", auth::routes::router());
+60
View File
@@ -1,3 +1,4 @@
use rust_decimal::Decimal;
use serde::Serialize; use serde::Serialize;
use time::OffsetDateTime; use time::OffsetDateTime;
use uuid::Uuid; use uuid::Uuid;
@@ -43,3 +44,62 @@ pub struct UserSettings {
pub theme: String, pub theme: String,
pub notify_email: bool, pub notify_email: bool,
} }
/// A topic-based wantlist ("altar"). Scoped to a user.
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct List {
pub id: Uuid,
#[serde(skip)]
pub user_id: Uuid,
pub name: String,
pub emoji: Option<String>,
pub description: Option<String>,
pub position: i32,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: OffsetDateTime,
}
/// A coveted thing inside a list. Price/metadata columns are filled by the
/// Phase 3 refetch worker and stay None until then.
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct Item {
pub id: Uuid,
pub list_id: Uuid,
pub title: String,
pub url: Option<String>,
pub note: Option<String>,
pub status: String,
pub target_price: Option<Decimal>,
pub position: i32,
pub title_fetched: Option<String>,
pub current_price: Option<Decimal>,
pub currency: Option<String>,
pub image_url: Option<String>,
pub in_stock: Option<bool>,
pub source: Option<String>,
#[serde(with = "time::serde::rfc3339::option")]
pub fetched_at: Option<OffsetDateTime>,
pub track_enabled: bool,
pub last_error: Option<String>,
#[serde(with = "time::serde::rfc3339::option")]
pub checked_at: Option<OffsetDateTime>,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: OffsetDateTime,
}
/// One observed price point for an item. Append-only.
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct PricePoint {
pub price: Decimal,
pub currency: String,
pub in_stock: Option<bool>,
#[serde(with = "time::serde::rfc3339")]
pub fetched_at: OffsetDateTime,
}
+143
View File
@@ -0,0 +1,143 @@
//! Phase 4: price-drop notifications.
//!
//! After a successful refetch we check whether an item's watched price has
//! fallen to or below the owner's target. If so — and we haven't already told
//! them about this drop — we email them in the house gospel voice. The
//! `items.notified_at` column is the de-dupe latch:
//! - `NULL` = armed; a drop to/under target fires one email and stamps `now()`.
//! - non-NULL = already announced; stays quiet until the price rises back
//! above target, which clears the latch (re-arms) for the next drop.
use rust_decimal::Decimal;
use uuid::Uuid;
use crate::state::AppState;
/// Row gathered for one item's notification decision.
#[derive(sqlx::FromRow)]
struct NotifyRow {
title: String,
title_fetched: Option<String>,
url: Option<String>,
current_price: Option<Decimal>,
target_price: Option<Decimal>,
currency: Option<String>,
in_stock: Option<bool>,
notified_at: Option<time::OffsetDateTime>,
email: String,
display_name: Option<String>,
notify_email: bool,
}
/// Inspect one item after refetch and email its owner if the price just reached
/// the target. Best-effort: never returns an error to the caller (a failed
/// send must not fail the refetch); failures are logged.
pub async fn maybe_notify_drop(state: &AppState, item_id: Uuid) {
if let Err(e) = run(state, item_id).await {
tracing::warn!(item = %item_id, error = %e, "price-drop notification failed");
}
}
async fn run(state: &AppState, item_id: Uuid) -> anyhow::Result<()> {
let row: Option<NotifyRow> = sqlx::query_as(
"SELECT i.title, i.title_fetched, i.url, i.current_price, i.target_price,
i.currency, i.in_stock, i.notified_at,
u.email, u.display_name, s.notify_email
FROM items i
JOIN lists l ON l.id = i.list_id
JOIN users u ON u.id = l.user_id
JOIN user_settings s ON s.user_id = u.id
WHERE i.id = $1",
)
.bind(item_id)
.fetch_optional(&state.pool)
.await?;
let Some(row) = row else { return Ok(()) };
// Need both a watched price and a target to judge a drop.
let (Some(price), Some(target)) = (row.current_price, row.target_price) else {
return Ok(());
};
let on_sale = price <= target;
match (on_sale, row.notified_at.is_some()) {
// Reached target and not yet announced → email + latch.
(true, false) => {
if row.notify_email {
send(state, &row, price, target).await?;
}
// Latch even if the user has email off, so flipping it on later
// doesn't replay an old drop. Re-arms when price climbs back up.
sqlx::query("UPDATE items SET notified_at = now() WHERE id = $1")
.bind(item_id)
.execute(&state.pool)
.await?;
}
// Price rose back above target → clear the latch (re-arm).
(false, true) => {
sqlx::query("UPDATE items SET notified_at = NULL WHERE id = $1")
.bind(item_id)
.execute(&state.pool)
.await?;
}
_ => {}
}
Ok(())
}
async fn send(
state: &AppState,
row: &NotifyRow,
price: Decimal,
target: Decimal,
) -> anyhow::Result<()> {
let name = row.title_fetched.as_deref().unwrap_or(&row.title);
let cur = row.currency.as_deref().unwrap_or("EUR");
let now = format!("{cur} {price:.2}");
let goal = format!("{cur} {target:.2}");
let stock = match row.in_stock {
Some(false) => " (sold out for now — but the sign is given)",
_ => "",
};
let greeting = match row.display_name.as_deref() {
Some(n) if !n.is_empty() => format!("{n}, "),
_ => String::new(),
};
let link = row.url.as_deref();
let subject = format!("✦ The price has fallen — {name}");
let mut text = format!(
"{greeting}your vigil is rewarded.\n\n\
{name} now asks {now}, at or beneath your target of {goal}{stock}.\n\n\
The moment is upon you. Consume, and ascend.\n"
);
if let Some(l) = link {
text.push_str(&format!("\nApproach the shrine: {l}\n"));
}
text.push_str("\n— consume·rs\n");
let link_html = link
.map(|l| {
format!("<p><a href=\"{l}\" style=\"color:#7c6cf0\">Approach the shrine ↗</a></p>")
})
.unwrap_or_default();
let html = format!(
"<div style=\"font-family:Georgia,serif;color:#1a1726\">\
<p><em>{greeting}your vigil is rewarded.</em></p>\
<p style=\"font-size:1.1em\"><strong>{name}</strong> now asks \
<strong>{now}</strong>, at or beneath your target of {goal}{stock}.</p>\
<p>The moment is upon you. Consume, and ascend.</p>\
{link_html}\
<p style=\"color:#8a849c\">— consume·rs</p>\
</div>"
);
state
.mailer
.send(&row.email, &subject, &text, &html)
.await?;
tracing::info!(to = %row.email, item = %name, "sent price-drop notification");
Ok(())
}
+356
View File
@@ -0,0 +1,356 @@
use axum::extract::{Path, State};
use axum::routing::{get, post};
use axum::{Json, Router};
use rust_decimal::Decimal;
use serde::Deserialize;
use uuid::Uuid;
use validator::Validate;
use crate::auth::session::AuthUser;
use crate::error::{AppError, AppResult};
use crate::models::{Item, List, PricePoint};
use crate::state::AppState;
use crate::worker;
pub fn router() -> Router<AppState> {
Router::new()
.route("/lists", get(list_lists).post(create_list))
.route(
"/lists/{id}",
axum::routing::patch(update_list).delete(delete_list),
)
.route("/lists/{id}/items", get(list_items).post(create_item))
.route(
"/items/{id}",
axum::routing::patch(update_item).delete(delete_item),
)
.route("/items/{id}/refetch", post(refetch_item))
.route("/items/{id}/history", get(item_history))
}
pub const ITEM_COLS: &str = "id, list_id, title, url, note, status::text AS status, target_price, \
position, title_fetched, current_price, currency, image_url, in_stock, source, fetched_at, \
track_enabled, last_error, checked_at, created_at, updated_at";
// Same columns, qualified with the `i` alias for use in UPDATE … FROM lists,
// where bare `id`/`position`/`created_at` would be ambiguous across both tables.
const ITEM_COLS_I: &str = "i.id, i.list_id, i.title, i.url, i.note, i.status::text AS status, \
i.target_price, i.position, i.title_fetched, i.current_price, i.currency, i.image_url, \
i.in_stock, i.source, i.fetched_at, i.track_enabled, i.last_error, i.checked_at, \
i.created_at, i.updated_at";
const ALLOWED_STATUS: &[&str] = &["coveted", "acquired", "renounced"];
// ---- Lists ----------------------------------------------------------------
async fn list_lists(
State(state): State<AppState>,
AuthUser(user): AuthUser,
) -> AppResult<Json<Vec<List>>> {
let lists = sqlx::query_as::<_, List>(
"SELECT id, user_id, name, emoji, description, position, created_at, updated_at
FROM lists WHERE user_id = $1 ORDER BY position, created_at",
)
.bind(user.id)
.fetch_all(&state.pool)
.await?;
Ok(Json(lists))
}
#[derive(Debug, Deserialize, Validate)]
struct CreateListReq {
#[validate(length(min = 1, max = 80, message = "name must be 180 chars"))]
name: String,
#[validate(length(max = 16))]
emoji: Option<String>,
#[validate(length(max = 500))]
description: Option<String>,
}
async fn create_list(
State(state): State<AppState>,
AuthUser(user): AuthUser,
Json(req): Json<CreateListReq>,
) -> AppResult<Json<List>> {
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
let list = sqlx::query_as::<_, List>(
"INSERT INTO lists (user_id, name, emoji, description, position)
VALUES ($1, $2, $3, $4,
COALESCE((SELECT MAX(position) + 1 FROM lists WHERE user_id = $1), 0))
RETURNING id, user_id, name, emoji, description, position, created_at, updated_at",
)
.bind(user.id)
.bind(req.name.trim())
.bind(opt_trim(req.emoji))
.bind(opt_trim(req.description))
.fetch_one(&state.pool)
.await?;
Ok(Json(list))
}
#[derive(Debug, Deserialize, Validate)]
struct UpdateListReq {
#[validate(length(min = 1, max = 80, message = "name must be 180 chars"))]
name: Option<String>,
#[validate(length(max = 16))]
emoji: Option<String>,
#[validate(length(max = 500))]
description: Option<String>,
position: Option<i32>,
}
async fn update_list(
State(state): State<AppState>,
AuthUser(user): AuthUser,
Path(id): Path<Uuid>,
Json(req): Json<UpdateListReq>,
) -> AppResult<Json<List>> {
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
let list = sqlx::query_as::<_, List>(
"UPDATE lists SET
name = COALESCE($3, name),
emoji = COALESCE($4, emoji),
description = COALESCE($5, description),
position = COALESCE($6, position)
WHERE id = $1 AND user_id = $2
RETURNING id, user_id, name, emoji, description, position, created_at, updated_at",
)
.bind(id)
.bind(user.id)
.bind(req.name.map(|s| s.trim().to_string()))
.bind(opt_trim(req.emoji))
.bind(opt_trim(req.description))
.bind(req.position)
.fetch_optional(&state.pool)
.await?
.ok_or(AppError::NotFound)?;
Ok(Json(list))
}
async fn delete_list(
State(state): State<AppState>,
AuthUser(user): AuthUser,
Path(id): Path<Uuid>,
) -> AppResult<Json<serde_json::Value>> {
let res = sqlx::query("DELETE FROM lists WHERE id = $1 AND user_id = $2")
.bind(id)
.bind(user.id)
.execute(&state.pool)
.await?;
if res.rows_affected() == 0 {
return Err(AppError::NotFound);
}
Ok(Json(serde_json::json!({ "deleted": id })))
}
// ---- Items ----------------------------------------------------------------
/// Confirm the list exists and belongs to the user. Returns NotFound otherwise.
async fn assert_list_owner(state: &AppState, list_id: Uuid, user_id: Uuid) -> AppResult<()> {
let owns = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM lists WHERE id = $1 AND user_id = $2)",
)
.bind(list_id)
.bind(user_id)
.fetch_one(&state.pool)
.await?;
if owns {
Ok(())
} else {
Err(AppError::NotFound)
}
}
async fn list_items(
State(state): State<AppState>,
AuthUser(user): AuthUser,
Path(list_id): Path<Uuid>,
) -> AppResult<Json<Vec<Item>>> {
assert_list_owner(&state, list_id, user.id).await?;
let items = sqlx::query_as::<_, Item>(&format!(
"SELECT {ITEM_COLS} FROM items WHERE list_id = $1 ORDER BY position, created_at"
))
.bind(list_id)
.fetch_all(&state.pool)
.await?;
Ok(Json(items))
}
#[derive(Debug, Deserialize, Validate)]
struct CreateItemReq {
#[validate(length(min = 1, max = 200, message = "title must be 1200 chars"))]
title: String,
#[validate(url(message = "url must be a valid URL"))]
url: Option<String>,
#[validate(length(max = 1000))]
note: Option<String>,
target_price: Option<Decimal>,
}
async fn create_item(
State(state): State<AppState>,
AuthUser(user): AuthUser,
Path(list_id): Path<Uuid>,
Json(req): Json<CreateItemReq>,
) -> AppResult<Json<Item>> {
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
assert_list_owner(&state, list_id, user.id).await?;
let item = sqlx::query_as::<_, Item>(&format!(
"INSERT INTO items (list_id, title, url, note, target_price, position)
VALUES ($1, $2, $3, $4, $5,
COALESCE((SELECT MAX(position) + 1 FROM items WHERE list_id = $1), 0))
RETURNING {ITEM_COLS}"
))
.bind(list_id)
.bind(req.title.trim())
.bind(opt_trim(req.url))
.bind(opt_trim(req.note))
.bind(req.target_price)
.fetch_one(&state.pool)
.await?;
Ok(Json(item))
}
#[derive(Debug, Deserialize, Validate)]
struct UpdateItemReq {
#[validate(length(min = 1, max = 200, message = "title must be 1200 chars"))]
title: Option<String>,
#[validate(url(message = "url must be a valid URL"))]
url: Option<String>,
#[validate(length(max = 1000))]
note: Option<String>,
status: Option<String>,
target_price: Option<Decimal>,
position: Option<i32>,
}
async fn update_item(
State(state): State<AppState>,
AuthUser(user): AuthUser,
Path(id): Path<Uuid>,
Json(req): Json<UpdateItemReq>,
) -> AppResult<Json<Item>> {
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
if let Some(s) = &req.status {
if !ALLOWED_STATUS.contains(&s.as_str()) {
return Err(AppError::Validation(format!("unknown status: {s}")));
}
}
// Ownership enforced via the join to lists.user_id.
let item = sqlx::query_as::<_, Item>(&format!(
"UPDATE items i SET
title = COALESCE($3, i.title),
url = COALESCE($4, i.url),
note = COALESCE($5, i.note),
status = COALESCE($6::item_status, i.status),
target_price = COALESCE($7, i.target_price),
position = COALESCE($8, i.position)
FROM lists l
WHERE i.id = $1 AND i.list_id = l.id AND l.user_id = $2
RETURNING {ITEM_COLS_I}"
))
.bind(id)
.bind(user.id)
.bind(req.title.map(|s| s.trim().to_string()))
.bind(opt_trim(req.url))
.bind(opt_trim(req.note))
.bind(req.status)
.bind(req.target_price)
.bind(req.position)
.fetch_optional(&state.pool)
.await?
.ok_or(AppError::NotFound)?;
Ok(Json(item))
}
async fn delete_item(
State(state): State<AppState>,
AuthUser(user): AuthUser,
Path(id): Path<Uuid>,
) -> AppResult<Json<serde_json::Value>> {
let res = sqlx::query(
"DELETE FROM items i USING lists l
WHERE i.id = $1 AND i.list_id = l.id AND l.user_id = $2",
)
.bind(id)
.bind(user.id)
.execute(&state.pool)
.await?;
if res.rows_affected() == 0 {
return Err(AppError::NotFound);
}
Ok(Json(serde_json::json!({ "deleted": id })))
}
// ---- Tracking -------------------------------------------------------------
/// Owned item's URL, or NotFound. Inner Option is the (nullable) url.
async fn owned_item_url(
state: &AppState,
item_id: Uuid,
user_id: Uuid,
) -> AppResult<Option<String>> {
let row = sqlx::query_as::<_, (Option<String>,)>(
"SELECT i.url FROM items i JOIN lists l ON l.id = i.list_id
WHERE i.id = $1 AND l.user_id = $2",
)
.bind(item_id)
.bind(user_id)
.fetch_optional(&state.pool)
.await?
.ok_or(AppError::NotFound)?;
Ok(row.0)
}
/// Refetch a single item's price on demand. Surfaces fetch errors to the user.
async fn refetch_item(
State(state): State<AppState>,
AuthUser(user): AuthUser,
Path(id): Path<Uuid>,
) -> AppResult<Json<Item>> {
let url = owned_item_url(&state, id, user.id).await?.ok_or_else(|| {
AppError::BadRequest("this temptation has no URL to keep vigil over".into())
})?;
worker::refetch(&state, id, &url)
.await
.map_err(|e| AppError::BadRequest(e.to_string()))?;
let item = sqlx::query_as::<_, Item>(&format!("SELECT {ITEM_COLS} FROM items WHERE id = $1"))
.bind(id)
.fetch_one(&state.pool)
.await?;
Ok(Json(item))
}
/// Price observations for an item, newest first.
async fn item_history(
State(state): State<AppState>,
AuthUser(user): AuthUser,
Path(id): Path<Uuid>,
) -> AppResult<Json<Vec<PricePoint>>> {
// Ownership: NotFound if the item isn't the user's.
owned_item_url(&state, id, user.id).await?;
let history = sqlx::query_as::<_, PricePoint>(
"SELECT price, currency, in_stock, fetched_at
FROM price_history WHERE item_id = $1
ORDER BY fetched_at DESC LIMIT 200",
)
.bind(id)
.fetch_all(&state.pool)
.await?;
Ok(Json(history))
}
fn opt_trim(s: Option<String>) -> Option<String> {
s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
}
+11 -2
View File
@@ -10,11 +10,14 @@ use crate::error::{AppError, AppResult};
use crate::models::UserSettings; use crate::models::UserSettings;
use crate::state::AppState; use crate::state::AppState;
mod lists;
pub fn router() -> Router<AppState> { pub fn router() -> Router<AppState> {
Router::new() Router::new()
.route("/health", get(health)) .route("/health", get(health))
.route("/settings", patch(update_settings)) .route("/settings", patch(update_settings))
.route("/profile", patch(update_profile)) .route("/profile", patch(update_profile))
.merge(lists::router())
} }
async fn health() -> Json<Value> { async fn health() -> Json<Value> {
@@ -50,7 +53,9 @@ async fn update_settings(
} }
if let Some(cur) = &req.currency { if let Some(cur) = &req.currency {
if cur.len() != 3 { if cur.len() != 3 {
return Err(AppError::Validation("currency must be a 3-letter code".into())); return Err(AppError::Validation(
"currency must be a 3-letter code".into(),
));
} }
} }
@@ -88,7 +93,11 @@ async fn update_profile(
req.validate() req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?; .map_err(|e| AppError::Validation(e.to_string()))?;
let display = req.display_name.as_deref().map(str::trim).filter(|s| !s.is_empty()); let display = req
.display_name
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
sqlx::query("UPDATE users SET display_name = $2 WHERE id = $1") sqlx::query("UPDATE users SET display_name = $2 WHERE id = $1")
.bind(user.id) .bind(user.id)
.bind(display) .bind(display)
+2
View File
@@ -11,4 +11,6 @@ pub struct AppState {
pub pool: PgPool, pub pool: PgPool,
pub config: Arc<Config>, pub config: Arc<Config>,
pub mailer: Mailer, pub mailer: Mailer,
/// Shared outbound HTTP client for product fetches.
pub http: reqwest::Client,
} }
+130
View File
@@ -0,0 +1,130 @@
//! Background price-refetch worker. Periodically pulls product data for
//! trackable items via the generic adapters in [`crate::fetch`], updates each
//! item's metadata columns, and appends a `price_history` row on success.
use std::time::Duration;
use sqlx::PgPool;
use uuid::Uuid;
use crate::fetch::{self, FetchedProduct};
use crate::notify;
use crate::state::AppState;
const BATCH: i64 = 20;
/// Spawn the periodic worker. A zero interval disables it.
pub fn spawn(state: AppState) {
let interval = state.config.refetch_interval_secs;
if interval == 0 {
tracing::info!("refetch worker disabled (REFETCH_INTERVAL_SECS=0)");
return;
}
tokio::spawn(async move {
let mut ticker = tokio::time::interval(Duration::from_secs(interval));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
tracing::info!(interval_secs = interval, "refetch worker started");
loop {
ticker.tick().await;
if let Err(e) = run_once(&state).await {
tracing::error!(error = ?e, "refetch tick failed");
}
}
});
}
/// One pass: refetch a batch of due items.
async fn run_once(state: &AppState) -> anyhow::Result<()> {
let due: Vec<(Uuid, String)> = sqlx::query_as(
"SELECT id, url FROM items
WHERE url IS NOT NULL AND track_enabled
AND (checked_at IS NULL OR checked_at < now() - ($1 * interval '1 second'))
ORDER BY checked_at NULLS FIRST
LIMIT $2",
)
.bind(state.config.refetch_min_age_secs)
.bind(BATCH)
.fetch_all(&state.pool)
.await?;
if due.is_empty() {
return Ok(());
}
tracing::debug!(count = due.len(), "refetching due items");
for (id, url) in due {
if let Err(e) = refetch(state, id, &url).await {
tracing::warn!(item = %id, error = %e, "item refetch failed");
}
// Be a polite guest on storefronts.
tokio::time::sleep(Duration::from_millis(500)).await;
}
Ok(())
}
/// Fetch one item and persist the outcome. Records `last_error` + `checked_at`
/// on failure (and still returns `Err` so callers can surface it). On success,
/// fires a price-drop notification if the item just reached its target price.
pub async fn refetch(state: &AppState, item_id: Uuid, url: &str) -> anyhow::Result<()> {
match fetch::fetch_product(&state.http, url, &state.config.default_currency).await {
Ok(p) => {
apply_success(&state.pool, item_id, &p).await?;
notify::maybe_notify_drop(state, item_id).await;
Ok(())
}
Err(e) => {
let msg = e.to_string();
apply_failure(&state.pool, item_id, &msg).await?;
Err(e)
}
}
}
async fn apply_success(pool: &PgPool, item_id: Uuid, p: &FetchedProduct) -> anyhow::Result<()> {
let mut tx = pool.begin().await?;
sqlx::query(
"UPDATE items SET
title_fetched = $2,
current_price = $3,
currency = $4,
image_url = COALESCE($5, image_url),
in_stock = $6,
source = $7,
fetched_at = now(),
checked_at = now(),
last_error = NULL
WHERE id = $1",
)
.bind(item_id)
.bind(&p.title)
.bind(p.price)
.bind(&p.currency)
.bind(p.image_url.as_deref())
.bind(p.in_stock)
.bind(p.source)
.execute(&mut *tx)
.await?;
sqlx::query(
"INSERT INTO price_history (item_id, price, currency, in_stock)
VALUES ($1, $2, $3, $4)",
)
.bind(item_id)
.bind(p.price)
.bind(&p.currency)
.bind(p.in_stock)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
async fn apply_failure(pool: &PgPool, item_id: Uuid, msg: &str) -> anyhow::Result<()> {
sqlx::query("UPDATE items SET checked_at = now(), last_error = $2 WHERE id = $1")
.bind(item_id)
.bind(msg)
.execute(pool)
.await?;
Ok(())
}
+165 -89
View File
@@ -1,26 +1,30 @@
@import 'tailwindcss'; @import "tailwindcss";
/* Web fonts loaded via <link> in app.html (terminal + brutalist mono vibe). */ /* Web fonts loaded via <link> in app.html (ethereal serif + clean sans + mono). */
/* ── Design tokens ─────────────────────────────────────────── */ /* ── Design tokens ─────────────────────────────────────────── */
@theme { @theme {
--color-void: #0a0a0b; /* Twilight base — dark but dreamy, never pitch black. */
--color-ash: #131316; --color-void: #0c0a14;
--color-panel: #17171b; --color-ash: #14111f;
--color-smoke: #2a2a30; --color-panel: #181425;
--color-ink: #e9e7e1; --color-smoke: #2e2740;
--color-mute: #8a8a93; --color-ink: #f3eefb;
--color-mute: #9a90b5;
--color-acid: #c2f73f; /* toxic green */ /* Ethereal pastels — heaven's clearance sale. */
--color-blood: #ff1f6b; /* hot magenta */ --color-iris: #b9a7ff; /* lavender-violet (primary) */
--color-cyber: #28e0e0; /* cyan */ --color-rose: #ffaecb; /* rose quartz */
--color-bruise: #7a3cff; /* electric purple */ --color-mint: #9af7d8; /* aqua halo */
--color-gold: #ffe6a3; /* divine gilt */
--color-holo: #cbd6ff; /* holographic sheen */
--font-display: 'Space Grotesk', system-ui, sans-serif; --font-display: "Space Grotesk", system-ui, sans-serif;
--font-mono: 'Space Mono', ui-monospace, monospace; --font-gospel: "Fraunces", "Times New Roman", serif;
--font-term: 'VT323', monospace; --font-mono: "Space Mono", ui-monospace, monospace;
--radius-none: 0px; --radius-none: 0px;
--radius-soft: 0.625rem; /* one radius language for panels/fields/buttons */
} }
/* ── Base ──────────────────────────────────────────────────── */ /* ── Base ──────────────────────────────────────────────────── */
@@ -42,38 +46,40 @@
overflow-x: hidden; overflow-x: hidden;
} }
/* Film grain + scanlines layered over everything. */ /* Soft drifting aurora — celestial light pollution. */
body::before { body::before {
content: ''; content: "";
position: fixed; position: fixed;
inset: 0; inset: -20%;
pointer-events: none; pointer-events: none;
z-index: 9999; z-index: -1;
opacity: 0.05;
background-image: repeating-linear-gradient(
0deg,
rgba(255, 255, 255, 0.6) 0px,
rgba(255, 255, 255, 0.6) 1px,
transparent 1px,
transparent 3px
);
mix-blend-mode: overlay;
}
body::after {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9998;
opacity: 0.4;
background: background:
radial-gradient(circle at 20% 10%, rgba(122, 60, 255, 0.12), transparent 40%), radial-gradient(
radial-gradient(circle at 85% 80%, rgba(255, 31, 107, 0.1), transparent 45%); 40% 35% at 18% 12%,
rgba(185, 167, 255, 0.18),
transparent 70%
),
radial-gradient(
38% 40% at 85% 20%,
rgba(255, 174, 203, 0.14),
transparent 70%
),
radial-gradient(
45% 45% at 70% 88%,
rgba(154, 247, 216, 0.12),
transparent 70%
),
radial-gradient(
50% 50% at 30% 80%,
rgba(203, 214, 255, 0.1),
transparent 70%
);
filter: blur(10px);
animation: drift 26s ease-in-out infinite alternate;
} }
::selection { ::selection {
background: var(--color-acid); background: var(--color-iris);
color: var(--color-void); color: var(--color-void);
} }
@@ -85,14 +91,15 @@
} }
a { a {
color: var(--color-cyber); color: var(--color-iris);
text-decoration: none; text-decoration: none;
transition: color 0.12s ease;
} }
a:hover { a:hover {
color: var(--color-acid); color: var(--color-rose);
} }
/* Brutalist scrollbar. */ /* Soft scrollbar with a pastel edge. */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 10px; width: 10px;
} }
@@ -101,21 +108,29 @@
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: var(--color-smoke); background: var(--color-smoke);
border: 1px solid var(--color-blood); border: 1px solid var(--color-iris);
} }
} }
/* ── Components ────────────────────────────────────────────── */ /* ── Components ────────────────────────────────────────────── */
@layer components { @layer components {
/* Hard-edged panel with offset shadow — xerox/zine look. */ /* Panel: glassy twilight slab with a pastel halo glow + faint offset. */
.panel { .panel {
background: var(--color-panel); position: relative;
border: 2px solid var(--color-smoke); border-radius: var(--radius-soft);
box-shadow: 6px 6px 0 0 var(--color-void), 9px 9px 0 0 var(--color-blood); background:
linear-gradient(160deg, rgba(255, 255, 255, 0.03), transparent 60%),
var(--color-panel);
border: 1px solid var(--color-smoke);
box-shadow:
0 0 0 1px rgba(185, 167, 255, 0.06),
0 18px 50px -24px rgba(185, 167, 255, 0.45);
} }
.panel-acid { .panel-acid {
box-shadow: 6px 6px 0 0 var(--color-void), 9px 9px 0 0 var(--color-acid); box-shadow:
0 0 0 1px rgba(154, 247, 216, 0.12),
0 18px 50px -22px rgba(154, 247, 216, 0.45);
} }
.label { .label {
@@ -126,25 +141,46 @@
color: var(--color-mute); color: var(--color-mute);
} }
/* Ethereal gilt serif italic — the consumerist gospel voice. */
.gospel {
font-family: var(--font-gospel);
font-style: italic;
font-weight: 300;
background: linear-gradient(
100deg,
var(--color-gold),
var(--color-rose),
var(--color-iris)
);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.field { .field {
width: 100%; width: 100%;
background: var(--color-void); border-radius: var(--radius-soft);
border: 2px solid var(--color-smoke); background: rgba(12, 10, 20, 0.7);
border: 1px solid var(--color-smoke);
color: var(--color-ink); color: var(--color-ink);
padding: 0.7rem 0.8rem; padding: 0.7rem 0.8rem;
font-family: var(--font-mono); font-family: var(--font-mono);
outline: none; outline: none;
transition: border-color 0.1s ease, box-shadow 0.1s ease; transition:
border-color 0.12s ease,
box-shadow 0.12s ease;
} }
.field::placeholder { .field::placeholder {
color: #55555e; color: #5d5474;
} }
.field:focus { .field:focus {
border-color: var(--color-acid); border-color: var(--color-iris);
box-shadow: 0 0 0 1px var(--color-acid), 0 0 18px -6px var(--color-acid); box-shadow:
0 0 0 1px var(--color-iris),
0 0 24px -6px var(--color-iris);
} }
/* Chunky brutalist button. */ /* Button: soft glow, gentle lift. Halo instead of hard shadow. */
.btn { .btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -155,33 +191,45 @@
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
padding: 0.7rem 1.2rem; padding: 0.7rem 1.2rem;
border: 2px solid var(--color-ink); border-radius: var(--radius-soft);
border: 1px solid var(--color-ink);
background: var(--color-ink); background: var(--color-ink);
color: var(--color-void); color: var(--color-void);
cursor: pointer; cursor: pointer;
transition: transform 0.06s ease, box-shadow 0.06s ease, background 0.1s; transition:
box-shadow: 4px 4px 0 0 var(--color-blood); transform 0.1s ease,
box-shadow 0.15s ease,
filter 0.15s;
box-shadow: 0 8px 24px -10px rgba(255, 174, 203, 0.7);
} }
.btn:hover { .btn:hover {
transform: translate(-2px, -2px); transform: translateY(-2px);
box-shadow: 6px 6px 0 0 var(--color-blood); box-shadow: 0 14px 34px -10px rgba(255, 174, 203, 0.85);
} }
.btn:active { .btn:active {
transform: translate(2px, 2px); transform: translateY(0);
box-shadow: 1px 1px 0 0 var(--color-blood); box-shadow: 0 6px 16px -10px rgba(255, 174, 203, 0.7);
} }
/* Primary "ascend" button — holographic gradient. */
.btn-acid { .btn-acid {
background: var(--color-acid); border: none;
border-color: var(--color-acid); color: var(--color-void);
box-shadow: 4px 4px 0 0 var(--color-void); background: linear-gradient(
110deg,
var(--color-iris),
var(--color-rose) 55%,
var(--color-gold)
);
box-shadow: 0 10px 30px -8px rgba(185, 167, 255, 0.8);
} }
.btn-acid:hover { .btn-acid:hover {
box-shadow: 6px 6px 0 0 var(--color-void); box-shadow: 0 16px 40px -8px rgba(185, 167, 255, 0.95);
} }
.btn-ghost { .btn-ghost {
background: transparent; background: transparent;
color: var(--color-ink); color: var(--color-ink);
box-shadow: 4px 4px 0 0 var(--color-smoke); border-color: var(--color-smoke);
box-shadow: 0 8px 24px -14px rgba(185, 167, 255, 0.6);
} }
.btn:disabled { .btn:disabled {
opacity: 0.4; opacity: 0.4;
@@ -196,9 +244,10 @@
text-transform: uppercase; text-transform: uppercase;
padding: 0.15rem 0.5rem; padding: 0.15rem 0.5rem;
border: 1px solid currentColor; border: 1px solid currentColor;
border-radius: 999px;
} }
/* Glitchy duplicated-layer heading. */ /* Chromatic aura heading — pastel iris/rose ghosts drift behind the text. */
.glitch { .glitch {
position: relative; position: relative;
color: var(--color-ink); color: var(--color-ink);
@@ -209,44 +258,71 @@
position: absolute; position: absolute;
inset: 0; inset: 0;
pointer-events: none; pointer-events: none;
filter: blur(0.5px);
} }
.glitch::before { .glitch::before {
color: var(--color-blood); color: var(--color-iris);
transform: translate(-2px, 0); transform: translate(-1.5px, 0);
mix-blend-mode: screen; mix-blend-mode: screen;
clip-path: inset(0 0 55% 0); opacity: 0.7;
animation: glitch-x 3.5s infinite steps(2); animation: aura 5s ease-in-out infinite;
} }
.glitch::after { .glitch::after {
color: var(--color-cyber); color: var(--color-rose);
transform: translate(2px, 0); transform: translate(1.5px, 0);
mix-blend-mode: screen; mix-blend-mode: screen;
clip-path: inset(55% 0 0 0); opacity: 0.7;
animation: glitch-x 2.7s infinite steps(2) reverse; animation: aura 5s ease-in-out infinite reverse;
} }
} }
@keyframes glitch-x { @keyframes aura {
0%, 92%, 100% { transform: translate(0, 0); } 0%,
93% { transform: translate(-3px, 1px); } 100% {
96% { transform: translate(3px, -1px); } transform: translate(-1.5px, 0);
}
50% {
transform: translate(1.5px, 0.5px);
}
}
@keyframes drift {
from {
transform: translate(0, 0) scale(1);
}
to {
transform: translate(2%, -2%) scale(1.08);
}
} }
.marquee { .marquee {
white-space: nowrap; white-space: nowrap;
animation: marquee 22s linear infinite; animation: marquee 28s linear infinite;
} }
@keyframes marquee { @keyframes marquee {
from { transform: translateX(0); } from {
to { transform: translateX(-50%); } transform: translateX(0);
}
to {
transform: translateX(-50%);
}
} }
.flicker { .flicker {
animation: flicker 4s infinite; animation: flicker 6s infinite;
} }
@keyframes flicker { @keyframes flicker {
0%, 100% { opacity: 1; } 0%,
97% { opacity: 1; } 100% {
98% { opacity: 0.4; } opacity: 1;
99% { opacity: 0.9; } }
97% {
opacity: 1;
}
98% {
opacity: 0.6;
}
99% {
opacity: 0.95;
}
} }
+2 -2
View File
@@ -4,11 +4,11 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" /> <link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#0a0a0a" /> <meta name="theme-color" content="#0c0a14" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Space+Mono:ital,wght@0,400;0,700;1,400&family=VT323&display=swap" href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@1,9..144,300;1,9..144,400&family=Space+Grotesk:wght@400;500;700&family=Space+Mono:ital,wght@0,400;0,700;1,400&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
%sveltekit.head% %sveltekit.head%
+14 -7
View File
@@ -1,6 +1,6 @@
import { env } from '$env/dynamic/public'; import { env } from "$env/dynamic/public";
const BASE = env.PUBLIC_API_BASE || 'http://localhost:8080'; const BASE = env.PUBLIC_API_BASE || "http://localhost:8080";
export class ApiError extends Error { export class ApiError extends Error {
status: number; status: number;
@@ -12,9 +12,9 @@ export class ApiError extends Error {
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> { async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE}/api${path}`, { const res = await fetch(`${BASE}/api${path}`, {
credentials: 'include', credentials: "include",
headers: { 'Content-Type': 'application/json', ...(opts.headers ?? {}) }, headers: { "Content-Type": "application/json", ...(opts.headers ?? {}) },
...opts ...opts,
}); });
if (res.status === 204) return undefined as T; if (res.status === 204) return undefined as T;
@@ -31,7 +31,14 @@ async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
export const api = { export const api = {
get: <T>(p: string) => request<T>(p), get: <T>(p: string) => request<T>(p),
post: <T>(p: string, body?: unknown) => post: <T>(p: string, body?: unknown) =>
request<T>(p, { method: 'POST', body: body ? JSON.stringify(body) : undefined }), request<T>(p, {
method: "POST",
body: body ? JSON.stringify(body) : undefined,
}),
patch: <T>(p: string, body?: unknown) => patch: <T>(p: string, body?: unknown) =>
request<T>(p, { method: 'PATCH', body: body ? JSON.stringify(body) : undefined }) request<T>(p, {
method: "PATCH",
body: body ? JSON.stringify(body) : undefined,
}),
del: <T>(p: string) => request<T>(p, { method: "DELETE" }),
}; };
+3 -3
View File
@@ -1,4 +1,4 @@
import { api } from './api'; import { api } from "./api";
export type User = { export type User = {
id: string; id: string;
@@ -23,7 +23,7 @@ class AuthStore {
async refresh() { async refresh() {
try { try {
const me = await api.get<Me>('/auth/me'); const me = await api.get<Me>("/auth/me");
this.user = me.user; this.user = me.user;
this.settings = me.settings; this.settings = me.settings;
} catch { } catch {
@@ -42,7 +42,7 @@ class AuthStore {
async logout() { async logout() {
try { try {
await api.post('/auth/logout'); await api.post("/auth/logout");
} finally { } finally {
this.user = null; this.user = null;
this.settings = null; this.settings = null;
+101
View File
@@ -0,0 +1,101 @@
import { api } from "./api";
export type ItemStatus = "coveted" | "acquired" | "renounced";
export type List = {
id: string;
name: string;
emoji: string | null;
description: string | null;
position: number;
created_at: string;
updated_at: string;
};
export type Item = {
id: string;
list_id: string;
title: string;
url: string | null;
note: string | null;
status: ItemStatus;
target_price: number | null;
position: number;
// Filled by the Phase 3 fetcher; null until then.
title_fetched: string | null;
current_price: number | null;
currency: string | null;
image_url: string | null;
in_stock: boolean | null;
source: string | null;
fetched_at: string | null;
track_enabled: boolean;
last_error: string | null;
checked_at: string | null;
created_at: string;
updated_at: string;
};
export type PricePoint = {
price: number;
currency: string;
in_stock: boolean | null;
fetched_at: string;
};
export type NewList = {
name: string;
emoji?: string | null;
description?: string | null;
};
export type NewItem = {
title: string;
url?: string | null;
note?: string | null;
target_price?: number | null;
};
// ---- Lists ----------------------------------------------------------------
export const listsApi = {
all: () => api.get<List[]>("/lists"),
create: (b: NewList) => api.post<List>("/lists", b),
update: (id: string, b: Partial<NewList> & { position?: number }) =>
api.patch<List>(`/lists/${id}`, b),
remove: (id: string) => api.del<{ deleted: string }>(`/lists/${id}`),
items: (listId: string) => api.get<Item[]>(`/lists/${listId}/items`),
addItem: (listId: string, b: NewItem) =>
api.post<Item>(`/lists/${listId}/items`, b),
updateItem: (
id: string,
b: Partial<NewItem> & { status?: ItemStatus; position?: number },
) => api.patch<Item>(`/items/${id}`, b),
removeItem: (id: string) => api.del<{ deleted: string }>(`/items/${id}`),
refetch: (id: string) => api.post<Item>(`/items/${id}/refetch`, {}),
history: (id: string) => api.get<PricePoint[]>(`/items/${id}/history`),
};
/** Reactive store for the user's lists. */
class ListsStore {
items = $state<List[]>([]);
loaded = $state(false);
async load() {
this.items = await listsApi.all();
this.loaded = true;
}
async create(b: NewList): Promise<List> {
const created = await listsApi.create(b);
this.items.push(created);
return created;
}
async remove(id: string) {
await listsApi.remove(id);
this.items = this.items.filter((l) => l.id !== id);
}
}
export const lists = new ListsStore();
+15 -10
View File
@@ -17,12 +17,15 @@
} }
const ticker = const ticker =
'BUY LESS · WANT MORE · TRACK THE DROP · NO IMPULSE · GRAB THE DEAL · '; 'CONSUME · ASCEND · ACCUMULATE · YOU DESERVE IT · MANIFEST THE DEBT · TREAT YOURSELF · ONE MORE WONT HURT · ';
</script> </script>
<div class="min-h-dvh flex flex-col"> <div class="min-h-dvh flex flex-col">
<!-- Ticker strip --> <!-- Ticker strip -->
<div class="bg-acid text-void overflow-hidden border-b-2 border-void"> <div
class="overflow-hidden border-b border-void text-void"
style="background-image:linear-gradient(110deg,var(--color-iris),var(--color-rose) 55%,var(--color-gold));"
>
<div class="marquee py-1 font-mono text-xs font-bold tracking-widest"> <div class="marquee py-1 font-mono text-xs font-bold tracking-widest">
{ticker.repeat(6)} {ticker.repeat(6)}
</div> </div>
@@ -34,15 +37,16 @@
<a href="/" class="group flex items-baseline gap-2"> <a href="/" class="group flex items-baseline gap-2">
<span <span
class="glitch flicker font-display text-2xl font-bold tracking-tighter text-ink" class="glitch flicker font-display text-2xl font-bold tracking-tighter text-ink"
data-text="//WANTLIST" data-text="consume·rs"
> >
//WANTLIST consume<span class="text-iris">·</span>rs
</span> </span>
</a> </a>
<nav class="flex items-center gap-2 text-sm"> <nav class="flex items-center gap-2 text-sm">
{#if auth.loaded && auth.user} {#if auth.loaded && auth.user}
<a href="/settings" class="tag border-smoke text-mute hover:text-acid"> <a href="/lists" class="tag border-smoke text-mute hover:text-iris">lists</a>
<a href="/settings" class="tag border-smoke text-mute hover:text-iris">
{auth.user.display_name ?? auth.user.email} {auth.user.display_name ?? auth.user.email}
</a> </a>
<button class="btn btn-ghost !px-3 !py-1 text-xs" onclick={doLogout}> <button class="btn btn-ghost !px-3 !py-1 text-xs" onclick={doLogout}>
@@ -50,7 +54,7 @@
</button> </button>
{:else if auth.loaded} {:else if auth.loaded}
{#if page.url.pathname !== '/login'} {#if page.url.pathname !== '/login'}
<a href="/login" class="tag border-smoke text-mute hover:text-acid">login</a> <a href="/login" class="tag border-smoke text-mute hover:text-iris">login</a>
{/if} {/if}
{#if page.url.pathname !== '/register'} {#if page.url.pathname !== '/register'}
<a href="/register" class="btn btn-acid !px-3 !py-1 text-xs">sign up</a> <a href="/register" class="btn btn-acid !px-3 !py-1 text-xs">sign up</a>
@@ -62,8 +66,8 @@
<!-- Unverified banner --> <!-- Unverified banner -->
{#if auth.loaded && auth.user && !auth.user.email_verified} {#if auth.loaded && auth.user && !auth.user.email_verified}
<div class="border-b-2 border-blood bg-blood/10 px-4 py-2 text-center text-xs text-blood"> <div class="border-b border-rose bg-rose/10 px-4 py-2 text-center text-xs text-rose">
email not verified — check your inbox. lost it? email unconfirmed — your indulgence awaits. lost the link?
<a href="/settings" class="underline">resend from settings</a> <a href="/settings" class="underline">resend from settings</a>
</div> </div>
{/if} {/if}
@@ -72,7 +76,8 @@
{@render children()} {@render children()}
</main> </main>
<footer class="border-t-2 border-smoke px-4 py-6 text-center"> <footer class="border-t border-smoke px-4 py-6 text-center">
<p class="label">self-hosted · rust + sveltekit · phase 1</p> <p class="gospel text-base">spend now, ascend later</p>
<p class="label mt-1">consume·rs · self-hosted · rust + sveltekit</p>
</footer> </footer>
</div> </div>
+21 -38
View File
@@ -1,70 +1,53 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import { auth } from '$lib/auth.svelte'; import { auth } from '$lib/auth.svelte';
// Logged-in users go straight to their lists — no placeholder dashboard.
$effect(() => {
if (auth.loaded && auth.user) goto('/lists', { replaceState: true });
});
</script> </script>
<svelte:head> <svelte:head>
<title>//WANTLIST</title> <title>consume·rs — want more, ascend</title>
</svelte:head> </svelte:head>
{#if auth.loaded && auth.user} {#if auth.loaded && auth.user}
<!-- Logged-in placeholder dashboard --> <p class="text-center text-mute flicker">entering…</p>
<section class="space-y-6">
<div>
<p class="label">logged in as</p>
<h1 class="font-display text-3xl font-bold">
{auth.user.display_name ?? auth.user.email}
</h1>
</div>
<div class="panel panel-acid p-6">
<p class="tag mb-3 inline-block border-acid text-acid">phase 2</p>
<h2 class="mb-2 text-xl font-bold">your lists land here</h2>
<p class="max-w-prose text-mute">
topic-based wantlists (clothes, gear, whatever), item tracking, and pasted
product URLs that get refetched for price drops. building it next.
</p>
</div>
<div class="grid gap-4 sm:grid-cols-3">
{#each [['LISTS', 'soon'], ['TRACKED URLS', 'soon'], ['DEAL ALERTS', 'soon']] as [k, v]}
<div class="panel p-4">
<p class="label">{k}</p>
<p class="font-display text-2xl text-blood">{v}</p>
</div>
{/each}
</div>
</section>
{:else} {:else}
<!-- Marketing hero --> <!-- Marketing hero -->
<section class="space-y-10"> <section class="space-y-10">
<div class="space-y-4"> <div class="space-y-4">
<p class="tag inline-block border-cyber text-cyber">self-hosted · rust core</p> <p class="tag inline-block border-mint text-mint">self-hosted · rust core · ✦ blessed</p>
<h1 <h1
class="glitch font-display text-5xl font-bold leading-[0.95] sm:text-7xl" class="glitch font-display text-5xl font-bold leading-[0.95] sm:text-7xl"
data-text="TRACK WHAT YOU WANT. STRIKE ON THE DROP." data-text="WANT MORE. ASCEND. ACCUMULATE THE DEBT."
> >
TRACK WHAT YOU WANT. STRIKE ON THE DROP. WANT MORE. ASCEND. ACCUMULATE THE DEBT.
</h1> </h1>
<p class="max-w-xl text-lg text-mute"> <p class="max-w-xl text-lg text-mute">
Topic-based shopping lists for the things you actually want. Paste a product A serene little shrine to your every craving. Paste a product URL; we keep
URL, and get mailed the moment the price tanks. No feed. No algorithm. Your vigil over the price and summon you the instant it falls. No feed. No
server, your rules. algorithm. Just you, your wants, and the gentle hum of impending debt.
<span class="gospel">You deserve it.</span>
</p> </p>
</div> </div>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
<a href="/register" class="btn btn-acid">make an account</a> <a href="/register" class="btn btn-acid">begin ascension</a>
<a href="/login" class="btn btn-ghost">log in</a> <a href="/login" class="btn btn-ghost">return to worship</a>
</div> </div>
<div class="grid gap-4 sm:grid-cols-3"> <div class="grid gap-4 sm:grid-cols-3">
{#each [['01', 'LIST IT', 'group wants by topic'], ['02', 'PASTE URL', 'we watch the price'], ['03', 'GET MAILED', 'strike on the drop']] as [n, t, d]} {#each [['I', 'COVET', 'group wants by topic'], ['II', 'PASTE URL', 'we keep the vigil'], ['III', 'BE SUMMONED', 'strike on the drop']] as [n, t, d]}
<div class="panel p-5"> <div class="panel p-5">
<p class="font-term text-4xl text-blood">{n}</p> <p class="gospel text-4xl">{n}</p>
<p class="mt-1 font-display text-lg font-bold">{t}</p> <p class="mt-1 font-display text-lg font-bold">{t}</p>
<p class="text-sm text-mute">{d}</p> <p class="text-sm text-mute">{d}</p>
</div> </div>
{/each} {/each}
</div> </div>
<p class="gospel text-center text-lg">“blessed are the carts, for they shall be filled”</p>
</section> </section>
{/if} {/if}
+4 -4
View File
@@ -21,15 +21,15 @@
} }
</script> </script>
<svelte:head><title>reset · //WANTLIST</title></svelte:head> <svelte:head><title>reset · consume·rs</title></svelte:head>
<div class="mx-auto max-w-md"> <div class="mx-auto max-w-md">
<div class="panel p-8"> <div class="panel p-8">
<p class="label">password reset</p> <p class="label">password reset</p>
<h1 class="mb-6 font-display text-3xl font-bold">LOST THE KEY</h1> <h1 class="mb-6 font-display text-3xl font-bold">RECLAIM THE KEY</h1>
{#if done} {#if done}
<p class="border-2 border-acid bg-acid/10 px-3 py-3 text-sm text-acid"> <p class="border-2 border-mint bg-mint/10 px-3 py-3 text-sm text-mint">
if that email exists, a reset link is on its way. check your inbox. if that email exists, a reset link is on its way. check your inbox.
</p> </p>
{:else} {:else}
@@ -39,7 +39,7 @@
<input id="em" class="field mt-1" type="email" bind:value={email} required autocomplete="email" /> <input id="em" class="field mt-1" type="email" bind:value={email} required autocomplete="email" />
</div> </div>
{#if error} {#if error}
<p class="border-2 border-blood bg-blood/10 px-3 py-2 text-sm text-blood">{error}</p> <p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{error}</p>
{/if} {/if}
<button class="btn w-full" disabled={busy}>{busy ? 'sending…' : 'send reset link'}</button> <button class="btn w-full" disabled={busy}>{busy ? 'sending…' : 'send reset link'}</button>
</form> </form>
+125
View File
@@ -0,0 +1,125 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ApiError } from '$lib/api';
import { auth } from '$lib/auth.svelte';
import { lists } from '$lib/lists.svelte';
let name = $state('');
let emoji = $state('');
let description = $state('');
let busy = $state(false);
let error = $state('');
let showForm = $state(false);
// Gate on auth, load lists once.
$effect(() => {
if (auth.loaded && !auth.user) {
goto('/login');
} else if (auth.loaded && auth.user && !lists.loaded) {
lists.load().catch((e) => (error = e instanceof ApiError ? e.message : 'failed to load'));
}
});
async function create(e: SubmitEvent) {
e.preventDefault();
if (!name.trim()) return;
error = '';
busy = true;
try {
await lists.create({ name, emoji: emoji || null, description: description || null });
name = '';
emoji = '';
description = '';
showForm = false;
} catch (err) {
error = err instanceof ApiError ? err.message : 'failed to create';
} finally {
busy = false;
}
}
async function remove(id: string, listName: string) {
if (!confirm(`delete the list “${listName}” and everything on it?`)) return;
try {
await lists.remove(id);
} catch (err) {
error = err instanceof ApiError ? err.message : 'failed to delete';
}
}
</script>
<svelte:head><title>your lists · consume·rs</title></svelte:head>
{#if auth.loaded && auth.user}
<section class="space-y-8">
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<p class="label">your devotion</p>
<h1 class="font-display text-4xl font-bold">YOUR LISTS</h1>
<p class="gospel mt-1 text-lg">each a temple to a different craving</p>
</div>
<button class="btn btn-acid" onclick={() => (showForm = !showForm)}>
{showForm ? 'never mind' : 'new list +'}
</button>
</div>
{#if showForm}
<form class="panel panel-acid space-y-4 p-6" onsubmit={create}>
<div class="grid gap-4 sm:grid-cols-[5rem_1fr]">
<div>
<label class="label" for="emoji">glyph</label>
<input id="emoji" class="field mt-1 text-center" bind:value={emoji} maxlength="4" placeholder="🛍" />
</div>
<div>
<label class="label" for="name">name</label>
<input id="name" class="field mt-1" bind:value={name} maxlength="80" placeholder="clothes, gear, indulgences…" />
</div>
</div>
<div>
<label class="label" for="desc">creed <span class="text-mute">(optional)</span></label>
<input id="desc" class="field mt-1" bind:value={description} maxlength="500" placeholder="what you tell yourself you need" />
</div>
{#if error}<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{error}</p>{/if}
<button class="btn btn-acid" disabled={busy}>{busy ? 'creating…' : 'create it'}</button>
</form>
{/if}
{#if !lists.loaded}
<p class="text-center text-mute flicker">summoning your lists…</p>
{:else if lists.items.length === 0}
<div class="panel p-10 text-center">
<p class="gospel text-2xl">no lists yet</p>
<p class="mt-2 text-mute">make your first and begin the accumulation.</p>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each lists.items as l (l.id)}
<div class="panel group relative flex flex-col p-5 transition-transform hover:-translate-y-0.5">
<a href="/lists/{l.id}" class="flex-1">
<div class="flex items-start gap-3">
<span class="text-3xl leading-none">{l.emoji ?? '✦'}</span>
<div class="min-w-0">
<h2 class="truncate font-display text-xl font-bold">{l.name}</h2>
{#if l.description}
<p class="mt-1 line-clamp-2 text-sm text-mute">{l.description}</p>
{/if}
</div>
</div>
</a>
<div class="mt-4 flex items-center justify-between">
<a href="/lists/{l.id}" class="tag border-smoke text-mute hover:text-iris">enter →</a>
<button
class="text-xs text-mute opacity-0 transition-opacity hover:text-rose group-hover:opacity-100"
onclick={() => remove(l.id, l.name)}
>
renounce
</button>
</div>
</div>
{/each}
</div>
{/if}
</section>
{:else}
<p class="text-center text-mute flicker">loading…</p>
{/if}
+336
View File
@@ -0,0 +1,336 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { ApiError } from '$lib/api';
import { auth } from '$lib/auth.svelte';
import {
lists,
listsApi,
type Item,
type ItemStatus,
type List,
type PricePoint
} from '$lib/lists.svelte';
const id = $derived(page.params.id);
let list = $state<List | null>(null);
let items = $state<Item[]>([]);
let loaded = $state(false);
let loadError = $state('');
// form
let title = $state('');
let url = $state('');
let note = $state('');
let targetPrice = $state('');
let busy = $state(false);
let formError = $state('');
// tracking
let refetchingId = $state<string | null>(null);
let historyFor = $state<string | null>(null);
let history = $state<PricePoint[]>([]);
let historyLoading = $state(false);
let lastId = '';
$effect(() => {
if (auth.loaded && !auth.user) {
goto('/login');
return;
}
if (auth.loaded && auth.user && id && id !== lastId) {
lastId = id;
load(id);
}
});
async function load(listId: string) {
loaded = false;
loadError = '';
try {
if (!lists.loaded) await lists.load();
list = lists.items.find((l) => l.id === listId) ?? null;
items = await listsApi.items(listId);
} catch (e) {
loadError = e instanceof ApiError ? e.message : 'failed to load';
} finally {
loaded = true;
}
}
async function addItem(e: SubmitEvent) {
e.preventDefault();
if (!title.trim() || !id) return;
formError = '';
busy = true;
try {
const tp = targetPrice.trim() ? Number(targetPrice) : null;
const created = await listsApi.addItem(id, {
title,
url: url.trim() || null,
note: note.trim() || null,
target_price: Number.isFinite(tp as number) ? tp : null
});
items.push(created);
title = '';
url = '';
note = '';
targetPrice = '';
} catch (err) {
formError = err instanceof ApiError ? err.message : 'failed to add';
} finally {
busy = false;
}
}
const cycle: Record<ItemStatus, ItemStatus> = {
coveted: 'acquired',
acquired: 'renounced',
renounced: 'coveted'
};
async function cycleStatus(item: Item) {
const next = cycle[item.status];
try {
const updated = await listsApi.updateItem(item.id, { status: next });
const i = items.findIndex((x) => x.id === item.id);
if (i >= 0) items[i] = updated;
} catch (err) {
formError = err instanceof ApiError ? err.message : 'failed to update';
}
}
async function removeItem(item: Item) {
if (!confirm(`cast out “${item.title}”?`)) return;
try {
await listsApi.removeItem(item.id);
items = items.filter((x) => x.id !== item.id);
} catch (err) {
formError = err instanceof ApiError ? err.message : 'failed to delete';
}
}
async function refetchItem(item: Item) {
refetchingId = item.id;
formError = '';
try {
const updated = await listsApi.refetch(item.id);
const i = items.findIndex((x) => x.id === item.id);
if (i >= 0) items[i] = updated;
if (historyFor === item.id) history = await listsApi.history(item.id);
} catch (err) {
formError = err instanceof ApiError ? err.message : 'failed to keep vigil';
} finally {
refetchingId = null;
}
}
async function toggleHistory(item: Item) {
if (historyFor === item.id) {
historyFor = null;
return;
}
historyFor = item.id;
history = [];
historyLoading = true;
try {
history = await listsApi.history(item.id);
} catch (err) {
formError = err instanceof ApiError ? err.message : 'failed to read the chronicle';
} finally {
historyLoading = false;
}
}
// A want is "answered" when its watched price falls to/under the target.
function onSale(item: Item): boolean {
return (
item.current_price != null &&
item.target_price != null &&
item.current_price <= item.target_price
);
}
function fmtDate(s: string): string {
return new Date(s).toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
}
const STATUS_STYLE: Record<ItemStatus, string> = {
coveted: 'border-iris text-iris',
acquired: 'border-mint text-mint',
renounced: 'border-smoke text-mute'
};
const STATUS_LABEL: Record<ItemStatus, string> = {
coveted: 'coveted',
acquired: 'acquired',
renounced: 'renounced'
};
function money(v: number | null, cur: string | null) {
if (v == null) return null;
return `${cur ?? 'EUR'} ${v.toFixed(2)}`;
}
</script>
<svelte:head><title>{list?.name ?? 'list'} · consume·rs</title></svelte:head>
{#if auth.loaded && auth.user}
<section class="space-y-8">
<div>
<a href="/lists" class="label hover:text-iris">← all lists</a>
<div class="mt-2 flex items-start gap-3">
<span class="text-4xl leading-none">{list?.emoji ?? '✦'}</span>
<div>
<h1 class="font-display text-4xl font-bold">{list?.name ?? '…'}</h1>
{#if list?.description}<p class="gospel mt-1 text-lg">{list.description}</p>{/if}
</div>
</div>
</div>
<!-- Add temptation -->
<form class="panel panel-acid space-y-4 p-6" onsubmit={addItem}>
<p class="label">add a temptation</p>
<input class="field" bind:value={title} maxlength="200" placeholder="what you covet" />
<div class="grid gap-4 sm:grid-cols-[1fr_8rem]">
<input class="field" bind:value={url} placeholder="product URL (we'll keep vigil on the price)" />
<input class="field" bind:value={targetPrice} inputmode="decimal" placeholder="target price" />
</div>
<input class="field" bind:value={note} maxlength="1000" placeholder="note to self (optional)" />
{#if formError}<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{formError}</p>{/if}
<button class="btn btn-acid" disabled={busy}>{busy ? 'coveting…' : 'covet it +'}</button>
</form>
{#if !loaded}
<p class="text-center text-mute flicker">unveiling temptations…</p>
{:else if loadError}
<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{loadError}</p>
{:else if items.length === 0}
<div class="panel p-10 text-center">
<p class="gospel text-2xl">this list is bare</p>
<p class="mt-2 text-mute">paste a craving above to begin.</p>
</div>
{:else}
<ul class="space-y-3">
{#each items as item (item.id)}
<li
class="panel flex flex-col gap-3 p-4"
class:opacity-60={item.status === 'renounced'}
class:ring-1={onSale(item)}
class:ring-mint={onSale(item)}
>
<!-- Line 1: identity + state -->
<div class="flex items-start gap-4">
{#if item.image_url}
<img src={item.image_url} alt="" class="size-14 shrink-0 rounded-lg object-cover" />
{/if}
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h3 class="truncate font-display font-bold" class:line-through={item.status === 'renounced'}>
{item.title_fetched ?? item.title}
</h3>
{#if item.in_stock === true}
<span class="tag shrink-0 border-mint text-mint">in stock</span>
{:else if item.in_stock === false}
<span class="tag shrink-0 border-rose text-rose">sold out</span>
{/if}
</div>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-mute">
{#if money(item.current_price, item.currency)}
<span class:text-mint={onSale(item)} class:text-ink={!onSale(item)}>
now {money(item.current_price, item.currency)}
</span>
{/if}
{#if item.target_price != null}
<span>target {money(item.target_price, item.currency)}</span>
{/if}
{#if item.url}
<a href={item.url} target="_blank" rel="noopener noreferrer" class="hover:text-iris" title="open product page">visit ↗</a>
{/if}
{#if item.note}<span class="italic">{item.note}</span>{/if}
{#if !item.url && item.current_price == null}
<span class="text-mute">not tracked</span>
{/if}
{#if item.checked_at}
<span class="text-mute" title="last price check">checked {fmtDate(item.checked_at)}</span>
{/if}
</div>
</div>
<!-- Status is the primary control: a real, cyclable badge. -->
<button
class="tag shrink-0 cursor-pointer transition hover:brightness-125 {STATUS_STYLE[item.status]}"
title="click to cycle: coveted → acquired → renounced"
onclick={() => cycleStatus(item)}
>
{STATUS_LABEL[item.status]}
</button>
</div>
<!-- Line 2: utility actions — plain verbs, real button chrome -->
<div class="flex flex-wrap items-center justify-end gap-2 text-xs">
{#if item.url}
<button
class="rounded border border-smoke px-2 py-1 text-mute transition hover:border-iris hover:text-iris disabled:opacity-40"
title="refetch price now (keep vigil)"
disabled={refetchingId === item.id}
onclick={() => refetchItem(item)}
>
{refetchingId === item.id ? '↻ checking…' : '↻ refresh'}
</button>
<button
class="rounded border border-smoke px-2 py-1 text-mute transition hover:border-iris hover:text-iris"
title="price history (the chronicle)"
onclick={() => toggleHistory(item)}
>
{historyFor === item.id ? 'hide history' : 'history'}
</button>
{/if}
<button
class="rounded border border-smoke px-2 py-1 text-mute transition hover:border-rose hover:text-rose"
title="remove from this list"
onclick={() => removeItem(item)}
>
✕ remove
</button>
</div>
{#if onSale(item)}
<p class="gospel text-sm text-mint">✦ the price has fallen — your moment is upon you</p>
{/if}
{#if item.last_error}
<p class="text-xs text-rose">vigil faltered: {item.last_error}</p>
{/if}
{#if historyFor === item.id}
<div class="border-t border-smoke pt-3">
{#if historyLoading}
<p class="text-xs text-mute flicker">unrolling the chronicle…</p>
{:else if history.length === 0}
<p class="text-xs text-mute">no observations yet — keep vigil to begin the record.</p>
{:else}
<ul class="space-y-1 text-xs">
{#each history as h}
<li class="flex items-center justify-between gap-3">
<span class="text-ink">{money(h.price, h.currency)}</span>
{#if h.in_stock === false}<span class="text-rose">sold out</span>{/if}
<span class="text-mute">{fmtDate(h.fetched_at)}</span>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
{:else}
<p class="text-center text-mute flicker">loading…</p>
{/if}
+3 -3
View File
@@ -24,12 +24,12 @@
} }
</script> </script>
<svelte:head><title>log in · //WANTLIST</title></svelte:head> <svelte:head><title>log in · consume·rs</title></svelte:head>
<div class="mx-auto max-w-md"> <div class="mx-auto max-w-md">
<div class="panel p-8"> <div class="panel p-8">
<p class="label">welcome back</p> <p class="label">welcome back</p>
<h1 class="mb-6 font-display text-3xl font-bold">RE-ENTER THE PIT</h1> <h1 class="mb-6 font-display text-3xl font-bold">RETURN TO WORSHIP</h1>
<form class="space-y-4" onsubmit={submit}> <form class="space-y-4" onsubmit={submit}>
<div> <div>
@@ -42,7 +42,7 @@
</div> </div>
{#if error} {#if error}
<p class="border-2 border-blood bg-blood/10 px-3 py-2 text-sm text-blood">{error}</p> <p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{error}</p>
{/if} {/if}
<button class="btn w-full" disabled={busy}>{busy ? 'entering…' : 'log in'}</button> <button class="btn w-full" disabled={busy}>{busy ? 'entering…' : 'log in'}</button>
+4 -4
View File
@@ -29,12 +29,12 @@
} }
</script> </script>
<svelte:head><title>sign up · //WANTLIST</title></svelte:head> <svelte:head><title>sign up · consume·rs</title></svelte:head>
<div class="mx-auto max-w-md"> <div class="mx-auto max-w-md">
<div class="panel panel-acid p-8"> <div class="panel panel-acid p-8">
<p class="label">new account</p> <p class="label">new devotee</p>
<h1 class="mb-6 font-display text-3xl font-bold">CARVE YOUR MARK</h1> <h1 class="mb-6 font-display text-3xl font-bold">BEGIN ASCENSION</h1>
<form class="space-y-4" onsubmit={submit}> <form class="space-y-4" onsubmit={submit}>
<div> <div>
@@ -51,7 +51,7 @@
</div> </div>
{#if error} {#if error}
<p class="border-2 border-blood bg-blood/10 px-3 py-2 text-sm text-blood">{error}</p> <p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{error}</p>
{/if} {/if}
<button class="btn btn-acid w-full" disabled={busy}> <button class="btn btn-acid w-full" disabled={busy}>
+5 -5
View File
@@ -26,20 +26,20 @@
} }
</script> </script>
<svelte:head><title>new password · //WANTLIST</title></svelte:head> <svelte:head><title>new password · consume·rs</title></svelte:head>
<div class="mx-auto max-w-md"> <div class="mx-auto max-w-md">
<div class="panel panel-acid p-8"> <div class="panel panel-acid p-8">
<p class="label">set new password</p> <p class="label">set new password</p>
<h1 class="mb-6 font-display text-3xl font-bold">CUT A NEW KEY</h1> <h1 class="mb-6 font-display text-3xl font-bold">FORGE A NEW KEY</h1>
{#if !token} {#if !token}
<p class="border-2 border-blood bg-blood/10 px-3 py-3 text-sm text-blood"> <p class="border-2 border-rose bg-rose/10 px-3 py-3 text-sm text-rose">
no reset token in this link. request a fresh one. no reset token in this link. request a fresh one.
</p> </p>
<p class="mt-5 text-center text-sm text-mute"><a href="/forgot">request reset</a></p> <p class="mt-5 text-center text-sm text-mute"><a href="/forgot">request reset</a></p>
{:else if done} {:else if done}
<p class="border-2 border-acid bg-acid/10 px-3 py-3 text-sm text-acid"> <p class="border-2 border-mint bg-mint/10 px-3 py-3 text-sm text-mint">
password changed. redirecting to login… password changed. redirecting to login…
</p> </p>
{:else} {:else}
@@ -49,7 +49,7 @@
<input id="pw" class="field mt-1" type="password" bind:value={password} required minlength="10" autocomplete="new-password" /> <input id="pw" class="field mt-1" type="password" bind:value={password} required minlength="10" autocomplete="new-password" />
</div> </div>
{#if error} {#if error}
<p class="border-2 border-blood bg-blood/10 px-3 py-2 text-sm text-blood">{error}</p> <p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{error}</p>
{/if} {/if}
<button class="btn btn-acid w-full" disabled={busy}>{busy ? 'cutting…' : 'set password'}</button> <button class="btn btn-acid w-full" disabled={busy}>{busy ? 'cutting…' : 'set password'}</button>
</form> </form>
+11 -28
View File
@@ -4,7 +4,7 @@
import { auth, type Settings } from '$lib/auth.svelte'; import { auth, type Settings } from '$lib/auth.svelte';
let displayName = $state(''); let displayName = $state('');
let settings = $state<Settings>({ locale: 'de', currency: 'EUR', theme: 'breakcore', notify_email: true }); let settings = $state<Settings>({ locale: 'de', currency: 'EUR', theme: 'ethereal', notify_email: true });
let msg = $state(''); let msg = $state('');
let error = $state(''); let error = $state('');
@@ -24,7 +24,6 @@
} }
}); });
const themes = ['breakcore', 'grunge', 'minimal'];
const locales = ['de', 'en']; const locales = ['de', 'en'];
async function save(e: SubmitEvent) { async function save(e: SubmitEvent) {
@@ -55,13 +54,14 @@
} }
</script> </script>
<svelte:head><title>settings · //WANTLIST</title></svelte:head> <svelte:head><title>settings · consume·rs</title></svelte:head>
{#if auth.loaded && auth.user} {#if auth.loaded && auth.user}
<div class="mx-auto max-w-2xl space-y-6"> <div class="mx-auto max-w-2xl space-y-6">
<div> <div>
<p class="label">configuration</p> <p class="label">your rites</p>
<h1 class="font-display text-4xl font-bold">CONTROL PANEL</h1> <h1 class="font-display text-4xl font-bold">THE SANCTUM</h1>
<p class="gospel mt-1 text-lg">tune your devotion</p>
</div> </div>
<!-- Verification status --> <!-- Verification status -->
@@ -70,10 +70,10 @@
<div class="mt-1 flex flex-wrap items-center justify-between gap-3"> <div class="mt-1 flex flex-wrap items-center justify-between gap-3">
<span class="font-mono">{auth.user.email}</span> <span class="font-mono">{auth.user.email}</span>
{#if auth.user.email_verified} {#if auth.user.email_verified}
<span class="tag border-acid text-acid">verified</span> <span class="tag border-mint text-mint">verified</span>
{:else} {:else}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="tag border-blood text-blood">unverified</span> <span class="tag border-rose text-rose">unverified</span>
<button class="btn btn-ghost !px-3 !py-1 text-xs" onclick={resend}>resend</button> <button class="btn btn-ghost !px-3 !py-1 text-xs" onclick={resend}>resend</button>
</div> </div>
{/if} {/if}
@@ -100,33 +100,16 @@
</div> </div>
</div> </div>
<div>
<p class="label mb-2">theme</p>
<div class="grid grid-cols-3 gap-2">
{#each themes as t}
<button
type="button"
class="tag border-smoke py-2 text-center transition-colors"
class:!border-acid={settings.theme === t}
class:text-acid={settings.theme === t}
onclick={() => (settings.theme = t)}
>
{t}
</button>
{/each}
</div>
</div>
<label class="flex cursor-pointer items-center gap-3"> <label class="flex cursor-pointer items-center gap-3">
<input type="checkbox" class="size-5 accent-acid" bind:checked={settings.notify_email} /> <input type="checkbox" class="size-5 accent-mint" bind:checked={settings.notify_email} />
<span class="font-mono text-sm">email me about deals & price drops</span> <span class="font-mono text-sm">summon me when the price falls</span>
</label> </label>
{#if error} {#if error}
<p class="border-2 border-blood bg-blood/10 px-3 py-2 text-sm text-blood">{error}</p> <p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{error}</p>
{/if} {/if}
{#if msg} {#if msg}
<p class="border-2 border-acid bg-acid/10 px-3 py-2 text-sm text-acid">{msg}</p> <p class="border-2 border-mint bg-mint/10 px-3 py-2 text-sm text-mint">{msg}</p>
{/if} {/if}
<button class="btn btn-acid" disabled={busy}>{busy ? 'saving…' : 'save changes'}</button> <button class="btn btn-acid" disabled={busy}>{busy ? 'saving…' : 'save changes'}</button>
+3 -3
View File
@@ -26,7 +26,7 @@
}); });
</script> </script>
<svelte:head><title>verify · //WANTLIST</title></svelte:head> <svelte:head><title>verify · consume·rs</title></svelte:head>
<div class="mx-auto max-w-md"> <div class="mx-auto max-w-md">
<div class="panel p-8 text-center"> <div class="panel p-8 text-center">
@@ -35,11 +35,11 @@
{#if status === 'working'} {#if status === 'working'}
<h1 class="mt-4 font-display text-3xl font-bold flicker">VERIFYING…</h1> <h1 class="mt-4 font-display text-3xl font-bold flicker">VERIFYING…</h1>
{:else if status === 'ok'} {:else if status === 'ok'}
<h1 class="mt-4 font-display text-3xl font-bold text-acid">VERIFIED ✓</h1> <h1 class="mt-4 font-display text-3xl font-bold text-mint">VERIFIED ✓</h1>
<p class="mt-3 text-mute">your email is confirmed. you're all set.</p> <p class="mt-3 text-mute">your email is confirmed. you're all set.</p>
<a href="/" class="btn btn-acid mt-6">enter →</a> <a href="/" class="btn btn-acid mt-6">enter →</a>
{:else} {:else}
<h1 class="mt-4 font-display text-3xl font-bold text-blood">FAILED ✗</h1> <h1 class="mt-4 font-display text-3xl font-bold text-rose">FAILED ✗</h1>
<p class="mt-3 text-mute">{message}</p> <p class="mt-3 text-mute">{message}</p>
<a href="/" class="btn btn-ghost mt-6">go home</a> <a href="/" class="btn btn-ghost mt-6">go home</a>
{/if} {/if}
+13 -3
View File
@@ -1,5 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#0a0a0b"/> <defs>
<rect x="6" y="6" width="20" height="20" fill="none" stroke="#c2f73f" stroke-width="2"/> <linearGradient id="halo" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<path d="M10 16 l4 4 l8 -9" fill="none" stroke="#ff1f6b" stroke-width="3"/> <stop offset="0" stop-color="#b9a7ff"/>
<stop offset="0.55" stop-color="#ffaecb"/>
<stop offset="1" stop-color="#ffe6a3"/>
</linearGradient>
</defs>
<rect width="32" height="32" fill="#0c0a14"/>
<!-- halo ring -->
<ellipse cx="16" cy="8.5" rx="9" ry="3" fill="none" stroke="url(#halo)" stroke-width="2"/>
<!-- ascending cart / consumption glyph -->
<path d="M9 15 h14 l-2 9 h-10 z" fill="none" stroke="url(#halo)" stroke-width="2" stroke-linejoin="round"/>
<path d="M13 28.5 a1.3 1.3 0 1 0 0.01 0 M19 28.5 a1.3 1.3 0 1 0 0.01 0" fill="url(#halo)"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 285 B

After

Width:  |  Height:  |  Size: 754 B