init
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { ApiError } from '$lib/api';
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
import {
|
||||
lists,
|
||||
listsApi,
|
||||
type Item,
|
||||
type ItemStatus,
|
||||
type List,
|
||||
type PricePoint
|
||||
} from '$lib/lists.svelte';
|
||||
|
||||
const id = $derived(page.params.id);
|
||||
|
||||
let list = $state<List | null>(null);
|
||||
let items = $state<Item[]>([]);
|
||||
let loaded = $state(false);
|
||||
let loadError = $state('');
|
||||
|
||||
// form
|
||||
let title = $state('');
|
||||
let url = $state('');
|
||||
let note = $state('');
|
||||
let targetPrice = $state('');
|
||||
let busy = $state(false);
|
||||
let formError = $state('');
|
||||
|
||||
// tracking
|
||||
let refetchingId = $state<string | null>(null);
|
||||
let historyFor = $state<string | null>(null);
|
||||
let history = $state<PricePoint[]>([]);
|
||||
let historyLoading = $state(false);
|
||||
|
||||
let lastId = '';
|
||||
$effect(() => {
|
||||
if (auth.loaded && !auth.user) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
if (auth.loaded && auth.user && id && id !== lastId) {
|
||||
lastId = id;
|
||||
load(id);
|
||||
}
|
||||
});
|
||||
|
||||
async function load(listId: string) {
|
||||
loaded = false;
|
||||
loadError = '';
|
||||
try {
|
||||
if (!lists.loaded) await lists.load();
|
||||
list = lists.items.find((l) => l.id === listId) ?? null;
|
||||
items = await listsApi.items(listId);
|
||||
} catch (e) {
|
||||
loadError = e instanceof ApiError ? e.message : 'failed to load';
|
||||
} finally {
|
||||
loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function addItem(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!title.trim() || !id) return;
|
||||
formError = '';
|
||||
busy = true;
|
||||
try {
|
||||
const tp = targetPrice.trim() ? Number(targetPrice) : null;
|
||||
const created = await listsApi.addItem(id, {
|
||||
title,
|
||||
url: url.trim() || null,
|
||||
note: note.trim() || null,
|
||||
target_price: Number.isFinite(tp as number) ? tp : null
|
||||
});
|
||||
items.push(created);
|
||||
title = '';
|
||||
url = '';
|
||||
note = '';
|
||||
targetPrice = '';
|
||||
} catch (err) {
|
||||
formError = err instanceof ApiError ? err.message : 'failed to add';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
const cycle: Record<ItemStatus, ItemStatus> = {
|
||||
coveted: 'acquired',
|
||||
acquired: 'renounced',
|
||||
renounced: 'coveted'
|
||||
};
|
||||
|
||||
async function cycleStatus(item: Item) {
|
||||
const next = cycle[item.status];
|
||||
try {
|
||||
const updated = await listsApi.updateItem(item.id, { status: next });
|
||||
const i = items.findIndex((x) => x.id === item.id);
|
||||
if (i >= 0) items[i] = updated;
|
||||
} catch (err) {
|
||||
formError = err instanceof ApiError ? err.message : 'failed to update';
|
||||
}
|
||||
}
|
||||
|
||||
async function removeItem(item: Item) {
|
||||
if (!confirm(`cast out “${item.title}”?`)) return;
|
||||
try {
|
||||
await listsApi.removeItem(item.id);
|
||||
items = items.filter((x) => x.id !== item.id);
|
||||
} catch (err) {
|
||||
formError = err instanceof ApiError ? err.message : 'failed to delete';
|
||||
}
|
||||
}
|
||||
|
||||
async function refetchItem(item: Item) {
|
||||
refetchingId = item.id;
|
||||
formError = '';
|
||||
try {
|
||||
const updated = await listsApi.refetch(item.id);
|
||||
const i = items.findIndex((x) => x.id === item.id);
|
||||
if (i >= 0) items[i] = updated;
|
||||
if (historyFor === item.id) history = await listsApi.history(item.id);
|
||||
} catch (err) {
|
||||
formError = err instanceof ApiError ? err.message : 'failed to keep vigil';
|
||||
} finally {
|
||||
refetchingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleHistory(item: Item) {
|
||||
if (historyFor === item.id) {
|
||||
historyFor = null;
|
||||
return;
|
||||
}
|
||||
historyFor = item.id;
|
||||
history = [];
|
||||
historyLoading = true;
|
||||
try {
|
||||
history = await listsApi.history(item.id);
|
||||
} catch (err) {
|
||||
formError = err instanceof ApiError ? err.message : 'failed to read the chronicle';
|
||||
} finally {
|
||||
historyLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// A want is "answered" when its watched price falls to/under the target.
|
||||
function onSale(item: Item): boolean {
|
||||
return (
|
||||
item.current_price != null &&
|
||||
item.target_price != null &&
|
||||
item.current_price <= item.target_price
|
||||
);
|
||||
}
|
||||
|
||||
function fmtDate(s: string): string {
|
||||
return new Date(s).toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
const STATUS_STYLE: Record<ItemStatus, string> = {
|
||||
coveted: 'border-iris text-iris',
|
||||
acquired: 'border-mint text-mint',
|
||||
renounced: 'border-smoke text-mute'
|
||||
};
|
||||
const STATUS_LABEL: Record<ItemStatus, string> = {
|
||||
coveted: 'coveted',
|
||||
acquired: 'acquired',
|
||||
renounced: 'renounced'
|
||||
};
|
||||
|
||||
function money(v: number | null, cur: string | null) {
|
||||
if (v == null) return null;
|
||||
return `${cur ?? 'EUR'} ${v.toFixed(2)}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{list?.name ?? 'list'} · consume·rs</title></svelte:head>
|
||||
|
||||
{#if auth.loaded && auth.user}
|
||||
<section class="space-y-8">
|
||||
<div>
|
||||
<a href="/lists" class="label hover:text-iris">← all lists</a>
|
||||
<div class="mt-2 flex items-start gap-3">
|
||||
<span class="text-4xl leading-none">{list?.emoji ?? '✦'}</span>
|
||||
<div>
|
||||
<h1 class="font-display text-4xl font-bold">{list?.name ?? '…'}</h1>
|
||||
{#if list?.description}<p class="gospel mt-1 text-lg">{list.description}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add temptation -->
|
||||
<form class="panel panel-acid space-y-4 p-6" onsubmit={addItem}>
|
||||
<p class="label">add a temptation</p>
|
||||
<input class="field" bind:value={title} maxlength="200" placeholder="what you covet" />
|
||||
<div class="grid gap-4 sm:grid-cols-[1fr_8rem]">
|
||||
<input class="field" bind:value={url} placeholder="product URL (we'll keep vigil on the price)" />
|
||||
<input class="field" bind:value={targetPrice} inputmode="decimal" placeholder="target price" />
|
||||
</div>
|
||||
<input class="field" bind:value={note} maxlength="1000" placeholder="note to self (optional)" />
|
||||
{#if formError}<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{formError}</p>{/if}
|
||||
<button class="btn btn-acid" disabled={busy}>{busy ? 'coveting…' : 'covet it +'}</button>
|
||||
</form>
|
||||
|
||||
{#if !loaded}
|
||||
<p class="text-center text-mute flicker">unveiling temptations…</p>
|
||||
{:else if loadError}
|
||||
<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{loadError}</p>
|
||||
{:else if items.length === 0}
|
||||
<div class="panel p-10 text-center">
|
||||
<p class="gospel text-2xl">this list is bare</p>
|
||||
<p class="mt-2 text-mute">paste a craving above to begin.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="space-y-3">
|
||||
{#each items as item (item.id)}
|
||||
<li
|
||||
class="panel flex flex-col gap-3 p-4"
|
||||
class:opacity-60={item.status === 'renounced'}
|
||||
class:ring-1={onSale(item)}
|
||||
class:ring-mint={onSale(item)}
|
||||
>
|
||||
<!-- Line 1: identity + state -->
|
||||
<div class="flex items-start gap-4">
|
||||
{#if item.image_url}
|
||||
<img src={item.image_url} alt="" class="size-14 shrink-0 rounded-lg object-cover" />
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="truncate font-display font-bold" class:line-through={item.status === 'renounced'}>
|
||||
{item.title_fetched ?? item.title}
|
||||
</h3>
|
||||
{#if item.in_stock === true}
|
||||
<span class="tag shrink-0 border-mint text-mint">in stock</span>
|
||||
{:else if item.in_stock === false}
|
||||
<span class="tag shrink-0 border-rose text-rose">sold out</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-mute">
|
||||
{#if money(item.current_price, item.currency)}
|
||||
<span class:text-mint={onSale(item)} class:text-ink={!onSale(item)}>
|
||||
now {money(item.current_price, item.currency)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if item.target_price != null}
|
||||
<span>target {money(item.target_price, item.currency)}</span>
|
||||
{/if}
|
||||
{#if item.url}
|
||||
<a href={item.url} target="_blank" rel="noopener noreferrer" class="hover:text-iris" title="open product page">visit ↗</a>
|
||||
{/if}
|
||||
{#if item.note}<span class="italic">“{item.note}”</span>{/if}
|
||||
{#if !item.url && item.current_price == null}
|
||||
<span class="text-mute">not tracked</span>
|
||||
{/if}
|
||||
{#if item.checked_at}
|
||||
<span class="text-mute" title="last price check">checked {fmtDate(item.checked_at)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status is the primary control: a real, cyclable badge. -->
|
||||
<button
|
||||
class="tag shrink-0 cursor-pointer transition hover:brightness-125 {STATUS_STYLE[item.status]}"
|
||||
title="click to cycle: coveted → acquired → renounced"
|
||||
onclick={() => cycleStatus(item)}
|
||||
>
|
||||
⇄ {STATUS_LABEL[item.status]}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Line 2: utility actions — plain verbs, real button chrome -->
|
||||
<div class="flex flex-wrap items-center justify-end gap-2 text-xs">
|
||||
{#if item.url}
|
||||
<button
|
||||
class="rounded border border-smoke px-2 py-1 text-mute transition hover:border-iris hover:text-iris disabled:opacity-40"
|
||||
title="refetch price now (keep vigil)"
|
||||
disabled={refetchingId === item.id}
|
||||
onclick={() => refetchItem(item)}
|
||||
>
|
||||
{refetchingId === item.id ? '↻ checking…' : '↻ refresh'}
|
||||
</button>
|
||||
<button
|
||||
class="rounded border border-smoke px-2 py-1 text-mute transition hover:border-iris hover:text-iris"
|
||||
title="price history (the chronicle)"
|
||||
onclick={() => toggleHistory(item)}
|
||||
>
|
||||
{historyFor === item.id ? 'hide history' : 'history'}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="rounded border border-smoke px-2 py-1 text-mute transition hover:border-rose hover:text-rose"
|
||||
title="remove from this list"
|
||||
onclick={() => removeItem(item)}
|
||||
>
|
||||
✕ remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if onSale(item)}
|
||||
<p class="gospel text-sm text-mint">✦ the price has fallen — your moment is upon you</p>
|
||||
{/if}
|
||||
{#if item.last_error}
|
||||
<p class="text-xs text-rose">vigil faltered: {item.last_error}</p>
|
||||
{/if}
|
||||
|
||||
{#if historyFor === item.id}
|
||||
<div class="border-t border-smoke pt-3">
|
||||
{#if historyLoading}
|
||||
<p class="text-xs text-mute flicker">unrolling the chronicle…</p>
|
||||
{:else if history.length === 0}
|
||||
<p class="text-xs text-mute">no observations yet — keep vigil to begin the record.</p>
|
||||
{:else}
|
||||
<ul class="space-y-1 text-xs">
|
||||
{#each history as h}
|
||||
<li class="flex items-center justify-between gap-3">
|
||||
<span class="text-ink">{money(h.price, h.currency)}</span>
|
||||
{#if h.in_stock === false}<span class="text-rose">sold out</span>{/if}
|
||||
<span class="text-mute">{fmtDate(h.fetched_at)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
{:else}
|
||||
<p class="text-center text-mute flicker">loading…</p>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user