diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md index d46cc1a9..10737d01 100644 --- a/e2e/USER_STORIES.md +++ b/e2e/USER_STORIES.md @@ -812,11 +812,14 @@ members can see the momentum. Acceptance: - Creating a goal requires a title and details; the sats target defaults to 1000 - and can be set by field or slider. + and can be set by field or slider, and a deadline and cover image are optional. +- The goals page is a board: space-wide totals, live/funded/ended filters, and a + sort by recent activity, age, progress or deadline. - The goal's detail page shows the amount funded against its target on a - progress bar. -- A goal with contributions shows a contributor count and how long it has been - running. + progress meter, with what is left to raise and how long it has left or has + been running. +- A goal with contributions names its backers, ranked by what each gave, with + the comment that came with the zap. ### US-051 — Post, edit, and close out a classified listing diff --git a/e2e/specs/community.spec.ts b/e2e/specs/community.spec.ts index c3fff7b3..97f19c99 100644 --- a/e2e/specs/community.spec.ts +++ b/e2e/specs/community.spec.ts @@ -594,23 +594,25 @@ test("US-050 create a funding goal and track its progress", async ({seed, as}) = await openCard(roof, "Repair the roof") - await expect(page.getByRole("heading", {name: "Repair the roof"})).toBeVisible() + // The space bar names the goal alongside the hero, so both carry it as a heading. + await expect(page.getByRole("heading", {name: "Repair the roof"}).first()).toBeVisible() - const goalCard = page.locator(".card.z-feature").filter({hasText: "funded of 6000 sats"}) + // Amounts are localized, so the thousands separator is the environment's to decide. + const goalCard = page.locator(".card.z-feature").filter({hasText: /of 6,?000 sats/}) - // Exactly, since "funded of 6000 sats" carries "0 sats" inside it. - await expect(goalCard.getByText("0 sats", {exact: true})).toBeVisible() - await expect(goalCard.locator("progress")).toHaveJSProperty("value", 0) - await expect(goalCard.locator("progress")).toHaveJSProperty("max", 6000) + await expect(goalCard.getByText("0%", {exact: true})).toBeVisible() + await expect(goalCard.getByText("sats to go", {exact: true}).locator("xpath=..")).toContainText( + /6,?000/, + ) await page.goto(`${spacePath(url)}/goals/${running.id}`) - const paCard = page.locator(".card.z-feature").filter({hasText: "funded of 50000 sats"}) + const paCard = page.locator(".card.z-feature").filter({hasText: /of 50,?000 sats/}) - await expect(paCard.getByText("contributors", {exact: true}).locator("xpath=..")).toContainText( - "0", + await expect(paCard.getByText("backers", {exact: true}).locator("xpath=..")).toContainText("0") + await expect(paCard.getByText("days running", {exact: true}).locator("xpath=..")).toContainText( + "3", ) - await expect(paCard.getByText("days old", {exact: true}).locator("xpath=..")).toContainText("3") // Registered after the page was opened, so it answers ahead of the empty dufflepud `as()` // installs, and before the navigation below, since a zapper is looked up once per page load. @@ -625,16 +627,24 @@ test("US-050 create a funding goal and track its progress", async ({seed, as}) = await page.goto(`${spacePath(url)}/goals/${soundproofing.id}`) - const soundCard = page.locator(".card.z-feature").filter({hasText: "funded of 20000 sats"}) + const soundCard = page.locator(".card.z-feature").filter({hasText: /of 20,?000 sats/}) - await expect(soundCard.getByText("1500 sats", {exact: true})).toBeVisible() - await expect(soundCard.locator("progress")).toHaveJSProperty("value", 1500) + await expect(soundCard.getByText(/^1,?500$/)).toBeVisible() + await expect(soundCard.getByText("8%", {exact: true})).toBeVisible() + await expect(soundCard.getByText("backers", {exact: true}).locator("xpath=..")).toContainText("2") await expect( - soundCard.getByText("contributors", {exact: true}).locator("xpath=.."), + soundCard.getByText("days running", {exact: true}).locator("xpath=.."), ).toContainText("2") - await expect(soundCard.getByText("days old", {exact: true}).locator("xpath=..")).toContainText( - "2", - ) + + // Each backer is named and ranked by what they gave, biggest first. + const supporters = page + .locator(".card") + .filter({has: page.getByRole("heading", {name: "Supporters"})}) + .last() + + await expect(supporters).toContainText("Alice Anderson") + await expect(supporters.getByText(/^1,?000$/)).toBeVisible() + await expect(supporters.getByText(/^500$/)).toBeVisible() }) test("US-051 post, edit, and close out a classified listing", async ({seed, as}) => { diff --git a/src/app/components/CommentCompose.svelte b/src/app/components/CommentCompose.svelte index 808e3a71..63d14657 100644 --- a/src/app/components/CommentCompose.svelte +++ b/src/app/components/CommentCompose.svelte @@ -133,7 +133,7 @@
+ {/if} + {:else} +
+

+ No one has contributed yet. Send the first zap and get top billing. +

+ {#if ENABLE_ZAPS && !progress.isEnded} + + + Be the first + + {/if} +
+ {/if} +
diff --git a/src/app/components/NoteContentGoal.svelte b/src/app/components/NoteContentGoal.svelte index 795642a5..d5596ed3 100644 --- a/src/app/components/NoteContentGoal.svelte +++ b/src/app/components/NoteContentGoal.svelte @@ -10,10 +10,14 @@ const goal = reader(ZapGoal)(props.event) const title = goal.title() + const image = goal.image() const summaryEvent = $derived({content: goal.summary(), tags: props.event.tags})
+ {#if image} + + {/if}

{title}

diff --git a/src/app/components/NoteContentMinimalGoal.svelte b/src/app/components/NoteContentMinimalGoal.svelte index cb9cb3ab..9efaca24 100644 --- a/src/app/components/NoteContentMinimalGoal.svelte +++ b/src/app/components/NoteContentMinimalGoal.svelte @@ -1,13 +1,11 @@
{title}
- {fromMsats($progress.amount)}/{fromMsats($progress.target)} sats funded + {$progress.raised.toLocaleString()}/{$progress.target.toLocaleString()} sats funded
diff --git a/src/app/components/ReactionSummary.svelte b/src/app/components/ReactionSummary.svelte index 04592287..e13ff0d4 100644 --- a/src/app/components/ReactionSummary.svelte +++ b/src/app/components/ReactionSummary.svelte @@ -1,6 +1,6 @@ -{#if $reactions.length > 0 || $zaps.length || $reports.length > 0 || children} +{#if $reactions.length > 0 || (!hideZaps && $zaps.length > 0) || $reports.length > 0 || children}
{#if url && $reports.length > 0 && canBanEvent} {/if} - {#each groupedZaps.entries() as [key, zaps] (key)} - {@const amount = fromMsats(sum(zaps.map(zap => zap.invoiceAmount)))} - {@const pubkeys = uniq(zaps.map(zap => zap.request.pubkey))} - {@const isOwn = pubkeys.includes($user.pubkey)} - {@const info = displayList(pubkeys.map(pubkey => $displays.get(pubkey) ?? ""))} - {@const tooltip = `${info} zapped`} - {@const onZapClickHandler = () => onZapClick(pubkeys, tooltip)} - - {/each} + {#if !hideZaps} + {#each groupedZaps.entries() as [key, zaps] (key)} + {@const amount = fromMsats(sum(zaps.map(zap => zap.invoiceAmount)))} + {@const pubkeys = uniq(zaps.map(zap => zap.request.pubkey))} + {@const isOwn = pubkeys.includes($user.pubkey)} + {@const info = displayList(pubkeys.map(pubkey => $displays.get(pubkey) ?? ""))} + {@const tooltip = `${info} zapped`} + {@const onZapClickHandler = () => onZapClick(pubkeys, tooltip)} + + {/each} + {/if} {#each groupedReactions.entries() as [key, events] (key)} {@const pubkeys = events.map(e => e.pubkey)} {@const isOwn = pubkeys.includes($user.pubkey)} diff --git a/src/app/components/ThunkFailure.svelte b/src/app/components/ThunkFailure.svelte index f01acf70..2826242e 100644 --- a/src/app/components/ThunkFailure.svelte +++ b/src/app/components/ThunkFailure.svelte @@ -1,4 +1,5 @@ -
+
Sending... diff --git a/src/app/components/ThunkStatus.svelte b/src/app/components/ThunkStatus.svelte index 4deb2dc9..bcf7cf43 100644 --- a/src/app/components/ThunkStatus.svelte +++ b/src/app/components/ThunkStatus.svelte @@ -6,17 +6,18 @@ interface Props { thunk: BaseThunk showToastOnRetry?: boolean + inline?: boolean class?: string } - const {thunk, showToastOnRetry, ...restProps}: Props = $props() + const {thunk, showToastOnRetry, inline = false, ...restProps}: Props = $props() const showFailure = $derived($thunk.isComplete() && $thunk.getFailedUrls().length > 0) const showPending = $derived(!$thunk.isComplete()) {#if showFailure} - + {:else if showPending} - + {/if} diff --git a/src/app/components/ThunkStatusOrDeleted.svelte b/src/app/components/ThunkStatusOrDeleted.svelte index ac73f1bd..a58ef25f 100644 --- a/src/app/components/ThunkStatusOrDeleted.svelte +++ b/src/app/components/ThunkStatusOrDeleted.svelte @@ -24,7 +24,7 @@
Deleted
{:else} {#if $thunk.thunks.length > 0 && !$thunk.hasStatus(PublishStatus.Success)} - + {:else} {@render status?.()} {/if} diff --git a/src/app/goals.ts b/src/app/goals.ts new file mode 100644 index 00000000..b3e8ec8a --- /dev/null +++ b/src/app/goals.ts @@ -0,0 +1,41 @@ +import {derived} from "svelte/store" +import type {Readable} from "svelte/store" +import {now, removeUndefined} from "@welshman/lib" +import {fromMsats} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {ZapGoal} from "@welshman/domain" +import type {Zap} from "@welshman/domain" +import {ZapGoals} from "@welshman/app" +import {app, reader} from "@app/core" + +export type GoalProgress = { + zaps: Zap[] + raised: number + target: number + percent: number + backers: string[] + isFunded: boolean + isEnded: boolean + closedAt?: number +} + +// Welshman tallies a goal in millisats; the pages show sats, and compare in millisats so a +// goal isn't funded a rounded sat early. +export const deriveGoalProgress = (event: TrustedEvent, url?: string): Readable => { + const closedAt = reader(ZapGoal)(event).closedAt() + const progress = app + .get() + .use(ZapGoals) + .progress(event, removeUndefined([url])).$ + + return derived(progress, $progress => ({ + zaps: $progress.zaps, + raised: fromMsats($progress.amount), + target: fromMsats($progress.target), + percent: $progress.target > 0 ? Math.round(($progress.amount / $progress.target) * 100) : 0, + backers: $progress.contributors, + isFunded: $progress.target > 0 && $progress.amount >= $progress.target, + isEnded: Boolean(closedAt && closedAt < now()), + closedAt, + })) +} diff --git a/src/lib/components/ImagesInput.svelte b/src/lib/components/ImagesInput.svelte index 96507cf5..1ab9a469 100644 --- a/src/lib/components/ImagesInput.svelte +++ b/src/lib/components/ImagesInput.svelte @@ -1,4 +1,5 @@
-
+
{#each value as item, index (index)}
handleDragStart(e, index)} ondragover={e => handleDragOver(e, index)} ondragend={handleDragEnd}> - Upload preview + Upload preview
{/each} - + {#if multiple || value.length === 0} + + {/if}
diff --git a/src/lib/components/Spinner.svelte b/src/lib/components/Spinner.svelte index e5c47ac6..553daf63 100644 --- a/src/lib/components/Spinner.svelte +++ b/src/lib/components/Spinner.svelte @@ -6,11 +6,15 @@ children, size = "md", loading = true, + reserveSpace = false, ...restProps }: { children?: Snippet size?: "md" | "sm" | "xs" loading?: boolean + // Reserves the spinner's slot even while idle, so toggling `loading` doesn't resize + // (and reflow) the button. Off by default since it costs unused width otherwise. + reserveSpace?: boolean class?: string } = $props() @@ -25,6 +29,8 @@
{#if loading} + {:else if reserveSpace} + {/if} {@render children?.()}
diff --git a/src/lib/components/badge.css b/src/lib/components/badge.css index 15f19a0d..ab17ce51 100644 --- a/src/lib/components/badge.css +++ b/src/lib/components/badge.css @@ -23,6 +23,12 @@ border-color: color-mix(in srgb, var(--primary) 22%, transparent); } + &.badge-success { + background: color-mix(in srgb, var(--success) 14%, transparent); + color: var(--success); + border-color: color-mix(in srgb, var(--success) 25%, transparent); + } + &.badge-warning { background: color-mix(in srgb, var(--warning) 14%, transparent); color: var(--warning); diff --git a/src/lib/components/card.css b/src/lib/components/card.css index 7876d0b2..6725bacd 100644 --- a/src/lib/components/card.css +++ b/src/lib/components/card.css @@ -89,7 +89,8 @@ var(--shadow-inset); } -[data-fl-theme="clay"] .card-interactive:active { +/* excludes presses on nested buttons/links, which :active otherwise cascades to too */ +[data-fl-theme="clay"] .card-interactive:active:not(:has(button:active, a:active)) { transform: translateY(2px); box-shadow: 2px 2px 0 rgba(var(--shadow-color), 0.3); } diff --git a/src/routes/spaces/[relay]/goals/+page.svelte b/src/routes/spaces/[relay]/goals/+page.svelte index a29b55bb..2eff360d 100644 --- a/src/routes/spaces/[relay]/goals/+page.svelte +++ b/src/routes/spaces/[relay]/goals/+page.svelte @@ -1,14 +1,18 @@ {#snippet title()} -

{$event?.content}

+

{$goal?.title() ?? "Funding goal"}

{/snippet}
- - {#if $event} -
- -
- - -
-
- {#if !showAll && $replies.length > 4} -
- -
- {/if} - {#each $replies.slice(0, showAll ? undefined : 4) as reply (reply.id)} - -
- - + + {#if $event && $goal && $progress} + +
+ {#if $goal.image()} + + {/if} +
+

{$goal.title()}

+
+ + {#if $goal.room()} + in + {/if} + started {formatTimestampRelative($event.created_at)}
- - {/each} -
- {#if showReply} - - {:else} -
- +
+ +
+
+ {$progress.backers.length} + {#if $progress.backers.length > 0} + + {/if} + + {$progress.backers.length === 1 ? "backer" : "backers"} + +
+
+ {remaining.toLocaleString()} + sats to go +
+
+ {$progress.isEnded ? "—" : days} + + {#if $progress.isEnded && $progress.closedAt} + ended {formatTimestampRelative($progress.closedAt)} + {:else if deadline} + {days === 1 ? "day" : "days"} left + {:else} + {days === 1 ? "day" : "days"} running + {/if} + +
+
+ {#if ENABLE_ZAPS && !$progress.isEnded} + + + {$progress.isFunded ? "Chip in anyway" : "Contribute to this goal"} + + {/if} + +
+
+ {#if $goal.summary()} +
+

About this goal

+
{/if} + + {:else}
{#await sleep(5000)}