CrowdPay currently has no discovery layer — contributors must know the direct URL of a campaign to reach it. There is no search, no trending page, no personalisation, and no way for campaigns to surface organically on third-party sites. A crowdfunding platform lives and dies by discovery. This issue implements the complete discovery stack: a full-text and semantic search engine over campaign metadata, a personalised campaign feed based on contribution history and asset preferences, a trending algorithm that balances recent velocity with absolute funding progress, and an embeddable discovery widget that any website can drop in to surface live CrowdPay campaigns matching a topic or asset filter.
-
Full-text search index: on campaign creation and update, write { id, title, description, category, tags, targetAsset, goalAmountUsd, totalRaisedUsd, status, creatorPublicKey, createdAt } to a PostgreSQL campaigns_search table using tsvector with setweight ranking (title = A, tags = B, description = C)
-
GET /api/campaigns/search?q=<query>&asset=<asset>&category=<category>&status=active|completed|all&minGoal=<n>&maxGoal=<n>&page=<n>&limit=<n>:
- Executes a
to_tsquery query against the campaigns_search index
- Applies asset, category, status, and goal range filters
- Returns campaigns ranked by
ts_rank_cd (text relevance) × funding_recency_score (higher score for campaigns with recent contributions)
- Also returns
facets: { byAsset: [{ asset, count }], byCategory: [{ category, count }], byStatus: [{ status, count }] } — computed in the same query for fast faceted navigation
-
GET /api/campaigns/trending:
- Trending score formula per campaign:
(contributions_last_24h / max_contributions_24h * 40) + (percent_funded * 30) + (unique_contributors_last_7d / max_contributors_7d * 20) + (days_until_deadline_score * 10)
days_until_deadline_score: campaigns with 1–7 days remaining score highest (urgency boost); campaigns with > 30 days remaining score lowest
- Returns top 20 campaigns by trending score; recomputed every 15 minutes via a BullMQ job and cached in Redis
-
GET /api/campaigns/personalised — authenticated; returns a feed personalised to the contributor:
- Categories of campaigns the contributor has previously funded (weighted by amount)
- Assets the contributor has contributed in (weighted by frequency)
- Geographic proximity inferred from federation addresses in the contributor's history
- Excludes campaigns the contributor has already funded and campaigns that do not meet the contributor's identity requirements
- Falls back to trending campaigns for unauthenticated or new users with no history
backend/src/services/searchIndex.js — Index maintenance
-
BullMQ job SearchIndexJob triggered on: campaign creation, campaign update (title/description/tags change), contribution landed (updates totalRaisedUsd and percentFunded), campaign status change
-
Full re-index job runs nightly: truncates campaigns_search and rebuilds from campaigns table — ensures no stale data
-
EXPLAIN ANALYZE query plan logged for any search query that takes > 200ms — used to detect index degradation
backend/src/routes/embed.js — Discovery widget API
-
GET /api/embed/discover?topic=<topic>&asset=<asset>&limit=<n>&embedToken=<token> — public endpoint validated by embed token:
- Runs the search query with the provided topic and asset filter
- Returns a stripped response:
{ campaigns: [{ id, title, description_truncated, goalAmountUsd, totalRaisedUsd, percentFunded, daysRemaining, asset, status, shareUrl }] } — no internal IDs, no creator email
- Rate limited: 100 requests per hour per embed token
frontend/src/pages/Discover.jsx — Discovery page
-
Hero search bar: full-text input with live suggestions (debounced 300ms; calls search API with limit=5 for autocomplete)
-
Filters sidebar:
- Asset filter: multi-select (XLM, USDC, EURC, other)
- Category filter: multi-select (generated from the facets response)
- Goal range: dual-handle slider ($0 – $100,000+)
- Status: Active / Completed / All
- "Clear filters" button
-
Campaign grid: responsive 3-column layout; each campaign card shows title, description excerpt, progress bar, days remaining, asset badge, funding percentage
-
Trending carousel above the grid: horizontal scroll of top 10 trending campaigns with animated funding bars
-
Personalised feed tab (authenticated users): "For You" tab showing the personalised feed with a subtle "Based on your contribution history" label
-
Infinite scroll pagination — appends next page of results as the user scrolls to the bottom
frontend/src/embed/discover-widget.js — Embeddable discovery widget
-
Standalone < 3KB JS bundle distributed via CDN; pasted by third parties as: <script src="https://cdn.crowdpay.com/discover.js" data-token="<embedToken>" data-topic="education" data-asset="USDC" data-limit="3"></script>
-
Renders an iframe pointing to /embed/discover-widget.html populated by the discovery API
-
Widget renders 1–5 campaign cards (configurable via data-limit); each card is fully styled, shows progress bar, and links to the campaign's CrowdPay page on click
-
postMessage events: CROWDPAY_WIDGET_READY, CROWDPAY_CAMPAIGN_CLICKED (with campaign ID)
Database migrations
-
campaigns_search: campaign_id (PK), search_vector (tsvector), title, description, category, tags (text array), target_asset, goal_amount_usd (numeric), total_raised_usd (numeric), percent_funded (numeric), status, contributions_last_24h (int), unique_contributors_last_7d (int), trending_score (numeric), last_indexed_at
-
GIN index on search_vector
-
Index on (trending_score DESC, last_indexed_at DESC) for trending queries
-
campaign_categories: id, name, slug — seeded with 12 categories (Technology, Art, Community, Health, Education, Environment, Food, Music, Film, Gaming, Sports, Other)
-
Alter campaigns: add category_id (FK to campaign_categories), tags (text array)
Overview
CrowdPay currently has no discovery layer — contributors must know the direct URL of a campaign to reach it. There is no search, no trending page, no personalisation, and no way for campaigns to surface organically on third-party sites. A crowdfunding platform lives and dies by discovery. This issue implements the complete discovery stack: a full-text and semantic search engine over campaign metadata, a personalised campaign feed based on contribution history and asset preferences, a trending algorithm that balances recent velocity with absolute funding progress, and an embeddable discovery widget that any website can drop in to surface live CrowdPay campaigns matching a topic or asset filter.
What needs to be built
backend/src/services/search.js— Search and indexing engineFull-text search index: on campaign creation and update, write
{ id, title, description, category, tags, targetAsset, goalAmountUsd, totalRaisedUsd, status, creatorPublicKey, createdAt }to a PostgreSQLcampaigns_searchtable usingtsvectorwithsetweightranking (title = A, tags = B, description = C)GET /api/campaigns/search?q=<query>&asset=<asset>&category=<category>&status=active|completed|all&minGoal=<n>&maxGoal=<n>&page=<n>&limit=<n>:to_tsqueryquery against thecampaigns_searchindexts_rank_cd(text relevance) ×funding_recency_score(higher score for campaigns with recent contributions)facets:{ byAsset: [{ asset, count }], byCategory: [{ category, count }], byStatus: [{ status, count }] }— computed in the same query for fast faceted navigationGET /api/campaigns/trending:(contributions_last_24h / max_contributions_24h * 40) + (percent_funded * 30) + (unique_contributors_last_7d / max_contributors_7d * 20) + (days_until_deadline_score * 10)days_until_deadline_score: campaigns with 1–7 days remaining score highest (urgency boost); campaigns with > 30 days remaining score lowestGET /api/campaigns/personalised— authenticated; returns a feed personalised to the contributor:backend/src/services/searchIndex.js— Index maintenanceBullMQ job
SearchIndexJobtriggered on: campaign creation, campaign update (title/description/tags change), contribution landed (updatestotalRaisedUsdandpercentFunded), campaign status changeFull re-index job runs nightly: truncates
campaigns_searchand rebuilds fromcampaignstable — ensures no stale dataEXPLAIN ANALYZEquery plan logged for any search query that takes > 200ms — used to detect index degradationbackend/src/routes/embed.js— Discovery widget APIGET /api/embed/discover?topic=<topic>&asset=<asset>&limit=<n>&embedToken=<token>— public endpoint validated by embed token:{ campaigns: [{ id, title, description_truncated, goalAmountUsd, totalRaisedUsd, percentFunded, daysRemaining, asset, status, shareUrl }] }— no internal IDs, no creator emailfrontend/src/pages/Discover.jsx— Discovery pageHero search bar: full-text input with live suggestions (debounced 300ms; calls search API with
limit=5for autocomplete)Filters sidebar:
Campaign grid: responsive 3-column layout; each campaign card shows title, description excerpt, progress bar, days remaining, asset badge, funding percentage
Trending carousel above the grid: horizontal scroll of top 10 trending campaigns with animated funding bars
Personalised feed tab (authenticated users): "For You" tab showing the personalised feed with a subtle "Based on your contribution history" label
Infinite scroll pagination — appends next page of results as the user scrolls to the bottom
frontend/src/embed/discover-widget.js— Embeddable discovery widgetStandalone < 3KB JS bundle distributed via CDN; pasted by third parties as:
<script src="https://cdn.crowdpay.com/discover.js" data-token="<embedToken>" data-topic="education" data-asset="USDC" data-limit="3"></script>Renders an iframe pointing to
/embed/discover-widget.htmlpopulated by the discovery APIWidget renders 1–5 campaign cards (configurable via
data-limit); each card is fully styled, shows progress bar, and links to the campaign's CrowdPay page on clickpostMessageevents:CROWDPAY_WIDGET_READY,CROWDPAY_CAMPAIGN_CLICKED(with campaign ID)Database migrations
campaigns_search:campaign_id(PK),search_vector(tsvector),title,description,category,tags(text array),target_asset,goal_amount_usd(numeric),total_raised_usd(numeric),percent_funded(numeric),status,contributions_last_24h(int),unique_contributors_last_7d(int),trending_score(numeric),last_indexed_atGIN index on
search_vectorIndex on
(trending_score DESC, last_indexed_at DESC)for trending queriescampaign_categories:id,name,slug— seeded with 12 categories (Technology, Art, Community, Health, Education, Environment, Food, Music, Film, Gaming, Sports, Other)Alter
campaigns: addcategory_id(FK tocampaign_categories),tags(text array)Acceptance criteria
campaignsarray and accuratefacetsshowing zero counts — not a 404 or 500EXPLAIN ANALYZEon a full-text search query confirms the GIN index is used — no sequential scan on thecampaignstable