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.
//!
//! 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
//! After a successful refetch we email the owner about a price drop. Two modes:
//! - **Target set:** notify when the price falls to/under the target. The
//! `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
//! 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 uuid::Uuid;
@@ -55,17 +58,33 @@ async fn run(state: &AppState, item_id: Uuid) -> anyhow::Result<()> {
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 {
// Need a current price to judge anything.
let Some(price) = row.current_price else {
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()) {
// Reached target and not yet announced → email + latch.
(true, false) => {
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
// 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(())
}
/// 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(
state: &AppState,
row: &NotifyRow,
price: Decimal,
target: Decimal,
target: Option<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}");
// 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 {
Some(false) => " (sold out for now — but the sign is given)",
_ => "",
@@ -110,7 +157,7 @@ async fn send(
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\
{name} now asks {now}{against}{stock}.\n\n\
The moment is upon you. Consume, and ascend.\n"
);
if let Some(l) = link {
@@ -127,7 +174,7 @@ async fn send(
"<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>\
<strong>{now}</strong>{against}{stock}.</p>\
<p>The moment is upon you. Consume, and ascend.</p>\
{link_html}\
<p style=\"color:#8a849c\">— consume·rs</p>\
+37 -5
View File
@@ -226,8 +226,15 @@ struct UpdateItemReq {
#[validate(length(max = 1000))]
note: 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>,
/// 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(
@@ -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!(
"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)
target_price = CASE WHEN $7 THEN $8 ELSE i.target_price END,
position = COALESCE($9, i.position),
currency_override = COALESCE($10, i.currency_override),
currency = COALESCE($10, i.currency)
FROM lists l
WHERE i.id = $1 AND i.list_id = l.id AND l.user_id = $2
RETURNING {ITEM_COLS_I}"
@@ -263,8 +282,10 @@ async fn update_item(
.bind(opt_trim(req.url))
.bind(opt_trim(req.note))
.bind(req.status)
.bind(req.target_price)
.bind(set_target)
.bind(target_val)
.bind(req.position)
.bind(currency)
.fetch_optional(&state.pool)
.await?
.ok_or(AppError::NotFound)?;
@@ -354,3 +375,14 @@ async fn item_history(
fn opt_trim(s: Option<String>) -> Option<String> {
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<()> {
let mut tx = pool.begin().await?;
// A manual currency_override (set by the user) wins over the fetched value.
sqlx::query(
"UPDATE items SET
title_fetched = $2,
current_price = $3,
currency = $4,
currency = COALESCE(currency_override, $4),
image_url = COALESCE($5, image_url),
in_stock = $6,
source = $7,
@@ -107,7 +108,8 @@ async fn apply_success(pool: &PgPool, item_id: Uuid, p: &FetchedProduct) -> anyh
sqlx::query(
"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(p.price)
+1
View File
@@ -53,6 +53,7 @@ export type NewItem = {
url?: string | null;
note?: string | null;
target_price?: number | null;
currency?: string | null;
};
// ---- Lists ----------------------------------------------------------------
+3 -4
View File
@@ -17,7 +17,7 @@
}
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>
<div class="min-h-dvh flex flex-col">
@@ -67,7 +67,7 @@
<!-- Unverified banner -->
{#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">
✦ 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>
</div>
{/if}
@@ -77,7 +77,6 @@
</main>
<footer class="border-t border-smoke px-4 py-6 text-center">
<p class="gospel text-base">spend now, ascend later</p>
<p class="label mt-1">consume·rs · self-hosted · rust + sveltekit</p>
<p class="gospel text-base">spend now, panic later</p>
</footer>
</div>
+10 -11
View File
@@ -9,7 +9,7 @@
</script>
<svelte:head>
<title>consume·rs — want more, ascend</title>
<title>consume·rs — want more, spend more</title>
</svelte:head>
{#if auth.loaded && auth.user}
@@ -18,28 +18,27 @@
<!-- Marketing hero -->
<section class="space-y-10">
<div class="space-y-4">
<p class="tag inline-block border-mint text-mint">self-hosted · rust core · ✦ blessed</p>
<h1
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>
<p class="max-w-xl text-lg text-mute">
A serene little shrine to your every craving. Paste a product URL; we keep
vigil over the price and summon you the instant it falls. No feed. No
algorithm. Just you, your wants, and the gentle hum of impending debt.
A wishlist for your every craving. Paste a product link; we watch the price
and email you the moment it drops. No feed. No algorithm. Just you, your
wants, and the gentle hum of impending debt.
<span class="gospel">You deserve it.</span>
</p>
</div>
<div class="flex flex-wrap gap-3">
<a href="/register" class="btn btn-acid">begin ascension</a>
<a href="/login" class="btn btn-ghost">return to worship</a>
<a href="/register" class="btn btn-acid">create account</a>
<a href="/login" class="btn btn-ghost">log in</a>
</div>
<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">
<p class="gospel text-4xl">{n}</p>
<p class="mt-1 font-display text-lg font-bold">{t}</p>
@@ -48,6 +47,6 @@
{/each}
</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>
{/if}
+1 -1
View File
@@ -26,7 +26,7 @@
<div class="mx-auto max-w-md">
<div class="panel p-8">
<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}
<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">
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<p class="label">your devotion</p>
<p class="label">your wishlists</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>
<p class="gospel mt-1 text-lg">a list for every craving</p>
</div>
<button class="btn btn-acid" onclick={() => (showForm = !showForm)}>
{showForm ? 'never mind' : 'new list +'}
@@ -67,7 +67,7 @@
<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>
<label class="label" for="emoji">emoji</label>
<input id="emoji" class="field mt-1 text-center" bind:value={emoji} maxlength="4" placeholder="🛍" />
</div>
<div>
@@ -76,7 +76,7 @@
</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" />
</div>
{#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 !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}
<div class="panel p-10 text-center">
<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"
onclick={() => remove(l.id, l.name)}
>
renounce
delete
</button>
</div>
</div>
+113 -21
View File
@@ -27,6 +27,12 @@
let busy = $state(false);
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
let refetchingId = $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) {
if (!confirm(`cast out${item.title}”?`)) return;
if (!confirm(`remove${item.title}”?`)) return;
try {
await listsApi.removeItem(item.id);
items = items.filter((x) => x.id !== item.id);
@@ -120,7 +175,7 @@
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';
formError = err instanceof ApiError ? err.message : 'failed to check price';
} finally {
refetchingId = null;
}
@@ -137,7 +192,7 @@
try {
history = await listsApi.history(item.id);
} catch (err) {
formError = err instanceof ApiError ? err.message : 'failed to read the chronicle';
formError = err instanceof ApiError ? err.message : 'failed to load history';
} finally {
historyLoading = false;
}
@@ -167,9 +222,9 @@
renounced: 'border-smoke text-mute'
};
const STATUS_LABEL: Record<ItemStatus, string> = {
coveted: 'coveted',
acquired: 'acquired',
renounced: 'renounced'
coveted: 'want',
acquired: 'bought',
renounced: 'skip'
};
function money(v: number | null, cur: string | null) {
@@ -193,27 +248,27 @@
</div>
</div>
<!-- Add temptation -->
<!-- Add item -->
<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" />
<p class="label">add an item</p>
<input class="field" bind:value={title} maxlength="200" placeholder="what do you want?" />
<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" />
</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>
<button class="btn btn-acid" disabled={busy}>{busy ? 'adding…' : 'add item +'}</button>
</form>
{#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}
<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>
<p class="gospel text-2xl">this list is empty</p>
<p class="mt-2 text-mute">add something you want above to begin.</p>
</div>
{:else}
<ul class="space-y-3">
@@ -265,7 +320,7 @@
<!-- 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"
title="click to cycle: want → bought → skip"
onclick={() => cycleStatus(item)}
>
{STATUS_LABEL[item.status]}
@@ -277,7 +332,7 @@
{#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)"
title="check the price now"
disabled={refetchingId === item.id}
onclick={() => refetchItem(item)}
>
@@ -285,12 +340,19 @@
</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)"
title="price history"
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-iris hover:text-iris"
title="edit this item"
onclick={() => (editingId === item.id ? cancelEdit() : startEdit(item))}
>
{editingId === item.id ? 'close' : '✎ edit'}
</button>
<button
class="rounded border border-smoke px-2 py-1 text-mute transition hover:border-rose hover:text-rose"
title="remove from this list"
@@ -300,19 +362,49 @@
</button>
</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)}
<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 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 historyFor === item.id}
<div class="border-t border-smoke pt-3">
{#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}
<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}
<ul class="space-y-1 text-xs">
{#each history as h}
+1 -1
View File
@@ -29,7 +29,7 @@
<div class="mx-auto max-w-md">
<div class="panel p-8">
<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}>
<div>
+3 -3
View File
@@ -33,8 +33,8 @@
<div class="mx-auto max-w-md">
<div class="panel panel-acid p-8">
<p class="label">new devotee</p>
<h1 class="mb-6 font-display text-3xl font-bold">BEGIN ASCENSION</h1>
<p class="label">new here</p>
<h1 class="mb-6 font-display text-3xl font-bold">CREATE ACCOUNT</h1>
<form class="space-y-4" onsubmit={submit}>
<div>
@@ -55,7 +55,7 @@
{/if}
<button class="btn btn-acid w-full" disabled={busy}>
{busy ? 'carving…' : 'sign up'}
{busy ? 'creating…' : 'sign up'}
</button>
</form>
+2 -2
View File
@@ -31,7 +31,7 @@
<div class="mx-auto max-w-md">
<div class="panel panel-acid p-8">
<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}
<p class="border-2 border-rose bg-rose/10 px-3 py-3 text-sm text-rose">
@@ -51,7 +51,7 @@
{#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 w-full" disabled={busy}>{busy ? 'cutting…' : 'set password'}</button>
<button class="btn btn-acid w-full" disabled={busy}>{busy ? 'saving…' : 'set password'}</button>
</form>
{/if}
</div>
+4 -4
View File
@@ -59,9 +59,9 @@
{#if auth.loaded && auth.user}
<div class="mx-auto max-w-2xl space-y-6">
<div>
<p class="label">your rites</p>
<h1 class="font-display text-4xl font-bold">THE SANCTUM</h1>
<p class="gospel mt-1 text-lg">tune your devotion</p>
<p class="label">your account</p>
<h1 class="font-display text-4xl font-bold">SETTINGS</h1>
<p class="gospel mt-1 text-lg">tune your spending habit</p>
</div>
<!-- Verification status -->
@@ -102,7 +102,7 @@
<label class="flex cursor-pointer items-center gap-3">
<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>
{#if error}