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 @@
Cancel
-
+
{#if parent}
Reply
{:else}
diff --git a/src/app/components/GoalActions.svelte b/src/app/components/GoalActions.svelte
index 127134a4..c1244659 100644
--- a/src/app/components/GoalActions.svelte
+++ b/src/app/components/GoalActions.svelte
@@ -31,7 +31,7 @@
const createReaction = (values: EventContent) => publishReaction(event, values, {url})
-
+
{#if h && showRoom}
Posted in #
@@ -39,6 +39,7 @@
{/if}
import {writable} from "svelte/store"
+ import {now} from "@welshman/lib"
import {relay, toMsats} from "@welshman/util"
import {ZapGoal} from "@welshman/domain"
import {publish} from "@welshman/app"
@@ -12,6 +13,8 @@
import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
+ import DateTimeInput from "@lib/components/DateTimeInput.svelte"
+ import ImagesInput from "@lib/components/ImagesInput.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
@@ -24,11 +27,14 @@
import {publishRoomQuote} from "@app/rooms"
import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/drafts"
+ import {compressFileForUpload, uploadFile} from "@app/uploads"
type Values = {
title: string
content: string | object
amount: number
+ images: (string | File)[]
+ closedAt?: number
}
type Props = {
@@ -76,9 +82,30 @@
})
}
+ if (closedAt && closedAt < now()) {
+ return pushToast({
+ theme: "error",
+ message: "Please choose a deadline in the future.",
+ })
+ }
+
loading = true
try {
+ const [image] = images
+
+ let imageUrl = typeof image === "string" ? image : undefined
+
+ if (image instanceof File) {
+ const {result, error} = await uploadFile(await compressFileForUpload(image), {url})
+
+ if (error) {
+ return pushToast({theme: "error", message: `Failed to upload ${image.name}`})
+ }
+
+ imageUrl = result?.url
+ }
+
const protect = await shouldProtect
const eventWriter = writer(ZapGoal)
.setTitle(title)
@@ -89,6 +116,14 @@
.addTags(...ed.storage.nostr.getEditorTags())
.forceRoutes(relay(url))
+ if (imageUrl) {
+ eventWriter.setImage(imageUrl)
+ }
+
+ if (closedAt) {
+ eventWriter.setClosedAt(closedAt)
+ }
+
if (h) {
eventWriter.setRoom(url, h)
}
@@ -116,6 +151,8 @@
let title = $state(initialValues?.title ?? "")
let amount = $state(initialValues?.amount ?? 1000)
let content = $state(initialValues?.content ?? "")
+ let images = $state(initialValues?.images ?? [])
+ let closedAt: number | undefined = $state(initialValues?.closedAt)
const onChange = (json: object) => {
content = json
@@ -131,7 +168,7 @@
})
$effect(() => {
- draftKey.update({title, content, amount})
+ draftKey.update({title, content, amount, images, closedAt})
})
@@ -204,6 +241,25 @@
step="1000"
bind:value={amount} />
+
+ {#snippet label()}
+ Deadline (optional)
+ {/snippet}
+ {#snippet input()}
+
+ {/snippet}
+ {#snippet info()}
+ Contributions made after this date won't count toward the goal.
+ {/snippet}
+
+
+ {#snippet label()}
+ Cover image (optional)
+ {/snippet}
+ {#snippet input()}
+
+ {/snippet}
+
diff --git a/src/app/components/GoalItem.svelte b/src/app/components/GoalItem.svelte
index 3429f778..afaf2fa5 100644
--- a/src/app/components/GoalItem.svelte
+++ b/src/app/components/GoalItem.svelte
@@ -1,4 +1,5 @@
-
+
- {title}
-
-
-
-
- Posted by
+ {#if image}
+
+ {/if}
+ {title}
+ {#if summary}
+
+
+
+ {/if}
+
+
+
+ by
{#if h}
in
{/if}
diff --git a/src/app/components/GoalMeter.svelte b/src/app/components/GoalMeter.svelte
new file mode 100644
index 00000000..60872c62
--- /dev/null
+++ b/src/app/components/GoalMeter.svelte
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+ {progress.raised.toLocaleString()}
+
+
+ of {progress.target.toLocaleString()} sats
+
+
+
+ {progress.percent}%
+
+
+
+
+
+ {#each [25, 50, 75] as tick (tick)}
+
+ {/each}
+
+
diff --git a/src/app/components/GoalStatus.svelte b/src/app/components/GoalStatus.svelte
new file mode 100644
index 00000000..187f0d81
--- /dev/null
+++ b/src/app/components/GoalStatus.svelte
@@ -0,0 +1,42 @@
+
+
+{#if progress.isFunded}
+
+
+ Funded
+
+{:else if progress.isEnded}
+
+
+ Ended
+
+{:else if progress.closedAt}
+
+
+ Ends {formatTimestampRelative(progress.closedAt)}
+
+{:else}
+
+
+ Fundraising
+
+{/if}
diff --git a/src/app/components/GoalSummary.svelte b/src/app/components/GoalSummary.svelte
index 94123b58..0fbc3ee7 100644
--- a/src/app/components/GoalSummary.svelte
+++ b/src/app/components/GoalSummary.svelte
@@ -1,47 +1,54 @@
-
-
-
-
{fromMsats($progress.amount)} sats
-
funded of {fromMsats($progress.target)} sats
-
-
-
{$progress.contributors.length}
-
- {$progress.contributors.length === 1 ? "contributor" : "contributors"}
-
-
-
-
{daysOld}
-
{daysOld === 1 ? "day" : "days"} old
+
+
+
+
+ {#if backers > 0}
+
+ {/if}
+
+ {#if backers === 0}
+ No contributions yet
+ {:else}
+ {backers}
+ {backers === 1 ? "backer" : "backers"}
+ {/if}
+
+
-
-
-
- Contribute to this goal
-
+ {#if ENABLE_ZAPS && !$progress.isEnded}
+
+
+ {$progress.isFunded ? "Chip in anyway" : "Contribute"}
+
+ {/if}
diff --git a/src/app/components/GoalSupporters.svelte b/src/app/components/GoalSupporters.svelte
new file mode 100644
index 00000000..0c06ee4e
--- /dev/null
+++ b/src/app/components/GoalSupporters.svelte
@@ -0,0 +1,111 @@
+
+
+
+
+
Supporters
+ {#if supporters.length > 0}
+
+
+ {progress.raised.toLocaleString()} sats from {supporters.length}
+
+ {/if}
+
+ {#if supporters.length > 0}
+
+ {#each visible as supporter, rank (supporter.pubkey)}
+
+
+
+ {#if rank === 0}
+
+ {:else}
+ {rank + 1}
+ {/if}
+
+
+
+
+
+
+
+ {formatTimestampRelative(supporter.zappedAt)}
+
+
+
+ {supporter.amount.toLocaleString()}
+
+
+
+ {#if supporter.comment}
+
{supporter.comment}
+ {/if}
+
+ {/each}
+
+ {#if supporters.length > visible.length}
+
+ Show all {supporters.length} supporters
+
+ {/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}
{$reports.length}
{/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)}
-
-
- {amount}
-
- {/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)}
+
+
+ {amount}
+
+ {/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}>
-
+
removeItem(index)}>
{/each}
-
-
-
-
Drag and drop images or click to select
-
-
+ {#if multiple || value.length === 0}
+
+
+
+
+ Drag and drop {multiple ? "images" : "an image"} or click to select
+
+
+
+ {/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}
-
-
-
- Show all {$replies.length} replies
-
-
- {/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}
-
-
-
- Comment on this goal
-
+
+
+
+
+
{$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)}