added sharing and subscriptions

This commit is contained in:
2026-06-17 23:27:37 +02:00
parent 148e441425
commit 8a614cb1d1
16 changed files with 1019 additions and 26 deletions
+38
View File
@@ -80,6 +80,29 @@
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 latest = $derived(coords.length ? coords[coords.length - 1] : null);
const onSale = $derived(latest != null && target != null && latest.price <= target);
@@ -168,6 +191,18 @@
</filter>
</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 -->
{#each gridLines as g}
<line
@@ -270,6 +305,9 @@
<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-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>
{#if latest}
<span class:text-mint={onSale} class:text-ink={!onSale}>
+74
View File
@@ -7,11 +7,31 @@ export type List = {
name: string;
emoji: string | null;
description: string | null;
share_token: string | null;
position: number;
created_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 = {
id: string;
list_id: string;
@@ -64,6 +84,9 @@ export const listsApi = {
update: (id: string, b: Partial<NewList> & { position?: number }) =>
api.patch<List>(`/lists/${id}`, b),
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`),
addItem: (listId: string, b: NewItem) =>
@@ -75,6 +98,11 @@ export const listsApi = {
removeItem: (id: string) => api.del<{ deleted: string }>(`/items/${id}`),
refetch: (id: string) => api.post<Item>(`/items/${id}/refetch`, {}),
history: (id: string) => api.get<PricePoint[]>(`/items/${id}/history`),
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. */
@@ -97,6 +125,52 @@ class ListsStore {
await listsApi.remove(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();
/** 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();
+1
View File
@@ -46,6 +46,7 @@
<nav class="flex items-center gap-2 text-sm">
{#if auth.loaded && auth.user}
<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">
{auth.user.display_name ?? auth.user.email}
</a>
+102 -12
View File
@@ -27,6 +27,55 @@
let targetPrice = $state('');
let busy = $state(false);
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
let editingId = $state<string | null>(null);
@@ -242,24 +291,65 @@
<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>
<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 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>
{#if list?.share_token}
<div id="share-box" class="panel mt-4 flex flex-wrap items-center gap-2 p-3 text-sm">
<span class="label shrink-0">public link</span>
<input class="field flex-1 text-xs" readonly value={shareUrl} onclick={(e) => e.currentTarget.select()} />
<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 -->
<form class="panel panel-acid space-y-4 p-6" onsubmit={addItem}>
<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 link (we'll track the price)" />
<input class="field" bind:value={targetPrice} inputmode="decimal" placeholder="target price" />
<!-- 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>
<input class="field" bind:value={note} maxlength="1000" placeholder="note to self (optional)" />
<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={targetPrice} inputmode="decimal" placeholder="target price" />
</div>
<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}
<button class="btn btn-acid" disabled={busy}>{busy ? 'adding…' : 'add item +'}</button>
</form>
{#if !loaded}
@@ -298,8 +388,8 @@
</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 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}
+12 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { api, ApiError } from '$lib/api';
import { auth } from '$lib/auth.svelte';
@@ -8,6 +9,15 @@
let error = $state('');
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) {
e.preventDefault();
error = '';
@@ -15,7 +25,7 @@
try {
await api.post('/auth/login', { email, password });
await auth.refresh();
goto('/');
goto(next);
} catch (err) {
error = err instanceof ApiError ? err.message : 'something broke';
} finally {
@@ -49,7 +59,7 @@
</form>
<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>
</div>
</div>
+10 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { api, ApiError } from '$lib/api';
import { auth } from '$lib/auth.svelte';
@@ -9,6 +10,13 @@
let error = $state('');
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) {
e.preventDefault();
error = '';
@@ -20,7 +28,7 @@
display_name: displayName || null
});
await auth.refresh();
goto('/');
goto(next);
} catch (err) {
error = err instanceof ApiError ? err.message : 'something broke';
} finally {
@@ -60,7 +68,7 @@
</form>
<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>
</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}