@profullstack/builtindie 0.1.4 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/html.ts CHANGED
@@ -5,13 +5,26 @@
5
5
  */
6
6
  import { createHash } from 'node:crypto'
7
7
  import { readFileSync } from 'node:fs'
8
- import type { Maker, Product, User, Window } from './db.js'
8
+ import type { InviteStats, Maker, Product, Review, User, Window } from './db.js'
9
+ import type { KarmaSummary } from './karma.js'
10
+ import { categorize, CATEGORY_LABELS, FACETS, isEmpty, queryString, SORTS, toggle, type Query, type Result } from './browse.js'
11
+ import { pitFooter } from './pit-footer.js'
9
12
  import { RING_PLACEHOLDER } from './ring.js'
10
- import { CHAIN_LABELS } from './service.js'
13
+ import { ringFooter, ringFooterOptions, setRingFooterOptions } from './ring-footer.js'
11
14
  import { icon, SOCIAL_ICONS } from './icons.js'
15
+ import { COLUMNS as PROFILE_COLUMNS, renderProfileFields } from '@profullstack/profile'
12
16
 
13
17
  export const REPO = 'https://github.com/moshcoder/built.indie'
14
18
 
19
+ // The footer's own links; the ring nav and copyright come from @profullstack/footer.
20
+ setRingFooterOptions(
21
+ ringFooterOptions([
22
+ { label: 'API · CLI · MCP', href: '/help' },
23
+ { label: 'RSS', href: '/feed.xml' },
24
+ { label: 'Source', href: REPO },
25
+ ]),
26
+ )
27
+
15
28
  /** crawlproof analytics on every page (project 71b194a4…); CRAWLPROOF_SITE=off turns it off. */
16
29
  const TRACK_SITE = process.env.CRAWLPROOF_SITE ?? '71b194a4-9ae7-4b79-9ec1-0cf0d5ef203e'
17
30
  const TRACKER = TRACK_SITE === 'off' ? '' : `<script data-site="${TRACK_SITE}" src="https://crawlproof.com/stats.js" async></script>`
@@ -22,8 +35,8 @@ const TRACKER = TRACK_SITE === 'off' ? '' : `<script data-site="${TRACK_SITE}" s
22
35
  * CRAWLPROOF_AD_SLOT overrides the slot; 'off' turns ads off.
23
36
  */
24
37
  export const AD_SLOT = process.env.CRAWLPROOF_AD_SLOT ?? '7371269e-a3c9-4a84-8b22-00d5045068e7'
25
- export const adUnit = (format: 'banner_300x250' | 'banner_728x90' | 'text_link') =>
26
- AD_SLOT === 'off' ? '' : `<div class="ad-unit ad-${format}"><iframe src="https://crawlproof.com/api/ads/frame?slot=${encodeURIComponent(AD_SLOT)}&amp;format=${format}" title="Advertisement" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation" scrolling="no"></iframe></div>`
38
+ export const adUnit = () =>
39
+ AD_SLOT === 'off' ? '' : `<div class="ad-unit"><iframe src="https://crawlproof.com/api/ads/frame?slot=${encodeURIComponent(AD_SLOT)}&amp;format=text_link" title="Advertisement" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation" scrolling="no"></iframe></div>`
27
40
 
28
41
  const asset = (name: string) => {
29
42
  const body = readFileSync(new URL(`../public/${name}`, import.meta.url))
@@ -36,11 +49,14 @@ const ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"
36
49
  export const esc = (value: unknown) => String(value ?? '').replace(/[&<>"']/g, (c) => ESC[c]!)
37
50
  const enc = encodeURIComponent
38
51
 
52
+ /** The "Sign in with CoinPay" button: a plain link into the OAuth flow. */
53
+ const coinpayButton = (next: string | null, label = 'Sign in with CoinPay') => `<a class="btn ghost wide coinpay-btn" href="/api/v1/coinpay/login${next ? `?next=${enc(next)}` : ''}">${icon('dollar')} ${esc(label)}</a>`
54
+
39
55
  const LOGO = `<svg class="mark" viewBox="0 0 32 32" aria-hidden="true"><path d="M16 4 5 17h7v11h8V17h7z" fill="currentColor"/></svg>`
40
56
 
41
- export type Ctx = { origin: string; user?: User | null; voted?: (id: string) => boolean }
57
+ export type Ctx = { origin: string; user?: User | null; voted?: (id: string) => boolean; /** Sign in with CoinPay is on. */ coinpay?: boolean }
42
58
 
43
- type LayoutOptions = { title: string; description: string; path: string; body: string; ctx: Ctx; noindex?: boolean; image?: string | null; head?: string; ads?: boolean }
59
+ type LayoutOptions = { title: string; description: string; path: string; body: string; ctx: Ctx; noindex?: boolean; image?: string | null; head?: string; ads?: boolean; wide?: boolean; q?: string }
44
60
 
45
61
  function layout(o: LayoutOptions): string {
46
62
  const url = o.ctx.origin + o.path
@@ -75,25 +91,32 @@ ${TRACKER}
75
91
  </head>
76
92
  <body>
77
93
  ${RING_PLACEHOLDER}
78
- <header class="top">
94
+ <header class="top${o.wide ? ' wide' : ''}">
79
95
  <a class="brand" href="/">${LOGO}<span>built<b>.</b>indie</span></a>
96
+ <form class="top-search" role="search" method="get" action="/search" data-suggest>
97
+ <label class="sr" for="site-q">Search launches</label>
98
+ <input id="site-q" name="q" type="search" placeholder="Search apps, tags, makers…" value="${esc(o.q ?? '')}" autocomplete="off" maxlength="80" enterkeyhint="search" aria-autocomplete="list" aria-controls="site-suggest"><kbd aria-hidden="true">/</kbd>
99
+ <div class="suggest" id="site-suggest" role="listbox" hidden></div>
100
+ </form>
80
101
  <nav aria-label="Site">
102
+ <a href="/search">Browse</a>
81
103
  <a href="/top/today">Today</a>
82
104
  <a href="/top/week">Week</a>
83
105
  <a href="/new">New</a>
106
+ <a href="/tags">Tags</a>
107
+ <a href="/invite">Invite</a>
108
+ <a href="/leaderboard">Leaderboard</a>
84
109
  <a href="/help">API</a>
85
110
  ${o.ctx.user ? '<a href="/me">Mine</a>' : '<a href="/login">Sign in</a>'}
86
111
  <a class="btn small-btn" href="/submit">${icon('rocket')} Launch</a>
87
112
  </nav>
88
113
  </header>
89
- <main>
114
+ <main${o.wide ? ' class="wide"' : ''}>
115
+ ${o.ads ? adUnit() : ''}
90
116
  ${o.body}
91
117
  </main>
92
- ${o.ads ? adUnit('text_link') : ''}
93
- <footer>
94
- <span>built.indie: launch your indie product with one URL. Free, open source.</span>
95
- <span><a href="/help">API · CLI · MCP</a> · <a href="/feed.xml">RSS</a> · <a href="${REPO}">Source</a> · <a href="https://pit.world/ring">Moshpit ring</a></span>
96
- </footer>
118
+ ${ringFooter()}
119
+ ${pitFooter()}
97
120
  </body>
98
121
  </html>`
99
122
  }
@@ -124,7 +147,7 @@ function row(ctx: Ctx, p: Product, rank?: number): string {
124
147
  <a class="row-main" href="/p/${esc(p.slug)}">
125
148
  ${logoImg(p)}
126
149
  <span class="row-text"><b>${esc(p.name)}</b>${p.verifiedAt ? ' <span class="tick" title="Links back to built.indie">✓</span>' : ''}<span class="muted">${esc(p.tagline || p.domain)}</span>
127
- ${p.tags.length ? `<span class="tags">${p.tags.slice(0, 3).map((t) => `<span class="tag">${esc(t)}</span>`).join('')}</span>` : ''}</span>
150
+ <span class="tags">${p.rating !== null ? `<span class="stars-mini" title="${p.rating} of 5 from ${p.reviews} review${p.reviews === 1 ? '' : 's'}">★ ${p.rating.toFixed(1)} <span class="muted">(${p.reviews})</span></span>` : ''}${categorize(p).slice(0, 1).filter((c) => c !== 'other').map((c) => `<span class="tag cat">${esc(CATEGORY_LABELS[c])}</span>`).join('')}${p.tags.slice(0, 3).map((t) => `<span class="tag">${esc(t)}</span>`).join('')}</span></span>
128
151
  </a>
129
152
  ${voteButton(ctx, p)}
130
153
  </li>`
@@ -141,69 +164,276 @@ function submitForm(compact = false, values: Record<string, string> = {}, error?
141
164
  <label for="makerName">Your name</label><input id="makerName" name="makerName" maxlength="60" value="${esc(values.makerName ?? '')}">
142
165
  <label for="makerProfile">Your OpenProfile.md <span class="muted">URL, e.g. yoursite.com/.well-known/openprofile.md (<a href="https://logicsrc.com/openprofile">what is it?</a>)</span></label><input id="makerProfile" name="makerProfile" type="text" inputmode="url" value="${esc(values.makerProfile ?? '')}"${error?.field === 'makerProfile' ? ' aria-invalid="true"' : ''}>
143
166
  </details>`}
144
- <p class="muted small">Free. We read the homepage: name, tagline, logo, screenshots, videos, socials, <code>llms.txt</code>, and the <a href="https://logicsrc.com/specs">LogicSRC</a> files: <code>opensite.json</code>, <code>openprofile.md</code>, <code>openstack.md</code>, <code>opensaas.json</code>, <code>openicon.json</code>. Anyone can launch any site; the owner claims it with a DNS record.</p>
167
+ <p class="muted small">Free. We read the homepage: name, tagline, logo, screenshots, videos, socials, <code>llms.txt</code>, and the <a href="https://logicsrc.com/specs">LogicSRC</a> files: <code>opensite.json</code>, <code>openprofile.md</code>, <code>openstack.md</code>, <code>opensaas.json</code>, <code>openicon.json</code>. Anyone can launch any site; the owner claims it with the badge or a DNS record.</p>
145
168
  </form>`
146
169
  }
147
170
 
148
- export function homePage(ctx: Ctx, d: { today: Product[]; featured: Product[]; yesterday: Product[]; stats: { products: number; votes: number } }): string {
171
+ /** One inviter on a board: rank, public name, how many of their invitees launched, badges. */
172
+ export type InviterRow = { rank: number | null; id: string; name: string; value: number; badges: { emoji: string; label: string }[]; url: string }
173
+
174
+ function invitersList(rows: InviterRow[]): string {
175
+ return `<ol class="board inviters">${rows
176
+ .map((r) => `<li class="row-item"><span class="rank">${r.rank ?? ''}</span><a class="row-main" href="${esc(r.url)}"><span class="row-text"><b>${esc(r.name)}</b><span class="muted">${r.value} maker${r.value === 1 ? '' : 's'} launched ${r.badges.map((b) => `<span title="${esc(b.label)}">${esc(b.emoji)}</span>`).join('')}</span></span></a></li>`)
177
+ .join('')}</ol>`
178
+ }
179
+
180
+ /** The invite offer, in one sentence, wherever it is promoted. */
181
+ export const inviteOffer = (days: number, usd: number) => `Every maker you invite who launches a listing earns you a free Featured week: ${days} days pinned above today’s board (a $${usd} spot). No cap.`
182
+
183
+ function invitePromo(ctx: Ctx, d: { inviters: InviterRow[]; featureDays: number; featureUsd: number }): string {
184
+ return `<section class="card invite-promo" id="invite">
185
+ <div>
186
+ <h2>${icon('star')} Invite a maker, get Featured free</h2>
187
+ <p class="muted">${esc(inviteOffer(d.featureDays, d.featureUsd))}</p>
188
+ <p class="row"><a class="btn small-btn" href="${ctx.user ? '/invite' : '/login?next=/invite'}">Get your invite link</a><a class="chip" href="/leaderboard">${icon('trophy')} Top inviters</a></p>
189
+ </div>
190
+ <div><h3 class="mini-h">Top inviters this week</h3>${d.inviters.length ? invitersList(d.inviters) : '<p class="muted small">Nobody yet. Invite one maker who launches and you top the board.</p>'}</div>
191
+ </section>`
192
+ }
193
+
194
+ /** Karma rows: rank, public name, karma, badges. */
195
+ function karmaList(rows: InviterRow[]): string {
196
+ return `<ol class="board inviters">${rows
197
+ .map((r) => `<li class="row-item"><span class="rank">${r.rank ?? ''}</span><a class="row-main" href="${esc(r.url)}"><span class="row-text"><b>${esc(r.name)}</b><span class="muted"><span class="karma">${r.value} karma</span> ${r.badges.map((b) => `<span title="${esc(b.label)}">${esc(b.emoji)}</span>`).join('')}</span></span></a></li>`)
198
+ .join('')}</ol>`
199
+ }
200
+
201
+ /** The "how to earn" table, wherever karma is explained. */
202
+ export function howToEarnTable(rows: { action: string; points: number; rule: string }[]): string {
203
+ return `<table class="earn"><thead><tr><th scope="col">Do this</th><th scope="col">Karma</th><th scope="col">Rule</th></tr></thead><tbody>${rows.map((r) => `<tr><td>${esc(r.action)}</td><td class="pts">+${r.points}</td><td class="muted small">${esc(r.rule)}</td></tr>`).join('')}</tbody></table>`
204
+ }
205
+
206
+ function karmaPromo(ctx: Ctx, d: { karma: { period: 'week' | 'all'; rows: InviterRow[] }; featureDays: number }): string {
207
+ return `<section class="card invite-promo karma-promo" id="karma">
208
+ <div>
209
+ <h2>${icon('trophy')} Earn karma, get Featured free</h2>
210
+ <p class="muted">Launch, upvote, review, comment and share to earn karma. Every 500 karma is a free Featured week (${d.featureDays} days pinned on the homepage).</p>
211
+ <p class="row"><a class="btn small-btn" href="${ctx.user ? '/me#karma' : '/login?next=/me'}">${ctx.user ? 'Your karma' : 'Sign in to earn'}</a><a class="chip" href="/leaderboard">${icon('trophy')} Leaderboard</a></p>
212
+ </div>
213
+ <div><h3 class="mini-h">${d.karma.period === 'week' ? 'Top karma this week' : 'Top karma of all time'}</h3>${d.karma.rows.length ? karmaList(d.karma.rows) : `<p class="muted small">Nobody yet. <a href="${ctx.user ? '/top/today' : '/login?next=/top/today'}">Upvote a launch</a> and you top the board.</p>`}</div>
214
+ </section>`
215
+ }
216
+
217
+ export function homePage(ctx: Ctx, d: { today: Product[]; featured: Product[]; yesterday: Product[]; stats: { products: number; votes: number }; inviters: InviterRow[]; karma: { period: 'week' | 'all'; rows: InviterRow[] }; featureDays: number; featureUsd: number }): string {
149
218
  return layout({
150
219
  ctx,
151
220
  title: 'built.indie: launch your indie product with one URL',
152
221
  description: 'Paste a URL. We fetch everything: logo, screenshots, videos, llms.txt. Get upvotes and a badge. Free.',
153
222
  path: '/',
154
- ads: true,
155
223
  body: `<section class="hero">
156
224
  <h1>Built it? Launch it.</h1>
157
225
  <p class="lede">Paste one URL. We pull your logo, screenshots, videos and story from your homepage. People upvote. You get a badge.</p>
158
226
  ${submitForm(true)}
159
227
  <p class="muted small">${d.stats.products} launched · ${d.stats.votes} upvotes · free, no sign-up to launch</p>
160
228
  </section>
229
+ ${karmaPromo(ctx, d)}
230
+ ${invitePromo(ctx, d)}
161
231
  ${d.featured.length ? `<h2 class="section-h">Featured</h2>${board(ctx, d.featured, false)}` : ''}
162
232
  <h2 class="section-h">Launching today <span class="muted small">resets at 00:00 UTC</span></h2>
163
233
  ${board(ctx, d.today) || `<p class="empty">Nothing launched yet today. <a href="/submit">Be first</a>: first launch of the day gets the top spot until someone beats it.</p>`}
164
234
  ${d.yesterday.length ? `<h2 class="section-h">Yesterday’s winners</h2>${board(ctx, d.yesterday.slice(0, 3))}` : ''}
165
- <p class="center"><a href="/top/week">This week</a> · <a href="/top/month">This month</a> · <a href="/top/all">All time</a> · <a href="/new">Newest</a></p>
166
- ${adUnit('banner_728x90')}`,
235
+ <p class="center"><a href="/top/week">This week</a> · <a href="/top/month">This month</a> · <a href="/top/all">All time</a> · <a href="/new">Newest</a></p>`,
167
236
  })
168
237
  }
169
238
 
170
239
  const WINDOW_TITLES: Record<Window, string> = { today: 'Top today', week: 'Top this week', month: 'Top this month', all: 'Top of all time', new: 'Newest launches', featured: 'Featured' }
171
240
 
172
- export function boardPage(ctx: Ctx, window: Window, products: Product[], q?: string): string {
173
- const tabs = (['today', 'week', 'month', 'all', 'new'] as Window[]).map((w) => `<a href="${w === 'new' ? '/new' : `/top/${w}`}"${w === window && !q ? ' aria-current="page"' : ''}>${esc(WINDOW_TITLES[w].replace(/^Top (this )?|^Top of /, '').replace(/^Newest launches$/, 'New'))}</a>`).join('')
241
+ export function boardPage(ctx: Ctx, window: Window, products: Product[]): string {
242
+ const tabs = (['today', 'week', 'month', 'all', 'new'] as Window[]).map((w) => `<a href="${w === 'new' ? '/new' : `/top/${w}`}"${w === window ? ' aria-current="page"' : ''}>${esc(WINDOW_TITLES[w].replace(/^Top (this )?|^Top of /, '').replace(/^Newest launches$/, 'New'))}</a>`).join('')
174
243
  return layout({
175
244
  ctx,
176
- title: q ? `“${q}” on built.indie` : `${WINDOW_TITLES[window]} · built.indie`,
245
+ title: `${WINDOW_TITLES[window]} · built.indie`,
177
246
  description: `${WINDOW_TITLES[window]}: indie products launched on built.indie.`,
178
- path: q ? `/search?q=${enc(q)}` : window === 'new' ? '/new' : `/top/${window}`,
179
- noindex: Boolean(q),
247
+ path: window === 'new' ? '/new' : `/top/${window}`,
248
+ ads: true,
249
+ body: `<h1 class="page-h">${esc(WINDOW_TITLES[window])}</h1>
250
+ <nav class="tabs" aria-label="Boards">${tabs}<a href="/search">Browse &amp; filter</a></nav>
251
+ ${board(ctx, products, window !== 'new') || '<p class="empty">Nothing here yet.</p>'}`,
252
+ })
253
+ }
254
+
255
+ /** Every tag, sized by how many launches carry it. */
256
+ export function tagsPage(ctx: Ctx, tags: { tag: string; count: number }[]): string {
257
+ const max = Math.max(1, ...tags.map((t) => t.count))
258
+ return layout({
259
+ ctx,
260
+ title: 'Tags · built.indie',
261
+ description: 'Browse indie products launched on built.indie by topic.',
262
+ path: '/tags',
263
+ body: `<h1 class="page-h">Tags</h1>
264
+ <p class="muted">Every tag, sized by use. <a href="/search">Browse with filters</a> to combine tags, categories and more.</p>
265
+ ${tags.length ? `<p class="tag-cloud">${tags.map((t) => `<a class="tag" href="/t/${enc(t.tag)}" style="font-size:${(0.8 + (0.7 * t.count) / max).toFixed(2)}rem">${esc(t.tag)} <span class="muted">${t.count}</span></a>`).join('')}</p>` : '<p class="empty">No tags yet.</p>'}`,
266
+ })
267
+ }
268
+
269
+ const searchHref = (q: Query) => {
270
+ const s = queryString(q)
271
+ return s ? `/search?${s}` : '/search'
272
+ }
273
+
274
+ /** The facet rail: every value is a link that switches it on or off, with how many it would show. */
275
+ function facetRail(r: Result): string {
276
+ const groups = FACETS.map((f) => {
277
+ const values = r.facets[f.name]
278
+ if (!values.length) return ''
279
+ const selected = f.name === 'launched' ? (r.query.launched ? [r.query.launched] : []) : r.query[f.name]
280
+ const open = ['category', 'launched', 'has'].includes(f.name) || selected.length > 0
281
+ return `<details class="facet-group"${open ? ' open' : ''}><summary>${esc(f.label)}${selected.length ? ` <span class="facet-n">${selected.length}</span>` : ''}</summary><ul>${values
282
+ .map((v) => {
283
+ const on = selected.includes(v.value)
284
+ return `<li><a class="facet-item${on ? ' on' : ''}" href="${esc(searchHref(toggle(r.query, f.name, v.value)))}" rel="nofollow"${on ? ' aria-current="true"' : ''}><span class="facet-check${f.multi ? '' : ' radio'}" aria-hidden="true"></span><span class="facet-label">${esc(v.label)}</span><span class="facet-count">${v.count}</span></a></li>`
285
+ })
286
+ .join('')}</ul></details>`
287
+ }).join('')
288
+ return groups || '<p class="muted small">Nothing to narrow yet.</p>'
289
+ }
290
+
291
+ function chipsHtml(r: Result): string {
292
+ if (!r.chips.length) return ''
293
+ return `<ul class="chips">${r.chips
294
+ .map((c) => `<li><a class="chip on" href="${esc(searchHref(c.facet === 'q' ? { ...r.query, q: '', sort: r.query.sort === 'relevance' ? 'top' : r.query.sort, page: 1 } : toggle(r.query, c.facet, c.value)))}" rel="nofollow" aria-label="Remove ${esc(c.label)}">${esc(c.label)} <span aria-hidden="true">×</span></a></li>`)
295
+ .join('')}<li><a class="chip clear" href="/search">Clear all</a></li></ul>`
296
+ }
297
+
298
+ function pager(r: Result): string {
299
+ const pages = Math.ceil(r.total / r.limit)
300
+ if (pages <= 1) return ''
301
+ const at = r.query.page
302
+ return `<nav class="pager" aria-label="Pages">${at > 1 ? `<a class="chip" href="${esc(searchHref({ ...r.query, page: at - 1 }))}" rel="prev">← Previous</a>` : ''}<span class="muted small">Page ${at} of ${pages}</span>${at < pages ? `<a class="chip" href="${esc(searchHref({ ...r.query, page: at + 1 }))}" rel="next">Next →</a>` : ''}</nav>`
303
+ }
304
+
305
+ /**
306
+ * Browse and search: the facet rail, what is switched on, and the results.
307
+ * /search takes any combination; /c/<category> and /t/<tag> are the same page
308
+ * for one filter, under an address worth indexing.
309
+ */
310
+ export function browsePage(ctx: Ctx, r: Result, d: { path: string; heading?: string; intro?: string } = { path: '/search' }): string {
311
+ const q = r.query
312
+ const what = r.chips.map((c) => c.label).join(' · ')
313
+ const heading = d.heading ?? (isEmpty(q) ? 'Browse every launch' : q.q && r.chips.length === 1 ? `Search: ${q.q}` : what)
314
+ const hidden = (k: string, v: string) => `<input type="hidden" name="${esc(k)}" value="${esc(v)}">`
315
+ const keep = [
316
+ ...(q.q ? [hidden('q', q.q)] : []),
317
+ ...(['category', 'tag', 'has', 'stack', 'maker'] as const).flatMap((k) => q[k].map((v) => hidden(k, v))),
318
+ ...(q.launched ? [hidden('launched', q.launched)] : []),
319
+ ].join('')
320
+ return layout({
321
+ ctx,
322
+ title: `${heading} (${r.total}) · built.indie`,
323
+ description: d.intro ?? `${r.total} indie launch${r.total === 1 ? '' : 'es'}${what ? ` matching ${what}` : ''}. Filter by category, tag, stack, maker and more.`,
324
+ path: d.path,
325
+ // Filter combinations are endless; only the plain pages are worth indexing.
326
+ noindex: d.path === '/search' && !isEmpty(q),
180
327
  ads: true,
181
- body: `<h1 class="page-h">${q ? `Search: ${esc(q)}` : esc(WINDOW_TITLES[window])}</h1>
182
- <nav class="tabs" aria-label="Boards">${tabs}</nav>
183
- <form class="search" method="get" action="/search"><label class="sr" for="q">Search</label><input id="q" name="q" placeholder="Search launches" value="${esc(q ?? '')}"></form>
184
- ${board(ctx, products, window !== 'new') || '<p class="empty">Nothing here yet.</p>'}
185
- ${adUnit('banner_300x250')}`,
328
+ wide: true,
329
+ q: q.q,
330
+ body: `<div class="browse">
331
+ <details class="facets" open data-facets>
332
+ <summary class="facets-toggle">Filters${r.chips.filter((c) => c.facet !== 'q').length ? ` <span class="facet-n">${r.chips.filter((c) => c.facet !== 'q').length}</span>` : ''}</summary>
333
+ <div class="facet-rail">${facetRail(r)}</div>
334
+ </details>
335
+ <section class="results">
336
+ <header class="results-head">
337
+ <div><h1 class="page-h">${esc(heading)}</h1><p class="muted small">${r.total} launch${r.total === 1 ? '' : 'es'}${d.intro ? ` · ${esc(d.intro)}` : ''}</p></div>
338
+ <form class="sort-form" method="get" action="/search" data-autosubmit>${keep}<label class="sr" for="sort">Sort by</label><select id="sort" name="sort">${(Object.keys(SORTS) as (keyof typeof SORTS)[])
339
+ .filter((s) => s !== 'relevance' || q.q)
340
+ .map((s) => `<option value="${s}"${q.sort === s ? ' selected' : ''}>${esc(SORTS[s])}</option>`)
341
+ .join('')}</select><noscript><button class="chip">Sort</button></noscript></form>
342
+ </header>
343
+ ${chipsHtml(r)}
344
+ ${board(ctx, r.products, false) || `<p class="empty">Nothing matches all of that. Remove a filter, or <a href="/search">start over</a>.${q.q ? ` Or <a href="/submit?url=${enc(q.q)}">launch it</a> if it is yours.` : ''}</p>`}
345
+ ${pager(r)}
346
+ </section>
347
+ </div>`,
186
348
  })
187
349
  }
188
350
 
351
+ /** Share intents. Each click sends a beacon to /p/<slug>/share?net=… first (app.js): a signed-in member earns karma. */
189
352
  function share(origin: string, p: Product): string {
190
353
  const url = `${origin}/p/${p.slug}`
191
354
  const text = `${p.name}: ${p.tagline || p.domain}. Launched on built.indie, upvote it 👇`
192
- const links: [string, string][] = [
193
- ['X', `https://x.com/intent/post?text=${enc(text)}&url=${enc(url)}`],
194
- ['Bluesky', `https://bsky.app/intent/compose?text=${enc(`${text} ${url}`)}`],
195
- ['LinkedIn', `https://www.linkedin.com/sharing/share-offsite/?url=${enc(url)}`],
196
- ['Reddit', `https://www.reddit.com/submit?url=${enc(url)}&title=${enc(`${p.name}: ${p.tagline}`)}`],
197
- ['Hacker News', `https://news.ycombinator.com/submitlink?u=${enc(p.url)}&t=${enc(`Show HN: ${p.name} – ${p.tagline}`)}`],
355
+ const links: [string, string, string, string][] = [
356
+ ['X', 'x', 'x', `https://x.com/intent/post?text=${enc(text)}&url=${enc(url)}`],
357
+ ['Bluesky', 'bluesky', 'bluesky', `https://bsky.app/intent/compose?text=${enc(`${text} ${url}`)}`],
358
+ ['Mastodon', 'mastodon', 'share', `https://mastodonshare.com/?text=${enc(text)}&url=${enc(url)}`],
359
+ ['LinkedIn', 'linkedin', 'linkedin', `https://www.linkedin.com/sharing/share-offsite/?url=${enc(url)}`],
360
+ ['Reddit', 'reddit', 'reddit', `https://www.reddit.com/submit?url=${enc(url)}&title=${enc(`${p.name}: ${p.tagline}`)}`],
361
+ ['Hacker News', 'hn', 'code', `https://news.ycombinator.com/submitlink?u=${enc(p.url)}&t=${enc(`Show HN: ${p.name} – ${p.tagline}`)}`],
198
362
  ]
199
- const icons: Record<string, string> = { X: 'x', Bluesky: 'bluesky', LinkedIn: 'linkedin', Reddit: 'reddit', 'Hacker News': 'code' }
200
- return `<div class="share">${links.map(([n, h]) => `<a class="chip" href="${esc(h)}" target="_blank" rel="noopener">${icon(icons[n] ?? 'share')} ${esc(n)}</a>`).join('')}<button class="chip" data-copy="${esc(url)}">${icon('link')} Copy link</button></div>`
363
+ return `<div class="share" data-share-slug="${esc(p.slug)}">${links.map(([n, net, ic, h]) => `<a class="chip" href="${esc(h)}" target="_blank" rel="noopener" data-share="${net}">${icon(ic)} ${esc(n)}</a>`).join('')}<button class="chip" data-copy="${esc(url)}" data-share="copy">${icon('link')} Copy link</button></div>`
364
+ }
365
+
366
+ /** A comment or review as the page shows it: the public name and karma, never the email. */
367
+ export type CommentView = { id: string; parentId: string | null; body: string; deleted: boolean; at: number; author: string; karma: number; maker: boolean; mine: boolean }
368
+ export type ReviewView = { rating: number; body: string; at: number; author: string; karma: number; mine: boolean }
369
+
370
+ const when = (ms: number) => new Date(ms).toISOString().slice(0, 10)
371
+ const stars = (n: number) => `<span class="stars" aria-label="${n} of 5 stars">${'★'.repeat(n)}<span class="off">${'★'.repeat(5 - n)}</span></span>`
372
+ /** Paragraphs from plain text: escaped, line breaks kept. */
373
+ const prose = (t: string) => t.split(/\n{2,}/).map((para) => `<p>${esc(para).replace(/\n/g, '<br>')}</p>`).join('')
374
+ const byline = (name: string, karma: number, extra = '') => `<b class="who">${esc(name)}</b> <span class="karma" title="Karma">${karma} karma</span>${extra}`
375
+
376
+ function reviewsHtml(ctx: Ctx, p: Product, reviews: ReviewView[], mine: Review | null, self: boolean): string {
377
+ const form = !ctx.user
378
+ ? `<p class="muted small"><a href="/login?next=${enc(`/p/${p.slug}#reviews`)}">Sign in</a> to review it (+5 karma for stars and a few sentences).</p>`
379
+ : self
380
+ ? '<p class="muted small">This is your listing: reviews come from everyone else.</p>'
381
+ : `<form method="post" action="/p/${esc(p.slug)}/review" class="review-form">
382
+ <fieldset class="rate"><legend>${mine ? 'Your review' : 'Rate it'}</legend>${[5, 4, 3, 2, 1].map((n) => `<input type="radio" id="rate-${n}" name="rating" value="${n}"${mine?.rating === n ? ' checked' : ''} required><label for="rate-${n}" title="${n} star${n === 1 ? '' : 's'}">★</label>`).join('')}</fieldset>
383
+ <label for="review-body" class="sr">Your review</label><textarea id="review-body" name="body" rows="3" maxlength="2000" placeholder="What is it like to use? (30 characters or more earns karma)">${esc(mine?.body ?? '')}</textarea>
384
+ <div class="row"><button class="btn small-btn">${mine ? 'Update review' : 'Post review'}</button>${mine ? '<button class="chip danger" name="action" value="delete" formnovalidate>Delete</button>' : ''}</div>
385
+ </form>`
386
+ return `<section class="card discuss" id="reviews">
387
+ <h2 class="mini-h">${icon('star')} Reviews ${p.rating !== null ? `<span class="muted small">${stars(Math.round(p.rating))} ${p.rating.toFixed(1)} from ${p.reviews}</span>` : ''}</h2>
388
+ ${reviews.length ? `<ul class="thread">${reviews.map((r) => `<li class="post"><header>${stars(r.rating)} ${byline(r.author, r.karma, r.mine ? ' <span class="pill">you</span>' : '')} <time class="muted small" datetime="${new Date(r.at).toISOString()}">${when(r.at)}</time></header>${r.body ? `<div class="body">${prose(r.body)}</div>` : ''}</li>`).join('')}</ul>` : '<p class="muted small">No reviews yet.</p>'}
389
+ ${form}
390
+ </section>`
391
+ }
392
+
393
+ function commentsHtml(ctx: Ctx, p: Product, comments: CommentView[]): string {
394
+ const top = comments.filter((c) => !c.parentId)
395
+ const replies = (id: string) => comments.filter((c) => c.parentId === id)
396
+ const del = (c: CommentView) => (c.mine && !c.deleted ? `<form method="post" action="/p/${esc(p.slug)}/comments/${esc(c.id)}/delete" class="inline" data-confirm="Delete this comment?"><button class="linkish small">delete</button></form>` : '')
397
+ const one = (c: CommentView) =>
398
+ c.deleted
399
+ ? `<article class="post deleted" id="c-${esc(c.id)}"><p class="muted small">[deleted]</p></article>`
400
+ : `<article class="post" id="c-${esc(c.id)}"><header>${byline(c.author, c.karma, c.maker ? ' <span class="pill ok">maker</span>' : '')} <a class="muted small" href="#c-${esc(c.id)}"><time datetime="${new Date(c.at).toISOString()}">${when(c.at)}</time></a> ${del(c)}</header><div class="body">${prose(c.body)}</div></article>`
401
+ const box = (parent?: string) =>
402
+ ctx.user
403
+ ? `<form method="post" action="/p/${esc(p.slug)}/comments" class="comment-form">${parent ? `<input type="hidden" name="parent" value="${esc(parent)}">` : ''}<label class="sr" for="cb-${parent ?? 'new'}">${parent ? 'Reply' : 'Comment'}</label><textarea id="cb-${parent ?? 'new'}" name="body" rows="${parent ? 2 : 3}" maxlength="2000" required placeholder="${parent ? 'Reply…' : 'Ask the maker something, or say what you think'}"></textarea><button class="${parent ? 'chip' : 'btn small-btn'}">${parent ? 'Reply' : 'Comment'}</button></form>`
404
+ : ''
405
+ return `<section class="card discuss" id="comments">
406
+ <h2 class="mini-h">${icon('user')} Comments <span class="muted small">${comments.filter((c) => !c.deleted).length}</span></h2>
407
+ ${top.length ? `<ol class="thread">${top.map((c) => `<li>${one(c)}${replies(c.id).length ? `<ol class="thread replies">${replies(c.id).map((r) => `<li>${one(r)}</li>`).join('')}</ol>` : ''}${ctx.user && !c.deleted ? `<details class="reply"><summary class="small">Reply</summary>${box(c.id)}</details>` : ''}</li>`).join('')}</ol>` : '<p class="muted small">No comments yet. Start the conversation.</p>'}
408
+ ${ctx.user ? box() : `<p class="muted small"><a href="/login?next=${enc(`/p/${p.slug}#comments`)}">Sign in</a> to comment (+2 karma).</p>`}
409
+ </section>`
410
+ }
411
+
412
+ /** schema.org data for search engines; the rating only once someone has reviewed it. */
413
+ function productJsonLd(origin: string, p: Product, reviews: ReviewView[]): string {
414
+ const data: Record<string, unknown> = {
415
+ '@context': 'https://schema.org',
416
+ '@type': 'SoftwareApplication',
417
+ name: p.name,
418
+ description: p.tagline || p.description || undefined,
419
+ url: p.url,
420
+ applicationCategory: 'WebApplication',
421
+ ...(cardImage(origin, p) ? { image: cardImage(origin, p) } : {}),
422
+ ...(p.rating !== null && p.reviews ? { aggregateRating: { '@type': 'AggregateRating', ratingValue: p.rating, reviewCount: p.reviews, bestRating: 5, worstRating: 1 } } : {}),
423
+ ...(reviews.length ? { review: reviews.slice(0, 5).map((r) => ({ '@type': 'Review', author: { '@type': 'Person', name: r.author }, reviewRating: { '@type': 'Rating', ratingValue: r.rating, bestRating: 5 }, ...(r.body ? { reviewBody: r.body } : {}), datePublished: when(r.at) })) } : {}),
424
+ }
425
+ return `<script type="application/ld+json">${JSON.stringify(data).replace(/</g, '\\u003c')}</script>`
201
426
  }
202
427
 
203
428
  export function badgeSnippet(origin: string, p: Product): string {
204
429
  return `<a href="${origin}/p/${p.slug}?ref=badge"><img src="${origin}/badge/${p.slug}.svg" alt="${p.name} on built.indie" width="200" height="44"></a>`
205
430
  }
206
431
 
432
+ /** The badge with this account's badge id on it: placed on the homepage, it claims the listing (and earns the ✓). */
433
+ export function claimBadgeSnippet(origin: string, p: Product, badgeId: string): string {
434
+ return `<a href="${origin}/p/${p.slug}"><img src="${origin}/images.svg?id=${badgeId}" alt="Launched on built.indie" width="200" height="44"></a>`
435
+ }
436
+
207
437
  /** "Built with", from the product's own OpenStack.md. */
208
438
  function stackHtml(p: Product): string {
209
439
  const s = p.extras.stack
@@ -223,7 +453,7 @@ function pricingHtml(p: Product): string {
223
453
 
224
454
  const SOCIAL_LABELS: Record<string, string> = { x: 'X', github: 'GitHub', linkedin: 'LinkedIn', discord: 'Discord', youtube: 'YouTube', bluesky: 'Bluesky', instagram: 'Instagram', tiktok: 'TikTok', reddit: 'Reddit', telegram: 'Telegram', producthunt: 'Product Hunt', appstore: 'App Store', playstore: 'Google Play', chrome: 'Chrome Web Store' }
225
455
 
226
- export function productPage(ctx: Ctx, d: { product: Product; award: { day: string; rank: number } | null; maker: Maker | null; featuredOk?: boolean; launched?: boolean; canEdit?: boolean; chains: string[]; featureUsd: number; featureDays: number; payments: boolean }): string {
456
+ export function productPage(ctx: Ctx, d: { comments?: CommentView[]; reviews?: ReviewView[]; myReview?: Review | null; selfListing?: boolean; error?: string; product: Product; award: { day: string; rank: number } | null; maker: Maker | null; featuredOk?: boolean; featuredCredit?: boolean; featuredDaysUsed?: boolean; launched?: boolean; canEdit?: boolean; credits?: number; daysBalance?: number; buyerEmail?: string; featureUsd: number; featureDays: number; payments: boolean }): string {
227
457
  const p = d.product
228
458
  const shots = [p.screenshot, ...p.images, p.ogImage].filter((f): f is string => Boolean(f))
229
459
  const rel = p.verifiedAt ? 'noopener' : 'noopener nofollow ugc'
@@ -237,16 +467,18 @@ export function productPage(ctx: Ctx, d: { product: Product; award: { day: strin
237
467
  image: cardImage(ctx.origin, p),
238
468
  noindex: !live,
239
469
  ads: live,
240
- head: `<link rel="opensite" href="/p/${esc(p.slug)}/opensite.json"><link rel="alternate" type="text/markdown" title="OpenProfile" href="/p/${esc(p.slug)}/openprofile.md">`,
241
- body: `${d.launched && live ? `<div class="note launched"><b>🚀 ${esc(p.name)} is live.</b> Today’s board resets at midnight UTC: share it now. ${p.ownerId ? '' : `Is it yours? <a href="/p/${esc(p.slug)}/claim">Claim it</a> with a DNS record to edit it and get the ✓.`}</div>` : ''}
470
+ head: `<link rel="opensite" href="/p/${esc(p.slug)}/opensite.json"><link rel="alternate" type="text/markdown" title="OpenProfile" href="/p/${esc(p.slug)}/openprofile.md">${live ? productJsonLd(ctx.origin, p, d.reviews ?? []) : ''}`,
471
+ body: `${d.error ? `<p class="note error" role="alert">${esc(d.error)}</p>` : ''}${d.launched && live ? `<div class="note launched"><b>🚀 ${esc(p.name)} is live.</b> Today’s board resets at midnight UTC: share it now. ${p.ownerId ? '' : `Is it yours? <a href="/p/${esc(p.slug)}/claim">Claim it</a> with your badge (or a DNS record) to edit it and get the ✓.`}</div>` : ''}
242
472
  ${d.featuredOk ? '<p class="note">Thanks! Your payment is on its way; the Featured spot starts the moment CoinPay confirms it.</p>' : ''}
473
+ ${d.featuredCredit ? `<p class="note launched">★ Featured for ${d.featureDays} more days with an invite credit. <a href="/invite">Invite another maker</a> to earn the next one.</p>` : ''}
474
+ ${d.featuredDaysUsed ? `<p class="note launched">★ Featured with your Featured days${p.featuredUntil ? ` until ${new Date(p.featuredUntil).toISOString().slice(0, 10)}` : ''}.</p>` : ''}
243
475
  ${!live ? `<p class="note">${p.status === 'draft' ? 'Draft: only people with the edit link see this.' : 'Taken down.'}</p>` : ''}
244
476
  <section class="product-head">
245
477
  ${logoImg(p, 72)}
246
478
  <div class="product-title">
247
479
  <h1>${esc(p.name)}</h1>
248
480
  <p class="lede">${esc(p.tagline)}</p>
249
- <p class="badges">${awardText ? `<span class="pill gold">🏆 ${esc(awardText)}</span>` : ''}${p.featuredUntil && p.featuredUntil > Date.now() ? '<span class="pill">★ Featured</span>' : ''}${p.verifiedAt ? '<span class="pill ok" title="Their homepage links back to built.indie">✓ Verified</span>' : ''}${p.llms ? `<a class="pill" href="${esc(p.llms.url)}" rel="nofollow noopener">llms.txt</a>` : ''}${p.profile ? `<a class="pill" href="${esc(p.profile.url)}" rel="nofollow noopener">OpenProfile</a>` : ''}${p.record?.source === 'declared' ? '<span class="pill">OpenSite</span>' : ''}${p.extras.stack ? `<a class="pill" href="${esc(p.extras.stack.url)}" rel="nofollow noopener">OpenStack</a>` : ''}${p.extras.pricing ? `<a class="pill" href="${esc(p.extras.pricing.url)}" rel="nofollow noopener">OpenSaaS</a>` : ''}${p.extras.openicon ? `<a class="pill" href="${esc(p.extras.openicon)}" rel="nofollow noopener">OpenIcon</a>` : ''}</p>
481
+ <p class="badges">${awardText ? `<span class="pill gold">🏆 ${esc(awardText)}</span>` : ''}${p.featuredUntil && p.featuredUntil > Date.now() ? '<span class="pill">★ Featured</span>' : ''}${p.verifiedAt ? '<span class="pill ok" title="Their homepage links back to built.indie">✓ Verified</span>' : ''}${p.rating !== null ? `<a class="pill" href="#reviews">★ ${p.rating.toFixed(1)} · ${p.reviews} review${p.reviews === 1 ? '' : 's'}</a>` : ''}${p.llms ? `<a class="pill" href="${esc(p.llms.url)}" rel="nofollow noopener">llms.txt</a>` : ''}${p.profile ? `<a class="pill" href="${esc(p.profile.url)}" rel="nofollow noopener">OpenProfile</a>` : ''}${p.record?.source === 'declared' ? '<span class="pill">OpenSite</span>' : ''}${p.extras.stack ? `<a class="pill" href="${esc(p.extras.stack.url)}" rel="nofollow noopener">OpenStack</a>` : ''}${p.extras.pricing ? `<a class="pill" href="${esc(p.extras.pricing.url)}" rel="nofollow noopener">OpenSaaS</a>` : ''}${p.extras.openicon ? `<a class="pill" href="${esc(p.extras.openicon)}" rel="nofollow noopener">OpenIcon</a>` : ''}</p>
250
482
  </div>
251
483
  <div class="product-actions">
252
484
  ${live ? voteButton(ctx, p, true) : ''}
@@ -258,14 +490,16 @@ ${shots.length ? `<div class="gallery">${shots.map((f, i) => `<a href="${esc(med
258
490
  ${p.videos.length ? `<div class="videos">${p.videos.map((v) => (v.embed ? `<div class="video"><iframe src="${esc(v.embed)}" title="${esc(p.name)} video" loading="lazy" allow="fullscreen; picture-in-picture" allowfullscreen referrerpolicy="strict-origin-when-cross-origin" sandbox="allow-scripts allow-same-origin allow-popups allow-presentation"></iframe></div>` : `<p><a href="${esc(v.url)}" rel="nofollow noopener" target="_blank">▶ Watch the video</a></p>`)).join('')}</div>` : ''}
259
491
  <div class="card">
260
492
  ${p.description ? `<p class="desc">${esc(p.description)}</p>` : ''}
261
- ${p.tags.length ? `<p class="tags">${p.tags.map((t) => `<a class="tag" href="/search?q=${enc(t)}">${esc(t)}</a>`).join('')}</p>` : ''}
493
+ <p class="tags">${categorize(p).map((c) => `<a class="tag cat" href="/c/${esc(c)}">${esc(CATEGORY_LABELS[c])}</a>`).join('')}${p.tags.map((t) => `<a class="tag" href="/t/${enc(t)}">${esc(t)}</a>`).join('')}</p>
262
494
  ${Object.keys(p.socials).length ? `<p class="socials">${Object.entries(p.socials).map(([k, v]) => `<a class="chip" href="${esc(v)}" rel="nofollow noopener" target="_blank">${icon(SOCIAL_ICONS[k] ?? 'globe')} ${esc(SOCIAL_LABELS[k] ?? k)}</a>`).join('')}</p>` : ''}
263
495
  ${stackHtml(p)}
264
496
  ${pricingHtml(p)}
265
497
  ${p.makerName || d.maker ? `<p class="maker">Made by ${d.maker ? `<a href="/makers/${esc(d.maker.id)}">${esc(p.makerName || d.maker.name)}</a>${p.makerVerified ? ' <span class="tick" title="Their OpenProfile names this product">✓</span>' : ''}` : esc(p.makerName)}</p>` : ''}
266
498
  <p class="muted small">Launched ${p.launchedAt ? new Date(p.launchedAt).toISOString().slice(0, 10) : 'soon'} · ${p.votes} upvote${p.votes === 1 ? '' : 's'} · <a href="/p/${esc(p.slug)}/opensite.json">OpenSite record</a> · <a href="/p/${esc(p.slug)}/openprofile.md">OpenProfile.md</a></p>
267
499
  </div>
268
- ${live ? `<h2 class="section-h">Share it</h2>${share(ctx.origin, p)}
500
+ ${live ? `${reviewsHtml(ctx, p, d.reviews ?? [], d.myReview ?? null, Boolean(d.selfListing))}
501
+ ${commentsHtml(ctx, p, d.comments ?? [])}
502
+ <h2 class="section-h">Share it</h2>${share(ctx.origin, p)}
269
503
  <details class="card" id="badge"${p.verifiedAt || !d.canEdit ? '' : ' open'}>
270
504
  <summary>Add the badge to your site</summary>
271
505
  <p class="muted">Put this on your homepage. It shows your live upvote count, and once we see it your link here becomes a followed link and you get the ✓.</p>
@@ -273,11 +507,12 @@ ${live ? `<h2 class="section-h">Share it</h2>${share(ctx.origin, p)}
273
507
  <pre><code>${esc(badgeSnippet(ctx.origin, p))}</code></pre>
274
508
  <div class="row"><button class="chip" data-copy="${esc(badgeSnippet(ctx.origin, p))}">Copy badge HTML</button><form method="post" action="/p/${esc(p.slug)}/verify"><button class="chip">I added it: check</button></form></div>
275
509
  </details>
276
- ${d.payments ? `<details class="card" id="feature"><summary>Feature it: ${esc(`$${d.featureUsd}`)} for ${d.featureDays} days</summary>
277
- <p class="muted">Pinned above today’s board on the homepage for ${d.featureDays} days. Paid in crypto through CoinPay.</p>
278
- <form method="post" action="/p/${esc(p.slug)}/feature" class="row"><label class="sr" for="chain">Pay with</label><select id="chain" name="chain">${d.chains.map((c) => `<option value="${esc(c)}">${esc(CHAIN_LABELS[c] ?? c)}</option>`).join('')}</select><button class="btn">Pay $${esc(d.featureUsd)}</button></form>
279
- </details>` : ''}
280
- ${adUnit('banner_300x250')}` : ''}`,
510
+ ${d.payments || d.canEdit ? `<details class="card" id="feature"${d.canEdit && (d.credits || d.daysBalance) ? ' open' : ''}><summary>Feature it: ${esc(`$${d.featureUsd}`)} for ${d.featureDays} days, or free with an invite</summary>
511
+ <p class="muted">Pinned above today’s board on the homepage for ${d.featureDays} days.</p>
512
+ ${d.canEdit && d.credits ? `<form method="post" action="/p/${esc(p.slug)}/credit" class="row"><button class="btn">${icon('star')} Use a free invite credit</button><span class="muted small">${d.credits} left</span></form>` : `<p class="muted small">Free: ${esc(inviteOffer(d.featureDays, d.featureUsd))} <a href="/invite">Get your invite link</a>.</p>`}
513
+ ${d.canEdit && d.daysBalance ? featureDaysForm(p, d.daysBalance) : ''}
514
+ ${d.payments ? `<form method="post" action="/p/${esc(p.slug)}/feature" class="row"><label class="sr" for="feature-email">Your email</label><input id="feature-email" name="email" type="email" required autocomplete="email" placeholder="you@example.com" value="${esc(d.buyerEmail ?? '')}"><button class="btn${d.canEdit && d.credits ? ' ghost' : ''}">Pay $${esc(d.featureUsd)}</button></form><p class="muted small">You pick any coin or PayPal on CoinPay's checkout page. The receipt goes to your email.</p>` : ''}
515
+ </details>` : ''}` : ''}`,
281
516
  })
282
517
  }
283
518
 
@@ -307,7 +542,7 @@ export function editPage(ctx: Ctx, d: { product: Product; fresh?: boolean; flash
307
542
  description: 'Edit your launch.',
308
543
  path: `/p/${p.slug}/edit`,
309
544
  noindex: true,
310
- body: `${d.fresh ? `<p class="note">Here is what we read from ${esc(p.domain)}. Fix anything, then launch. It is in <a href="/me">your launches</a>${p.ownerId ? '' : `; <a href="/p/${esc(p.slug)}/claim">claim it</a> with a DNS record so it stays yours`}.</p>` : ''}
545
+ body: `${d.fresh ? `<p class="note">Here is what we read from ${esc(p.domain)}. Fix anything, then launch. It is in <a href="/me">your launches</a>${p.ownerId ? '' : `; <a href="/p/${esc(p.slug)}/claim">claim it</a> with your badge or a DNS record so it stays yours`}.</p>` : ''}
311
546
  ${d.flash ? `<p class="note">${esc(d.flash)}</p>` : ''}
312
547
  ${d.error ? `<p class="note error" role="alert">${esc(d.error.message)}</p>` : ''}
313
548
  <h1 class="page-h">${p.status === 'draft' ? 'Check it, then launch' : `Edit ${esc(p.name)}`}</h1>
@@ -335,14 +570,32 @@ ${d.error ? `<p class="note error" role="alert">${esc(d.error.message)}</p>` : '
335
570
  * access to the domain's DNS. Everything it needs is in it: the exact record,
336
571
  * where it goes, how to check it took, and how to finish the claim.
337
572
  */
338
- export function claimMarkdown(origin: string, p: Product, record: { name: string; value: string }): string {
573
+ export function claimMarkdown(origin: string, p: Product, record: { name: string; value: string }, badge: string): string {
339
574
  const bare = p.domain.replace(/^www\./, '')
340
575
  const label = record.name.slice(0, -(bare.length + 1))
341
- return `# Claim ${p.name} on built.indie: add one DNS TXT record
576
+ const id = badge.match(/images\.svg\?id=([A-Za-z0-9_-]+)/)?.[1] ?? ''
577
+ return `# Claim ${p.name} on built.indie: the badge on the homepage, or one DNS TXT record
342
578
 
343
- Prove control of \`${bare}\` so the listing ${origin}/p/${p.slug} can be claimed and edited.
579
+ Prove control of \`${bare}\` so the listing ${origin}/p/${p.slug} can be claimed and edited. Either proof works; the badge is the easy one.
344
580
 
345
- ## The record
581
+ ## Option A (easiest): the badge on the homepage
582
+
583
+ Put this HTML on the homepage, ${p.url}, and deploy it:
584
+
585
+ \`\`\`html
586
+ ${badge}
587
+ \`\`\`
588
+
589
+ It shows the live built.indie badge, and its id (\`${id}\`) is unique to this listing and this built.indie account, so finding it on the homepage proves the site is yours. It also makes your link on built.indie a followed link and earns the ✓, in the same step. The badge must be served in the HTML of the homepage itself (not added later by JavaScript). Check with:
590
+
591
+ \`\`\`sh
592
+ curl -s ${p.url} | grep -o 'images.svg?id=[A-Za-z0-9_-]*'
593
+ # expect: images.svg?id=${id}
594
+ \`\`\`
595
+
596
+ Then finish the claim (below). Keep the badge up for the followed link.
597
+
598
+ ## Option B: a DNS TXT record
346
599
 
347
600
  | Field | Value |
348
601
  | --- | --- |
@@ -353,7 +606,7 @@ Prove control of \`${bare}\` so the listing ${origin}/p/${p.slug} can be claimed
353
606
 
354
607
  The same TXT value on \`${bare}\` itself also works, if a \`${label}\` name is not possible.
355
608
 
356
- ## Steps
609
+ ### Steps
357
610
 
358
611
  1. Find the DNS zone that serves \`${bare}\`. It is the zone of the registered domain (for \`a.b.example.com\` that is usually \`example.com\`): check with \`dig +short NS ${bare}\`.
359
612
  2. In that zone, add the TXT record above. Add it; do not replace or delete any existing TXT records (SPF, DKIM, other verifications).
@@ -364,66 +617,118 @@ The same TXT value on \`${bare}\` itself also works, if a \`${label}\` name is n
364
617
  # expect: "${record.value}"
365
618
  \`\`\`
366
619
 
367
- 4. Finish the claim, any one of:
368
- - open ${origin}/p/${p.slug}/claim (signed in as the same account) and press **Check DNS**
620
+ 4. Finish the claim (below). The record can be removed once the claim succeeds.
621
+
622
+ A \`<meta name="built-indie-verify" content="${record.value}">\` tag (the bare \`${record.value}\` string anywhere in the homepage HTML) also counts.
623
+
624
+ ## Finish the claim
625
+
626
+ Any one of:
627
+ - open ${origin}/p/${p.slug}/claim (signed in as the same account) and press **Check**
369
628
  - \`BUILTINDIE_KEY=bi_… builtindie claim ${p.slug}\`
370
629
  - \`curl -X POST ${origin}/api/v1/products/${p.slug}/claim -H 'authorization: Bearer bi_…'\` (answers \`{"claimed": true}\`)
371
630
 
372
- The value is tied to one built.indie account: a record made for another account will not claim it for this one. The record can be removed once the claim succeeds.
631
+ We check DNS, then the homepage. The badge id and the record are tied to one built.indie account: a badge or record made for another account will not claim it for this one.
373
632
  `
374
633
  }
375
634
 
376
- export function claimPage(ctx: Ctx, d: { product: Product; record: { name: string; value: string }; owner: boolean; result?: { ok: boolean; looked: string[] } }): string {
635
+
636
+ export function claimPage(ctx: Ctx, d: { product: Product; record: { name: string; value: string }; badge: string; owner: boolean; result?: { ok: boolean; looked: string[]; method?: string } }): string {
377
637
  const p = d.product
378
- const md = claimMarkdown(ctx.origin, p, d.record)
638
+ const md = claimMarkdown(ctx.origin, p, d.record, d.badge)
379
639
  const done = d.owner || d.result?.ok
380
640
  return layout({
381
641
  ctx,
382
642
  title: `Claim ${p.name} · built.indie`,
383
- description: `Prove you own ${p.domain} with a DNS record.`,
643
+ description: `Prove you own ${p.domain} with the badge or a DNS record.`,
384
644
  path: `/p/${p.slug}/claim`,
385
645
  noindex: true,
386
646
  body: `<h1 class="page-h">${done ? `${esc(p.name)} is yours ✓` : `Claim ${esc(p.name)}`}</h1>
387
- ${done ? `<p class="note">You own this listing. <a href="/p/${esc(p.slug)}/edit">Edit it</a>, then add the <a href="/p/${esc(p.slug)}#badge">badge</a>.</p>` : `
647
+ ${done ? `<p class="note">You own this listing${d.result?.method === 'badge' ? ' (we found your badge)' : ''}. <a href="/p/${esc(p.slug)}/edit">Edit it</a>${d.result?.method === 'badge' ? '' : `, then add the <a href="/p/${esc(p.slug)}#badge">badge</a>`}.</p>` : `
388
648
  ${p.ownerId ? '<p class="note">Someone already claimed this listing. If you own the domain, adding your record below moves it to you.</p>' : ''}
389
- ${d.result && !d.result.ok ? `<p class="note error" role="alert">We did not find the record yet at ${d.result.looked.map((n) => `<code>${esc(n)}</code>`).join(' or ')}. DNS can take a few minutes; try again.</p>` : ''}
390
- <p class="muted">Prove you control <b>${esc(p.domain)}</b>: add this TXT record at your DNS host, then check.</p>
649
+ ${d.result && !d.result.ok ? `<p class="note error" role="alert">We did not find the badge or the record yet at ${d.result.looked.map((n) => `<code>${esc(n)}</code>`).join(' or ')}. DNS can take a few minutes; try again.</p>` : ''}
650
+ <p class="muted">Prove you control <b>${esc(p.domain)}</b>, either way: put your badge on the homepage (easiest), or add a TXT record at your DNS host. Then check.</p>
651
+ <div class="card" id="badge-claim">
652
+ <h2 class="mini-h">1. The badge on your homepage (easiest)</h2>
653
+ <p class="muted small">Put this badge on <b>${esc(p.url)}</b> and deploy. Its id is yours alone, so it proves the site is yours, and it also makes your link here a followed link and earns the ✓, in one step. It must be in the page’s HTML, not added by JavaScript.</p>
654
+ <p><img src="/badge/${esc(p.slug)}.svg" alt="badge" width="200" height="44"></p>
655
+ <pre><code>${esc(d.badge)}</code></pre>
656
+ <div class="row"><button class="chip" data-copy="${esc(d.badge)}">Copy badge HTML</button></div>
657
+ </div>
391
658
  <div class="card">
659
+ <h2 class="mini-h">2. Or a DNS record</h2>
392
660
  <p class="small muted">Type</p><p><code>TXT</code></p>
393
661
  <p class="small muted">Name / host</p><div class="address"><code>${esc(d.record.name)}</code><button class="copy" data-copy="${esc(d.record.name.replace(`.${p.domain.replace(/^www\./, '')}`, ''))}">Copy</button></div>
394
662
  <p class="small muted">Some DNS hosts want only <code>_built-indie</code> here; others want the full name.</p>
395
663
  <p class="small muted">Value</p><div class="address"><code>${esc(d.record.value)}</code><button class="copy" data-copy="${esc(d.record.value)}">Copy</button></div>
396
664
  <p class="small muted">A TXT record on ${esc(p.domain.replace(/^www\./, ''))} itself with the same value works too. You can remove it once claimed.</p>
397
- <form method="post" action="/p/${esc(p.slug)}/claim" data-busy="Asking DNS…"><button class="btn">Check DNS</button></form>
398
665
  </div>
666
+ <form method="post" action="/p/${esc(p.slug)}/claim" data-busy="Checking your homepage and DNS…"><button class="btn">Check</button></form>
399
667
  <div class="card">
400
- <h2 class="mini-h">${icon('terminal')} Have an AI do the DNS</h2>
401
- <p class="muted small">Copy these instructions as Markdown and paste them to an agent (or a person) with access to your DNS. They hold your record, how to check it, and how to finish the claim.</p>
668
+ <h2 class="mini-h">${icon('terminal')} Have an AI do it</h2>
669
+ <p class="muted small">Copy these instructions as Markdown and paste them to an agent (or a person) with access to your site or your DNS. They hold your badge and your record, how to check them, and how to finish the claim.</p>
402
670
  <div class="row"><button class="chip" data-copy="${esc(md)}">Copy as Markdown for AI</button><a class="chip" href="/p/${esc(p.slug)}/claim.md">Open as .md</a></div>
403
671
  <details><summary class="small">Preview</summary><pre><code>${esc(md)}</code></pre></details>
404
672
  </div>`}`,
405
673
  })
406
674
  }
407
675
 
408
- export function loginPage(ctx: Ctx, d: { next: string | null; sent?: boolean; error?: string; email?: string }): string {
676
+ /** The shared profile fields (@profullstack/profile) beside the form's own email box. */
677
+ const PROFILE_FIELDS = PROFILE_COLUMNS.filter((f) => f !== 'email')
678
+
679
+ export function loginPage(ctx: Ctx, d: { next: string | null; sent?: boolean; error?: string; email?: string; invitedBy?: string | null; signup?: boolean; values?: Record<string, string>; errors?: Record<string, string> }): string {
680
+ const signup = Boolean(d.signup) && !d.sent
409
681
  return layout({
410
682
  ctx,
411
- title: 'Sign in · built.indie',
412
- description: 'Sign in with a link by email.',
413
- path: '/login',
683
+ title: signup ? 'Create your account · built.indie' : 'Sign in · built.indie',
684
+ description: signup ? 'Create a built.indie account with a link by email.' : 'Sign in with a link by email.',
685
+ path: signup ? '/signup' : '/login',
414
686
  noindex: true,
415
- body: `<section class="hero narrow"><h1>${d.sent ? 'Check your email' : 'Sign in'}</h1>
416
- ${d.sent ? '<p class="lede">If that address can receive mail, a sign-in link is on its way. It works once, for 20 minutes.</p>' : `<p class="lede">No password. We email you a link; a new address makes an account. You need one only to claim and edit a listing. Launching is free and needs nothing.</p>
687
+ body: `<section class="hero narrow"><h1>${d.sent ? 'Check your email' : signup ? 'Create your account' : 'Sign in'}</h1>
688
+ ${d.sent ? '<p class="lede">If that address can receive mail, a sign-in link is on its way. It works once, for 20 minutes.</p>' : `<p class="lede">No password. We email you a link${signup ? '' : '; a new address makes an account'}. You need one only to claim and edit a listing. Launching is free and needs nothing.</p>
689
+ ${d.invitedBy ? `<p class="note launched">${esc(d.invitedBy)} invited you. Sign up here, then launch your product: it is free, and it earns them a Featured week.</p>` : ''}
417
690
  ${d.error ? `<p class="note error" role="alert">${esc(d.error)}</p>` : ''}
418
691
  <form method="post" action="/login" class="card">
419
692
  <input type="hidden" name="next" value="${esc(d.next ?? '')}">
693
+ ${signup ? '<input type="hidden" name="mode" value="signup">' : ''}
420
694
  <label for="email">Email</label><input id="email" name="email" type="email" autocomplete="email" required value="${esc(d.email ?? '')}">
421
- <p><button class="btn wide">Email me a link</button></p>
422
- </form>`}</section>`,
695
+ ${signup ? renderProfileFields({ fields: PROFILE_FIELDS, values: d.values, errors: d.errors }) : ''}
696
+ <p><button class="btn wide">${signup ? 'Create account' : 'Email me a link'}</button></p>
697
+ </form>
698
+ <p class="muted small">${signup ? `Already a member? <a href="/login${d.next ? `?next=${encodeURIComponent(d.next)}` : ''}">Sign in</a>` : `New here? <a href="/signup${d.next ? `?next=${encodeURIComponent(d.next)}` : ''}">Create an account</a>`}</p>
699
+ ${signup ? '<script src="/vendor-profile.js" defer></script>' : ''}
700
+ ${ctx.coinpay ? `<p class="or muted small">or</p>${coinpayButton(d.next)}<p class="muted small">One CoinPay account signs you in here and on the rest of the network.</p>` : ''}`}</section>`,
423
701
  })
424
702
  }
425
703
 
426
- export function mePage(ctx: Ctx, d: { user: User; products: Product[]; apiKey?: string }): string {
704
+ function karmaCard(k: KarmaSummary, howToEarn: { action: string; points: number; rule: string }[], featureDays: number): string {
705
+ const pct = Math.round(k.progress * 100)
706
+ return `<div class="card" id="karma"><h2>${icon('trophy')} Karma <span class="karma-total">${k.total}</span></h2>
707
+ <p class="muted">${k.week >= 0 ? `+${k.week}` : k.week} this week · ${k.credits.granted} free Featured week${k.credits.granted === 1 ? '' : 's'} earned from karma${k.credits.used ? ` (${k.credits.used} used)` : ''} · <a href="/leaderboard">leaderboard</a></p>
708
+ <p><b>${k.nextCreditIn} karma</b> to your next free Featured week (${featureDays} days pinned on the homepage).</p>
709
+ <div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="${k.perCredit}" aria-valuenow="${k.perCredit - k.nextCreditIn}" aria-label="Progress to the next free Featured week"><span style="inline-size:${pct}%"></span></div>
710
+ ${k.events.length ? `<h3 class="mini-h">History</h3><ol class="ledger">${k.events.map((e) => `<li><span class="pts${e.points < 0 ? ' neg' : ''}">${e.points > 0 ? '+' : ''}${e.points}</span> ${esc(e.label)} <time class="muted small" datetime="${new Date(e.at).toISOString()}">${when(e.at)}</time></li>`).join('')}</ol>` : '<p class="muted small">No karma yet. Here is how to earn it:</p>'}
711
+ <details${k.events.length ? '' : ' open'}><summary>How to earn karma</summary>${howToEarnTable(howToEarn)}<p class="muted small">Signed-in members only; acting on your own listing earns nothing. Every ${k.perCredit} karma grants one free Featured week, once.</p></details>
712
+ </div>`
713
+ }
714
+
715
+ /** Spend Featured days from the balance on one listing. */
716
+ function featureDaysForm(p: Product, balance: number): string {
717
+ return `<form method="post" action="/p/${esc(p.slug)}/feature-days" class="row"><label class="sr" for="days-${esc(p.slug)}">Days</label><input id="days-${esc(p.slug)}" name="days" type="number" min="1" max="${balance}" value="${balance}" inputmode="numeric" style="inline-size:6em"><button class="chip">${icon('star')} Feature ${esc(p.name)} with your days</button><span class="muted small">${balance} day${balance === 1 ? '' : 's'} left</span></form>`
718
+ }
719
+
720
+ /** "Have a code?": redeem a credit code, see the Featured days it added, spend them on a listing. */
721
+ function featuredDaysCard(d: { products: Product[]; daysBalance?: number; redeemNote?: string; redeemError?: boolean }): string {
722
+ const balance = d.daysBalance ?? 0
723
+ const live = d.products.filter((p) => p.status === 'live')
724
+ return `<div class="card" id="redeem"><h2>${icon('star')} Featured days <span class="karma-total">${balance}</span></h2>
725
+ ${d.redeemNote ? `<p class="note${d.redeemError ? ' error' : ' launched'}" role="status">${esc(d.redeemNote)}</p>` : ''}
726
+ <form method="post" action="/me/redeem" class="row"><label class="sr" for="redeem-code">Credit code</label><input id="redeem-code" name="code" required placeholder="Have a code? CR-XXXX-XXXX-XXXX-XXXX" autocomplete="off" spellcheck="false"><button class="chip">Redeem</button></form>
727
+ <p class="muted small">A code or an invite adds days to pin any live listing you own on the homepage, the same Featured slot a paid week buys.</p>
728
+ ${balance ? (live.length ? live.map((p) => featureDaysForm(p, balance)).join('') : '<p class="muted">You have no live listing yet. <a href="/submit">Launch one</a>, then come back to feature it.</p>') : ''}</div>`
729
+ }
730
+
731
+ export function mePage(ctx: Ctx, d: { user: User; products: Product[]; apiKey?: string; invite: InviteStats; daysBalance?: number; redeemNote?: string; redeemError?: boolean; karma?: KarmaSummary; howToEarn?: { action: string; points: number; rule: string }[]; featureDays?: number; coinpay?: { name: string | null } | null; emailless?: boolean; coinpayNote?: string | null; profile?: object | null; detailsNote?: string | null; detailsError?: boolean; detailsErrors?: Record<string, string> }): string {
427
732
  return layout({
428
733
  ctx,
429
734
  title: 'Your launches · built.indie',
@@ -431,8 +736,16 @@ export function mePage(ctx: Ctx, d: { user: User; products: Product[]; apiKey?:
431
736
  path: '/me',
432
737
  noindex: true,
433
738
  body: `<h1 class="page-h">Your launches</h1>
434
- <p class="muted">Signed in as ${esc(d.user.email)}. <form class="inline" method="post" action="/logout"><button class="linkish">Sign out</button></form></p>
739
+ ${d.coinpayNote ? `<p class="note">${esc(d.coinpayNote)}</p>` : ''}
740
+ <p class="muted">Signed in ${d.emailless ? `with CoinPay${d.coinpay?.name ? ` as ${esc(d.coinpay.name)}` : ''}` : `as ${esc(d.user.email)}`}. <form class="inline" method="post" action="/logout"><button class="linkish">Sign out</button></form></p>
435
741
  ${d.products.length ? `<ol class="board">${d.products.map((p) => `<li class="row-item"><a class="row-main" href="/p/${esc(p.slug)}">${logoImg(p)}<span class="row-text"><b>${esc(p.name)}</b><span class="muted">${p.status === 'draft' ? 'draft' : `▲ ${p.votes}`} · ${p.ownerId === d.user.id ? 'claimed ✓' : 'submitted by you, not claimed'}</span></span></a><a class="chip" href="/p/${esc(p.slug)}/edit">Edit</a>${p.ownerId === d.user.id ? '' : `<a class="chip" href="/p/${esc(p.slug)}/claim">Claim</a>`}</li>`).join('')}</ol>` : '<p class="empty">Nothing yet. <a href="/submit">Launch something</a>, or open a listing that is yours and claim it.</p>'}
742
+ ${featuredDaysCard(d)}
743
+ ${d.karma ? karmaCard(d.karma, d.howToEarn ?? [], d.featureDays ?? 7) : ''}
744
+ <div class="card"><h2>${icon('star')} Invites</h2><p class="muted">${d.invite.invited} joined through your link · ${d.invite.launched} launched · <b>${d.invite.credits}</b> free Featured week${d.invite.credits === 1 ? '' : 's'} to use${d.invite.karmaCredits ? ` (${d.invite.karmaCredits} from karma)` : ''}.</p><p><a class="chip" href="/invite">Your invite link and credits</a></p></div>
745
+ ${ctx.coinpay || d.coinpay ? `<div class="card" id="signin"><h2>Sign in with CoinPay</h2>${d.coinpay ? `<p class="muted">Connected${d.coinpay.name ? ` (${esc(d.coinpay.name)})` : ''}: your CoinPay account signs you in here.</p>` : `<p class="muted">Connect your CoinPay account to sign in with it, here and across the network. We link the account itself, never by email.</p>${coinpayButton('/me', 'Connect CoinPay')}`}</div>` : ''}
746
+ <div class="card" id="details"><h2>Your details</h2><p class="muted small">Private to your account, never shown on a public page.</p>
747
+ ${d.detailsNote ? `<p class="note${d.detailsError ? ' error' : ''}" role="status">${esc(d.detailsNote)}</p>` : ''}
748
+ <form method="post" action="/me/profile">${renderProfileFields({ fields: PROFILE_FIELDS, values: (d.profile ?? {}) as Record<string, string | null>, errors: d.detailsErrors, idPrefix: 'details-' })}<p><button class="chip">Save details</button></p></form></div>
436
749
  <div class="card" id="api"><h2>API key</h2>
437
750
  ${d.apiKey ? `<p class="note"><b>Copy it now</b>, we show it once. Making a new one retires this one.</p><div class="address"><code>${esc(d.apiKey)}</code><button class="copy" data-copy="${esc(d.apiKey)}">Copy</button></div><pre>export BUILTINDIE_KEY=${esc(d.apiKey)}</pre>` : '<p class="muted">For the CLI, the TUI and the MCP server: edit and claim from the terminal or an agent.</p>'}
438
751
  <form method="post" action="/me/key"><button class="chip">${d.apiKey ? 'Make another' : 'Make an API key'}</button></form></div>`,
@@ -463,22 +776,42 @@ export function helpPage(ctx: Ctx): string {
463
776
  description: 'Launch, read and upvote from code: a JSON API, a CLI, a TUI and an MCP server.',
464
777
  path: '/help',
465
778
  body: `<h1 class="page-h">Launch from anywhere</h1>
466
- <p class="muted">Everything on the site is an API. Launching and reading need nothing. To edit, sign in, claim your product with a DNS record, and make an API key on <a href="/me">your page</a>.</p>
779
+ <p class="muted">Everything on the site is an API. Launching and reading need nothing. To edit, sign in (an email link, or Sign in with CoinPay), claim your product by putting your badge on its homepage (or with a DNS record), and make an API key on <a href="/me">your page</a>.</p>
467
780
  <div class="card"><h2>API</h2>
468
781
  <pre>curl -X POST ${o}/api/v1/products -H 'content-type: application/json' \\
469
782
  -d '{"url":"yourproduct.com","maker_name":"You"}'
470
783
  # → { product, claim_url } free; live at once
471
784
 
472
785
  curl ${o}/api/v1/products?window=today # today|week|month|all|new|featured
786
+ curl ${o}/api/v1/products?tag=game # one tag
787
+ curl ${o}/api/v1/tags # every tag with its count
788
+ curl '${o}/api/v1/search?q=crm&amp;category=ai&amp;tag=productivity&amp;has=verified&amp;sort=new' # + facets with counts
789
+ curl ${o}/api/v1/categories # every category and tag, with counts
473
790
  curl ${o}/api/v1/products/&lt;slug&gt;
474
791
  curl -X POST ${o}/api/v1/products/&lt;slug&gt;/vote
475
792
  curl -X POST ${o}/api/v1/preview -d '{"url":"any.site"}' -H 'content-type: application/json'
476
- curl ${o}/api/v1/products/&lt;slug&gt;/claim -H 'authorization: Bearer bi_…' # the TXT record to add
477
- curl -X POST ${o}/api/v1/products/&lt;slug&gt;/claim -H 'authorization: Bearer bi_…' # check it
793
+ curl ${o}/api/v1/products/&lt;slug&gt;/claim -H 'authorization: Bearer bi_…' # your badge (with your badge id), or the TXT record
794
+ curl -X POST ${o}/api/v1/products/&lt;slug&gt;/claim -H 'authorization: Bearer bi_…' # check for the badge on your homepage, or DNS
478
795
  curl -X PATCH ${o}/api/v1/products/&lt;slug&gt; -H 'authorization: Bearer bi_…' \\
479
796
  -H 'content-type: application/json' -d '{"tagline":"…","videos":["https://youtu.be/…"]}'
480
797
  curl -X POST ${o}/api/v1/products/&lt;slug&gt;/refetch -H 'authorization: Bearer bi_…'
481
- curl -X POST ${o}/api/v1/products/&lt;slug&gt;/publish -H 'authorization: Bearer bi_…'</pre>
798
+ curl -X POST ${o}/api/v1/products/&lt;slug&gt;/publish -H 'authorization: Bearer bi_…'
799
+ curl ${o}/api/v1/invites -H 'authorization: Bearer bi_…' # your invite link and credits
800
+ curl -X POST ${o}/api/v1/products/&lt;slug&gt;/credit -H 'authorization: Bearer bi_…' # spend one: Featured for a week
801
+ curl -X POST ${o}/api/v1/redeem -H 'authorization: Bearer bi_…' -d '{"code":"CR-…"}' # a credit code: Featured days to your balance
802
+ curl -X POST ${o}/api/v1/products/&lt;slug&gt;/feature-days -H 'authorization: Bearer bi_…' -d '{"days":3}' # spend Featured days on a listing
803
+ curl -X POST ${o}/api/v1/invites -H 'authorization: Bearer bi_…' -d '{"note":"for Ada"}' # one of your 3 single-use invite codes (both get Featured days)
804
+ curl -X POST ${o}/api/v1/products/&lt;slug&gt;/feature -H 'authorization: Bearer bi_…' -d '{"email":"you@…"}' # pay: a CoinPay checkout_url (any coin or PayPal)
805
+ curl ${o}/leaderboard/invites.json?period=week # top inviters (all|week|month)
806
+ curl '${o}/api/v1/leaderboard?board=karma&amp;period=week' # top karma (board karma|invites)
807
+ curl ${o}/api/v1/karma -H 'authorization: Bearer bi_…' # your karma, history, next free Featured week
808
+ curl ${o}/api/v1/products/&lt;slug&gt;/comments
809
+ curl -X POST ${o}/api/v1/products/&lt;slug&gt;/comments -H 'authorization: Bearer bi_…' -d '{"body":"…","parent":"&lt;comment id, optional&gt;"}'
810
+ curl -X DELETE ${o}/api/v1/comments/&lt;id&gt; -H 'authorization: Bearer bi_…' # your own (the points go back)
811
+ curl ${o}/api/v1/products/&lt;slug&gt;/reviews
812
+ curl -X POST ${o}/api/v1/products/&lt;slug&gt;/reviews -H 'authorization: Bearer bi_…' -d '{"rating":5,"body":"…"}'
813
+ curl -X POST ${o}/api/v1/products/&lt;slug&gt;/share -H 'authorization: Bearer bi_…' -d '{"network":"bluesky"}' # x|bluesky|linkedin|reddit|hn|mastodon|copy</pre>
814
+ <p class="muted small">Claiming, the easy way: the badge. <code>GET …/claim</code> answers a <code>badge</code> snippet next to the DNS <code>record</code>; put it on your homepage (<code>&lt;img src="${o}/images.svg?id=&lt;id&gt;"&gt;</code>, the id is yours for that listing) and the <code>POST</code> answers <code>{"claimed": true, "method": "badge"}</code>. It also earns the followed link and the ✓ in the same step.</p>
482
815
  <p class="muted small">OpenSite (logicsrc.com/docs/opensite): <code>GET /api/v1/sites?url=…</code>, <code>POST /api/v1/sites</code>, and every product at <code>/p/&lt;slug&gt;/opensite.json</code>. OpenProfile: <code>/p/&lt;slug&gt;/openprofile.md</code>, <code>/makers/&lt;id&gt;/openprofile.md</code>.</p></div>
483
816
  <div class="card" id="cli"><h2>CLI and TUI</h2>
484
817
  <pre>curl -fsSL ${o}/install.sh | sh # installs builtindie
@@ -486,13 +819,22 @@ builtindie # TUI: today's board, vote, launch
486
819
  bunx @profullstack/builtindie # or run it without installing
487
820
  bunx @profullstack/builtindie launch yourproduct.com
488
821
  bunx @profullstack/builtindie top week
822
+ bunx @profullstack/builtindie search crm --category ai --has verified --facets
489
823
  bunx @profullstack/builtindie show &lt;slug&gt;
490
824
  bunx @profullstack/builtindie vote &lt;slug&gt;
491
- BUILTINDIE_KEY=bi_… bunx @profullstack/builtindie claim &lt;slug&gt; # TXT record, then checks it
825
+ BUILTINDIE_KEY=bi_… bunx @profullstack/builtindie claim &lt;slug&gt; # your badge (or a TXT record), then checks it
826
+ BUILTINDIE_KEY=bi_… bunx @profullstack/builtindie invite # your invite link and free Featured credits
827
+ BUILTINDIE_KEY=bi_… bunx @profullstack/builtindie karma # your karma and history
828
+ bunx @profullstack/builtindie leaderboard [karma|invites] [--period week|month|all]
829
+ BUILTINDIE_KEY=bi_… bunx @profullstack/builtindie comment &lt;slug&gt; "text" [--reply &lt;id&gt;]
830
+ BUILTINDIE_KEY=bi_… bunx @profullstack/builtindie review &lt;slug&gt; 5 "text"
831
+ bunx @profullstack/builtindie comments &lt;slug&gt; · reviews &lt;slug&gt;
492
832
  bunx @profullstack/builtindie preview any.site --json</pre></div>
493
833
  <div class="card" id="mcp"><h2>MCP</h2>
494
834
  <pre>claude mcp add builtindie -- bunx @profullstack/builtindie mcp</pre>
495
- <p class="muted">Tools: <code>launch_product</code>, <code>preview_site</code>, <code>list_products</code>, <code>get_product</code>, <code>upvote</code>, <code>search</code>, and with <code>BUILTINDIE_KEY</code>: <code>claim_product</code>, <code>edit_product</code>, <code>my_products</code>.</p></div>
835
+ <p class="muted">Tools: <code>launch_product</code>, <code>preview_site</code>, <code>list_products</code>, <code>get_product</code>, <code>upvote</code>, <code>search</code> (with filters and facets), <code>list_categories</code>, and with <code>BUILTINDIE_KEY</code>: <code>claim_product</code>, <code>edit_product</code>, <code>my_products</code>, <code>my_invites</code>, <code>feature_with_credit</code>, <code>my_karma</code>, <code>comment</code>, <code>review</code>; without a key: <code>leaderboard</code>, <code>list_comments</code>, <code>list_reviews</code>.</p></div>
836
+ <div class="card" id="karma"><h2>Karma</h2><p class="muted">Signed-in members earn karma; anonymous actions still work and earn nothing. Acting on your own listing earns nothing. Every 500 karma grants one free Featured week. The same rules run on pwamart.com.</p>
837
+ <table class="earn"><thead><tr><th scope="col">Do this</th><th scope="col">Karma</th></tr></thead><tbody><tr><td>Launch a listing</td><td class="pts">+10</td></tr><tr><td>Upvote (20 paid a day)</td><td class="pts">+1</td></tr><tr><td>Review: stars and 30+ characters</td><td class="pts">+5</td></tr><tr><td>Comment: 10+ characters (10 paid a day; deleting takes it back)</td><td class="pts">+2</td></tr><tr><td>Share button (once per listing, network and day; 5 a day)</td><td class="pts">+1</td></tr><tr><td>Your launch gets an upvote from a member</td><td class="pts">+1</td></tr><tr><td>Your launch gets a review</td><td class="pts">+2</td></tr><tr><td>Claim your listing</td><td class="pts">+5</td></tr><tr><td>An invited maker launches</td><td class="pts">+25</td></tr><tr><td>Badge verified on your homepage</td><td class="pts">+5</td></tr></tbody></table></div>
496
838
  <div class="card" id="bot"><h2>Our reader</h2>
497
839
  <p class="muted">We fetch only what a person submits, once, plus a re-read when the maker asks. User agent <code>built.indie/1.0</code>. We honour <code>robots.txt</code> and <code>index.allow: false</code> in <code>/.well-known/opensite.json</code>. Screenshots are taken with headless Chrome.</p></div>
498
840
  <div class="card"><h2>Make your page read well</h2>
@@ -500,11 +842,148 @@ bunx @profullstack/builtindie preview any.site --json</pre></div>
500
842
  })
501
843
  }
502
844
 
845
+ /** Your invite link, what it has earned, and the listings a credit can feature. */
846
+ export type InviteCodes = {
847
+ quota: { total: number; used: number; left: number }
848
+ invites: { id: string; note: string; createdAt: number; expiresAt: number | null; usedAt: number | null; revokedAt?: number | null }[]
849
+ bonusDays: number
850
+ newLink?: string
851
+ }
852
+
853
+ /** Scarce single-use invite codes: a few each, and both people get Featured days when one is used. */
854
+ function inviteCodesCard(c: InviteCodes): string {
855
+ const state = (i: InviteCodes['invites'][number]) => (i.usedAt ? `used ${new Date(i.usedAt).toISOString().slice(0, 10)}` : i.revokedAt ? 'withdrawn' : i.expiresAt && i.expiresAt < Date.now() ? 'expired' : `open${i.expiresAt ? ` until ${new Date(i.expiresAt).toISOString().slice(0, 10)}` : ''}`)
856
+ return `<div class="card" id="codes"><h2>${icon('mail')} Invite codes <span class="karma-total">${c.quota.left}/${c.quota.total}</span></h2>
857
+ <p class="muted">Your link above is open to anyone. These are not: you get ${c.quota.total}, each works once, for someone new. When they sign up with it, you both get <b>${c.bonusDays} Featured days</b>.</p>
858
+ ${c.newLink ? `<p class="note launched">Your new invite, shown once. Send it to one person:</p><div class="address"><code>${esc(c.newLink)}</code><button class="copy" data-copy="${esc(c.newLink)}">Copy</button></div>` : ''}
859
+ ${c.quota.left > 0 ? `<form method="post" action="/invite/code" class="row"><label class="sr" for="invite-note">Who is it for?</label><input id="invite-note" name="note" maxlength="80" placeholder="Who is it for? (only you see this)"><button class="chip">Make an invite code</button></form>` : '<p class="muted small">All used. Thank you for bringing people in.</p>'}
860
+ ${c.invites.length ? `<ol class="ledger">${c.invites.map((i) => `<li>${esc(i.note || 'invite')} <span class="muted small">${esc(state(i))}</span></li>`).join('')}</ol>` : ''}</div>`
861
+ }
862
+
863
+ export function invitePage(ctx: Ctx, d: { featureDays: number; featureUsd: number; inviters: InviterRow[]; me?: { link: string; stats: InviteStats; name: string; displayName: string | null; listings: Product[]; codes?: InviteCodes; flash?: string; error?: string } }): string {
864
+ const m = d.me
865
+ const text = 'I launch my indie products on built.indie: one URL and it pulls in the logo, screenshots and story. Launch yours free:'
866
+ const shares: [string, string, string][] = m
867
+ ? [
868
+ ['X', 'x', `https://x.com/intent/post?text=${enc(text)}&url=${enc(m.link)}`],
869
+ ['Bluesky', 'bluesky', `https://bsky.app/intent/compose?text=${enc(`${text} ${m.link}`)}`],
870
+ ['LinkedIn', 'linkedin', `https://www.linkedin.com/sharing/share-offsite/?url=${enc(m.link)}`],
871
+ ['Email', 'mail', `mailto:?subject=${enc('Launch your product on built.indie')}&body=${enc(`${text}\n\n${m.link}`)}`],
872
+ ]
873
+ : []
874
+ const featuring = (p: Product) => (p.featuredUntil && p.featuredUntil > Date.now() ? `Featured until ${new Date(p.featuredUntil).toISOString().slice(0, 10)}` : p.domain)
875
+ return layout({
876
+ ctx,
877
+ title: 'Invite makers, get Featured free · built.indie',
878
+ description: inviteOffer(d.featureDays, d.featureUsd),
879
+ path: '/invite',
880
+ ads: !m,
881
+ body: `<section class="hero"><h1>Invite a maker.<br>Get Featured free.</h1>
882
+ <p class="lede">${esc(inviteOffer(d.featureDays, d.featureUsd))}</p></section>
883
+ ${m?.flash ? `<p class="note launched">${esc(m.flash)}</p>` : ''}${m?.error ? `<p class="note error" role="alert">${esc(m.error)}</p>` : ''}
884
+ ${
885
+ m
886
+ ? `<div class="card"><h2>Your invite link</h2>
887
+ <div class="address"><code>${esc(m.link)}</code><button class="copy" data-copy="${esc(m.link)}">Copy</button></div>
888
+ <div class="share">${shares.map(([n, i, h]) => `<a class="chip" href="${esc(h)}" target="_blank" rel="noopener">${icon(i)} ${esc(n)}</a>`).join('')}</div>
889
+ <div class="grid3 stats"><div><b>${m.stats.invited}</b><span class="muted small">joined</span></div><div><b>${m.stats.launched}</b><span class="muted small">launched</span></div><div><b>${m.stats.credits}</b><span class="muted small">free weeks to use</span></div></div>
890
+ </div>
891
+ ${
892
+ m.stats.credits
893
+ ? `<div class="card"><h2>${icon('star')} Use a credit</h2>${
894
+ m.listings.length
895
+ ? `<ol class="board">${m.listings.map((p) => `<li class="row-item"><a class="row-main" href="/p/${esc(p.slug)}">${logoImg(p, 36)}<span class="row-text"><b>${esc(p.name)}</b><span class="muted">${esc(featuring(p))}</span></span></a><form method="post" action="/p/${esc(p.slug)}/credit"><button class="chip">Feature ${d.featureDays} days</button></form></li>`).join('')}</ol>`
896
+ : '<p class="muted">You have no live listing yet. <a href="/submit">Launch one</a>, then come back to feature it.</p>'
897
+ }</div>`
898
+ : ''
899
+ }
900
+ ${m.codes ? inviteCodesCard(m.codes) : ''}
901
+ <div class="card"><h2>Your name on the board</h2><p class="muted small">Shown as <b>${esc(m.name)}</b>. We never show your email.</p>
902
+ <form method="post" action="/invite/name" class="row"><label class="sr" for="displayName">Display name</label><input id="displayName" name="displayName" maxlength="40" placeholder="Your name or handle" value="${esc(m.displayName ?? '')}"><button class="chip">Save</button></form></div>`
903
+ : '<p class="center"><a class="btn" href="/login?next=/invite">Sign in to get your invite link</a></p>'
904
+ }
905
+ <div class="grid3">
906
+ <div class="card"><h2><span class="step-n">1</span>Share</h2><p class="muted">Send your link to a maker who has shipped something.</p></div>
907
+ <div class="card"><h2><span class="step-n">2</span>They launch</h2><p class="muted">They sign up through it and publish a listing. One URL; we fetch the rest.</p></div>
908
+ <div class="card"><h2><span class="step-n">3</span>You are Featured</h2><p class="muted">You get a credit: ${d.featureDays} days pinned on the homepage, on any listing you own.</p></div>
909
+ </div>
910
+ <h2 class="section-h">Top inviters this week <a class="small" href="/leaderboard">full board</a></h2>
911
+ ${d.inviters.length ? invitersList(d.inviters) : '<p class="empty">Nobody yet. One invite that launches puts you at #1.</p>'}
912
+ <p class="muted small">It counts once per new member, the first time a listing they submitted goes live. Inviting yourself does not count.</p>`,
913
+ })
914
+ }
915
+
916
+ /** Where an invite link lands: who invited you, and one step to launch. */
917
+ export function inviteLanding(ctx: Ctx, d: { inviter: string | null; bonusDays?: number }): string {
918
+ // A scarce invite code: nobody is named until it is used, and both people get Featured days.
919
+ if (!d.inviter)
920
+ return layout({
921
+ ctx,
922
+ title: "You're invited to built.indie",
923
+ description: 'Launch your indie product with one URL. We fetch the logo, screenshots and story; people upvote; you get a badge. Free.',
924
+ path: '/invite',
925
+ noindex: true,
926
+ body: `<section class="hero"><h1>You're invited.</h1>
927
+ <p class="lede">Someone on built.indie spent one of their few invites on you. Sign up and you both get <b>${d.bonusDays ?? 0} Featured days</b>: your launch pinned on the homepage.</p>
928
+ ${ctx.user ? '<p class="muted">You are already signed in, so this invite is for someone new. Pass it on.</p>' : `<p><a class="btn" href="/signup?next=/me">${icon('rocket')} Accept: sign up</a></p><p class="muted small">Already a member? Invites are for new members; your days come from <a href="/me#redeem">codes</a> and your own invites.</p>`}
929
+ </section>`,
930
+ })
931
+ return layout({
932
+ ctx,
933
+ title: `${d.inviter} invited you to launch on built.indie`,
934
+ description: 'Launch your indie product with one URL. We fetch the logo, screenshots and story; people upvote; you get a badge. Free.',
935
+ path: '/invite',
936
+ noindex: true,
937
+ body: `<section class="hero"><h1>${esc(d.inviter)} invited you.</h1>
938
+ <p class="lede">Launch your indie product with one URL. We pull your logo, screenshots, videos and story from your homepage. People upvote. You get a badge. Free.</p>
939
+ ${
940
+ ctx.user
941
+ ? `<p><a class="btn" href="/submit">${icon('rocket')} Launch your product</a></p>`
942
+ : `<p><a class="btn" href="/login?next=/submit">${icon('rocket')} Sign up and launch</a></p><p class="muted small">Sign up with your email first, then launch: that is what earns ${esc(d.inviter)} a free Featured week, and you get your own invite link too.</p>`
943
+ }
944
+ </section>`,
945
+ })
946
+ }
947
+
948
+ /** The boards in the site's layout: karma (default) and inviters; the widget is @profullstack/leaderboard's. */
949
+ export function leaderboardPage(ctx: Ctx, d: { board: 'karma' | 'invites'; period: 'week' | 'all'; featureDays: number; featureUsd: number; perCredit: number; howToEarn: { action: string; points: number; rule: string }[]; me?: string | null }): string {
950
+ const karma = d.board === 'karma'
951
+ const title = karma ? 'Top karma' : 'Top inviters'
952
+ const tabs = `<nav class="tabs" aria-label="Boards"><a href="/leaderboard"${karma ? ' aria-current="page"' : ''}>Karma</a><a href="/leaderboard?board=invites"${karma ? '' : ' aria-current="page"'}>Inviters</a></nav>`
953
+ const intro = karma ? `Members earn karma for launching, upvoting, reviewing, commenting and sharing. Every ${d.perCredit} karma is a free Featured week (${d.featureDays} days pinned on the homepage, a $${d.featureUsd} spot).` : inviteOffer(d.featureDays, d.featureUsd)
954
+ return layout({
955
+ ctx,
956
+ title: `${title} · built.indie`,
957
+ description: karma ? `The most helpful members of built.indie. ${intro}` : `The members who brought the most makers to built.indie. ${intro}`,
958
+ path: karma ? '/leaderboard' : '/leaderboard?board=invites',
959
+ ads: true,
960
+ head: `<link rel="alternate" type="application/rss+xml" title="${title}" href="/leaderboard/${d.board}.xml?period=week">`,
961
+ body: `<h1 class="page-h">${icon('trophy')} ${title}</h1>
962
+ ${tabs}
963
+ <p class="muted">${esc(intro)} ${karma ? `<a href="${ctx.user ? '/me#karma' : '/login?next=/me'}">${ctx.user ? 'Your karma' : 'Sign in to earn'}</a>` : '<a href="/invite">Get your link</a>'}</p>
964
+ ${d.period === 'all' ? '<p class="muted small">Nobody has scored this week yet, so this opens on all time. Be the first this week.</p>' : ''}
965
+ <div class="card"><div data-leaderboard></div><script src="/leaderboard/embed.js" data-board="${d.board}" data-period="${d.period}" data-limit="25"${d.me ? ` data-me="${esc(d.me)}"` : ''}></script></div>
966
+ ${karma ? `<div class="card"><h2 class="mini-h">How to earn karma</h2>${howToEarnTable(d.howToEarn)}<p class="muted small">Signed-in members only; anonymous upvotes still count on the board but earn nothing. Acting on your own listing earns nothing.</p><p><a class="btn small-btn" href="/top/today">Upvote today’s launches</a></p></div>` : ''}
967
+ <p class="muted small">Put this board on your site: <code>&lt;div data-leaderboard&gt;&lt;/div&gt;&lt;script src="${esc(ctx.origin)}/leaderboard/embed.js" data-board="${d.board}"&gt;&lt;/script&gt;</code> · <a href="/api/v1/leaderboard?board=${d.board}&amp;period=week">JSON</a> · <a href="/leaderboard/${d.board}.xml?period=week">RSS</a></p>`,
968
+ })
969
+ }
970
+
503
971
  export function messagePage(ctx: Ctx, d: { title: string; message: string; status?: number }): string {
504
972
  return layout({ ctx, title: `${d.title} · built.indie`, description: d.message, path: '/', noindex: true, body: `<section class="hero"><h1>${esc(d.title)}</h1><p class="lede">${esc(d.message)}</p><a class="btn" href="/">Home</a></section>` })
505
973
  }
506
974
 
507
975
  /** The embeddable badge: live upvotes, or the day's award once it is won. */
976
+ /** The badge for a site with no listing (the shared footer asks for every site): an invitation to launch. */
977
+ export function launchBadgeSvg(theme: 'dark' | 'light'): string {
978
+ const dark = theme === 'dark'
979
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="200" height="44" viewBox="0 0 200 44" role="img" aria-label="Launch yours on built.indie">
980
+ <rect x=".5" y=".5" width="199" height="43" rx="9" fill="${dark ? '#111018' : '#ffffff'}" stroke="#ff5c39"/>
981
+ <path d="M22 9 12 21h6v12h8V21h6z" fill="#ff5c39"/>
982
+ <text x="42" y="18" font-family="system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="8.5" font-weight="700" letter-spacing=".6" fill="${dark ? '#a7a1bd' : '#5d5870'}">LAUNCH YOURS FREE ON</text>
983
+ <text x="42" y="33" font-family="system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="15" font-weight="800" fill="${dark ? '#f4f1ff' : '#14121c'}">built.indie</text>
984
+ </svg>`
985
+ }
986
+
508
987
  export function badgeSvg(p: Product, award: { day: string; rank: number } | null, theme: 'dark' | 'light'): string {
509
988
  const dark = theme === 'dark'
510
989
  const bg = dark ? '#111018' : '#ffffff'