init
This commit is contained in:
Generated
+633
-36
File diff suppressed because it is too large
Load Diff
+5
-2
@@ -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"
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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};
|
||||||
|
|||||||
@@ -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,8 +194,12 @@ 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,
|
||||||
|
user.id,
|
||||||
|
TokenPurpose::PasswordReset,
|
||||||
|
Duration::hours(1),
|
||||||
|
)
|
||||||
.await?;
|
.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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()?,
|
||||||
|
|||||||
@@ -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)")
|
||||||
|
}
|
||||||
@@ -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}"))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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 1–80 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 1–80 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 1–200 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 1–200 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())
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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" }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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 WON’T 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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 |
Reference in New Issue
Block a user