@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/OpenStack.md CHANGED
@@ -48,7 +48,7 @@ Launch your indie product with one URL: we read the homepage and the LogicSRC fi
48
48
 
49
49
  ## Services
50
50
 
51
- - CoinPay: crypto payments for Featured
51
+ - CoinPay: hosted checkout sessions for Featured (any coin or PayPal)
52
52
  - Resend: sign-in links
53
53
  - crawlproof: analytics and ads
54
54
  - Headless Chrome: homepage screenshots, run as nobody
package/README.md CHANGED
@@ -14,9 +14,24 @@ Paste a URL and built.indie reads the homepage the way a careful person would: t
14
14
 
15
15
  Then it goes on today's board. People upvote (one per visitor). The board resets at midnight UTC; yesterday's winners get "#1 Product of the day" on their page and badge.
16
16
 
17
- **Free to launch, no account.** Anyone can launch any site. To edit a listing, sign in (an emailed link) and **claim** it by adding a DNS TXT record, `_built-indie.<domain>` → `built-indie-verify=<token>`. A signed-in submitter can edit until the owner claims it.
17
+ **Free to launch, no account.** Anyone can launch any site. To edit a listing, sign in (an emailed link) and **claim** it. The easy way is the badge: put `<img src="https://built.indie.pit.world/images.svg?id=<id>">` on the homepage, where the id is unique to that listing and your account (the claim page, `claim.md`, the API and the CLI hand you the full snippet). Finding it claims the listing for you and earns the followed link and the ✓ in one step. Or add a DNS TXT record, `_built-indie.<domain>` → `built-indie-verify=<token>`. (Badges placed earlier as `/badge/<slug>.svg?claim=<token>` still claim.) A signed-in submitter can edit until the owner claims it.
18
18
 
19
- Viral bits: a live badge with the upvote count (`/badge/<slug>.svg`) that turns a link back into a followed link and a ✓ once we see it on the homepage, share buttons, an OG card per product, RSS, and an optional crypto-paid **Featured** slot (CoinPay).
19
+ Viral bits: a live badge with the upvote count (`/badge/<slug>.svg`) that turns a link back into a followed link and a ✓ once we see it on the homepage, share buttons, an OG card per product, RSS, and an optional paid **Featured** slot: CoinPay's hosted checkout, where the buyer picks any coin or PayPal.
20
+
21
+ **Tags.** Every listing is tagged with what its homepage declares (opensite.json `tags`/`keywords`, `<meta name="keywords">`, `article:tag`, JSON-LD `keywords`, `applicationCategory` and `genre`, manifest `categories`). A site that declares none gets up to four topics read from its own name, tagline and description (`src/tags.ts`), and listings launched before that are backfilled on start. Owners edit them on the listing. Browse at `/tags` and `/t/<tag>`; `GET /api/v1/tags`, `GET /api/v1/products?tag=<tag>`, `builtindie tags [tag]`, MCP `list_tags` and `tagged`.
22
+
23
+ **Invite program.** Every account has an invite link, `/i/<code>` (on `/invite`, `/me`, `builtindie invite`, `GET /api/v1/invites`, MCP `my_invites`). When a new member who signed up through it gets their first listing live, the inviter earns a credit: a free **Featured** week (the same slot that costs $19 in crypto). Spend it on any listing you own from `/invite`, the listing's page, `builtindie credit <slug>`, `POST /api/v1/products/<slug>/credit` or MCP `feature_with_credit`. Once per new member, never yourself; the code rides in the magic link, so it counts when the mail opens in another browser; an admin takedown of the listing takes back an unspent credit. The inviter gets an email when a credit lands.
24
+
25
+ **Credit codes and invite codes (0.7.0).** A credit code (`CR-XXXX-XXXX-XXXX-XXXX`, [@profullstack/redeem-credits](https://www.npmjs.com/package/@profullstack/redeem-credits), unit `featured_days`) adds Featured days to a member's balance: "Have a code?" on `/me`, `POST /api/v1/redeem`, `builtindie redeem <code>`, MCP `redeem_code`. The days go on any live listing you own through the same `featured_until` a paid week extends (`/me`, the listing's Feature card, `POST /api/v1/products/<slug>/feature-days {"days":n}`, `builtindie feature-days <slug> <n>`, MCP `feature_with_days`). Every member also has **3 single-use invite codes** ([@profullstack/affiliates](https://www.npmjs.com/package/@profullstack/affiliates)) on `/invite#codes`, `POST /api/v1/invites`, `builtindie invite-code`, MCP `create_invite`: someone new who signs up through `/i/INV-…` gives both people **2 Featured days**, and the sign-up counts once in the invite program above (same `invites` row). `?ref=<your invite code>` on any page is your invite link too (the same `bi_ref` cookie, first touch). Mint codes on the server:
26
+
27
+ ```sh
28
+ cd /opt/built.indie && /root/.bun/bin/bun $(readlink -f node_modules/.bin/redeem-credits) mint \
29
+ --db /root/.local/share/built-indie/built-indie.db --unit featured_days --amount 7 --label "for Ada"
30
+ ```
31
+
32
+ **Leaderboard.** `/leaderboard` ranks the top inviters (week, month, all time) with badges, on [@profullstack/leaderboard](https://github.com/profullstack/leaderboard). It projects the `invites` table, so there is no second ledger; badges live in the `lb_` tables of the same database. The package also serves `/leaderboard.json`, `/leaderboard/invites.json|.xml`, a share card per member at `/leaderboard/u/<id>`, and a widget any site can embed (`/leaderboard/embed.js`). Members appear by the name they set on `/invite`, else as "Maker of <their best listing>"; never by email.
33
+
34
+ **Browse and search.** A search box in the header of every page (press `/`; it suggests launches as you type) opens `/search`: a facet rail in the style of [r4ck.dev](https://r4ck.dev) with counts for category, tags, when it launched, what it has (✓ verified, claimed, video, llms.txt, OpenSite/OpenProfile/OpenStack/OpenSaaS), what it is built with (OpenStack.md) and maker, plus sort and active-filter chips. Values in one facet are OR'd, facets are AND'd, and each facet counts as if only it changed. Tags are what the page or the maker said; categories are a fixed set of twenty-one read from a listing's tags, name, tagline and description (`src/browse.ts`), so every launch is filed even with no tags. `/c/<category>` and `/t/<tag>` are indexable pages for one filter; the same query is `GET /api/v1/search` (products + facets), `GET /api/v1/categories`, `builtindie search … --category ai --has verified --facets`, and the MCP `search` / `list_categories` tools.
20
35
 
21
36
  Every product is published back as an OpenSite record (`/p/<slug>/opensite.json`) and an OpenProfile (`/p/<slug>/openprofile.md`); makers are OpenProfiles too (`/makers/<id>/openprofile.md`). Icons are the [OpenIcon](https://github.com/profullstack/openicon) reference set.
22
37
 
@@ -28,11 +43,17 @@ curl -fsSL https://built.indie.pit.world/install.sh | sh
28
43
  builtindie # TUI: today's board, ←/→ boards, v upvote, l launch
29
44
  builtindie launch yourproduct.com
30
45
  builtindie top week
46
+ builtindie search crm --category ai --has verified # filters, as on /search
31
47
  builtindie show linear
32
48
  builtindie vote linear
33
49
  builtindie preview any.site --json
34
- BUILTINDIE_KEY=bi_… builtindie claim linear # prints the TXT record, then checks it
50
+ BUILTINDIE_KEY=bi_… builtindie claim linear # prints your badge and the TXT record, then checks them
35
51
  BUILTINDIE_KEY=bi_… builtindie edit linear --tagline "…"
52
+ BUILTINDIE_KEY=bi_… builtindie invite # your invite link and free Featured credits
53
+ BUILTINDIE_KEY=bi_… builtindie credit linear # spend one: Featured for a week
54
+ BUILTINDIE_KEY=bi_… builtindie redeem CR-… # a credit code: Featured days
55
+ BUILTINDIE_KEY=bi_… builtindie invite-code # one of your 3 single-use invites
56
+ BUILTINDIE_KEY=bi_… builtindie feature linear # or pay: a CoinPay checkout link (any coin or PayPal)
36
57
 
37
58
  claude mcp add builtindie -- bunx @profullstack/builtindie mcp
38
59
  ```
@@ -54,7 +75,7 @@ bun test
54
75
  | `BUILTINDIE_DATA` | where the database and pictures live (default ~/.local/share/built-indie) |
55
76
  | `BUILTINDIE_ADMIN_KEY` | bearer that may take a listing down |
56
77
  | `RESEND_API_KEY`, `MAIL_FROM` | sign-in mail; without them the link is logged |
57
- | `COINPAY_API_KEY`, `COINPAY_BUSINESS_ID`, `COINPAY_WEBHOOK_SECRET` | Featured payments |
78
+ | `COINPAY_API_KEY`, `COINPAY_BUSINESS_ID`, `COINPAY_WEBHOOK_SECRET` | Featured payments: a CoinPay hosted checkout session per purchase, settled by the signed webhook at `/hooks/coinpay` |
58
79
  | `FEATURE_USD`, `FEATURE_DAYS` | price and length of Featured (19, 7) |
59
80
  | `BUILTINDIE_CHROME` | Chrome path, or `off`; `BUILTINDIE_SCREENSHOTS=off` skips screenshots |
60
81
  | `CRAWLPROOF_SITE`, `CRAWLPROOF_AD_SLOT` | analytics and ads; `off` turns either off |
@@ -62,3 +83,11 @@ bun test
62
83
  Production runs on dev2 under `bin/install.sh` (OpenInstall); every merge to main deploys. See `OpenStack.md`.
63
84
 
64
85
  MIT. Part of the [Moshpit ring](https://pit.world/ring).
86
+
87
+ ## Karma (0.6.0)
88
+
89
+ Signed-in members earn karma for launching (+10), upvoting (+1, 20 a day), reviewing (+5, stars and 30+ characters), commenting (+2, 10 a day), sharing (+1, 5 a day), claiming (+5), a verified badge (+5) and an invited maker who launches (+25); listing owners get +1 per upvote and +2 per review from members. Acting on your own listing earns nothing; admin imports earn nothing. Every 500 karma (`KARMA_PER_CREDIT`) grants a free Featured week, spent like an invite credit. The ledger is append-only (`karma_events`, dedupe key per fact; reversals are negative rows). `/leaderboard` shows the karma board (default) and the inviters board; `GET /api/v1/karma`, `GET /api/v1/leaderboard?board=karma&period=week`; CLI `karma`, `leaderboard`, `comment`, `review`; MCP `my_karma`, `leaderboard`, `comment`, `review`. Same rules as pwamart.com.
90
+
91
+ ## Sign in with CoinPay
92
+
93
+ Set `COINPAY_OAUTH_CLIENT_ID` and `COINPAY_OAUTH_CLIENT_SECRET` (an OAuth client at coinpayportal.com with redirect `<origin>/api/v1/coinpay/callback`) and /login and /me show "Sign in with CoinPay" (OIDC, code + PKCE S256). Accounts link by CoinPay `sub` in `user_identities`, never by email.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/builtindie",
3
- "version": "0.1.4",
3
+ "version": "0.7.1",
4
4
  "description": "built.indie: launch your indie product with one URL. We fetch the logo, screenshots, videos, llms.txt, OpenSite and OpenProfile from your homepage; people upvote; you get a badge. Web (PWA), API, CLI, TUI and MCP.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,15 +23,24 @@
23
23
  "typecheck": "tsc --noEmit"
24
24
  },
25
25
  "dependencies": {
26
- "@modelcontextprotocol/sdk": "^1.17.0",
27
- "@profullstack/coinpay-onboard": "^0.1.1",
28
- "@profullstack/hqtui": "^0.10.3",
26
+ "@modelcontextprotocol/sdk": "^1.32.2",
27
+ "@profullstack/affiliates": "0.1.0",
28
+ "@profullstack/coinpay-onboard": "0.2.0",
29
+ "@profullstack/footer": "latest",
30
+ "@profullstack/hqtui": "^0.11.1",
31
+ "@profullstack/leaderboard": "^0.3.1",
29
32
  "@profullstack/openprofile": "^0.2.0",
30
- "hono": "^4.13.13",
31
- "zod": "^3.25.0"
33
+ "@profullstack/profile": "0.1.6",
34
+ "@profullstack/redeem-credits": "0.1.0",
35
+ "@profullstack/stack": "0.5.0",
36
+ "hono": "^4.13.14",
37
+ "zod": "^4.6.7"
32
38
  },
33
39
  "devDependencies": {
34
- "@types/bun": "^1.4.3",
40
+ "@types/bun": "^1.4.4",
35
41
  "typescript": "^7.0.2"
42
+ },
43
+ "overrides": {
44
+ "zod": "^4.6.7"
36
45
  }
37
46
  }
package/public/app.js CHANGED
@@ -1,4 +1,12 @@
1
1
  // built.indie: vote without a page load, copy buttons, progress while we read a site, the service worker.
2
+ // A share button: tell the server before the share window opens (a signed-in member earns karma).
3
+ document.addEventListener('click', (e) => {
4
+ const btn = e.target.closest('[data-share]')
5
+ const slug = btn?.closest('[data-share-slug]')?.dataset.shareSlug
6
+ if (!btn || !slug) return
7
+ const url = '/p/' + encodeURIComponent(slug) + '/share?net=' + encodeURIComponent(btn.dataset.share)
8
+ if (!(navigator.sendBeacon && navigator.sendBeacon(url))) fetch(url, { method: 'POST', keepalive: true }).catch(() => {})
9
+ }, true)
2
10
  document.addEventListener('click', async (e) => {
3
11
  const copy = e.target.closest('[data-copy]')
4
12
  if (copy) {
@@ -32,4 +40,54 @@ document.addEventListener('submit', (e) => {
32
40
  note.textContent = form.dataset.busy
33
41
  form.append(note)
34
42
  })
43
+ // Search, on every page: "/" jumps to it; typing suggests launches (Enter still opens the full results).
44
+ const box = document.querySelector('[data-suggest]')
45
+ if (box) {
46
+ const input = box.querySelector('input')
47
+ const list = box.querySelector('.suggest')
48
+ let timer, seq = 0
49
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`)
50
+ const items = () => [...list.querySelectorAll('a')]
51
+ const close = () => { list.hidden = true; input.removeAttribute('aria-activedescendant') }
52
+ document.addEventListener('keydown', (e) => {
53
+ if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey || /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName) || document.activeElement?.isContentEditable) return
54
+ e.preventDefault()
55
+ input.focus()
56
+ input.select()
57
+ })
58
+ input.addEventListener('input', () => {
59
+ clearTimeout(timer)
60
+ const q = input.value.trim()
61
+ if (q.length < 2) return close()
62
+ timer = setTimeout(async () => {
63
+ const mine = ++seq
64
+ try {
65
+ const r = await (await fetch('/api/v1/search?facets=0&limit=6&q=' + encodeURIComponent(q), { headers: { accept: 'application/json' } })).json()
66
+ if (mine !== seq) return
67
+ list.innerHTML = r.products.map((p) => `<a href="/p/${esc(p.slug)}" role="option">${p.logo ? `<img src="${esc(p.logo)}" alt="">` : `<b class="logo logo-letter">${esc(p.name.slice(0, 1).toUpperCase())}</b>`}<span><b>${esc(p.name)}</b><small>${esc(p.tagline || p.domain)}</small></span></a>`).join('') + `<a class="all" href="/search?q=${encodeURIComponent(q)}" role="option">${r.total ? `All ${r.total} result${r.total === 1 ? '' : 's'} for “${esc(q)}” →` : `No launches match “${esc(q)}”. Browse everything →`}</a>`
68
+ list.hidden = false
69
+ } catch {}
70
+ }, 140)
71
+ })
72
+ input.addEventListener('keydown', (e) => {
73
+ const all = items()
74
+ if (list.hidden || !all.length) return
75
+ const at = all.findIndex((a) => a.getAttribute('aria-selected') === 'true')
76
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
77
+ e.preventDefault()
78
+ const step = e.key === 'ArrowDown' ? 1 : -1
79
+ const next = all[at < 0 ? (step > 0 ? 0 : all.length - 1) : (at + step + all.length) % all.length]
80
+ all.forEach((a) => a.removeAttribute('aria-selected'))
81
+ next.setAttribute('aria-selected', 'true')
82
+ } else if (e.key === 'Enter' && at >= 0) {
83
+ e.preventDefault()
84
+ location.href = all[at].href
85
+ } else if (e.key === 'Escape') close()
86
+ })
87
+ document.addEventListener('click', (e) => { if (!box.contains(e.target)) close() })
88
+ }
89
+ // Browse: sorting applies at once; on a phone the filters start folded behind a button.
90
+ document.querySelectorAll('[data-autosubmit] select').forEach((s) => s.addEventListener('change', () => s.form.submit()))
91
+ const facets = document.querySelector('[data-facets]')
92
+ if (facets && matchMedia('(max-width: 860px)').matches) facets.open = false
35
93
  if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {})
package/public/style.css CHANGED
@@ -128,16 +128,11 @@ a.tag:hover { color: var(--text); text-decoration: none; }
128
128
  .pic img { width: 100%; height: 90px; object-fit: cover; border-radius: 8px; border: 1px solid var(--line); display: block; margin-bottom: .3rem; background: var(--bg); }
129
129
  .pic input { width: auto; }
130
130
  .payee { text-align: center; padding: 2rem 0 .5rem; }
131
- .ad-unit { margin: 24px auto; display: flex; justify-content: center; }
132
- .ad-unit iframe { border: 0; display: block; background: transparent; }
133
- .ad-text_link { max-width: 820px; padding: 0 16px; }
134
- .ad-text_link iframe { width: 100%; height: 40px; }
135
- .ad-banner_300x250 iframe { width: 300px; height: 250px; }
136
- .ad-banner_728x90 iframe { width: 728px; max-width: 100%; height: 90px; }
137
- @media (max-width: 760px) { .ad-banner_728x90 { display: none; } }
138
- @media (max-width: 340px) { .ad-banner_300x250 { margin-inline: calc(50% - 50vw); } }
139
- footer { max-width: 820px; margin: 2rem auto 0; padding: 1.5rem 16px 2.5rem; border-top: 1px solid var(--line); color: var(--muted); font-size: .88rem; display: flex; gap: 1rem; flex-wrap: wrap; justify-content: space-between; }
140
- footer a { color: var(--muted); }
131
+ .ad-unit { max-width: 820px; margin: 10px auto; padding: 0 16px; }
132
+ .ad-unit iframe { border: 0; display: block; background: transparent; width: 100%; height: 40px; }
133
+ /* @profullstack/footer (src/ring-footer.ts) on one wrapping row: links, ring nav, copyright. */
134
+ footer.pfs-footer { margin-top: 2rem; border-top: 1px solid var(--line); color: var(--muted); font-size: .88rem; }
135
+ footer.pfs-footer .pfs-footer__inner { max-width: 820px; padding: 1rem 16px; flex-direction: row; flex-wrap: wrap; justify-content: center; gap: .5rem 1.25rem; }
141
136
  @media (max-width: 560px) {
142
137
  .url-row { flex-direction: column; }
143
138
  .gallery img { height: 190px; }
@@ -152,7 +147,8 @@ footer a { color: var(--muted); }
152
147
  .ring-bar .ring-home { color: var(--accent); font-weight: 700; }
153
148
  .ring-bar .ring-sites { display: inline-flex; gap: 12px; padding: 0 12px; border-left: 1px solid var(--line); border-right: 1px solid var(--line); }
154
149
  .ring-bar [aria-current='page'] { color: var(--text); }
155
- @media (max-width: 640px) { .ring-bar .ring-sites, .ring-bar .ring-home span { display: none; } }
150
+ .ring-bar .ring-start { margin-left: auto; color: var(--accent); font-weight: 700; }
151
+ @media (max-width: 640px) { .ring-bar .ring-home span { display: none; } }
156
152
  /* OpenIcon icons: inline SVG in currentColor */
157
153
  .i { width: 1em; height: 1em; vertical-align: -.125em; flex: none; }
158
154
  .chip .i, .btn .i { margin-right: .15em; }
@@ -160,3 +156,109 @@ footer a { color: var(--muted); }
160
156
  .mini-h { font-size: .95rem; margin: 1.1rem 0 .4rem; display: flex; gap: .4rem; align-items: center; }
161
157
  .stack p { margin: .25rem 0; }
162
158
  .plans { list-style: none; padding: 0; margin: 0; display: grid; gap: .25rem; }
159
+ .invite-promo { display: grid; grid-template-columns: 1.2fr 1fr; gap: 1.2rem; align-items: start; border-color: var(--accent); }
160
+ .invite-promo h2 { font-size: 1.2rem; }
161
+ .invite-promo .row { margin-bottom: 0; }
162
+ @media (max-width: 640px) { .invite-promo { grid-template-columns: 1fr; } }
163
+ .invite-promo .mini-h { margin: 0 0 .4rem; color: var(--muted); font-weight: 600; }
164
+ .inviters .row-item { padding: .45rem .3rem; }
165
+ .stats { text-align: center; }
166
+ .stats div { display: flex; flex-direction: column; background: var(--bg); border: 1px solid var(--line); border-radius: 10px; padding: .7rem; }
167
+ .stats b { font-size: 1.6rem; }
168
+ /* @profullstack/leaderboard widget, in our colours (it injects its own CSS at runtime, hence the specificity). */
169
+ :root { --bi-panel: var(--panel); --bi-text: var(--text); --bi-muted: var(--muted); --bi-line: var(--line); --bi-accent: var(--accent); --bi-gold: var(--gold); }
170
+ .card .pflb.pflb { --bg: var(--bi-panel); --fg: var(--bi-text); --muted: var(--bi-muted); --line: var(--bi-line); --accent: var(--bi-accent); --gold: var(--bi-gold); --hi: color-mix(in srgb, var(--bi-accent) 12%, var(--bi-panel)); }
171
+ .tag-cloud { display: flex; flex-wrap: wrap; gap: .5rem; line-height: 1.6; }
172
+ /* search, on every page (header), and browse (/search, /c/<category>, /t/<tag>) */
173
+ .top.wide, main.wide { max-width: 1120px; }
174
+ .top-search { position: relative; flex: 1 1 220px; max-width: 380px; margin: 0; }
175
+ .top-search input { width: 100%; padding: .45rem 2rem .45rem .8rem; border-radius: 999px; font-size: .92rem; margin: 0; }
176
+ .top-search kbd { position: absolute; right: .6rem; top: 50%; transform: translateY(-50%); font: .72rem ui-monospace, monospace; color: var(--muted); border: 1px solid var(--line); border-radius: 4px; padding: 0 .3rem; pointer-events: none; }
177
+ .top-search input:focus + kbd, .top-search input:not(:placeholder-shown) + kbd { display: none; }
178
+ .suggest { position: absolute; z-index: 20; top: calc(100% + 6px); left: 0; right: 0; min-width: 280px; background: var(--panel); border: 1px solid var(--line); border-radius: 12px; box-shadow: 0 12px 32px #0006; padding: .3rem; }
179
+ .suggest a { display: flex; align-items: center; gap: .6rem; padding: .45rem .55rem; border-radius: 8px; color: var(--text); }
180
+ .suggest a:hover, .suggest a[aria-selected="true"] { background: var(--line); text-decoration: none; }
181
+ .suggest img, .suggest .logo-letter { width: 28px; height: 28px; border-radius: 7px; flex: none; font-size: .85rem; }
182
+ .suggest span { min-width: 0; display: flex; flex-direction: column; line-height: 1.3; }
183
+ .suggest small { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
184
+ .suggest .all { border-top: 1px solid var(--line); border-radius: 0 0 8px 8px; margin-top: .2rem; color: var(--accent); font-size: .88rem; }
185
+ @media (max-width: 640px) { .top-search { order: 3; flex-basis: 100%; max-width: none; } .top-search kbd { display: none; } }
186
+ .tag.cat { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--text); }
187
+ .browse { display: grid; grid-template-columns: 250px 1fr; gap: 1.5rem; align-items: start; }
188
+ .facets { position: sticky; top: 1rem; max-height: calc(100vh - 2rem); overflow-y: auto; scrollbar-width: thin; }
189
+ .facets > summary.facets-toggle { display: none; }
190
+ .facet-rail { display: grid; gap: .5rem; }
191
+ .facet-group { border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
192
+ .facet-group summary { margin: 0; padding: .55rem .8rem; font-size: .78rem; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); list-style: none; display: flex; align-items: center; gap: .4rem; }
193
+ .facet-group summary::-webkit-details-marker { display: none; }
194
+ .facet-group summary::after { content: "+"; margin-left: auto; font-family: ui-monospace, monospace; }
195
+ .facet-group[open] summary::after { content: "−"; }
196
+ .facet-group ul { list-style: none; margin: 0; padding: 0 .35rem .4rem; max-height: 340px; overflow-y: auto; }
197
+ .facet-item { display: grid; grid-template-columns: 1rem 1fr auto; gap: .5rem; align-items: center; padding: .3rem .45rem; border-radius: 6px; color: var(--muted); font-size: .9rem; }
198
+ .facet-item:hover { background: var(--line); color: var(--text); text-decoration: none; }
199
+ .facet-check { width: .9rem; height: .9rem; border: 1.5px solid var(--muted); border-radius: 4px; }
200
+ .facet-check.radio { border-radius: 50%; }
201
+ .facet-item.on { color: var(--text); font-weight: 600; }
202
+ .facet-item.on .facet-check { background: var(--accent); border-color: var(--accent); box-shadow: inset 0 0 0 2px var(--panel); }
203
+ .facet-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
204
+ .facet-count { font: .74rem ui-monospace, monospace; color: var(--muted); }
205
+ .facet-n { background: var(--accent); color: var(--accent-ink); border-radius: 999px; padding: 0 .45rem; font-size: .72rem; letter-spacing: 0; }
206
+ .results { min-width: 0; }
207
+ .results-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 1rem; flex-wrap: wrap; }
208
+ .results-head .page-h { margin-bottom: .1rem; }
209
+ .results-head p { margin: 0; }
210
+ .sort-form { margin: 0; }
211
+ .sort-form select { margin: 0; width: auto; padding: .4rem .6rem; }
212
+ .chips { list-style: none; padding: 0; margin: .8rem 0; display: flex; gap: .4rem; flex-wrap: wrap; }
213
+ .chip.on { border-color: var(--accent); }
214
+ .chip.clear { color: var(--muted); }
215
+ .pager { display: flex; justify-content: center; align-items: center; gap: 1rem; margin: 1.2rem 0; }
216
+ @media (max-width: 860px) {
217
+ .browse { grid-template-columns: 1fr; gap: .5rem; }
218
+ .facets { position: static; max-height: none; overflow: visible; }
219
+ .facets > summary.facets-toggle { display: inline-flex; align-items: center; gap: .4rem; margin: .4rem 0; padding: .4rem .9rem; border: 1px solid var(--line); border-radius: 999px; background: var(--panel); font-size: .9rem; list-style: none; }
220
+ .facets-toggle::-webkit-details-marker { display: none; }
221
+ .facets[open] > .facets-toggle { border-color: var(--accent); }
222
+ }
223
+ /* Karma, reviews, comments (0.6.0). */
224
+ .karma-promo { border-color: var(--gold); }
225
+ .karma { font-size: .78rem; color: var(--gold); font-variant-numeric: tabular-nums; white-space: nowrap; }
226
+ .karma-total { color: var(--gold); font-variant-numeric: tabular-nums; }
227
+ .stars-mini { color: var(--gold); font-size: .78rem; margin-inline-end: .35rem; white-space: nowrap; }
228
+ .stars { color: var(--gold); letter-spacing: .05em; }
229
+ .stars .off { color: var(--line); }
230
+ .discuss { margin-block: 1rem; }
231
+ .thread { list-style: none; padding: 0; margin: 0 0 .8rem; display: grid; gap: .7rem; }
232
+ .thread.replies { margin: .5rem 0 0; padding-inline-start: 1rem; border-inline-start: 2px solid var(--line); }
233
+ .post header { display: flex; flex-wrap: wrap; align-items: baseline; gap: .4rem; }
234
+ .post .body { overflow-wrap: anywhere; }
235
+ .post .body p { margin: .25rem 0; }
236
+ .post.deleted { opacity: .7; }
237
+ .reply summary { cursor: pointer; color: var(--muted); }
238
+ .comment-form, .review-form { display: grid; gap: .5rem; margin-top: .6rem; }
239
+ .comment-form textarea, .review-form textarea { field-sizing: content; min-block-size: 3lh; }
240
+ .comment-form button { justify-self: start; }
241
+ /* Stars as radio buttons, highest first in the DOM so :checked ~ fills the lower ones (row-reverse puts 1 on the left). */
242
+ .rate { border: 0; padding: 0; margin: 0; display: inline-flex; flex-direction: row-reverse; justify-content: flex-end; gap: .1rem; }
243
+ .rate legend { float: inline-start; inline-size: 100%; font-size: .9rem; color: var(--muted); margin-bottom: .2rem; }
244
+ .rate input { position: absolute; opacity: 0; inline-size: 1px; block-size: 1px; }
245
+ .rate label { font-size: 1.6rem; line-height: 1; cursor: pointer; color: var(--line); transition: color .12s, scale .12s; }
246
+ .rate input:checked ~ label, .rate label:hover, .rate label:hover ~ label { color: var(--gold); }
247
+ .rate input:focus-visible + label { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
248
+ .rate label:hover { scale: 1.15; }
249
+ .progress { block-size: .6rem; border-radius: 999px; background: var(--line); overflow: hidden; margin: .4rem 0 .8rem; }
250
+ .progress span { display: block; block-size: 100%; background: linear-gradient(90deg, var(--accent), var(--gold)); border-radius: inherit; transition: inline-size .4s; }
251
+ .ledger { list-style: none; padding: 0; margin: 0 0 .8rem; display: grid; gap: .25rem; }
252
+ .ledger .pts { display: inline-block; min-inline-size: 2.6rem; font-weight: 700; color: var(--ok); font-variant-numeric: tabular-nums; }
253
+ .ledger .pts.neg { color: var(--fail); }
254
+ table.earn { inline-size: 100%; border-collapse: collapse; font-size: .92rem; }
255
+ table.earn th, table.earn td { text-align: start; padding: .4rem .5rem; border-bottom: 1px solid var(--line); vertical-align: top; }
256
+ table.earn td.pts { color: var(--gold); font-weight: 700; white-space: nowrap; }
257
+ @media (max-width: 640px) { table.earn td:nth-child(3) { display: block; padding-top: 0; border: 0; } }
258
+ .or { text-align: center; margin: .6rem 0; }
259
+ .coinpay-btn { display: flex; justify-content: center; gap: .4rem; }
260
+
261
+ /* The shared profile fields (@profullstack/profile renderProfileFields), made to read like the email box beside them. */
262
+ .pfs-profile { border: 0; margin: 0; padding: 0; min-width: 0; }
263
+ .pfs-profile__optional { color: var(--muted); font-weight: 400; }
264
+ .pfs-profile__error { color: var(--fail); font-size: 0.85rem; margin: 0.25rem 0 0; }
package/src/auth.ts CHANGED
@@ -20,6 +20,8 @@ export type Mailer = (mail: Mail) => Promise<unknown>
20
20
 
21
21
  export function resendMailer(apiKey: string, from: string, fetchImpl: typeof fetch = fetch): Mailer {
22
22
  return async ({ to, subject, text, html }) => {
23
+ // A CoinPay-only member has a placeholder address (…@coinpay.invalid): there is nobody to mail.
24
+ if (to.endsWith('.invalid')) return
23
25
  const res = await fetchImpl('https://api.resend.com/emails', {
24
26
  method: 'POST',
25
27
  headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
@@ -48,14 +50,15 @@ export class Auth {
48
50
  constructor(private store: Store, private mailer: Mailer, private now = () => Date.now()) {}
49
51
 
50
52
  /** Always resolves the same way, so nobody learns who has an account. */
51
- async sendLink(emailInput: string, next: string | null, base: string): Promise<boolean> {
53
+ async sendLink(emailInput: string, next: string | null, base: string, ref: string | null = null, profile: object | null = null): Promise<boolean> {
52
54
  const email = validEmail(emailInput)
53
55
  if (!email) return false
54
56
  const db = this.store.db
55
57
  const recent = (db.query('SELECT COUNT(*) AS n FROM magic_links WHERE email = ? AND created_at > ?').get(email, this.now() - 15 * 60_000) as { n: number }).n
56
58
  if (recent >= 5) return true
57
59
  const token = randomBytes(24).toString('base64url')
58
- db.query('INSERT INTO magic_links (token_hash, email, next, created_at, expires_at) VALUES (?, ?, ?, ?, ?)').run(hashKey(token), email, safeNext(next), this.now(), this.now() + LINK_MINUTES * 60_000)
60
+ // The invite code rides in the link, so it counts even when the mail opens in another browser.
61
+ db.query('INSERT INTO magic_links (token_hash, email, next, ref, created_at, expires_at, profile) VALUES (?, ?, ?, ?, ?, ?, ?)').run(hashKey(token), email, safeNext(next), ref ? String(ref).slice(0, 80) : null, this.now(), this.now() + LINK_MINUTES * 60_000, profile ? JSON.stringify(profile) : null)
59
62
  const url = `${base}/auth/verify?token=${token}`
60
63
  const text = `Sign in to built.indie:\n\n${url}\n\nThe link works once, for ${LINK_MINUTES} minutes. If you did not ask for it, ignore this email.`
61
64
  const html = `<p>Sign in to <strong>built.indie</strong>:</p><p><a href="${url}" style="display:inline-block;padding:10px 18px;background:#ff5c39;color:#fff;border-radius:8px;text-decoration:none;font-weight:700">Sign in</a></p><p style="color:#666;font-size:13px">Or open ${url}<br>The link works once, for ${LINK_MINUTES} minutes. If you did not ask for it, ignore this email.</p>`
@@ -67,14 +70,15 @@ export class Auth {
67
70
  return true
68
71
  }
69
72
 
70
- /** One use, then it is gone. Returns the user (made on first sign-in) and where to go. */
71
- consumeLink(token: string): { user: User; next: string | null } | null {
73
+ /** One use, then it is gone. Returns the user (made on first sign-in, `created`), where to go, the invite code it carried, and the signup form's profile fields. */
74
+ consumeLink(token: string): { user: User; next: string | null; created: boolean; ref: string | null; profile: Record<string, unknown> | null } | null {
72
75
  const hash = hashKey(String(token))
73
76
  const db = this.store.db
74
- const row = db.query('SELECT email, next, expires_at FROM magic_links WHERE token_hash = ?').get(hash) as { email: string; next: string | null; expires_at: number } | null
77
+ const row = db.query('SELECT email, next, ref, expires_at, profile FROM magic_links WHERE token_hash = ?').get(hash) as { email: string; next: string | null; ref: string | null; expires_at: number; profile: string | null } | null
75
78
  db.query('DELETE FROM magic_links WHERE token_hash = ? OR expires_at < ?').run(hash, this.now())
76
79
  if (!row || row.expires_at < this.now()) return null
77
- return { user: this.store.findOrCreateUser(row.email), next: row.next }
80
+ const created = !this.store.userByEmail(row.email)
81
+ return { user: this.store.findOrCreateUser(row.email), next: row.next, created, ref: row.ref, profile: row.profile ? JSON.parse(row.profile) : null }
78
82
  }
79
83
 
80
84
  createSession(userId: string): string {