added sharing and subscriptions
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
-- Public read-only sharing for lists.
|
||||||
|
-- NULL share_token = private (default). A non-NULL token is an unguessable
|
||||||
|
-- secret: anyone holding it can view the list read-only at /api/shared/{token},
|
||||||
|
-- no account required. Revoking (unshare) sets it back to NULL.
|
||||||
|
ALTER TABLE lists
|
||||||
|
ADD COLUMN share_token TEXT UNIQUE;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- Subscriptions: a logged-in user follows someone else's shared list or a
|
||||||
|
-- single shared item, to receive the same price-drop emails the owner gets.
|
||||||
|
-- Exactly one of list_id / item_id is set per row. A list subscription
|
||||||
|
-- implicitly covers every item on that list — present and future — expanded
|
||||||
|
-- at notify time rather than materialised per item.
|
||||||
|
CREATE TABLE subscriptions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
list_id UUID REFERENCES lists(id) ON DELETE CASCADE,
|
||||||
|
item_id UUID REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT subscription_target_exactly_one
|
||||||
|
CHECK ((list_id IS NOT NULL) <> (item_id IS NOT NULL))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One subscription per (user, list) and per (user, item).
|
||||||
|
CREATE UNIQUE INDEX subscriptions_user_list
|
||||||
|
ON subscriptions (user_id, list_id) WHERE list_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX subscriptions_user_item
|
||||||
|
ON subscriptions (user_id, item_id) WHERE item_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Per-subscriber, per-item de-dupe latch for target-price alerts — the
|
||||||
|
-- subscriber-side mirror of items.notified_at (which latches the owner).
|
||||||
|
-- Present = already announced this drop; absent = armed. Cleared when the
|
||||||
|
-- price climbs back above the item's target.
|
||||||
|
CREATE TABLE subscription_notify (
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
notified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (user_id, item_id)
|
||||||
|
);
|
||||||
@@ -54,6 +54,8 @@ pub struct List {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub emoji: Option<String>,
|
pub emoji: Option<String>,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
|
/// Unguessable secret for public read-only sharing; None = private.
|
||||||
|
pub share_token: Option<String>,
|
||||||
pub position: i32,
|
pub position: i32,
|
||||||
#[serde(with = "time::serde::rfc3339")]
|
#[serde(with = "time::serde::rfc3339")]
|
||||||
pub created_at: OffsetDateTime,
|
pub created_at: OffsetDateTime,
|
||||||
|
|||||||
@@ -129,6 +129,142 @@ async fn any_drop(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Subscriber fan-out ---------------------------------------------------
|
||||||
|
|
||||||
|
/// Item fields needed to judge a drop, plus its list + owner for subscriber lookup.
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct ItemFields {
|
||||||
|
title: String,
|
||||||
|
title_fetched: Option<String>,
|
||||||
|
url: Option<String>,
|
||||||
|
current_price: Option<Decimal>,
|
||||||
|
target_price: Option<Decimal>,
|
||||||
|
currency: Option<String>,
|
||||||
|
in_stock: Option<bool>,
|
||||||
|
list_id: Uuid,
|
||||||
|
owner_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Subscriber {
|
||||||
|
user_id: Uuid,
|
||||||
|
email: String,
|
||||||
|
display_name: Option<String>,
|
||||||
|
notify_email: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fan the same price-drop signal out to everyone subscribed to this item or to
|
||||||
|
/// its parent list (the owner is notified separately via [`maybe_notify_drop`]).
|
||||||
|
/// Best-effort: never errors the refetch.
|
||||||
|
pub async fn maybe_notify_subscribers(state: &AppState, item_id: Uuid) {
|
||||||
|
if let Err(e) = run_subs(state, item_id).await {
|
||||||
|
tracing::warn!(item = %item_id, error = %e, "subscriber notification failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_subs(state: &AppState, item_id: Uuid) -> anyhow::Result<()> {
|
||||||
|
let item: Option<ItemFields> = sqlx::query_as(
|
||||||
|
"SELECT i.title, i.title_fetched, i.url, i.current_price, i.target_price,
|
||||||
|
i.currency, i.in_stock, i.list_id, l.user_id AS owner_id
|
||||||
|
FROM items i JOIN lists l ON l.id = i.list_id
|
||||||
|
WHERE i.id = $1",
|
||||||
|
)
|
||||||
|
.bind(item_id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some(item) = item else { return Ok(()) };
|
||||||
|
let Some(price) = item.current_price else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Distinct subscribers reached via the item directly OR its parent list;
|
||||||
|
// never the owner (they get the owner-path email).
|
||||||
|
let subs: Vec<Subscriber> = sqlx::query_as(
|
||||||
|
"SELECT DISTINCT u.id AS user_id, u.email, u.display_name, st.notify_email
|
||||||
|
FROM users u
|
||||||
|
JOIN user_settings st ON st.user_id = u.id
|
||||||
|
JOIN subscriptions sub ON sub.user_id = u.id
|
||||||
|
WHERE (sub.item_id = $1 OR sub.list_id = $2) AND u.id <> $3",
|
||||||
|
)
|
||||||
|
.bind(item_id)
|
||||||
|
.bind(item.list_id)
|
||||||
|
.bind(item.owner_id)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for s in subs {
|
||||||
|
let row = NotifyRow {
|
||||||
|
title: item.title.clone(),
|
||||||
|
title_fetched: item.title_fetched.clone(),
|
||||||
|
url: item.url.clone(),
|
||||||
|
current_price: item.current_price,
|
||||||
|
target_price: item.target_price,
|
||||||
|
currency: item.currency.clone(),
|
||||||
|
in_stock: item.in_stock,
|
||||||
|
notified_at: None, // subscriber latch lives in subscription_notify
|
||||||
|
email: s.email,
|
||||||
|
display_name: s.display_name,
|
||||||
|
notify_email: s.notify_email,
|
||||||
|
};
|
||||||
|
let res = match item.target_price {
|
||||||
|
Some(target) => target_drop_sub(state, s.user_id, item_id, &row, price, target).await,
|
||||||
|
// No target → reuse the owner's any-drop logic (latch-free; reads
|
||||||
|
// only shared price_history). Each subscriber gets their own email.
|
||||||
|
None => any_drop(state, item_id, &row, price).await,
|
||||||
|
};
|
||||||
|
if let Err(e) = res {
|
||||||
|
tracing::warn!(item = %item_id, user = %s.user_id, error = %e, "subscriber notify failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscriber target mode: same semantics as [`target_drop`], but the de-dupe
|
||||||
|
/// latch lives per-subscriber in `subscription_notify(user_id, item_id)`.
|
||||||
|
async fn target_drop_sub(
|
||||||
|
state: &AppState,
|
||||||
|
user_id: Uuid,
|
||||||
|
item_id: Uuid,
|
||||||
|
row: &NotifyRow,
|
||||||
|
price: Decimal,
|
||||||
|
target: Decimal,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let latched: bool = sqlx::query_scalar(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM subscription_notify WHERE user_id = $1 AND item_id = $2)",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(item_id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let on_sale = price <= target;
|
||||||
|
match (on_sale, latched) {
|
||||||
|
(true, false) => {
|
||||||
|
if row.notify_email {
|
||||||
|
send(state, row, price, Some(target)).await?;
|
||||||
|
}
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO subscription_notify (user_id, item_id) VALUES ($1, $2)
|
||||||
|
ON CONFLICT (user_id, item_id) DO UPDATE SET notified_at = now()",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(item_id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
(false, true) => {
|
||||||
|
sqlx::query("DELETE FROM subscription_notify WHERE user_id = $1 AND item_id = $2")
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(item_id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn send(
|
async fn send(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
row: &NotifyRow,
|
row: &NotifyRow,
|
||||||
|
|||||||
+82
-10
@@ -19,6 +19,8 @@ pub fn router() -> Router<AppState> {
|
|||||||
"/lists/{id}",
|
"/lists/{id}",
|
||||||
axum::routing::patch(update_list).delete(delete_list),
|
axum::routing::patch(update_list).delete(delete_list),
|
||||||
)
|
)
|
||||||
|
.route("/lists/{id}/share", post(share_list).delete(unshare_list))
|
||||||
|
.route("/shared/{token}", get(shared_view))
|
||||||
.route("/lists/{id}/items", get(list_items).post(create_item))
|
.route("/lists/{id}/items", get(list_items).post(create_item))
|
||||||
.route(
|
.route(
|
||||||
"/items/{id}",
|
"/items/{id}",
|
||||||
@@ -39,6 +41,9 @@ const ITEM_COLS_I: &str = "i.id, i.list_id, i.title, i.url, i.note, i.status::te
|
|||||||
i.in_stock, i.source, i.fetched_at, i.track_enabled, i.last_error, i.checked_at, \
|
i.in_stock, i.source, i.fetched_at, i.track_enabled, i.last_error, i.checked_at, \
|
||||||
i.created_at, i.updated_at";
|
i.created_at, i.updated_at";
|
||||||
|
|
||||||
|
const LIST_COLS: &str =
|
||||||
|
"id, user_id, name, emoji, description, share_token, position, created_at, updated_at";
|
||||||
|
|
||||||
const ALLOWED_STATUS: &[&str] = &["coveted", "acquired", "renounced"];
|
const ALLOWED_STATUS: &[&str] = &["coveted", "acquired", "renounced"];
|
||||||
|
|
||||||
// ---- Lists ----------------------------------------------------------------
|
// ---- Lists ----------------------------------------------------------------
|
||||||
@@ -47,10 +52,10 @@ async fn list_lists(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AuthUser(user): AuthUser,
|
AuthUser(user): AuthUser,
|
||||||
) -> AppResult<Json<Vec<List>>> {
|
) -> AppResult<Json<Vec<List>>> {
|
||||||
let lists = sqlx::query_as::<_, List>(
|
let lists = sqlx::query_as::<_, List>(&format!(
|
||||||
"SELECT id, user_id, name, emoji, description, position, created_at, updated_at
|
"SELECT {LIST_COLS}
|
||||||
FROM lists WHERE user_id = $1 ORDER BY position, created_at",
|
FROM lists WHERE user_id = $1 ORDER BY position, created_at"
|
||||||
)
|
))
|
||||||
.bind(user.id)
|
.bind(user.id)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -75,12 +80,12 @@ async fn create_list(
|
|||||||
req.validate()
|
req.validate()
|
||||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
let list = sqlx::query_as::<_, List>(
|
let list = sqlx::query_as::<_, List>(&format!(
|
||||||
"INSERT INTO lists (user_id, name, emoji, description, position)
|
"INSERT INTO lists (user_id, name, emoji, description, position)
|
||||||
VALUES ($1, $2, $3, $4,
|
VALUES ($1, $2, $3, $4,
|
||||||
COALESCE((SELECT MAX(position) + 1 FROM lists WHERE user_id = $1), 0))
|
COALESCE((SELECT MAX(position) + 1 FROM lists WHERE user_id = $1), 0))
|
||||||
RETURNING id, user_id, name, emoji, description, position, created_at, updated_at",
|
RETURNING {LIST_COLS}"
|
||||||
)
|
))
|
||||||
.bind(user.id)
|
.bind(user.id)
|
||||||
.bind(req.name.trim())
|
.bind(req.name.trim())
|
||||||
.bind(opt_trim(req.emoji))
|
.bind(opt_trim(req.emoji))
|
||||||
@@ -110,15 +115,15 @@ async fn update_list(
|
|||||||
req.validate()
|
req.validate()
|
||||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
let list = sqlx::query_as::<_, List>(
|
let list = sqlx::query_as::<_, List>(&format!(
|
||||||
"UPDATE lists SET
|
"UPDATE lists SET
|
||||||
name = COALESCE($3, name),
|
name = COALESCE($3, name),
|
||||||
emoji = COALESCE($4, emoji),
|
emoji = COALESCE($4, emoji),
|
||||||
description = COALESCE($5, description),
|
description = COALESCE($5, description),
|
||||||
position = COALESCE($6, position)
|
position = COALESCE($6, position)
|
||||||
WHERE id = $1 AND user_id = $2
|
WHERE id = $1 AND user_id = $2
|
||||||
RETURNING id, user_id, name, emoji, description, position, created_at, updated_at",
|
RETURNING {LIST_COLS}"
|
||||||
)
|
))
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(user.id)
|
.bind(user.id)
|
||||||
.bind(req.name.map(|s| s.trim().to_string()))
|
.bind(req.name.map(|s| s.trim().to_string()))
|
||||||
@@ -147,6 +152,73 @@ async fn delete_list(
|
|||||||
Ok(Json(serde_json::json!({ "deleted": id })))
|
Ok(Json(serde_json::json!({ "deleted": id })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Sharing --------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Turn on public sharing: mint a token if the list doesn't have one yet
|
||||||
|
/// (idempotent — repeat calls keep the same link). Returns the updated list.
|
||||||
|
async fn share_list(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthUser(user): AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> AppResult<Json<List>> {
|
||||||
|
let token = Uuid::new_v4().simple().to_string();
|
||||||
|
let list = sqlx::query_as::<_, List>(&format!(
|
||||||
|
"UPDATE lists SET share_token = COALESCE(share_token, $3)
|
||||||
|
WHERE id = $1 AND user_id = $2
|
||||||
|
RETURNING {LIST_COLS}"
|
||||||
|
))
|
||||||
|
.bind(id)
|
||||||
|
.bind(user.id)
|
||||||
|
.bind(token)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::NotFound)?;
|
||||||
|
Ok(Json(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revoke sharing: any existing link stops working immediately.
|
||||||
|
async fn unshare_list(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthUser(user): AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> AppResult<Json<List>> {
|
||||||
|
let list = sqlx::query_as::<_, List>(&format!(
|
||||||
|
"UPDATE lists SET share_token = NULL
|
||||||
|
WHERE id = $1 AND user_id = $2
|
||||||
|
RETURNING {LIST_COLS}"
|
||||||
|
))
|
||||||
|
.bind(id)
|
||||||
|
.bind(user.id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::NotFound)?;
|
||||||
|
Ok(Json(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public, unauthenticated read-only view of a shared list + its items.
|
||||||
|
/// No `AuthUser` extractor: holding the secret token is the only credential.
|
||||||
|
async fn shared_view(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(token): Path<String>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let list = sqlx::query_as::<_, List>(&format!(
|
||||||
|
"SELECT {LIST_COLS} FROM lists WHERE share_token = $1"
|
||||||
|
))
|
||||||
|
.bind(&token)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::NotFound)?;
|
||||||
|
|
||||||
|
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(serde_json::json!({ "list": list, "items": items })))
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Items ----------------------------------------------------------------
|
// ---- Items ----------------------------------------------------------------
|
||||||
|
|
||||||
/// Confirm the list exists and belongs to the user. Returns NotFound otherwise.
|
/// Confirm the list exists and belongs to the user. Returns NotFound otherwise.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use crate::models::UserSettings;
|
|||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
mod lists;
|
mod lists;
|
||||||
|
mod subs;
|
||||||
|
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
@@ -18,6 +19,7 @@ pub fn router() -> Router<AppState> {
|
|||||||
.route("/settings", patch(update_settings))
|
.route("/settings", patch(update_settings))
|
||||||
.route("/profile", patch(update_profile))
|
.route("/profile", patch(update_profile))
|
||||||
.merge(lists::router())
|
.merge(lists::router())
|
||||||
|
.merge(subs::router())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn health() -> Json<Value> {
|
async fn health() -> Json<Value> {
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
//! Subscriptions: a logged-in user follows another user's shared list or item
|
||||||
|
//! to receive the same price-drop emails. Subscribing requires the target to be
|
||||||
|
//! currently shared (`lists.share_token IS NOT NULL`) and not the user's own.
|
||||||
|
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum::{Json, Router};
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::auth::session::AuthUser;
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/subscriptions", get(list_subscriptions).post(subscribe))
|
||||||
|
.route("/subscriptions/{id}", axum::routing::delete(unsubscribe))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One subscription, enriched with the followed list/item's display fields so
|
||||||
|
/// the /subscriptions page can render without extra round-trips.
|
||||||
|
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||||
|
struct SubscriptionView {
|
||||||
|
id: Uuid,
|
||||||
|
kind: String, // "list" | "item"
|
||||||
|
#[serde(with = "time::serde::rfc3339")]
|
||||||
|
created_at: OffsetDateTime,
|
||||||
|
list_id: Option<Uuid>,
|
||||||
|
item_id: Option<Uuid>,
|
||||||
|
title: String,
|
||||||
|
emoji: Option<String>,
|
||||||
|
share_token: Option<String>,
|
||||||
|
url: Option<String>,
|
||||||
|
image_url: Option<String>,
|
||||||
|
current_price: Option<Decimal>,
|
||||||
|
currency: Option<String>,
|
||||||
|
in_stock: Option<bool>,
|
||||||
|
target_price: Option<Decimal>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_subscriptions(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthUser(user): AuthUser,
|
||||||
|
) -> AppResult<Json<Vec<SubscriptionView>>> {
|
||||||
|
// List subs + item subs, unioned and enriched. Explicit casts keep the two
|
||||||
|
// SELECT arms type-compatible across the UNION.
|
||||||
|
let subs = sqlx::query_as::<_, SubscriptionView>(
|
||||||
|
"SELECT sub.id, 'list' AS kind, sub.created_at,
|
||||||
|
l.id AS list_id, NULL::uuid AS item_id,
|
||||||
|
l.name AS title, l.emoji, l.share_token,
|
||||||
|
NULL::text AS url, NULL::text AS image_url,
|
||||||
|
NULL::numeric AS current_price, NULL::text AS currency,
|
||||||
|
NULL::boolean AS in_stock, NULL::numeric AS target_price
|
||||||
|
FROM subscriptions sub
|
||||||
|
JOIN lists l ON l.id = sub.list_id
|
||||||
|
WHERE sub.user_id = $1 AND sub.list_id IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT sub.id, 'item' AS kind, sub.created_at,
|
||||||
|
l.id AS list_id, i.id AS item_id,
|
||||||
|
COALESCE(i.title_fetched, i.title) AS title, l.emoji, l.share_token,
|
||||||
|
i.url, i.image_url,
|
||||||
|
i.current_price, i.currency,
|
||||||
|
i.in_stock, i.target_price
|
||||||
|
FROM subscriptions sub
|
||||||
|
JOIN items i ON i.id = sub.item_id
|
||||||
|
JOIN lists l ON l.id = i.list_id
|
||||||
|
WHERE sub.user_id = $1 AND sub.item_id IS NOT NULL
|
||||||
|
ORDER BY created_at DESC",
|
||||||
|
)
|
||||||
|
.bind(user.id)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(subs))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct SubscribeReq {
|
||||||
|
list_id: Option<Uuid>,
|
||||||
|
item_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn subscribe(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthUser(user): AuthUser,
|
||||||
|
Json(req): Json<SubscribeReq>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let id = match (req.list_id, req.item_id) {
|
||||||
|
(Some(list_id), None) => subscribe_list(&state, user.id, list_id).await?,
|
||||||
|
(None, Some(item_id)) => subscribe_item(&state, user.id, item_id).await?,
|
||||||
|
_ => {
|
||||||
|
return Err(AppError::Validation(
|
||||||
|
"provide exactly one of list_id or item_id".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(Json(serde_json::json!({ "id": id })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn subscribe_list(state: &AppState, user_id: Uuid, list_id: Uuid) -> AppResult<Uuid> {
|
||||||
|
let ok = sqlx::query_scalar::<_, bool>(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM lists
|
||||||
|
WHERE id = $1 AND share_token IS NOT NULL AND user_id <> $2)",
|
||||||
|
)
|
||||||
|
.bind(list_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
if !ok {
|
||||||
|
return Err(AppError::NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent: the partial unique index guards against duplicates.
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO subscriptions (user_id, list_id) SELECT $1, $2
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM subscriptions WHERE user_id = $1 AND list_id = $2)",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(list_id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let id = sqlx::query_scalar::<_, Uuid>(
|
||||||
|
"SELECT id FROM subscriptions WHERE user_id = $1 AND list_id = $2",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(list_id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn subscribe_item(state: &AppState, user_id: Uuid, item_id: Uuid) -> AppResult<Uuid> {
|
||||||
|
let ok = sqlx::query_scalar::<_, bool>(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM items i JOIN lists l ON l.id = i.list_id
|
||||||
|
WHERE i.id = $1 AND l.share_token IS NOT NULL AND l.user_id <> $2)",
|
||||||
|
)
|
||||||
|
.bind(item_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
if !ok {
|
||||||
|
return Err(AppError::NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO subscriptions (user_id, item_id) SELECT $1, $2
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM subscriptions WHERE user_id = $1 AND item_id = $2)",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(item_id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let id = sqlx::query_scalar::<_, Uuid>(
|
||||||
|
"SELECT id FROM subscriptions WHERE user_id = $1 AND item_id = $2",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(item_id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn unsubscribe(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthUser(user): AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let res = sqlx::query("DELETE FROM subscriptions 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 })))
|
||||||
|
}
|
||||||
@@ -70,6 +70,7 @@ pub async fn refetch(state: &AppState, item_id: Uuid, url: &str) -> anyhow::Resu
|
|||||||
Ok(p) => {
|
Ok(p) => {
|
||||||
apply_success(&state.pool, item_id, &p).await?;
|
apply_success(&state.pool, item_id, &p).await?;
|
||||||
notify::maybe_notify_drop(state, item_id).await;
|
notify::maybe_notify_drop(state, item_id).await;
|
||||||
|
notify::maybe_notify_subscribers(state, item_id).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -80,6 +80,29 @@
|
|||||||
coords.length ? coords.reduce((hi, c, i) => (c.price > coords[hi].price ? i : hi), 0) : -1
|
coords.length ? coords.reduce((hi, c, i) => (c.price > coords[hi].price ? i : hi), 0) : -1
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Out-of-stock spans — shade the time the item couldn't be bought.
|
||||||
|
// Each maximal run of in_stock===false becomes a band, extended halfway
|
||||||
|
// to its in-stock neighbours so single checks still read as a region.
|
||||||
|
const oosBands = $derived.by(() => {
|
||||||
|
if (!coords.length) return [];
|
||||||
|
const bands: { x: number; w: number }[] = [];
|
||||||
|
let start: number | null = null;
|
||||||
|
for (let i = 0; i <= coords.length; i++) {
|
||||||
|
const oos = i < coords.length && coords[i].in_stock === false;
|
||||||
|
if (oos && start === null) start = i;
|
||||||
|
if (!oos && start !== null) {
|
||||||
|
const end = i - 1;
|
||||||
|
const left = start > 0 ? (coords[start - 1].cx + coords[start].cx) / 2 : coords[start].cx;
|
||||||
|
const right =
|
||||||
|
end < coords.length - 1 ? (coords[end].cx + coords[end + 1].cx) / 2 : coords[end].cx;
|
||||||
|
bands.push({ x: left, w: Math.max(right - left, 3) });
|
||||||
|
start = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bands;
|
||||||
|
});
|
||||||
|
const anyOos = $derived(coords.some((c) => c.in_stock === false));
|
||||||
|
|
||||||
const targetY = $derived(target != null && stats ? y(target) : null);
|
const targetY = $derived(target != null && stats ? y(target) : null);
|
||||||
const latest = $derived(coords.length ? coords[coords.length - 1] : null);
|
const latest = $derived(coords.length ? coords[coords.length - 1] : null);
|
||||||
const onSale = $derived(latest != null && target != null && latest.price <= target);
|
const onSale = $derived(latest != null && target != null && latest.price <= target);
|
||||||
@@ -168,6 +191,18 @@
|
|||||||
</filter>
|
</filter>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
|
<!-- Out-of-stock spans — rose shade behind everything -->
|
||||||
|
{#each oosBands as b}
|
||||||
|
<rect
|
||||||
|
x={b.x}
|
||||||
|
y={PAD.t}
|
||||||
|
width={b.w}
|
||||||
|
height={innerH}
|
||||||
|
fill="var(--color-rose)"
|
||||||
|
opacity="0.1"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
|
||||||
<!-- Gridlines + price ticks -->
|
<!-- Gridlines + price ticks -->
|
||||||
{#each gridLines as g}
|
{#each gridLines as g}
|
||||||
<line
|
<line
|
||||||
@@ -270,6 +305,9 @@
|
|||||||
<span class="flex items-center gap-3 text-mute">
|
<span class="flex items-center gap-3 text-mute">
|
||||||
<span class="flex items-center gap-1"><span class="inline-block size-2 rounded-full" style="background:var(--color-gold)"></span>low {fmtMoney(coords[lowIdx].price)}</span>
|
<span class="flex items-center gap-1"><span class="inline-block size-2 rounded-full" style="background:var(--color-gold)"></span>low {fmtMoney(coords[lowIdx].price)}</span>
|
||||||
<span class="flex items-center gap-1"><span class="inline-block size-2 rounded-full" style="background:var(--color-rose)"></span>high {fmtMoney(coords[highIdx].price)}</span>
|
<span class="flex items-center gap-1"><span class="inline-block size-2 rounded-full" style="background:var(--color-rose)"></span>high {fmtMoney(coords[highIdx].price)}</span>
|
||||||
|
{#if anyOos}
|
||||||
|
<span class="flex items-center gap-1"><span class="inline-block h-2 w-3 rounded-sm" style="background:var(--color-rose);opacity:0.35"></span>out of stock</span>
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{#if latest}
|
{#if latest}
|
||||||
<span class:text-mint={onSale} class:text-ink={!onSale}>
|
<span class:text-mint={onSale} class:text-ink={!onSale}>
|
||||||
|
|||||||
@@ -7,11 +7,31 @@ export type List = {
|
|||||||
name: string;
|
name: string;
|
||||||
emoji: string | null;
|
emoji: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
share_token: string | null;
|
||||||
position: number;
|
position: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SharedView = { list: List; items: Item[] };
|
||||||
|
|
||||||
|
export type Subscription = {
|
||||||
|
id: string;
|
||||||
|
kind: "list" | "item";
|
||||||
|
created_at: string;
|
||||||
|
list_id: string | null;
|
||||||
|
item_id: string | null;
|
||||||
|
title: string;
|
||||||
|
emoji: string | null;
|
||||||
|
share_token: string | null;
|
||||||
|
url: string | null;
|
||||||
|
image_url: string | null;
|
||||||
|
current_price: number | null;
|
||||||
|
currency: string | null;
|
||||||
|
in_stock: boolean | null;
|
||||||
|
target_price: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type Item = {
|
export type Item = {
|
||||||
id: string;
|
id: string;
|
||||||
list_id: string;
|
list_id: string;
|
||||||
@@ -64,6 +84,9 @@ export const listsApi = {
|
|||||||
update: (id: string, b: Partial<NewList> & { position?: number }) =>
|
update: (id: string, b: Partial<NewList> & { position?: number }) =>
|
||||||
api.patch<List>(`/lists/${id}`, b),
|
api.patch<List>(`/lists/${id}`, b),
|
||||||
remove: (id: string) => api.del<{ deleted: string }>(`/lists/${id}`),
|
remove: (id: string) => api.del<{ deleted: string }>(`/lists/${id}`),
|
||||||
|
share: (id: string) => api.post<List>(`/lists/${id}/share`, {}),
|
||||||
|
unshare: (id: string) => api.del<List>(`/lists/${id}/share`),
|
||||||
|
shared: (token: string) => api.get<SharedView>(`/shared/${token}`),
|
||||||
|
|
||||||
items: (listId: string) => api.get<Item[]>(`/lists/${listId}/items`),
|
items: (listId: string) => api.get<Item[]>(`/lists/${listId}/items`),
|
||||||
addItem: (listId: string, b: NewItem) =>
|
addItem: (listId: string, b: NewItem) =>
|
||||||
@@ -75,6 +98,11 @@ export const listsApi = {
|
|||||||
removeItem: (id: string) => api.del<{ deleted: string }>(`/items/${id}`),
|
removeItem: (id: string) => api.del<{ deleted: string }>(`/items/${id}`),
|
||||||
refetch: (id: string) => api.post<Item>(`/items/${id}/refetch`, {}),
|
refetch: (id: string) => api.post<Item>(`/items/${id}/refetch`, {}),
|
||||||
history: (id: string) => api.get<PricePoint[]>(`/items/${id}/history`),
|
history: (id: string) => api.get<PricePoint[]>(`/items/${id}/history`),
|
||||||
|
|
||||||
|
subscriptions: () => api.get<Subscription[]>("/subscriptions"),
|
||||||
|
subscribe: (b: { list_id?: string; item_id?: string }) =>
|
||||||
|
api.post<{ id: string }>("/subscriptions", b),
|
||||||
|
unsubscribe: (id: string) => api.del<{ deleted: string }>(`/subscriptions/${id}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Reactive store for the user's lists. */
|
/** Reactive store for the user's lists. */
|
||||||
@@ -97,6 +125,52 @@ class ListsStore {
|
|||||||
await listsApi.remove(id);
|
await listsApi.remove(id);
|
||||||
this.items = this.items.filter((l) => l.id !== id);
|
this.items = this.items.filter((l) => l.id !== id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Swap in an updated list (e.g. after share/unshare). */
|
||||||
|
replace(list: List) {
|
||||||
|
const i = this.items.findIndex((l) => l.id === list.id);
|
||||||
|
if (i >= 0) this.items[i] = list;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const lists = new ListsStore();
|
export const lists = new ListsStore();
|
||||||
|
|
||||||
|
/** The current user's subscriptions, with helpers to toggle by list/item. */
|
||||||
|
class SubsStore {
|
||||||
|
items = $state<Subscription[]>([]);
|
||||||
|
loaded = $state(false);
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
this.items = await listsApi.subscriptions();
|
||||||
|
this.loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Existing subscription id for a list, or null. */
|
||||||
|
forList(listId: string): string | null {
|
||||||
|
return this.items.find((s) => s.list_id === listId)?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Existing subscription id for an item, or null. */
|
||||||
|
forItem(itemId: string): string | null {
|
||||||
|
return this.items.find((s) => s.item_id === itemId)?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async subscribeList(listId: string) {
|
||||||
|
if (this.forList(listId)) return;
|
||||||
|
await listsApi.subscribe({ list_id: listId });
|
||||||
|
await this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async subscribeItem(itemId: string) {
|
||||||
|
if (this.forItem(itemId)) return;
|
||||||
|
await listsApi.subscribe({ item_id: itemId });
|
||||||
|
await this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async unsubscribe(id: string) {
|
||||||
|
await listsApi.unsubscribe(id);
|
||||||
|
this.items = this.items.filter((s) => s.id !== id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const subs = new SubsStore();
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
<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="/lists" class="tag border-smoke text-mute hover:text-iris">lists</a>
|
<a href="/lists" class="tag border-smoke text-mute hover:text-iris">lists</a>
|
||||||
|
<a href="/subscriptions" class="tag border-smoke text-mute hover:text-iris">following</a>
|
||||||
<a href="/settings" class="tag border-smoke text-mute hover:text-iris">
|
<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>
|
||||||
|
|||||||
@@ -27,6 +27,55 @@
|
|||||||
let targetPrice = $state('');
|
let targetPrice = $state('');
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let formError = $state('');
|
let formError = $state('');
|
||||||
|
let showDetails = $state(false);
|
||||||
|
|
||||||
|
// sharing
|
||||||
|
let sharing = $state(false);
|
||||||
|
let copied = $state(false);
|
||||||
|
const shareUrl = $derived(
|
||||||
|
list?.share_token ? `${page.url.origin}/shared/${list.share_token}` : ''
|
||||||
|
);
|
||||||
|
|
||||||
|
async function share() {
|
||||||
|
if (!list) return;
|
||||||
|
sharing = true;
|
||||||
|
formError = '';
|
||||||
|
try {
|
||||||
|
const updated = await listsApi.share(list.id);
|
||||||
|
list = updated;
|
||||||
|
lists.replace(updated);
|
||||||
|
} catch (err) {
|
||||||
|
formError = err instanceof ApiError ? err.message : 'failed to share';
|
||||||
|
} finally {
|
||||||
|
sharing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unshare() {
|
||||||
|
if (!list || !confirm('revoke the share link? anyone holding it loses access.')) return;
|
||||||
|
sharing = true;
|
||||||
|
formError = '';
|
||||||
|
try {
|
||||||
|
const updated = await listsApi.unshare(list.id);
|
||||||
|
list = updated;
|
||||||
|
lists.replace(updated);
|
||||||
|
} catch (err) {
|
||||||
|
formError = err instanceof ApiError ? err.message : 'failed to unshare';
|
||||||
|
} finally {
|
||||||
|
sharing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyShare() {
|
||||||
|
if (!shareUrl) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => (copied = false), 1500);
|
||||||
|
} catch {
|
||||||
|
/* clipboard blocked — the field is selectable as a fallback */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// inline edit
|
// inline edit
|
||||||
let editingId = $state<string | null>(null);
|
let editingId = $state<string | null>(null);
|
||||||
@@ -242,24 +291,65 @@
|
|||||||
<a href="/lists" class="label hover:text-iris">← all lists</a>
|
<a href="/lists" class="label hover:text-iris">← all lists</a>
|
||||||
<div class="mt-2 flex items-start gap-3">
|
<div class="mt-2 flex items-start gap-3">
|
||||||
<span class="text-4xl leading-none">{list?.emoji ?? '✦'}</span>
|
<span class="text-4xl leading-none">{list?.emoji ?? '✦'}</span>
|
||||||
<div>
|
<div class="min-w-0 flex-1">
|
||||||
<h1 class="font-display text-4xl font-bold">{list?.name ?? '…'}</h1>
|
<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}
|
{#if list?.description}<p class="gospel mt-1 text-lg">{list.description}</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{#if list}
|
||||||
|
{#if list.share_token}
|
||||||
|
<button
|
||||||
|
class="tag shrink-0 border-mint text-mint"
|
||||||
|
title="this list is shared — manage below"
|
||||||
|
onclick={() => document.getElementById('share-box')?.scrollIntoView({ behavior: 'smooth' })}
|
||||||
|
>
|
||||||
|
◈ shared
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button class="tag shrink-0 border-smoke text-mute hover:text-iris" disabled={sharing} onclick={share}>
|
||||||
|
{sharing ? '…' : '◈ share'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add item -->
|
{#if list?.share_token}
|
||||||
<form class="panel panel-acid space-y-4 p-6" onsubmit={addItem}>
|
<div id="share-box" class="panel mt-4 flex flex-wrap items-center gap-2 p-3 text-sm">
|
||||||
<p class="label">add an item</p>
|
<span class="label shrink-0">public link</span>
|
||||||
<input class="field" bind:value={title} maxlength="200" placeholder="what do you want?" />
|
<input class="field flex-1 text-xs" readonly value={shareUrl} onclick={(e) => e.currentTarget.select()} />
|
||||||
<div class="grid gap-4 sm:grid-cols-[1fr_8rem]">
|
<button class="rounded border border-smoke px-3 py-1.5 text-xs text-mute transition hover:border-iris hover:text-iris" onclick={copyShare}>
|
||||||
|
{copied ? '✓ copied' : 'copy'}
|
||||||
|
</button>
|
||||||
|
<a href={shareUrl} target="_blank" rel="noopener noreferrer" class="rounded border border-smoke px-3 py-1.5 text-xs text-mute transition hover:border-iris hover:text-iris">open ↗</a>
|
||||||
|
<button class="rounded border border-smoke px-3 py-1.5 text-xs text-mute transition hover:border-rose hover:text-rose" disabled={sharing} onclick={unshare}>
|
||||||
|
{sharing ? '…' : 'unshare'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add item — compact quick-add; tracking details on demand -->
|
||||||
|
<form class="panel space-y-3 p-4" onsubmit={addItem}>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input class="field" bind:value={title} maxlength="200" placeholder="add an item — what do you want?" />
|
||||||
|
<button class="btn btn-acid shrink-0" disabled={busy || !title.trim()}>{busy ? '…' : 'add +'}</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="label transition hover:text-iris"
|
||||||
|
onclick={() => (showDetails = !showDetails)}
|
||||||
|
>
|
||||||
|
{showDetails ? '− fewer' : '+ link, target price & note'}
|
||||||
|
</button>
|
||||||
|
{#if showDetails}
|
||||||
|
<div class="space-y-3 border-t border-smoke pt-3">
|
||||||
|
<div class="grid gap-3 sm:grid-cols-[1fr_8rem]">
|
||||||
<input class="field" bind:value={url} placeholder="product link (we'll track the price)" />
|
<input class="field" bind:value={url} placeholder="product link (we'll track the price)" />
|
||||||
<input class="field" bind:value={targetPrice} inputmode="decimal" placeholder="target price" />
|
<input class="field" bind:value={targetPrice} inputmode="decimal" placeholder="target price" />
|
||||||
</div>
|
</div>
|
||||||
<input class="field" bind:value={note} maxlength="1000" placeholder="note to self (optional)" />
|
<input class="field" bind:value={note} maxlength="1000" placeholder="note to self (optional)" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if formError}<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{formError}</p>{/if}
|
{#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 ? 'adding…' : 'add item +'}</button>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{#if !loaded}
|
{#if !loaded}
|
||||||
@@ -298,8 +388,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-mute">
|
<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)}
|
{#if money(item.current_price, item.currency)}
|
||||||
<span class:text-mint={onSale(item)} class:text-ink={!onSale(item)}>
|
<span class="text-sm font-bold" class:text-mint={onSale(item)} class:text-ink={!onSale(item)}>
|
||||||
now {money(item.current_price, item.currency)}
|
{money(item.current_price, item.currency)}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if item.target_price != null}
|
{#if item.target_price != null}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
import { api, ApiError } from '$lib/api';
|
import { api, ApiError } from '$lib/api';
|
||||||
import { auth } from '$lib/auth.svelte';
|
import { auth } from '$lib/auth.svelte';
|
||||||
|
|
||||||
@@ -8,6 +9,15 @@
|
|||||||
let error = $state('');
|
let error = $state('');
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
|
|
||||||
|
// Only follow same-origin internal paths, never an absolute/external URL.
|
||||||
|
const next = $derived.by(() => {
|
||||||
|
const n = page.url.searchParams.get('next');
|
||||||
|
return n && n.startsWith('/') && !n.startsWith('//') ? n : '/';
|
||||||
|
});
|
||||||
|
const registerHref = $derived(
|
||||||
|
next === '/' ? '/register' : `/register?next=${encodeURIComponent(next)}`
|
||||||
|
);
|
||||||
|
|
||||||
async function submit(e: SubmitEvent) {
|
async function submit(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
error = '';
|
error = '';
|
||||||
@@ -15,7 +25,7 @@
|
|||||||
try {
|
try {
|
||||||
await api.post('/auth/login', { email, password });
|
await api.post('/auth/login', { email, password });
|
||||||
await auth.refresh();
|
await auth.refresh();
|
||||||
goto('/');
|
goto(next);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof ApiError ? err.message : 'something broke';
|
error = err instanceof ApiError ? err.message : 'something broke';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -49,7 +59,7 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="mt-5 flex justify-between text-sm text-mute">
|
<div class="mt-5 flex justify-between text-sm text-mute">
|
||||||
<a href="/register">need an account?</a>
|
<a href={registerHref}>need an account?</a>
|
||||||
<a href="/forgot">forgot password?</a>
|
<a href="/forgot">forgot password?</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
import { api, ApiError } from '$lib/api';
|
import { api, ApiError } from '$lib/api';
|
||||||
import { auth } from '$lib/auth.svelte';
|
import { auth } from '$lib/auth.svelte';
|
||||||
|
|
||||||
@@ -9,6 +10,13 @@
|
|||||||
let error = $state('');
|
let error = $state('');
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
|
|
||||||
|
// Only follow same-origin internal paths, never an absolute/external URL.
|
||||||
|
const next = $derived.by(() => {
|
||||||
|
const n = page.url.searchParams.get('next');
|
||||||
|
return n && n.startsWith('/') && !n.startsWith('//') ? n : '/';
|
||||||
|
});
|
||||||
|
const loginHref = $derived(next === '/' ? '/login' : `/login?next=${encodeURIComponent(next)}`);
|
||||||
|
|
||||||
async function submit(e: SubmitEvent) {
|
async function submit(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
error = '';
|
error = '';
|
||||||
@@ -20,7 +28,7 @@
|
|||||||
display_name: displayName || null
|
display_name: displayName || null
|
||||||
});
|
});
|
||||||
await auth.refresh();
|
await auth.refresh();
|
||||||
goto('/');
|
goto(next);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof ApiError ? err.message : 'something broke';
|
error = err instanceof ApiError ? err.message : 'something broke';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -60,7 +68,7 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="mt-5 text-center text-sm text-mute">
|
<p class="mt-5 text-center text-sm text-mute">
|
||||||
already have one? <a href="/login">log in</a>
|
already have one? <a href={loginHref}>log in</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
<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 { listsApi, subs, type Item, type List } from '$lib/lists.svelte';
|
||||||
|
|
||||||
|
const token = $derived(page.params.token);
|
||||||
|
|
||||||
|
let list = $state<List | null>(null);
|
||||||
|
let items = $state<Item[]>([]);
|
||||||
|
let loaded = $state(false);
|
||||||
|
let loadError = $state('');
|
||||||
|
let subBusy = $state<string | null>(null);
|
||||||
|
|
||||||
|
let lastToken = '';
|
||||||
|
$effect(() => {
|
||||||
|
if (token && token !== lastToken) {
|
||||||
|
lastToken = token;
|
||||||
|
load(token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Once we know who's looking, pull their subscriptions so buttons reflect state.
|
||||||
|
$effect(() => {
|
||||||
|
if (auth.loaded && auth.user && !subs.loaded) subs.load();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send anonymous visitors to sign up, returning here afterwards.
|
||||||
|
function gateToSignup() {
|
||||||
|
goto(`/register?next=${encodeURIComponent(page.url.pathname)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleList() {
|
||||||
|
if (!list) return;
|
||||||
|
if (!auth.user) return gateToSignup();
|
||||||
|
const existing = subs.forList(list.id);
|
||||||
|
subBusy = `list:${list.id}`;
|
||||||
|
try {
|
||||||
|
if (existing) await subs.unsubscribe(existing);
|
||||||
|
else await subs.subscribeList(list.id);
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e instanceof ApiError ? e.message : 'failed';
|
||||||
|
} finally {
|
||||||
|
subBusy = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleItem(item: Item) {
|
||||||
|
if (!auth.user) return gateToSignup();
|
||||||
|
const existing = subs.forItem(item.id);
|
||||||
|
subBusy = `item:${item.id}`;
|
||||||
|
try {
|
||||||
|
if (existing) await subs.unsubscribe(existing);
|
||||||
|
else await subs.subscribeItem(item.id);
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e instanceof ApiError ? e.message : 'failed';
|
||||||
|
} finally {
|
||||||
|
subBusy = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(t: string) {
|
||||||
|
loaded = false;
|
||||||
|
loadError = '';
|
||||||
|
try {
|
||||||
|
const view = await listsApi.shared(t);
|
||||||
|
list = view.list;
|
||||||
|
items = view.items;
|
||||||
|
} catch (e) {
|
||||||
|
loadError =
|
||||||
|
e instanceof ApiError && e.status === 404
|
||||||
|
? 'this link is invalid or was revoked'
|
||||||
|
: e instanceof ApiError
|
||||||
|
? e.message
|
||||||
|
: 'failed to load';
|
||||||
|
} finally {
|
||||||
|
loaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSale(item: Item): boolean {
|
||||||
|
return (
|
||||||
|
item.current_price != null &&
|
||||||
|
item.target_price != null &&
|
||||||
|
item.current_price <= item.target_price
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(v: number | null, cur: string | null) {
|
||||||
|
if (v == null) return null;
|
||||||
|
return `${cur ?? 'EUR'} ${v.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only coveted/acquired are interesting to a guest; renounced items are noise.
|
||||||
|
const visible = $derived(items.filter((i) => i.status !== 'renounced'));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>{list?.name ?? 'shared list'} · consume·rs</title></svelte:head>
|
||||||
|
|
||||||
|
{#if !loaded}
|
||||||
|
<p class="text-center text-mute flicker">loading…</p>
|
||||||
|
{:else if loadError}
|
||||||
|
<div class="panel p-10 text-center">
|
||||||
|
<p class="gospel text-2xl">link broken</p>
|
||||||
|
<p class="mt-2 text-mute">{loadError}</p>
|
||||||
|
</div>
|
||||||
|
{:else if list}
|
||||||
|
<section class="space-y-8">
|
||||||
|
<div>
|
||||||
|
<p class="label">a shared wishlist</p>
|
||||||
|
<div class="mt-2 flex items-start gap-3">
|
||||||
|
<span class="text-4xl leading-none">{list.emoji ?? '✦'}</span>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<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>
|
||||||
|
{#if !auth.user}
|
||||||
|
<button class="tag shrink-0 border-iris text-iris hover:brightness-125" onclick={toggleList}>
|
||||||
|
☆ subscribe
|
||||||
|
</button>
|
||||||
|
{:else if subs.forList(list.id)}
|
||||||
|
<button
|
||||||
|
class="tag shrink-0 border-mint text-mint hover:brightness-125"
|
||||||
|
disabled={subBusy === `list:${list.id}`}
|
||||||
|
onclick={toggleList}
|
||||||
|
title="you're following this whole list — click to stop"
|
||||||
|
>
|
||||||
|
★ following
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="tag shrink-0 border-iris text-iris hover:brightness-125"
|
||||||
|
disabled={subBusy === `list:${list.id}`}
|
||||||
|
onclick={toggleList}
|
||||||
|
title="follow the whole list — get price-drop alerts on every item"
|
||||||
|
>
|
||||||
|
☆ follow list
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-mute">
|
||||||
|
read-only · shared by its owner · subscribe to get price-drop emails
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if visible.length === 0}
|
||||||
|
<div class="panel p-10 text-center">
|
||||||
|
<p class="gospel text-2xl">nothing here yet</p>
|
||||||
|
<p class="mt-2 text-mute">this list has no public temptations.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ul class="space-y-3">
|
||||||
|
{#each visible as item (item.id)}
|
||||||
|
<li
|
||||||
|
class="panel flex items-start gap-4 p-4"
|
||||||
|
class:ring-1={onSale(item)}
|
||||||
|
class:ring-mint={onSale(item)}
|
||||||
|
>
|
||||||
|
{#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">{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-sm font-bold" class:text-mint={onSale(item)} class:text-ink={!onSale(item)}>
|
||||||
|
{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">visit ↗</a>
|
||||||
|
{/if}
|
||||||
|
{#if item.note}<span class="italic">“{item.note}”</span>{/if}
|
||||||
|
</div>
|
||||||
|
{#if onSale(item)}
|
||||||
|
<p class="gospel mt-1 text-sm text-mint">✦ on sale — below target</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if auth.user && subs.forItem(item.id)}
|
||||||
|
<button
|
||||||
|
class="tag shrink-0 self-start border-mint text-mint hover:brightness-125"
|
||||||
|
disabled={subBusy === `item:${item.id}`}
|
||||||
|
onclick={() => toggleItem(item)}
|
||||||
|
title="following this item — click to stop"
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="tag shrink-0 self-start border-smoke text-mute hover:border-iris hover:text-iris"
|
||||||
|
disabled={subBusy === `item:${item.id}`}
|
||||||
|
onclick={() => toggleItem(item)}
|
||||||
|
title="follow this item for price-drop alerts"
|
||||||
|
>
|
||||||
|
☆ follow
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p class="text-center text-xs text-mute">
|
||||||
|
want your own? <a href="/register" class="text-iris hover:text-rose">join consume·rs</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { ApiError } from '$lib/api';
|
||||||
|
import { auth } from '$lib/auth.svelte';
|
||||||
|
import { subs, type Subscription } from '$lib/lists.svelte';
|
||||||
|
|
||||||
|
let error = $state('');
|
||||||
|
let busyId = $state<string | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (auth.loaded && !auth.user) {
|
||||||
|
goto('/login?next=/subscriptions');
|
||||||
|
} else if (auth.loaded && auth.user && !subs.loaded) {
|
||||||
|
subs.load().catch((e) => (error = e instanceof ApiError ? e.message : 'failed to load'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function drop(s: Subscription) {
|
||||||
|
busyId = s.id;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await subs.unsubscribe(s.id);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : 'failed to unsubscribe';
|
||||||
|
} finally {
|
||||||
|
busyId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSale(s: Subscription): boolean {
|
||||||
|
return s.current_price != null && s.target_price != null && s.current_price <= s.target_price;
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(v: number | null, cur: string | null) {
|
||||||
|
if (v == null) return null;
|
||||||
|
return `${cur ?? 'EUR'} ${v.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>your subscriptions · consume·rs</title></svelte:head>
|
||||||
|
|
||||||
|
{#if auth.loaded && auth.user}
|
||||||
|
<section class="space-y-8">
|
||||||
|
<div>
|
||||||
|
<p class="label">lists & items you follow</p>
|
||||||
|
<h1 class="font-display text-4xl font-bold">SUBSCRIPTIONS</h1>
|
||||||
|
<p class="gospel mt-1 text-lg">other people's cravings, kept close</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !subs.loaded}
|
||||||
|
<p class="text-center text-mute flicker">loading…</p>
|
||||||
|
{:else if subs.items.length === 0}
|
||||||
|
<div class="panel p-10 text-center">
|
||||||
|
<p class="gospel text-2xl">you follow nothing yet</p>
|
||||||
|
<p class="mt-2 text-mute">
|
||||||
|
open a shared list and hit <span class="text-iris">☆ follow</span> to get price-drop alerts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ul class="space-y-3">
|
||||||
|
{#each subs.items as s (s.id)}
|
||||||
|
<li
|
||||||
|
class="panel flex items-start gap-4 p-4"
|
||||||
|
class:ring-1={onSale(s)}
|
||||||
|
class:ring-mint={onSale(s)}
|
||||||
|
>
|
||||||
|
{#if s.image_url}
|
||||||
|
<img src={s.image_url} alt="" class="size-14 shrink-0 rounded-lg object-cover" />
|
||||||
|
{:else}
|
||||||
|
<span class="text-3xl leading-none">{s.emoji ?? '✦'}</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h3 class="truncate font-display font-bold">{s.title}</h3>
|
||||||
|
<span class="tag shrink-0 border-smoke text-mute">{s.kind}</span>
|
||||||
|
{#if s.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(s.current_price, s.currency)}
|
||||||
|
<span class="text-sm font-bold" class:text-mint={onSale(s)} class:text-ink={!onSale(s)}>
|
||||||
|
{money(s.current_price, s.currency)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if s.target_price != null}
|
||||||
|
<span>target {money(s.target_price, s.currency)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if s.url}
|
||||||
|
<a href={s.url} target="_blank" rel="noopener noreferrer" class="hover:text-iris">visit ↗</a>
|
||||||
|
{/if}
|
||||||
|
{#if s.share_token}
|
||||||
|
<a href="/shared/{s.share_token}" class="hover:text-iris">view list →</a>
|
||||||
|
{:else}
|
||||||
|
<span class="italic text-rose/80">owner stopped sharing</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if onSale(s)}
|
||||||
|
<p class="gospel mt-1 text-sm text-mint">✦ on sale — below target</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="shrink-0 self-start rounded border border-smoke px-2 py-1 text-xs text-mute transition hover:border-rose hover:text-rose"
|
||||||
|
disabled={busyId === s.id}
|
||||||
|
onclick={() => drop(s)}
|
||||||
|
>
|
||||||
|
{busyId === s.id ? '…' : 'unfollow'}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{:else}
|
||||||
|
<p class="text-center text-mute flicker">loading…</p>
|
||||||
|
{/if}
|
||||||
Reference in New Issue
Block a user