diff --git a/e2e/specs/community.spec.ts b/e2e/specs/community.spec.ts index aca8ef01..7714a201 100644 --- a/e2e/specs/community.spec.ts +++ b/e2e/specs/community.spec.ts @@ -685,7 +685,8 @@ test("US-051 post, edit, and close out a classified listing", async ({seed, as}) const listing = page.getByRole("link").filter({hasText: "Vintage Road Bike"}) await expect(listing).toBeVisible() - await expect(listing).toContainText("1200") + // The card groups thousands, and which separator depends on the browser locale. + await expect(listing).toContainText(/1[,.\s]?200/) await expect(listing).toContainText("#bicycles") await expect(listing.locator('img[src^="https://space.test/"]')).toBeVisible() diff --git a/src/app/classifieds.ts b/src/app/classifieds.ts new file mode 100644 index 00000000..07841ed8 --- /dev/null +++ b/src/app/classifieds.ts @@ -0,0 +1,74 @@ +import {max, partition, pushToMapKey, sortBy, spec} from "@welshman/lib" +import type {TrustedEvent} from "@welshman/util" +import {CLASSIFIED, tagSpec, tagValue} from "@welshman/util" +import {Classified} from "@welshman/domain" +import {normalizeTopic} from "@lib/util" +import {reader} from "@app/core" + +export const CLASSIFIED_STATUS_TABS = [ + {value: "all", label: "All"}, + {value: "active", label: "Active"}, + {value: "sold", label: "Sold"}, +] + +export const SUGGESTED_TOPICS = ["for sale", "wanted", "job", "service", "free"] + +// Splits a feed into listings and their comments, scoring each listing by last activity. +export const partitionListings = (events: TrustedEvent[]) => { + const scores = new Map() + const [listings, comments] = partition(spec({kind: CLASSIFIED}), events) + + for (const comment of comments) { + const id = tagValue(tagSpec("E"), comment.tags) + + if (id) { + pushToMapKey(scores, id, comment.created_at) + } + } + + const activeAt = new Map() + + for (const listing of listings) { + activeAt.set(listing.id, max([...(scores.get(listing.id) || []), listing.created_at])) + } + + return {listings, activeAt} +} + +export const deriveTopicCounts = (events: TrustedEvent[]) => { + const counts = new Map() + + for (const event of events) { + for (const topic of reader(Classified)(event).topics() ?? []) { + const normalized = normalizeTopic(topic) + + counts.set(normalized, (counts.get(normalized) || 0) + 1) + } + } + + return sortBy(([, count]) => -count, Array.from(counts.entries())) +} + +export const getStatus = (event: TrustedEvent) => reader(Classified)(event).status() ?? "active" + +export const matchesTopic = (event: TrustedEvent, topic: string) => + !topic || + Boolean( + reader(Classified)(event) + .topics() + ?.some(t => normalizeTopic(t) === topic), + ) + +export const matchesQuery = (event: TrustedEvent, query: string) => { + const q = query.trim().toLowerCase() + + if (!q) return true + + const classified = reader(Classified)(event) + const haystack = [classified.title(), classified.summary(), event.content] + .filter(Boolean) + .join(" ") + .toLowerCase() + + return haystack.includes(q) +} diff --git a/src/app/components/ClassifiedActions.svelte b/src/app/components/ClassifiedActions.svelte index f917cd5b..84db9948 100644 --- a/src/app/components/ClassifiedActions.svelte +++ b/src/app/components/ClassifiedActions.svelte @@ -2,6 +2,7 @@ import {uniq} from "@welshman/lib" import type {TrustedEvent, EventContent} from "@welshman/util" import {Classified} from "@welshman/domain" + import {goto} from "$app/navigation" import Pen2 from "@assets/icons/pen-2.svg?dataurl" import {normalizeTopic} from "@lib/util" import Link from "@lib/components/Link.svelte" @@ -17,7 +18,7 @@ import EventActions from "@app/components/EventActions.svelte" import ClassifiedEdit from "@app/components/ClassifiedEdit.svelte" import {reader, user} from "@app/core" - import {makeSpacePath} from "@app/routes" + import {makeClassifiedPath, makeSpacePath} from "@app/routes" import {pushModal} from "@app/modal" type Props = { @@ -26,9 +27,20 @@ showRoom?: boolean showActivity?: boolean context: FeedContext + // The grid card lays these out itself, so it opts out of both. + showTopics?: boolean + showStatus?: boolean } - const {url, event, showRoom, showActivity, context}: Props = $props() + const { + url, + event, + showRoom, + showActivity, + context, + showTopics = true, + showStatus = true, + }: Props = $props() // Editing a listing hands this a new version of the event, so every value read off it has to be // recomputed rather than captured when the component was created. @@ -38,27 +50,43 @@ const editClassified = () => pushModal(ClassifiedEdit, {url, event}) + const filterByTopic = (topic: string) => (e: MouseEvent) => { + e.stopPropagation() + e.preventDefault() + goto(`${makeClassifiedPath(url)}?topic=${encodeURIComponent(normalizeTopic(topic))}`) + } + const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h}) const createReaction = (values: EventContent) => publishReaction(event, values, {url, h}) -
- {#if h && showRoom} - - Posted in # - +
+ {#if (h && showRoom) || (showTopics && uniq(topics).length > 0)} +
+ {#if h && showRoom} + + Posted in # + + {/if} + {#if showTopics} + {#each uniq(topics) as topic (topic)} + + {/each} + {/if} +
+ {/if} -
- {#each uniq(topics) as topic (topic)} - - {/each} -
{#snippet status()} - + {#if showStatus} + + {/if} {/snippet} import type {Snippet} from "svelte" - import {removeUndefined, randomId, uniq} from "@welshman/lib" + import cx from "classnames" + import {removeUndefined, randomId, uniq, toggle} from "@welshman/lib" import {relay} from "@welshman/util" import {publish} from "@welshman/app" import {Classified} from "@welshman/domain" import {isMobile, preventDefault} from "@lib/html" import {normalizeTopic} from "@lib/util" + import {SUGGESTED_TOPICS} from "@app/classifieds" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import Icon from "@lib/components/Icon.svelte" import Field from "@lib/components/Field.svelte" @@ -148,6 +150,10 @@ content = json } + const toggleTopic = (topic: string) => () => { + topics = toggle(topic, topics) + } + const editor = makeEditor({url, submit, onChange, content}) $effect(() => { @@ -187,10 +193,25 @@ {#snippet label()} -

Topics

+

Category

{/snippet} {#snippet input()} - +
+
+ {#each SUGGESTED_TOPICS as topic (topic)} + + {/each} +
+ +
{/snippet}
@@ -198,7 +219,7 @@

Price*

{/snippet} {#snippet input()} -
+
diff --git a/src/app/components/ClassifiedItem.svelte b/src/app/components/ClassifiedItem.svelte index d8ba7a8f..504e25a3 100644 --- a/src/app/components/ClassifiedItem.svelte +++ b/src/app/components/ClassifiedItem.svelte @@ -1,18 +1,20 @@ - - - {#if title} -
-

- {title} — - {price?.amount ?? 0} -

-

- {formatTimestamp(event.created_at)} -

-
- {:else} - - {/if} - -
- {#each images as image (image)} - - {/each} + +
+ + {#if cover} + + {:else} +
+ +
+ {/if} + {#if isSold} + Sold + {/if} + {#if extraImages.length > 0} + + + {images.length} + + {/if}
-
- - Posted by - - {#if h} - in - {/if} +
+

{title}

+ {#if hasPrice} +

+ {price?.amount.toLocaleString()} +

+ {/if} + {#if summary} +

{summary}

+ {/if} + {#if topics.length > 0} +
+ {#each topics.slice(0, 2) as topic (topic)} + #{topic} + {/each} + {#if topics.length > 2} + +{topics.length - 2} + {/if} +
+ {/if} +
+
+ + + + + {formatTimestampRelative(event.created_at)} - +
diff --git a/src/app/components/ClassifiedStatus.svelte b/src/app/components/ClassifiedStatus.svelte index 316ffbcc..1369d6bc 100644 --- a/src/app/components/ClassifiedStatus.svelte +++ b/src/app/components/ClassifiedStatus.svelte @@ -15,7 +15,7 @@ {#if status} - + {ucFirst(status)} {/if} diff --git a/src/app/components/TopicMultiSelect.svelte b/src/app/components/TopicMultiSelect.svelte index 6a0156bc..4d032fa7 100644 --- a/src/app/components/TopicMultiSelect.svelte +++ b/src/app/components/TopicMultiSelect.svelte @@ -84,7 +84,7 @@ {#each value as topic (topic)}
#{topic}
diff --git a/src/lib/components/CurrencySymbol.svelte b/src/lib/components/CurrencySymbol.svelte index 39476dd0..8f9eb8ee 100644 --- a/src/lib/components/CurrencySymbol.svelte +++ b/src/lib/components/CurrencySymbol.svelte @@ -1,5 +1,5 @@ @@ -7,5 +7,5 @@ {#if code.match(/^sats?$/i)} ! {:else} - {currencyOptions.find(c => c.code)?.symbol || code} + {getCurrencyOption(code).symbol} {/if} diff --git a/src/lib/components/ImagesInput.svelte b/src/lib/components/ImagesInput.svelte index 89fe74d6..467f93e3 100644 --- a/src/lib/components/ImagesInput.svelte +++ b/src/lib/components/ImagesInput.svelte @@ -103,9 +103,9 @@ alt="Upload preview" class="h-full w-full object-cover rounded-2xl" />
{/each} diff --git a/src/lib/components/card.css b/src/lib/components/card.css index be53bb92..7876d0b2 100644 --- a/src/lib/components/card.css +++ b/src/lib/components/card.css @@ -26,6 +26,11 @@ box-shadow: none; } + /* content reaches the card's edges; padding moves to its inner sections */ + &.card-media { + @apply overflow-hidden p-0; + } + &.card-primary { --accent: var(--primary); } diff --git a/src/routes/spaces/[relay]/classifieds/+page.svelte b/src/routes/spaces/[relay]/classifieds/+page.svelte index a693c29a..2068a2f4 100644 --- a/src/routes/spaces/[relay]/classifieds/+page.svelte +++ b/src/routes/spaces/[relay]/classifieds/+page.svelte @@ -2,14 +2,18 @@ import {onDestroy, onMount} from "svelte" import {readable} from "svelte/store" import type {Readable} from "svelte/store" + import cx from "classnames" + import {goto} from "$app/navigation" import {page} from "$app/stores" - import {sortBy, partition, spec, max, pushToMapKey} from "@welshman/lib" + import {sortBy} from "@welshman/lib" import type {Maybe} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util" - import {CLASSIFIED, tagSpec, tagValue} from "@welshman/util" + import {CLASSIFIED} from "@welshman/util" + import {Classified} from "@welshman/domain" import {fly} from "@lib/transition" import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl" import Add from "@assets/icons/add.svg?dataurl" + import Magnifier from "@assets/icons/magnifier.svg?dataurl" import Icon from "@lib/components/Icon.svelte" import Button from "@lib/components/Button.svelte" import PageContent from "@lib/components/PageContent.svelte" @@ -20,6 +24,16 @@ import {decodeRelay} from "@app/relays" import {makeCommentFilter} from "@app/content" import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds" + import {makeClassifiedPath} from "@app/routes" + import { + CLASSIFIED_STATUS_TABS, + partitionListings, + deriveTopicCounts, + getStatus, + matchesTopic, + matchesQuery, + } from "@app/classifieds" + import {reader} from "@app/core" import {pushModal} from "@app/modal" const url = decodeRelay($page.params.relay!) @@ -27,28 +41,65 @@ onDestroy(context.cleanup) + const createClassified = () => pushModal(ClassifiedCreate, {url}) + + const setTopic = (value?: string) => () => { + const path = makeClassifiedPath(url) + (value ? `?topic=${encodeURIComponent(value)}` : "") + + goto(path, {replaceState: true, noScroll: true}) + } + + const setTab = (value: string) => () => { + tab = value + } + let older: Maybe> = $state() const loading = $derived(isFeedLoading($older)) const exhausted = $derived($older?.status === "exhausted") + + let tab = $state("all") + let sort = $state("active") + let query = $state("") let element: HTMLElement | undefined = $state() let events: Readable = $state(readable([])) - const createClassified = () => pushModal(ClassifiedCreate, {url}) + const topic = $derived($page.url.searchParams.get("topic") ?? "") - const items = $derived.by(() => { - const scores = new Map() - const [goals, comments] = partition(spec({kind: CLASSIFIED}), $events) + const {listings, activeAt} = $derived(partitionListings($events)) + const topicCounts = $derived(deriveTopicCounts(listings)) - for (const comment of comments) { - const id = tagValue(tagSpec("E"), comment.tags) + // Narrowed by everything except the status tab, so its counts match what it shows. + const scoped = $derived( + listings.filter(event => matchesQuery(event, query) && matchesTopic(event, topic)), + ) - if (id) { - pushToMapKey(scores, id, comment.created_at) - } + const counts = $derived.by(() => { + const result: Record = {all: scoped.length, active: 0, sold: 0} + + for (const event of scoped) { + const status = getStatus(event) + + result[status] = (result[status] ?? 0) + 1 } - return sortBy(e => -max([...(scores.get(e.id) || []), e.created_at]), goals) + return result + }) + + const items = $derived.by(() => { + const matches = scoped.filter(event => tab === "all" || getStatus(event) === tab) + + if (sort === "new") return sortBy(event => -event.created_at, matches) + + if (sort === "price-asc") { + return sortBy(event => reader(Classified)(event).price()?.amount ?? Infinity, matches) + } + + if (sort === "price-desc") { + return sortBy(event => -(reader(Classified)(event).price()?.amount ?? -1), matches) + } + + return sortBy(event => -(activeAt.get(event.id) ?? event.created_at), matches) }) onMount(() => { @@ -86,21 +137,82 @@ {/snippet} - - {#each items as event (event.id)} -
- + + {#if listings.length > 0} +
+ +
- {/each} -

- - {#if loading} - Looking for listings... - {:else if exhausted && items.length === 0} - No classified listings found. - {:else if exhausted} - That's all! + +

+ {#each CLASSIFIED_STATUS_TABS as { value, label } (value)} + + {/each} + {#if topicCounts.length > 0} + + {#each topicCounts as [value, count] (value)} + + {/each} {/if} - -

+
+ {/if} +
+ {#each items as event (event.id)} +
+ +
+ {/each} +
+ {#if exhausted && listings.length === 0} +
+ +

No listings yet

+

+ Classifieds let this space buy, sell, and find things together. +

+ +
+ {:else} +

+ + {#if loading} + Looking for listings... + {:else if exhausted && items.length === 0} + No listings match your filters. + {:else if exhausted} + That's all! + {/if} + +

+ {/if}
diff --git a/src/routes/spaces/[relay]/classifieds/[address]/+page.svelte b/src/routes/spaces/[relay]/classifieds/[address]/+page.svelte index 6f9ed7fe..6c0669c4 100644 --- a/src/routes/spaces/[relay]/classifieds/[address]/+page.svelte +++ b/src/routes/spaces/[relay]/classifieds/[address]/+page.svelte @@ -4,23 +4,16 @@ import {page} from "$app/stores" import {sleep} from "@welshman/lib" import type {MakeNonOptional} from "@welshman/lib" - import {getCommentFiltersForRoot} from "@welshman/util" - import {deriveEventsAsc} from "@welshman/store" import {Classified} from "@welshman/domain" - import SortVertical from "@assets/icons/sort-vertical.svg?dataurl" - import Reply from "@assets/icons/reply-2.svg?dataurl" - import Icon from "@lib/components/Icon.svelte" import PageContent from "@lib/components/PageContent.svelte" import Spinner from "@lib/components/Spinner.svelte" - import Button from "@lib/components/Button.svelte" import SpaceBar from "@app/components/SpaceBar.svelte" import NoteContent from "@app/components/NoteContent.svelte" import NoteCard from "@app/components/NoteCard.svelte" import ClassifiedActions from "@app/components/ClassifiedActions.svelte" - import CommentActions from "@app/components/CommentActions.svelte" - import EventReply from "@app/components/EventReply.svelte" - import {network, reader} from "@app/core" - import {deriveEvent, deriveEventsById} from "@app/repository" + import EventComments from "@app/components/EventComments.svelte" + import {reader} from "@app/core" + import {deriveEvent} from "@app/repository" import {makeFeedContext} from "@app/feeds" import {decodeRelay} from "@app/relays" @@ -29,37 +22,10 @@ const context = makeFeedContext({relays: [url]}) const event = deriveEvent(address, [url]) const classified = derived(event, $event => ($event ? reader(Classified)($event) : undefined)) - const filters = $derived($event ? getCommentFiltersForRoot([$event]) : []) - const replies = $derived(deriveEventsAsc(deriveEventsById(filters))) const back = () => history.back() - const openReply = () => { - showReply = true - } - - const closeReply = () => { - showReply = false - } - - const expand = () => { - showAll = true - } - onDestroy(context.cleanup) - - let showAll = $state(false) - let showReply = $state(false) - - $effect(() => { - if (filters.length > 0) { - const controller = new AbortController() - - $network.request({relays: [url], filters, signal: controller.signal}) - - return () => controller.abort() - } - }) @@ -70,40 +36,13 @@ {#if $event} -
- -
- - -
-
- {#if !showAll && $replies.length > 4} -
- -
- {/if} - {#each $replies.slice(0, showAll ? undefined : 4) as reply (reply.id)} - -
- - -
-
- {/each} -
- {#if showReply} - - {:else} -
- + +
+ +
- {/if} +
+ {:else}
{#await sleep(5000)}