@profullstack/builtindie 0.1.4 → 0.6.0

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,25 @@
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'
12
15
 
13
16
  export const REPO = 'https://github.com/moshcoder/built.indie'
14
17
 
18
+ // The footer's own links; the ring nav and copyright come from @profullstack/footer.
19
+ setRingFooterOptions(
20
+ ringFooterOptions([
21
+ { label: 'API · CLI · MCP', href: '/help' },
22
+ { label: 'RSS', href: '/feed.xml' },
23
+ { label: 'Source', href: REPO },
24
+ ]),
25
+ )
26
+
15
27
  /** crawlproof analytics on every page (project 71b194a4…); CRAWLPROOF_SITE=off turns it off. */
16
28
  const TRACK_SITE = process.env.CRAWLPROOF_SITE ?? '71b194a4-9ae7-4b79-9ec1-0cf0d5ef203e'
17
29
  const TRACKER = TRACK_SITE === 'off' ? '' : `<script data-site="${TRACK_SITE}" src="https://crawlproof.com/stats.js" async></script>`
@@ -22,8 +34,8 @@ const TRACKER = TRACK_SITE === 'off' ? '' : `<script data-site="${TRACK_SITE}" s
22
34
  * CRAWLPROOF_AD_SLOT overrides the slot; 'off' turns ads off.
23
35
  */
24
36
  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>`
37
+ export const adUnit = () =>
38
+ 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
39
 
28
40
  const asset = (name: string) => {
29
41
  const body = readFileSync(new URL(`../public/${name}`, import.meta.url))
@@ -36,11 +48,14 @@ const ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"
36
48
  export const esc = (value: unknown) => String(value ?? '').replace(/[&<>"']/g, (c) => ESC[c]!)
37
49
  const enc = encodeURIComponent
38
50
 
51
+ /** The "Sign in with CoinPay" button: a plain link into the OAuth flow. */
52
+ 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>`
53
+
39
54
  const LOGO = `<svg class="mark" viewBox="0 0 32 32" aria-hidden="true"><path d="M16 4 5 17h7v11h8V17h7z" fill="currentColor"/></svg>`
40
55
 
41
- export type Ctx = { origin: string; user?: User | null; voted?: (id: string) => boolean }
56
+ export type Ctx = { origin: string; user?: User | null; voted?: (id: string) => boolean; /** Sign in with CoinPay is on. */ coinpay?: boolean }
42
57
 
43
- type LayoutOptions = { title: string; description: string; path: string; body: string; ctx: Ctx; noindex?: boolean; image?: string | null; head?: string; ads?: boolean }
58
+ 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
59
 
45
60
  function layout(o: LayoutOptions): string {
46
61
  const url = o.ctx.origin + o.path
@@ -75,25 +90,32 @@ ${TRACKER}
75
90
  </head>
76
91
  <body>
77
92
  ${RING_PLACEHOLDER}
78
- <header class="top">
93
+ <header class="top${o.wide ? ' wide' : ''}">
79
94
  <a class="brand" href="/">${LOGO}<span>built<b>.</b>indie</span></a>
95
+ <form class="top-search" role="search" method="get" action="/search" data-suggest>
96
+ <label class="sr" for="site-q">Search launches</label>
97
+ <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>
98
+ <div class="suggest" id="site-suggest" role="listbox" hidden></div>
99
+ </form>
80
100
  <nav aria-label="Site">
101
+ <a href="/search">Browse</a>
81
102
  <a href="/top/today">Today</a>
82
103
  <a href="/top/week">Week</a>
83
104
  <a href="/new">New</a>
105
+ <a href="/tags">Tags</a>
106
+ <a href="/invite">Invite</a>
107
+ <a href="/leaderboard">Leaderboard</a>
84
108
  <a href="/help">API</a>
85
109
  ${o.ctx.user ? '<a href="/me">Mine</a>' : '<a href="/login">Sign in</a>'}
86
110
  <a class="btn small-btn" href="/submit">${icon('rocket')} Launch</a>
87
111
  </nav>
88
112
  </header>
89
- <main>
113
+ <main${o.wide ? ' class="wide"' : ''}>
114
+ ${o.ads ? adUnit() : ''}
90
115
  ${o.body}
91
116
  </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>
117
+ ${ringFooter()}
118
+ ${pitFooter()}
97
119
  </body>
98
120
  </html>`
99
121
  }
@@ -124,7 +146,7 @@ function row(ctx: Ctx, p: Product, rank?: number): string {
124
146
  <a class="row-main" href="/p/${esc(p.slug)}">
125
147
  ${logoImg(p)}
126
148
  <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>
149
+ <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
150
  </a>
129
151
  ${voteButton(ctx, p)}
130
152
  </li>`
@@ -141,69 +163,276 @@ function submitForm(compact = false, values: Record<string, string> = {}, error?
141
163
  <label for="makerName">Your name</label><input id="makerName" name="makerName" maxlength="60" value="${esc(values.makerName ?? '')}">
142
164
  <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
165
  </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>
166
+ <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
167
  </form>`
146
168
  }
147
169
 
148
- export function homePage(ctx: Ctx, d: { today: Product[]; featured: Product[]; yesterday: Product[]; stats: { products: number; votes: number } }): string {
170
+ /** One inviter on a board: rank, public name, how many of their invitees launched, badges. */
171
+ export type InviterRow = { rank: number | null; id: string; name: string; value: number; badges: { emoji: string; label: string }[]; url: string }
172
+
173
+ function invitersList(rows: InviterRow[]): string {
174
+ return `<ol class="board inviters">${rows
175
+ .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>`)
176
+ .join('')}</ol>`
177
+ }
178
+
179
+ /** The invite offer, in one sentence, wherever it is promoted. */
180
+ 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.`
181
+
182
+ function invitePromo(ctx: Ctx, d: { inviters: InviterRow[]; featureDays: number; featureUsd: number }): string {
183
+ return `<section class="card invite-promo" id="invite">
184
+ <div>
185
+ <h2>${icon('star')} Invite a maker, get Featured free</h2>
186
+ <p class="muted">${esc(inviteOffer(d.featureDays, d.featureUsd))}</p>
187
+ <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>
188
+ </div>
189
+ <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>
190
+ </section>`
191
+ }
192
+
193
+ /** Karma rows: rank, public name, karma, badges. */
194
+ function karmaList(rows: InviterRow[]): string {
195
+ return `<ol class="board inviters">${rows
196
+ .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>`)
197
+ .join('')}</ol>`
198
+ }
199
+
200
+ /** The "how to earn" table, wherever karma is explained. */
201
+ export function howToEarnTable(rows: { action: string; points: number; rule: string }[]): string {
202
+ 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>`
203
+ }
204
+
205
+ function karmaPromo(ctx: Ctx, d: { karma: { period: 'week' | 'all'; rows: InviterRow[] }; featureDays: number }): string {
206
+ return `<section class="card invite-promo karma-promo" id="karma">
207
+ <div>
208
+ <h2>${icon('trophy')} Earn karma, get Featured free</h2>
209
+ <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>
210
+ <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>
211
+ </div>
212
+ <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>
213
+ </section>`
214
+ }
215
+
216
+ 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
217
  return layout({
150
218
  ctx,
151
219
  title: 'built.indie: launch your indie product with one URL',
152
220
  description: 'Paste a URL. We fetch everything: logo, screenshots, videos, llms.txt. Get upvotes and a badge. Free.',
153
221
  path: '/',
154
- ads: true,
155
222
  body: `<section class="hero">
156
223
  <h1>Built it? Launch it.</h1>
157
224
  <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
225
  ${submitForm(true)}
159
226
  <p class="muted small">${d.stats.products} launched · ${d.stats.votes} upvotes · free, no sign-up to launch</p>
160
227
  </section>
228
+ ${karmaPromo(ctx, d)}
229
+ ${invitePromo(ctx, d)}
161
230
  ${d.featured.length ? `<h2 class="section-h">Featured</h2>${board(ctx, d.featured, false)}` : ''}
162
231
  <h2 class="section-h">Launching today <span class="muted small">resets at 00:00 UTC</span></h2>
163
232
  ${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
233
  ${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')}`,
234
+ <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
235
  })
168
236
  }
169
237
 
170
238
  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
239
 
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('')
240
+ export function boardPage(ctx: Ctx, window: Window, products: Product[]): string {
241
+ 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
242
  return layout({
175
243
  ctx,
176
- title: q ? `“${q}” on built.indie` : `${WINDOW_TITLES[window]} · built.indie`,
244
+ title: `${WINDOW_TITLES[window]} · built.indie`,
177
245
  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),
246
+ path: window === 'new' ? '/new' : `/top/${window}`,
247
+ ads: true,
248
+ body: `<h1 class="page-h">${esc(WINDOW_TITLES[window])}</h1>
249
+ <nav class="tabs" aria-label="Boards">${tabs}<a href="/search">Browse &amp; filter</a></nav>
250
+ ${board(ctx, products, window !== 'new') || '<p class="empty">Nothing here yet.</p>'}`,
251
+ })
252
+ }
253
+
254
+ /** Every tag, sized by how many launches carry it. */
255
+ export function tagsPage(ctx: Ctx, tags: { tag: string; count: number }[]): string {
256
+ const max = Math.max(1, ...tags.map((t) => t.count))
257
+ return layout({
258
+ ctx,
259
+ title: 'Tags · built.indie',
260
+ description: 'Browse indie products launched on built.indie by topic.',
261
+ path: '/tags',
262
+ body: `<h1 class="page-h">Tags</h1>
263
+ <p class="muted">Every tag, sized by use. <a href="/search">Browse with filters</a> to combine tags, categories and more.</p>
264
+ ${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>'}`,
265
+ })
266
+ }
267
+
268
+ const searchHref = (q: Query) => {
269
+ const s = queryString(q)
270
+ return s ? `/search?${s}` : '/search'
271
+ }
272
+
273
+ /** The facet rail: every value is a link that switches it on or off, with how many it would show. */
274
+ function facetRail(r: Result): string {
275
+ const groups = FACETS.map((f) => {
276
+ const values = r.facets[f.name]
277
+ if (!values.length) return ''
278
+ const selected = f.name === 'launched' ? (r.query.launched ? [r.query.launched] : []) : r.query[f.name]
279
+ const open = ['category', 'launched', 'has'].includes(f.name) || selected.length > 0
280
+ return `<details class="facet-group"${open ? ' open' : ''}><summary>${esc(f.label)}${selected.length ? ` <span class="facet-n">${selected.length}</span>` : ''}</summary><ul>${values
281
+ .map((v) => {
282
+ const on = selected.includes(v.value)
283
+ 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>`
284
+ })
285
+ .join('')}</ul></details>`
286
+ }).join('')
287
+ return groups || '<p class="muted small">Nothing to narrow yet.</p>'
288
+ }
289
+
290
+ function chipsHtml(r: Result): string {
291
+ if (!r.chips.length) return ''
292
+ return `<ul class="chips">${r.chips
293
+ .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>`)
294
+ .join('')}<li><a class="chip clear" href="/search">Clear all</a></li></ul>`
295
+ }
296
+
297
+ function pager(r: Result): string {
298
+ const pages = Math.ceil(r.total / r.limit)
299
+ if (pages <= 1) return ''
300
+ const at = r.query.page
301
+ 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>`
302
+ }
303
+
304
+ /**
305
+ * Browse and search: the facet rail, what is switched on, and the results.
306
+ * /search takes any combination; /c/<category> and /t/<tag> are the same page
307
+ * for one filter, under an address worth indexing.
308
+ */
309
+ export function browsePage(ctx: Ctx, r: Result, d: { path: string; heading?: string; intro?: string } = { path: '/search' }): string {
310
+ const q = r.query
311
+ const what = r.chips.map((c) => c.label).join(' · ')
312
+ const heading = d.heading ?? (isEmpty(q) ? 'Browse every launch' : q.q && r.chips.length === 1 ? `Search: ${q.q}` : what)
313
+ const hidden = (k: string, v: string) => `<input type="hidden" name="${esc(k)}" value="${esc(v)}">`
314
+ const keep = [
315
+ ...(q.q ? [hidden('q', q.q)] : []),
316
+ ...(['category', 'tag', 'has', 'stack', 'maker'] as const).flatMap((k) => q[k].map((v) => hidden(k, v))),
317
+ ...(q.launched ? [hidden('launched', q.launched)] : []),
318
+ ].join('')
319
+ return layout({
320
+ ctx,
321
+ title: `${heading} (${r.total}) · built.indie`,
322
+ description: d.intro ?? `${r.total} indie launch${r.total === 1 ? '' : 'es'}${what ? ` matching ${what}` : ''}. Filter by category, tag, stack, maker and more.`,
323
+ path: d.path,
324
+ // Filter combinations are endless; only the plain pages are worth indexing.
325
+ noindex: d.path === '/search' && !isEmpty(q),
180
326
  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')}`,
327
+ wide: true,
328
+ q: q.q,
329
+ body: `<div class="browse">
330
+ <details class="facets" open data-facets>
331
+ <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>
332
+ <div class="facet-rail">${facetRail(r)}</div>
333
+ </details>
334
+ <section class="results">
335
+ <header class="results-head">
336
+ <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>
337
+ <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)[])
338
+ .filter((s) => s !== 'relevance' || q.q)
339
+ .map((s) => `<option value="${s}"${q.sort === s ? ' selected' : ''}>${esc(SORTS[s])}</option>`)
340
+ .join('')}</select><noscript><button class="chip">Sort</button></noscript></form>
341
+ </header>
342
+ ${chipsHtml(r)}
343
+ ${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>`}
344
+ ${pager(r)}
345
+ </section>
346
+ </div>`,
186
347
  })
187
348
  }
188
349
 
350
+ /** Share intents. Each click sends a beacon to /p/<slug>/share?net=… first (app.js): a signed-in member earns karma. */
189
351
  function share(origin: string, p: Product): string {
190
352
  const url = `${origin}/p/${p.slug}`
191
353
  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}`)}`],
354
+ const links: [string, string, string, string][] = [
355
+ ['X', 'x', 'x', `https://x.com/intent/post?text=${enc(text)}&url=${enc(url)}`],
356
+ ['Bluesky', 'bluesky', 'bluesky', `https://bsky.app/intent/compose?text=${enc(`${text} ${url}`)}`],
357
+ ['Mastodon', 'mastodon', 'share', `https://mastodonshare.com/?text=${enc(text)}&url=${enc(url)}`],
358
+ ['LinkedIn', 'linkedin', 'linkedin', `https://www.linkedin.com/sharing/share-offsite/?url=${enc(url)}`],
359
+ ['Reddit', 'reddit', 'reddit', `https://www.reddit.com/submit?url=${enc(url)}&title=${enc(`${p.name}: ${p.tagline}`)}`],
360
+ ['Hacker News', 'hn', 'code', `https://news.ycombinator.com/submitlink?u=${enc(p.url)}&t=${enc(`Show HN: ${p.name} – ${p.tagline}`)}`],
198
361
  ]
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>`
362
+ 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>`
363
+ }
364
+
365
+ /** A comment or review as the page shows it: the public name and karma, never the email. */
366
+ export type CommentView = { id: string; parentId: string | null; body: string; deleted: boolean; at: number; author: string; karma: number; maker: boolean; mine: boolean }
367
+ export type ReviewView = { rating: number; body: string; at: number; author: string; karma: number; mine: boolean }
368
+
369
+ const when = (ms: number) => new Date(ms).toISOString().slice(0, 10)
370
+ const stars = (n: number) => `<span class="stars" aria-label="${n} of 5 stars">${'★'.repeat(n)}<span class="off">${'★'.repeat(5 - n)}</span></span>`
371
+ /** Paragraphs from plain text: escaped, line breaks kept. */
372
+ const prose = (t: string) => t.split(/\n{2,}/).map((para) => `<p>${esc(para).replace(/\n/g, '<br>')}</p>`).join('')
373
+ const byline = (name: string, karma: number, extra = '') => `<b class="who">${esc(name)}</b> <span class="karma" title="Karma">${karma} karma</span>${extra}`
374
+
375
+ function reviewsHtml(ctx: Ctx, p: Product, reviews: ReviewView[], mine: Review | null, self: boolean): string {
376
+ const form = !ctx.user
377
+ ? `<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>`
378
+ : self
379
+ ? '<p class="muted small">This is your listing: reviews come from everyone else.</p>'
380
+ : `<form method="post" action="/p/${esc(p.slug)}/review" class="review-form">
381
+ <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>
382
+ <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>
383
+ <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>
384
+ </form>`
385
+ return `<section class="card discuss" id="reviews">
386
+ <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>
387
+ ${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>'}
388
+ ${form}
389
+ </section>`
390
+ }
391
+
392
+ function commentsHtml(ctx: Ctx, p: Product, comments: CommentView[]): string {
393
+ const top = comments.filter((c) => !c.parentId)
394
+ const replies = (id: string) => comments.filter((c) => c.parentId === id)
395
+ 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>` : '')
396
+ const one = (c: CommentView) =>
397
+ c.deleted
398
+ ? `<article class="post deleted" id="c-${esc(c.id)}"><p class="muted small">[deleted]</p></article>`
399
+ : `<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>`
400
+ const box = (parent?: string) =>
401
+ ctx.user
402
+ ? `<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>`
403
+ : ''
404
+ return `<section class="card discuss" id="comments">
405
+ <h2 class="mini-h">${icon('user')} Comments <span class="muted small">${comments.filter((c) => !c.deleted).length}</span></h2>
406
+ ${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>'}
407
+ ${ctx.user ? box() : `<p class="muted small"><a href="/login?next=${enc(`/p/${p.slug}#comments`)}">Sign in</a> to comment (+2 karma).</p>`}
408
+ </section>`
409
+ }
410
+
411
+ /** schema.org data for search engines; the rating only once someone has reviewed it. */
412
+ function productJsonLd(origin: string, p: Product, reviews: ReviewView[]): string {
413
+ const data: Record<string, unknown> = {
414
+ '@context': 'https://schema.org',
415
+ '@type': 'SoftwareApplication',
416
+ name: p.name,
417
+ description: p.tagline || p.description || undefined,
418
+ url: p.url,
419
+ applicationCategory: 'WebApplication',
420
+ ...(cardImage(origin, p) ? { image: cardImage(origin, p) } : {}),
421
+ ...(p.rating !== null && p.reviews ? { aggregateRating: { '@type': 'AggregateRating', ratingValue: p.rating, reviewCount: p.reviews, bestRating: 5, worstRating: 1 } } : {}),
422
+ ...(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) })) } : {}),
423
+ }
424
+ return `<script type="application/ld+json">${JSON.stringify(data).replace(/</g, '\\u003c')}</script>`
201
425
  }
202
426
 
203
427
  export function badgeSnippet(origin: string, p: Product): string {
204
428
  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
429
  }
206
430
 
431
+ /** The badge with this account's badge id on it: placed on the homepage, it claims the listing (and earns the ✓). */
432
+ export function claimBadgeSnippet(origin: string, p: Product, badgeId: string): string {
433
+ return `<a href="${origin}/p/${p.slug}"><img src="${origin}/images.svg?id=${badgeId}" alt="Launched on built.indie" width="200" height="44"></a>`
434
+ }
435
+
207
436
  /** "Built with", from the product's own OpenStack.md. */
208
437
  function stackHtml(p: Product): string {
209
438
  const s = p.extras.stack
@@ -223,7 +452,7 @@ function pricingHtml(p: Product): string {
223
452
 
224
453
  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
454
 
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 {
455
+ 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; launched?: boolean; canEdit?: boolean; credits?: number; buyerEmail?: string; featureUsd: number; featureDays: number; payments: boolean }): string {
227
456
  const p = d.product
228
457
  const shots = [p.screenshot, ...p.images, p.ogImage].filter((f): f is string => Boolean(f))
229
458
  const rel = p.verifiedAt ? 'noopener' : 'noopener nofollow ugc'
@@ -237,16 +466,17 @@ export function productPage(ctx: Ctx, d: { product: Product; award: { day: strin
237
466
  image: cardImage(ctx.origin, p),
238
467
  noindex: !live,
239
468
  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>` : ''}
469
+ 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 ?? []) : ''}`,
470
+ 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
471
  ${d.featuredOk ? '<p class="note">Thanks! Your payment is on its way; the Featured spot starts the moment CoinPay confirms it.</p>' : ''}
472
+ ${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>` : ''}
243
473
  ${!live ? `<p class="note">${p.status === 'draft' ? 'Draft: only people with the edit link see this.' : 'Taken down.'}</p>` : ''}
244
474
  <section class="product-head">
245
475
  ${logoImg(p, 72)}
246
476
  <div class="product-title">
247
477
  <h1>${esc(p.name)}</h1>
248
478
  <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>
479
+ <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
480
  </div>
251
481
  <div class="product-actions">
252
482
  ${live ? voteButton(ctx, p, true) : ''}
@@ -258,14 +488,16 @@ ${shots.length ? `<div class="gallery">${shots.map((f, i) => `<a href="${esc(med
258
488
  ${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
489
  <div class="card">
260
490
  ${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>` : ''}
491
+ <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
492
  ${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
493
  ${stackHtml(p)}
264
494
  ${pricingHtml(p)}
265
495
  ${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
496
  <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
497
  </div>
268
- ${live ? `<h2 class="section-h">Share it</h2>${share(ctx.origin, p)}
498
+ ${live ? `${reviewsHtml(ctx, p, d.reviews ?? [], d.myReview ?? null, Boolean(d.selfListing))}
499
+ ${commentsHtml(ctx, p, d.comments ?? [])}
500
+ <h2 class="section-h">Share it</h2>${share(ctx.origin, p)}
269
501
  <details class="card" id="badge"${p.verifiedAt || !d.canEdit ? '' : ' open'}>
270
502
  <summary>Add the badge to your site</summary>
271
503
  <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 +505,11 @@ ${live ? `<h2 class="section-h">Share it</h2>${share(ctx.origin, p)}
273
505
  <pre><code>${esc(badgeSnippet(ctx.origin, p))}</code></pre>
274
506
  <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
507
  </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')}` : ''}`,
508
+ ${d.payments || d.canEdit ? `<details class="card" id="feature"${d.canEdit && d.credits ? ' open' : ''}><summary>Feature it: ${esc(`$${d.featureUsd}`)} for ${d.featureDays} days, or free with an invite</summary>
509
+ <p class="muted">Pinned above today’s board on the homepage for ${d.featureDays} days.</p>
510
+ ${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>`}
511
+ ${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>` : ''}
512
+ </details>` : ''}` : ''}`,
281
513
  })
282
514
  }
283
515
 
@@ -307,7 +539,7 @@ export function editPage(ctx: Ctx, d: { product: Product; fresh?: boolean; flash
307
539
  description: 'Edit your launch.',
308
540
  path: `/p/${p.slug}/edit`,
309
541
  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>` : ''}
542
+ 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
543
  ${d.flash ? `<p class="note">${esc(d.flash)}</p>` : ''}
312
544
  ${d.error ? `<p class="note error" role="alert">${esc(d.error.message)}</p>` : ''}
313
545
  <h1 class="page-h">${p.status === 'draft' ? 'Check it, then launch' : `Edit ${esc(p.name)}`}</h1>
@@ -335,14 +567,32 @@ ${d.error ? `<p class="note error" role="alert">${esc(d.error.message)}</p>` : '
335
567
  * access to the domain's DNS. Everything it needs is in it: the exact record,
336
568
  * where it goes, how to check it took, and how to finish the claim.
337
569
  */
338
- export function claimMarkdown(origin: string, p: Product, record: { name: string; value: string }): string {
570
+ export function claimMarkdown(origin: string, p: Product, record: { name: string; value: string }, badge: string): string {
339
571
  const bare = p.domain.replace(/^www\./, '')
340
572
  const label = record.name.slice(0, -(bare.length + 1))
341
- return `# Claim ${p.name} on built.indie: add one DNS TXT record
573
+ const id = badge.match(/images\.svg\?id=([A-Za-z0-9_-]+)/)?.[1] ?? ''
574
+ return `# Claim ${p.name} on built.indie: the badge on the homepage, or one DNS TXT record
575
+
576
+ 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.
577
+
578
+ ## Option A (easiest): the badge on the homepage
342
579
 
343
- Prove control of \`${bare}\` so the listing ${origin}/p/${p.slug} can be claimed and edited.
580
+ Put this HTML on the homepage, ${p.url}, and deploy it:
344
581
 
345
- ## The record
582
+ \`\`\`html
583
+ ${badge}
584
+ \`\`\`
585
+
586
+ 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:
587
+
588
+ \`\`\`sh
589
+ curl -s ${p.url} | grep -o 'images.svg?id=[A-Za-z0-9_-]*'
590
+ # expect: images.svg?id=${id}
591
+ \`\`\`
592
+
593
+ Then finish the claim (below). Keep the badge up for the followed link.
594
+
595
+ ## Option B: a DNS TXT record
346
596
 
347
597
  | Field | Value |
348
598
  | --- | --- |
@@ -353,7 +603,7 @@ Prove control of \`${bare}\` so the listing ${origin}/p/${p.slug} can be claimed
353
603
 
354
604
  The same TXT value on \`${bare}\` itself also works, if a \`${label}\` name is not possible.
355
605
 
356
- ## Steps
606
+ ### Steps
357
607
 
358
608
  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
609
  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,48 +614,63 @@ The same TXT value on \`${bare}\` itself also works, if a \`${label}\` name is n
364
614
  # expect: "${record.value}"
365
615
  \`\`\`
366
616
 
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**
617
+ 4. Finish the claim (below). The record can be removed once the claim succeeds.
618
+
619
+ A \`<meta name="built-indie-verify" content="${record.value}">\` tag (the bare \`${record.value}\` string anywhere in the homepage HTML) also counts.
620
+
621
+ ## Finish the claim
622
+
623
+ Any one of:
624
+ - open ${origin}/p/${p.slug}/claim (signed in as the same account) and press **Check**
369
625
  - \`BUILTINDIE_KEY=bi_… builtindie claim ${p.slug}\`
370
626
  - \`curl -X POST ${origin}/api/v1/products/${p.slug}/claim -H 'authorization: Bearer bi_…'\` (answers \`{"claimed": true}\`)
371
627
 
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.
628
+ 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
629
  `
374
630
  }
375
631
 
376
- export function claimPage(ctx: Ctx, d: { product: Product; record: { name: string; value: string }; owner: boolean; result?: { ok: boolean; looked: string[] } }): string {
632
+
633
+ 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
634
  const p = d.product
378
- const md = claimMarkdown(ctx.origin, p, d.record)
635
+ const md = claimMarkdown(ctx.origin, p, d.record, d.badge)
379
636
  const done = d.owner || d.result?.ok
380
637
  return layout({
381
638
  ctx,
382
639
  title: `Claim ${p.name} · built.indie`,
383
- description: `Prove you own ${p.domain} with a DNS record.`,
640
+ description: `Prove you own ${p.domain} with the badge or a DNS record.`,
384
641
  path: `/p/${p.slug}/claim`,
385
642
  noindex: true,
386
643
  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>` : `
644
+ ${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
645
  ${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>
646
+ ${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>` : ''}
647
+ <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>
648
+ <div class="card" id="badge-claim">
649
+ <h2 class="mini-h">1. The badge on your homepage (easiest)</h2>
650
+ <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>
651
+ <p><img src="/badge/${esc(p.slug)}.svg" alt="badge" width="200" height="44"></p>
652
+ <pre><code>${esc(d.badge)}</code></pre>
653
+ <div class="row"><button class="chip" data-copy="${esc(d.badge)}">Copy badge HTML</button></div>
654
+ </div>
391
655
  <div class="card">
656
+ <h2 class="mini-h">2. Or a DNS record</h2>
392
657
  <p class="small muted">Type</p><p><code>TXT</code></p>
393
658
  <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
659
  <p class="small muted">Some DNS hosts want only <code>_built-indie</code> here; others want the full name.</p>
395
660
  <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
661
  <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
662
  </div>
663
+ <form method="post" action="/p/${esc(p.slug)}/claim" data-busy="Checking your homepage and DNS…"><button class="btn">Check</button></form>
399
664
  <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>
665
+ <h2 class="mini-h">${icon('terminal')} Have an AI do it</h2>
666
+ <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
667
  <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
668
  <details><summary class="small">Preview</summary><pre><code>${esc(md)}</code></pre></details>
404
669
  </div>`}`,
405
670
  })
406
671
  }
407
672
 
408
- export function loginPage(ctx: Ctx, d: { next: string | null; sent?: boolean; error?: string; email?: string }): string {
673
+ export function loginPage(ctx: Ctx, d: { next: string | null; sent?: boolean; error?: string; email?: string; invitedBy?: string | null }): string {
409
674
  return layout({
410
675
  ctx,
411
676
  title: 'Sign in · built.indie',
@@ -414,16 +679,29 @@ export function loginPage(ctx: Ctx, d: { next: string | null; sent?: boolean; er
414
679
  noindex: true,
415
680
  body: `<section class="hero narrow"><h1>${d.sent ? 'Check your email' : 'Sign in'}</h1>
416
681
  ${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>
682
+ ${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
683
  ${d.error ? `<p class="note error" role="alert">${esc(d.error)}</p>` : ''}
418
684
  <form method="post" action="/login" class="card">
419
685
  <input type="hidden" name="next" value="${esc(d.next ?? '')}">
420
686
  <label for="email">Email</label><input id="email" name="email" type="email" autocomplete="email" required value="${esc(d.email ?? '')}">
421
687
  <p><button class="btn wide">Email me a link</button></p>
422
- </form>`}</section>`,
688
+ </form>
689
+ ${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
690
  })
424
691
  }
425
692
 
426
- export function mePage(ctx: Ctx, d: { user: User; products: Product[]; apiKey?: string }): string {
693
+ function karmaCard(k: KarmaSummary, howToEarn: { action: string; points: number; rule: string }[], featureDays: number): string {
694
+ const pct = Math.round(k.progress * 100)
695
+ return `<div class="card" id="karma"><h2>${icon('trophy')} Karma <span class="karma-total">${k.total}</span></h2>
696
+ <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>
697
+ <p><b>${k.nextCreditIn} karma</b> to your next free Featured week (${featureDays} days pinned on the homepage).</p>
698
+ <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>
699
+ ${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>'}
700
+ <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>
701
+ </div>`
702
+ }
703
+
704
+ export function mePage(ctx: Ctx, d: { user: User; products: Product[]; apiKey?: string; invite: InviteStats; karma?: KarmaSummary; howToEarn?: { action: string; points: number; rule: string }[]; featureDays?: number; coinpay?: { name: string | null } | null; emailless?: boolean; coinpayNote?: string | null }): string {
427
705
  return layout({
428
706
  ctx,
429
707
  title: 'Your launches · built.indie',
@@ -431,8 +709,12 @@ export function mePage(ctx: Ctx, d: { user: User; products: Product[]; apiKey?:
431
709
  path: '/me',
432
710
  noindex: true,
433
711
  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>
712
+ ${d.coinpayNote ? `<p class="note">${esc(d.coinpayNote)}</p>` : ''}
713
+ <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
714
  ${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>'}
715
+ ${d.karma ? karmaCard(d.karma, d.howToEarn ?? [], d.featureDays ?? 7) : ''}
716
+ <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>
717
+ ${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>` : ''}
436
718
  <div class="card" id="api"><h2>API key</h2>
437
719
  ${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
720
  <form method="post" action="/me/key"><button class="chip">${d.apiKey ? 'Make another' : 'Make an API key'}</button></form></div>`,
@@ -463,22 +745,39 @@ export function helpPage(ctx: Ctx): string {
463
745
  description: 'Launch, read and upvote from code: a JSON API, a CLI, a TUI and an MCP server.',
464
746
  path: '/help',
465
747
  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>
748
+ <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
749
  <div class="card"><h2>API</h2>
468
750
  <pre>curl -X POST ${o}/api/v1/products -H 'content-type: application/json' \\
469
751
  -d '{"url":"yourproduct.com","maker_name":"You"}'
470
752
  # → { product, claim_url } free; live at once
471
753
 
472
754
  curl ${o}/api/v1/products?window=today # today|week|month|all|new|featured
755
+ curl ${o}/api/v1/products?tag=game # one tag
756
+ curl ${o}/api/v1/tags # every tag with its count
757
+ curl '${o}/api/v1/search?q=crm&amp;category=ai&amp;tag=productivity&amp;has=verified&amp;sort=new' # + facets with counts
758
+ curl ${o}/api/v1/categories # every category and tag, with counts
473
759
  curl ${o}/api/v1/products/&lt;slug&gt;
474
760
  curl -X POST ${o}/api/v1/products/&lt;slug&gt;/vote
475
761
  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
762
+ curl ${o}/api/v1/products/&lt;slug&gt;/claim -H 'authorization: Bearer bi_…' # your badge (with your badge id), or the TXT record
763
+ 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
764
  curl -X PATCH ${o}/api/v1/products/&lt;slug&gt; -H 'authorization: Bearer bi_…' \\
479
765
  -H 'content-type: application/json' -d '{"tagline":"…","videos":["https://youtu.be/…"]}'
480
766
  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>
767
+ curl -X POST ${o}/api/v1/products/&lt;slug&gt;/publish -H 'authorization: Bearer bi_…'
768
+ curl ${o}/api/v1/invites -H 'authorization: Bearer bi_…' # your invite link and credits
769
+ curl -X POST ${o}/api/v1/products/&lt;slug&gt;/credit -H 'authorization: Bearer bi_…' # spend one: Featured for a week
770
+ 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)
771
+ curl ${o}/leaderboard/invites.json?period=week # top inviters (all|week|month)
772
+ curl '${o}/api/v1/leaderboard?board=karma&amp;period=week' # top karma (board karma|invites)
773
+ curl ${o}/api/v1/karma -H 'authorization: Bearer bi_…' # your karma, history, next free Featured week
774
+ curl ${o}/api/v1/products/&lt;slug&gt;/comments
775
+ curl -X POST ${o}/api/v1/products/&lt;slug&gt;/comments -H 'authorization: Bearer bi_…' -d '{"body":"…","parent":"&lt;comment id, optional&gt;"}'
776
+ curl -X DELETE ${o}/api/v1/comments/&lt;id&gt; -H 'authorization: Bearer bi_…' # your own (the points go back)
777
+ curl ${o}/api/v1/products/&lt;slug&gt;/reviews
778
+ curl -X POST ${o}/api/v1/products/&lt;slug&gt;/reviews -H 'authorization: Bearer bi_…' -d '{"rating":5,"body":"…"}'
779
+ 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>
780
+ <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
781
  <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
782
  <div class="card" id="cli"><h2>CLI and TUI</h2>
484
783
  <pre>curl -fsSL ${o}/install.sh | sh # installs builtindie
@@ -486,13 +785,22 @@ builtindie # TUI: today's board, vote, launch
486
785
  bunx @profullstack/builtindie # or run it without installing
487
786
  bunx @profullstack/builtindie launch yourproduct.com
488
787
  bunx @profullstack/builtindie top week
788
+ bunx @profullstack/builtindie search crm --category ai --has verified --facets
489
789
  bunx @profullstack/builtindie show &lt;slug&gt;
490
790
  bunx @profullstack/builtindie vote &lt;slug&gt;
491
- BUILTINDIE_KEY=bi_… bunx @profullstack/builtindie claim &lt;slug&gt; # TXT record, then checks it
791
+ BUILTINDIE_KEY=bi_… bunx @profullstack/builtindie claim &lt;slug&gt; # your badge (or a TXT record), then checks it
792
+ BUILTINDIE_KEY=bi_… bunx @profullstack/builtindie invite # your invite link and free Featured credits
793
+ BUILTINDIE_KEY=bi_… bunx @profullstack/builtindie karma # your karma and history
794
+ bunx @profullstack/builtindie leaderboard [karma|invites] [--period week|month|all]
795
+ BUILTINDIE_KEY=bi_… bunx @profullstack/builtindie comment &lt;slug&gt; "text" [--reply &lt;id&gt;]
796
+ BUILTINDIE_KEY=bi_… bunx @profullstack/builtindie review &lt;slug&gt; 5 "text"
797
+ bunx @profullstack/builtindie comments &lt;slug&gt; · reviews &lt;slug&gt;
492
798
  bunx @profullstack/builtindie preview any.site --json</pre></div>
493
799
  <div class="card" id="mcp"><h2>MCP</h2>
494
800
  <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>
801
+ <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>
802
+ <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>
803
+ <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
804
  <div class="card" id="bot"><h2>Our reader</h2>
497
805
  <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
806
  <div class="card"><h2>Make your page read well</h2>
@@ -500,11 +808,117 @@ bunx @profullstack/builtindie preview any.site --json</pre></div>
500
808
  })
501
809
  }
502
810
 
811
+ /** Your invite link, what it has earned, and the listings a credit can feature. */
812
+ export function invitePage(ctx: Ctx, d: { featureDays: number; featureUsd: number; inviters: InviterRow[]; me?: { link: string; stats: InviteStats; name: string; displayName: string | null; listings: Product[]; flash?: string; error?: string } }): string {
813
+ const m = d.me
814
+ const text = 'I launch my indie products on built.indie: one URL and it pulls in the logo, screenshots and story. Launch yours free:'
815
+ const shares: [string, string, string][] = m
816
+ ? [
817
+ ['X', 'x', `https://x.com/intent/post?text=${enc(text)}&url=${enc(m.link)}`],
818
+ ['Bluesky', 'bluesky', `https://bsky.app/intent/compose?text=${enc(`${text} ${m.link}`)}`],
819
+ ['LinkedIn', 'linkedin', `https://www.linkedin.com/sharing/share-offsite/?url=${enc(m.link)}`],
820
+ ['Email', 'mail', `mailto:?subject=${enc('Launch your product on built.indie')}&body=${enc(`${text}\n\n${m.link}`)}`],
821
+ ]
822
+ : []
823
+ const featuring = (p: Product) => (p.featuredUntil && p.featuredUntil > Date.now() ? `Featured until ${new Date(p.featuredUntil).toISOString().slice(0, 10)}` : p.domain)
824
+ return layout({
825
+ ctx,
826
+ title: 'Invite makers, get Featured free · built.indie',
827
+ description: inviteOffer(d.featureDays, d.featureUsd),
828
+ path: '/invite',
829
+ ads: !m,
830
+ body: `<section class="hero"><h1>Invite a maker.<br>Get Featured free.</h1>
831
+ <p class="lede">${esc(inviteOffer(d.featureDays, d.featureUsd))}</p></section>
832
+ ${m?.flash ? `<p class="note launched">${esc(m.flash)}</p>` : ''}${m?.error ? `<p class="note error" role="alert">${esc(m.error)}</p>` : ''}
833
+ ${
834
+ m
835
+ ? `<div class="card"><h2>Your invite link</h2>
836
+ <div class="address"><code>${esc(m.link)}</code><button class="copy" data-copy="${esc(m.link)}">Copy</button></div>
837
+ <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>
838
+ <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>
839
+ </div>
840
+ ${
841
+ m.stats.credits
842
+ ? `<div class="card"><h2>${icon('star')} Use a credit</h2>${
843
+ m.listings.length
844
+ ? `<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>`
845
+ : '<p class="muted">You have no live listing yet. <a href="/submit">Launch one</a>, then come back to feature it.</p>'
846
+ }</div>`
847
+ : ''
848
+ }
849
+ <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>
850
+ <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>`
851
+ : '<p class="center"><a class="btn" href="/login?next=/invite">Sign in to get your invite link</a></p>'
852
+ }
853
+ <div class="grid3">
854
+ <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>
855
+ <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>
856
+ <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>
857
+ </div>
858
+ <h2 class="section-h">Top inviters this week <a class="small" href="/leaderboard">full board</a></h2>
859
+ ${d.inviters.length ? invitersList(d.inviters) : '<p class="empty">Nobody yet. One invite that launches puts you at #1.</p>'}
860
+ <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>`,
861
+ })
862
+ }
863
+
864
+ /** Where an invite link lands: who invited you, and one step to launch. */
865
+ export function inviteLanding(ctx: Ctx, d: { inviter: string }): string {
866
+ return layout({
867
+ ctx,
868
+ title: `${d.inviter} invited you to launch on built.indie`,
869
+ description: 'Launch your indie product with one URL. We fetch the logo, screenshots and story; people upvote; you get a badge. Free.',
870
+ path: '/invite',
871
+ noindex: true,
872
+ body: `<section class="hero"><h1>${esc(d.inviter)} invited you.</h1>
873
+ <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>
874
+ ${
875
+ ctx.user
876
+ ? `<p><a class="btn" href="/submit">${icon('rocket')} Launch your product</a></p>`
877
+ : `<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>`
878
+ }
879
+ </section>`,
880
+ })
881
+ }
882
+
883
+ /** The boards in the site's layout: karma (default) and inviters; the widget is @profullstack/leaderboard's. */
884
+ 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 {
885
+ const karma = d.board === 'karma'
886
+ const title = karma ? 'Top karma' : 'Top inviters'
887
+ 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>`
888
+ 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)
889
+ return layout({
890
+ ctx,
891
+ title: `${title} · built.indie`,
892
+ description: karma ? `The most helpful members of built.indie. ${intro}` : `The members who brought the most makers to built.indie. ${intro}`,
893
+ path: karma ? '/leaderboard' : '/leaderboard?board=invites',
894
+ ads: true,
895
+ head: `<link rel="alternate" type="application/rss+xml" title="${title}" href="/leaderboard/${d.board}.xml?period=week">`,
896
+ body: `<h1 class="page-h">${icon('trophy')} ${title}</h1>
897
+ ${tabs}
898
+ <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>
899
+ ${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>' : ''}
900
+ <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>
901
+ ${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>` : ''}
902
+ <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>`,
903
+ })
904
+ }
905
+
503
906
  export function messagePage(ctx: Ctx, d: { title: string; message: string; status?: number }): string {
504
907
  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
908
  }
506
909
 
507
910
  /** The embeddable badge: live upvotes, or the day's award once it is won. */
911
+ /** The badge for a site with no listing (the shared footer asks for every site): an invitation to launch. */
912
+ export function launchBadgeSvg(theme: 'dark' | 'light'): string {
913
+ const dark = theme === 'dark'
914
+ 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">
915
+ <rect x=".5" y=".5" width="199" height="43" rx="9" fill="${dark ? '#111018' : '#ffffff'}" stroke="#ff5c39"/>
916
+ <path d="M22 9 12 21h6v12h8V21h6z" fill="#ff5c39"/>
917
+ <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>
918
+ <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>
919
+ </svg>`
920
+ }
921
+
508
922
  export function badgeSvg(p: Product, award: { day: string; rank: number } | null, theme: 'dark' | 'light'): string {
509
923
  const dark = theme === 'dark'
510
924
  const bg = dark ? '#111018' : '#ffffff'