updated wording

This commit is contained in:
2026-06-17 16:39:43 +02:00
parent 8b1b9cedc2
commit 7a90ced98e
14 changed files with 257 additions and 79 deletions
@@ -0,0 +1,6 @@
-- Manual currency override for items whose auto-detected currency is wrong.
-- Some Shopify shops price an item identically in base and presentment currency
-- (e.g. $49.90 == €49.90), which makes the conversion heuristic mislabel it.
-- When set, this wins over the fetched currency on every refetch and display.
ALTER TABLE items
ADD COLUMN currency_override TEXT;
+59 -12
View File
@@ -1,12 +1,15 @@
//! Phase 4: price-drop notifications. //! Phase 4: price-drop notifications.
//! //!
//! After a successful refetch we check whether an item's watched price has //! After a successful refetch we email the owner about a price drop. Two modes:
//! fallen to or below the owner's target. If so — and we haven't already told //! - **Target set:** notify when the price falls to/under the target. The
//! them about this drop — we email them in the house gospel voice. The
//! `items.notified_at` column is the de-dupe latch: //! `items.notified_at` column is the de-dupe latch:
//! - `NULL` = armed; a drop to/under target fires one email and stamps `now()`. //! - `NULL` = armed; a drop to/under target fires one email and stamps
//! `now()`.
//! - non-NULL = already announced; stays quiet until the price rises back //! - non-NULL = already announced; stays quiet until the price rises back
//! above target, which clears the latch (re-arms) for the next drop. //! above target, which clears the latch (re-arms) for the next drop.
//! - **No target:** notify on *any* drop — whenever this fetch's price is
//! lower than the immediately preceding observation in `price_history`.
//! No latch needed: a stable price has no preceding-higher observation.
use rust_decimal::Decimal; use rust_decimal::Decimal;
use uuid::Uuid; use uuid::Uuid;
@@ -55,17 +58,33 @@ async fn run(state: &AppState, item_id: Uuid) -> anyhow::Result<()> {
let Some(row) = row else { return Ok(()) }; let Some(row) = row else { return Ok(()) };
// Need both a watched price and a target to judge a drop. // Need a current price to judge anything.
let (Some(price), Some(target)) = (row.current_price, row.target_price) else { let Some(price) = row.current_price else {
return Ok(()); return Ok(());
}; };
let on_sale = price <= target;
match row.target_price {
// Target set → notify at/under target, latched against repeats.
Some(target) => target_drop(state, item_id, &row, price, target).await,
// No target → notify on any drop versus the previous observation.
None => any_drop(state, item_id, &row, price).await,
}
}
/// Target mode: email once when the price reaches the target; re-arm above it.
async fn target_drop(
state: &AppState,
item_id: Uuid,
row: &NotifyRow,
price: Decimal,
target: Decimal,
) -> anyhow::Result<()> {
let on_sale = price <= target;
match (on_sale, row.notified_at.is_some()) { match (on_sale, row.notified_at.is_some()) {
// Reached target and not yet announced → email + latch. // Reached target and not yet announced → email + latch.
(true, false) => { (true, false) => {
if row.notify_email { if row.notify_email {
send(state, &row, price, target).await?; send(state, row, price, Some(target)).await?;
} }
// Latch even if the user has email off, so flipping it on later // 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. // doesn't replay an old drop. Re-arms when price climbs back up.
@@ -86,16 +105,44 @@ async fn run(state: &AppState, item_id: Uuid) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
/// No-target mode: email whenever this fetch is cheaper than the one before it.
/// The newest `price_history` row is this fetch (inserted just before notify),
/// so the previous observation is the second-newest.
async fn any_drop(
state: &AppState,
item_id: Uuid,
row: &NotifyRow,
price: Decimal,
) -> anyhow::Result<()> {
let prev: Option<Decimal> = sqlx::query_scalar(
"SELECT price FROM price_history
WHERE item_id = $1 ORDER BY fetched_at DESC OFFSET 1 LIMIT 1",
)
.bind(item_id)
.fetch_optional(&state.pool)
.await?;
// A drop is a strictly lower price than the previous observation.
if matches!(prev, Some(p) if price < p) && row.notify_email {
send(state, row, price, None).await?;
}
Ok(())
}
async fn send( async fn send(
state: &AppState, state: &AppState,
row: &NotifyRow, row: &NotifyRow,
price: Decimal, price: Decimal,
target: Decimal, target: Option<Decimal>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let name = row.title_fetched.as_deref().unwrap_or(&row.title); let name = row.title_fetched.as_deref().unwrap_or(&row.title);
let cur = row.currency.as_deref().unwrap_or("EUR"); let cur = row.currency.as_deref().unwrap_or("EUR");
let now = format!("{cur} {price:.2}"); let now = format!("{cur} {price:.2}");
let goal = format!("{cur} {target:.2}"); // With a target, frame the drop against it; otherwise just announce the fall.
let against = match target {
Some(t) => format!(", at or beneath your target of {cur} {t:.2}"),
None => String::new(),
};
let stock = match row.in_stock { let stock = match row.in_stock {
Some(false) => " (sold out for now — but the sign is given)", Some(false) => " (sold out for now — but the sign is given)",
_ => "", _ => "",
@@ -110,7 +157,7 @@ async fn send(
let mut text = format!( let mut text = format!(
"{greeting}your vigil is rewarded.\n\n\ "{greeting}your vigil is rewarded.\n\n\
{name} now asks {now}, at or beneath your target of {goal}{stock}.\n\n\ {name} now asks {now}{against}{stock}.\n\n\
The moment is upon you. Consume, and ascend.\n" The moment is upon you. Consume, and ascend.\n"
); );
if let Some(l) = link { if let Some(l) = link {
@@ -127,7 +174,7 @@ async fn send(
"<div style=\"font-family:Georgia,serif;color:#1a1726\">\ "<div style=\"font-family:Georgia,serif;color:#1a1726\">\
<p><em>{greeting}your vigil is rewarded.</em></p>\ <p><em>{greeting}your vigil is rewarded.</em></p>\
<p style=\"font-size:1.1em\"><strong>{name}</strong> now asks \ <p style=\"font-size:1.1em\"><strong>{name}</strong> now asks \
<strong>{now}</strong>, at or beneath your target of {goal}{stock}.</p>\ <strong>{now}</strong>{against}{stock}.</p>\
<p>The moment is upon you. Consume, and ascend.</p>\ <p>The moment is upon you. Consume, and ascend.</p>\
{link_html}\ {link_html}\
<p style=\"color:#8a849c\">— consume·rs</p>\ <p style=\"color:#8a849c\">— consume·rs</p>\
+37 -5
View File
@@ -226,8 +226,15 @@ struct UpdateItemReq {
#[validate(length(max = 1000))] #[validate(length(max = 1000))]
note: Option<String>, note: Option<String>,
status: Option<String>, status: Option<String>,
target_price: Option<Decimal>, /// Absent = leave as-is; `null` = clear the target (→ notify on any drop);
/// a value = set the target.
#[serde(default, deserialize_with = "double_option")]
target_price: Option<Option<Decimal>>,
position: Option<i32>, position: Option<i32>,
/// Manual ISO 4217 currency override (e.g. "EUR"). Wins over the fetched
/// currency for items the conversion heuristic mislabels.
#[validate(length(equal = 3, message = "currency must be a 3-letter code"))]
currency: Option<String>,
} }
async fn update_item( async fn update_item(
@@ -244,15 +251,27 @@ async fn update_item(
} }
} }
// Ownership enforced via the join to lists.user_id. let currency = req
.currency
.map(|c| c.trim().to_uppercase())
.filter(|c| !c.is_empty());
// target_price: None = leave; Some(v) = set (v may be NULL to clear).
let set_target = req.target_price.is_some();
let target_val = req.target_price.flatten();
// Ownership enforced via the join to lists.user_id. A currency override
// both latches (currency_override) and takes effect immediately (currency).
let item = sqlx::query_as::<_, Item>(&format!( let item = sqlx::query_as::<_, Item>(&format!(
"UPDATE items i SET "UPDATE items i SET
title = COALESCE($3, i.title), title = COALESCE($3, i.title),
url = COALESCE($4, i.url), url = COALESCE($4, i.url),
note = COALESCE($5, i.note), note = COALESCE($5, i.note),
status = COALESCE($6::item_status, i.status), status = COALESCE($6::item_status, i.status),
target_price = COALESCE($7, i.target_price), target_price = CASE WHEN $7 THEN $8 ELSE i.target_price END,
position = COALESCE($8, i.position) position = COALESCE($9, i.position),
currency_override = COALESCE($10, i.currency_override),
currency = COALESCE($10, i.currency)
FROM lists l FROM lists l
WHERE i.id = $1 AND i.list_id = l.id AND l.user_id = $2 WHERE i.id = $1 AND i.list_id = l.id AND l.user_id = $2
RETURNING {ITEM_COLS_I}" RETURNING {ITEM_COLS_I}"
@@ -263,8 +282,10 @@ async fn update_item(
.bind(opt_trim(req.url)) .bind(opt_trim(req.url))
.bind(opt_trim(req.note)) .bind(opt_trim(req.note))
.bind(req.status) .bind(req.status)
.bind(req.target_price) .bind(set_target)
.bind(target_val)
.bind(req.position) .bind(req.position)
.bind(currency)
.fetch_optional(&state.pool) .fetch_optional(&state.pool)
.await? .await?
.ok_or(AppError::NotFound)?; .ok_or(AppError::NotFound)?;
@@ -354,3 +375,14 @@ async fn item_history(
fn opt_trim(s: Option<String>) -> Option<String> { fn opt_trim(s: Option<String>) -> Option<String> {
s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
} }
/// Deserialize a present-but-nullable field into `Option<Option<T>>`, so an
/// explicit JSON `null` (`Some(None)`) is distinguishable from an absent field
/// (`None`, handled by `#[serde(default)]`). Lets a PATCH clear a value.
fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error>
where
T: Deserialize<'de>,
D: serde::Deserializer<'de>,
{
Ok(Some(Option::<T>::deserialize(de)?))
}
+4 -2
View File
@@ -82,11 +82,12 @@ pub async fn refetch(state: &AppState, item_id: Uuid, url: &str) -> anyhow::Resu
async fn apply_success(pool: &PgPool, item_id: Uuid, p: &FetchedProduct) -> anyhow::Result<()> { async fn apply_success(pool: &PgPool, item_id: Uuid, p: &FetchedProduct) -> anyhow::Result<()> {
let mut tx = pool.begin().await?; let mut tx = pool.begin().await?;
// A manual currency_override (set by the user) wins over the fetched value.
sqlx::query( sqlx::query(
"UPDATE items SET "UPDATE items SET
title_fetched = $2, title_fetched = $2,
current_price = $3, current_price = $3,
currency = $4, currency = COALESCE(currency_override, $4),
image_url = COALESCE($5, image_url), image_url = COALESCE($5, image_url),
in_stock = $6, in_stock = $6,
source = $7, source = $7,
@@ -107,7 +108,8 @@ async fn apply_success(pool: &PgPool, item_id: Uuid, p: &FetchedProduct) -> anyh
sqlx::query( sqlx::query(
"INSERT INTO price_history (item_id, price, currency, in_stock) "INSERT INTO price_history (item_id, price, currency, in_stock)
VALUES ($1, $2, $3, $4)", VALUES ($1, $2,
COALESCE((SELECT currency_override FROM items WHERE id = $1), $3), $4)",
) )
.bind(item_id) .bind(item_id)
.bind(p.price) .bind(p.price)
+1
View File
@@ -53,6 +53,7 @@ export type NewItem = {
url?: string | null; url?: string | null;
note?: string | null; note?: string | null;
target_price?: number | null; target_price?: number | null;
currency?: string | null;
}; };
// ---- Lists ---------------------------------------------------------------- // ---- Lists ----------------------------------------------------------------
+3 -4
View File
@@ -17,7 +17,7 @@
} }
const ticker = const ticker =
'CONSUME · ASCEND · ACCUMULATE · YOU DESERVE IT · MANIFEST THE DEBT · TREAT YOURSELF · ONE MORE WONT HURT · '; 'CONSUME · SPEND · ACCUMULATE · YOU DESERVE IT · TREAT YOURSELF · ONE MORE WONT HURT · DEBT IS A LIFESTYLE · ';
</script> </script>
<div class="min-h-dvh flex flex-col"> <div class="min-h-dvh flex flex-col">
@@ -67,7 +67,7 @@
<!-- 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 border-rose bg-rose/10 px-4 py-2 text-center text-xs text-rose"> <div class="border-b border-rose bg-rose/10 px-4 py-2 text-center text-xs text-rose">
✦ email unconfirmed — your indulgence awaits. lost the link? ✦ email not confirmed — confirm it to start tracking prices. 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}
@@ -77,7 +77,6 @@
</main> </main>
<footer class="border-t border-smoke px-4 py-6 text-center"> <footer class="border-t border-smoke px-4 py-6 text-center">
<p class="gospel text-base">spend now, ascend later</p> <p class="gospel text-base">spend now, panic later</p>
<p class="label mt-1">consume·rs · self-hosted · rust + sveltekit</p>
</footer> </footer>
</div> </div>
+10 -11
View File
@@ -9,7 +9,7 @@
</script> </script>
<svelte:head> <svelte:head>
<title>consume·rs — want more, ascend</title> <title>consume·rs — want more, spend more</title>
</svelte:head> </svelte:head>
{#if auth.loaded && auth.user} {#if auth.loaded && auth.user}
@@ -18,28 +18,27 @@
<!-- 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-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="WANT MORE. ASCEND. ACCUMULATE THE DEBT." data-text="WANT MORE. SPEND MORE. ACCUMULATE THE DEBT."
> >
WANT MORE. ASCEND. ACCUMULATE THE DEBT. WANT MORE. SPEND MORE. ACCUMULATE THE DEBT.
</h1> </h1>
<p class="max-w-xl text-lg text-mute"> <p class="max-w-xl text-lg text-mute">
A serene little shrine to your every craving. Paste a product URL; we keep A wishlist for your every craving. Paste a product link; we watch the price
vigil over the price and summon you the instant it falls. No feed. No and email you the moment it drops. No feed. No algorithm. Just you, your
algorithm. Just you, your wants, and the gentle hum of impending debt. wants, and the gentle hum of impending debt.
<span class="gospel">You deserve it.</span> <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">begin ascension</a> <a href="/register" class="btn btn-acid">create account</a>
<a href="/login" class="btn btn-ghost">return to worship</a> <a href="/login" class="btn btn-ghost">log in</a>
</div> </div>
<div class="grid gap-4 sm:grid-cols-3"> <div class="grid gap-4 sm:grid-cols-3">
{#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]} {#each [['I', 'MAKE A LIST', 'group wants by topic'], ['II', 'PASTE A LINK', 'we track the price'], ['III', 'GET AN EMAIL', 'the moment it drops']] as [n, t, d]}
<div class="panel p-5"> <div class="panel p-5">
<p class="gospel text-4xl">{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>
@@ -48,6 +47,6 @@
{/each} {/each}
</div> </div>
<p class="gospel text-center text-lg">blessed are the carts, for they shall be filled</p> <p class="gospel text-center text-lg">your cart isnt going to fill itself</p>
</section> </section>
{/if} {/if}
+1 -1
View File
@@ -26,7 +26,7 @@
<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">RECLAIM THE KEY</h1> <h1 class="mb-6 font-display text-3xl font-bold">RESET PASSWORD</h1>
{#if done} {#if done}
<p class="border-2 border-mint bg-mint/10 px-3 py-3 text-sm text-mint"> <p class="border-2 border-mint bg-mint/10 px-3 py-3 text-sm text-mint">
+6 -6
View File
@@ -54,9 +54,9 @@
<section class="space-y-8"> <section class="space-y-8">
<div class="flex flex-wrap items-end justify-between gap-4"> <div class="flex flex-wrap items-end justify-between gap-4">
<div> <div>
<p class="label">your devotion</p> <p class="label">your wishlists</p>
<h1 class="font-display text-4xl font-bold">YOUR LISTS</h1> <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> <p class="gospel mt-1 text-lg">a list for every craving</p>
</div> </div>
<button class="btn btn-acid" onclick={() => (showForm = !showForm)}> <button class="btn btn-acid" onclick={() => (showForm = !showForm)}>
{showForm ? 'never mind' : 'new list +'} {showForm ? 'never mind' : 'new list +'}
@@ -67,7 +67,7 @@
<form class="panel panel-acid space-y-4 p-6" onsubmit={create}> <form class="panel panel-acid space-y-4 p-6" onsubmit={create}>
<div class="grid gap-4 sm:grid-cols-[5rem_1fr]"> <div class="grid gap-4 sm:grid-cols-[5rem_1fr]">
<div> <div>
<label class="label" for="emoji">glyph</label> <label class="label" for="emoji">emoji</label>
<input id="emoji" class="field mt-1 text-center" bind:value={emoji} maxlength="4" placeholder="🛍" /> <input id="emoji" class="field mt-1 text-center" bind:value={emoji} maxlength="4" placeholder="🛍" />
</div> </div>
<div> <div>
@@ -76,7 +76,7 @@
</div> </div>
</div> </div>
<div> <div>
<label class="label" for="desc">creed <span class="text-mute">(optional)</span></label> <label class="label" for="desc">description <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" /> <input id="desc" class="field mt-1" bind:value={description} maxlength="500" placeholder="what you tell yourself you need" />
</div> </div>
{#if error}<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{error}</p>{/if} {#if error}<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{error}</p>{/if}
@@ -85,7 +85,7 @@
{/if} {/if}
{#if !lists.loaded} {#if !lists.loaded}
<p class="text-center text-mute flicker">summoning your lists…</p> <p class="text-center text-mute flicker">loading your lists…</p>
{:else if lists.items.length === 0} {:else if lists.items.length === 0}
<div class="panel p-10 text-center"> <div class="panel p-10 text-center">
<p class="gospel text-2xl">no lists yet</p> <p class="gospel text-2xl">no lists yet</p>
@@ -112,7 +112,7 @@
class="text-xs text-mute opacity-0 transition-opacity hover:text-rose group-hover:opacity-100" class="text-xs text-mute opacity-0 transition-opacity hover:text-rose group-hover:opacity-100"
onclick={() => remove(l.id, l.name)} onclick={() => remove(l.id, l.name)}
> >
renounce delete
</button> </button>
</div> </div>
</div> </div>
+113 -21
View File
@@ -27,6 +27,12 @@
let busy = $state(false); let busy = $state(false);
let formError = $state(''); let formError = $state('');
// inline edit
let editingId = $state<string | null>(null);
let edit = $state({ title: '', url: '', note: '', target: '', currency: '' });
let editError = $state('');
let editBusy = $state(false);
// tracking // tracking
let refetchingId = $state<string | null>(null); let refetchingId = $state<string | null>(null);
let historyFor = $state<string | null>(null); let historyFor = $state<string | null>(null);
@@ -101,8 +107,57 @@
} }
} }
function startEdit(item: Item) {
editingId = item.id;
editError = '';
edit = {
title: item.title,
url: item.url ?? '',
note: item.note ?? '',
target: item.target_price != null ? String(item.target_price) : '',
currency: item.currency ?? ''
};
}
function cancelEdit() {
editingId = null;
editError = '';
}
async function saveEdit(item: Item) {
if (!edit.title.trim()) {
editError = 'title is required';
return;
}
editBusy = true;
editError = '';
try {
const t = edit.target.trim();
const tp = t ? Number(t) : null; // null clears the target → notify on any drop
if (t && !Number.isFinite(tp as number)) {
editError = 'target must be a number';
return;
}
const cur = edit.currency.trim().toUpperCase();
const updated = await listsApi.updateItem(item.id, {
title: edit.title.trim(),
url: edit.url.trim() || null,
note: edit.note.trim() || null,
target_price: tp,
...(cur ? { currency: cur } : {})
});
const i = items.findIndex((x) => x.id === item.id);
if (i >= 0) items[i] = updated;
editingId = null;
} catch (err) {
editError = err instanceof ApiError ? err.message : 'failed to save';
} finally {
editBusy = false;
}
}
async function removeItem(item: Item) { async function removeItem(item: Item) {
if (!confirm(`cast out${item.title}”?`)) return; if (!confirm(`remove${item.title}”?`)) return;
try { try {
await listsApi.removeItem(item.id); await listsApi.removeItem(item.id);
items = items.filter((x) => x.id !== item.id); items = items.filter((x) => x.id !== item.id);
@@ -120,7 +175,7 @@
if (i >= 0) items[i] = updated; if (i >= 0) items[i] = updated;
if (historyFor === item.id) history = await listsApi.history(item.id); if (historyFor === item.id) history = await listsApi.history(item.id);
} catch (err) { } catch (err) {
formError = err instanceof ApiError ? err.message : 'failed to keep vigil'; formError = err instanceof ApiError ? err.message : 'failed to check price';
} finally { } finally {
refetchingId = null; refetchingId = null;
} }
@@ -137,7 +192,7 @@
try { try {
history = await listsApi.history(item.id); history = await listsApi.history(item.id);
} catch (err) { } catch (err) {
formError = err instanceof ApiError ? err.message : 'failed to read the chronicle'; formError = err instanceof ApiError ? err.message : 'failed to load history';
} finally { } finally {
historyLoading = false; historyLoading = false;
} }
@@ -167,9 +222,9 @@
renounced: 'border-smoke text-mute' renounced: 'border-smoke text-mute'
}; };
const STATUS_LABEL: Record<ItemStatus, string> = { const STATUS_LABEL: Record<ItemStatus, string> = {
coveted: 'coveted', coveted: 'want',
acquired: 'acquired', acquired: 'bought',
renounced: 'renounced' renounced: 'skip'
}; };
function money(v: number | null, cur: string | null) { function money(v: number | null, cur: string | null) {
@@ -193,27 +248,27 @@
</div> </div>
</div> </div>
<!-- Add temptation --> <!-- Add item -->
<form class="panel panel-acid space-y-4 p-6" onsubmit={addItem}> <form class="panel panel-acid space-y-4 p-6" onsubmit={addItem}>
<p class="label">add a temptation</p> <p class="label">add an item</p>
<input class="field" bind:value={title} maxlength="200" placeholder="what you covet" /> <input class="field" bind:value={title} maxlength="200" placeholder="what do you want?" />
<div class="grid gap-4 sm:grid-cols-[1fr_8rem]"> <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={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)" />
{#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 ? 'coveting…' : 'covet it +'}</button> <button class="btn btn-acid" disabled={busy}>{busy ? 'adding…' : 'add item +'}</button>
</form> </form>
{#if !loaded} {#if !loaded}
<p class="text-center text-mute flicker">unveiling temptations…</p> <p class="text-center text-mute flicker">loading items…</p>
{:else if loadError} {:else if loadError}
<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{loadError}</p> <p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{loadError}</p>
{:else if items.length === 0} {:else if items.length === 0}
<div class="panel p-10 text-center"> <div class="panel p-10 text-center">
<p class="gospel text-2xl">this list is bare</p> <p class="gospel text-2xl">this list is empty</p>
<p class="mt-2 text-mute">paste a craving above to begin.</p> <p class="mt-2 text-mute">add something you want above to begin.</p>
</div> </div>
{:else} {:else}
<ul class="space-y-3"> <ul class="space-y-3">
@@ -265,7 +320,7 @@
<!-- Status is the primary control: a real, cyclable badge. --> <!-- Status is the primary control: a real, cyclable badge. -->
<button <button
class="tag shrink-0 cursor-pointer transition hover:brightness-125 {STATUS_STYLE[item.status]}" class="tag shrink-0 cursor-pointer transition hover:brightness-125 {STATUS_STYLE[item.status]}"
title="click to cycle: coveted → acquired → renounced" title="click to cycle: want → bought → skip"
onclick={() => cycleStatus(item)} onclick={() => cycleStatus(item)}
> >
{STATUS_LABEL[item.status]} {STATUS_LABEL[item.status]}
@@ -277,7 +332,7 @@
{#if item.url} {#if item.url}
<button <button
class="rounded border border-smoke px-2 py-1 text-mute transition hover:border-iris hover:text-iris disabled:opacity-40" 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)" title="check the price now"
disabled={refetchingId === item.id} disabled={refetchingId === item.id}
onclick={() => refetchItem(item)} onclick={() => refetchItem(item)}
> >
@@ -285,12 +340,19 @@
</button> </button>
<button <button
class="rounded border border-smoke px-2 py-1 text-mute transition hover:border-iris hover:text-iris" class="rounded border border-smoke px-2 py-1 text-mute transition hover:border-iris hover:text-iris"
title="price history (the chronicle)" title="price history"
onclick={() => toggleHistory(item)} onclick={() => toggleHistory(item)}
> >
{historyFor === item.id ? 'hide history' : 'history'} {historyFor === item.id ? 'hide history' : 'history'}
</button> </button>
{/if} {/if}
<button
class="rounded border border-smoke px-2 py-1 text-mute transition hover:border-iris hover:text-iris"
title="edit this item"
onclick={() => (editingId === item.id ? cancelEdit() : startEdit(item))}
>
{editingId === item.id ? 'close' : '✎ edit'}
</button>
<button <button
class="rounded border border-smoke px-2 py-1 text-mute transition hover:border-rose hover:text-rose" class="rounded border border-smoke px-2 py-1 text-mute transition hover:border-rose hover:text-rose"
title="remove from this list" title="remove from this list"
@@ -300,19 +362,49 @@
</button> </button>
</div> </div>
{#if editingId === item.id}
<div class="space-y-3 border-t border-smoke pt-3">
<input class="field" bind:value={edit.title} maxlength="200" placeholder="title" />
<input class="field" bind:value={edit.url} placeholder="product URL" />
<div class="grid gap-3 sm:grid-cols-[1fr_8rem]">
<input
class="field"
bind:value={edit.target}
inputmode="decimal"
placeholder="target price (blank = notify on any drop)"
/>
<input
class="field"
bind:value={edit.currency}
maxlength="3"
placeholder="currency"
title="3-letter code, e.g. EUR — overrides the detected currency"
/>
</div>
<input class="field" bind:value={edit.note} maxlength="1000" placeholder="note" />
{#if editError}<p class="text-xs text-rose">{editError}</p>{/if}
<div class="flex justify-end gap-2 text-xs">
<button class="rounded border border-smoke px-3 py-1 text-mute hover:text-ink" onclick={cancelEdit}>cancel</button>
<button class="btn btn-acid px-3 py-1" disabled={editBusy} onclick={() => saveEdit(item)}>
{editBusy ? 'saving…' : 'save'}
</button>
</div>
</div>
{/if}
{#if onSale(item)} {#if onSale(item)}
<p class="gospel text-sm text-mint"> the price has fallen — your moment is upon you</p> <p class="gospel text-sm text-mint">✦ price dropped — grab it now</p>
{/if} {/if}
{#if item.last_error} {#if item.last_error}
<p class="text-xs text-rose">vigil faltered: {item.last_error}</p> <p class="text-xs text-rose">price check failed: {item.last_error}</p>
{/if} {/if}
{#if historyFor === item.id} {#if historyFor === item.id}
<div class="border-t border-smoke pt-3"> <div class="border-t border-smoke pt-3">
{#if historyLoading} {#if historyLoading}
<p class="text-xs text-mute flicker">unrolling the chronicle</p> <p class="text-xs text-mute flicker">loading history</p>
{:else if history.length === 0} {:else if history.length === 0}
<p class="text-xs text-mute">no observations yet — keep vigil to begin the record.</p> <p class="text-xs text-mute">no price checks yet — hit refresh to start tracking.</p>
{:else} {:else}
<ul class="space-y-1 text-xs"> <ul class="space-y-1 text-xs">
{#each history as h} {#each history as h}
+1 -1
View File
@@ -29,7 +29,7 @@
<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">RETURN TO WORSHIP</h1> <h1 class="mb-6 font-display text-3xl font-bold">WELCOME BACK</h1>
<form class="space-y-4" onsubmit={submit}> <form class="space-y-4" onsubmit={submit}>
<div> <div>
+3 -3
View File
@@ -33,8 +33,8 @@
<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 devotee</p> <p class="label">new here</p>
<h1 class="mb-6 font-display text-3xl font-bold">BEGIN ASCENSION</h1> <h1 class="mb-6 font-display text-3xl font-bold">CREATE ACCOUNT</h1>
<form class="space-y-4" onsubmit={submit}> <form class="space-y-4" onsubmit={submit}>
<div> <div>
@@ -55,7 +55,7 @@
{/if} {/if}
<button class="btn btn-acid w-full" disabled={busy}> <button class="btn btn-acid w-full" disabled={busy}>
{busy ? 'carving…' : 'sign up'} {busy ? 'creating…' : 'sign up'}
</button> </button>
</form> </form>
+2 -2
View File
@@ -31,7 +31,7 @@
<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">FORGE A NEW KEY</h1> <h1 class="mb-6 font-display text-3xl font-bold">SET A NEW PASSWORD</h1>
{#if !token} {#if !token}
<p class="border-2 border-rose bg-rose/10 px-3 py-3 text-sm text-rose"> <p class="border-2 border-rose bg-rose/10 px-3 py-3 text-sm text-rose">
@@ -51,7 +51,7 @@
{#if error} {#if error}
<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{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 ? 'saving…' : 'set password'}</button>
</form> </form>
{/if} {/if}
</div> </div>
+4 -4
View File
@@ -59,9 +59,9 @@
{#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">your rites</p> <p class="label">your account</p>
<h1 class="font-display text-4xl font-bold">THE SANCTUM</h1> <h1 class="font-display text-4xl font-bold">SETTINGS</h1>
<p class="gospel mt-1 text-lg">tune your devotion</p> <p class="gospel mt-1 text-lg">tune your spending habit</p>
</div> </div>
<!-- Verification status --> <!-- Verification status -->
@@ -102,7 +102,7 @@
<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-mint" bind:checked={settings.notify_email} /> <input type="checkbox" class="size-5 accent-mint" bind:checked={settings.notify_email} />
<span class="font-mono text-sm">summon me when the price falls</span> <span class="font-mono text-sm">email me when a price drops</span>
</label> </label>
{#if error} {#if error}