@profullstack/builtindie 0.6.0 → 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/README.md CHANGED
@@ -22,6 +22,13 @@ Viral bits: a live badge with the upvote count (`/badge/<slug>.svg`) that turns
22
22
 
23
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
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
+
25
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.
26
33
 
27
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.
@@ -44,6 +51,8 @@ BUILTINDIE_KEY=bi_… builtindie claim linear # prints your badge and the TXT
44
51
  BUILTINDIE_KEY=bi_… builtindie edit linear --tagline "…"
45
52
  BUILTINDIE_KEY=bi_… builtindie invite # your invite link and free Featured credits
46
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
47
56
  BUILTINDIE_KEY=bi_… builtindie feature linear # or pay: a CoinPay checkout link (any coin or PayPal)
48
57
 
49
58
  claude mcp add builtindie -- bunx @profullstack/builtindie mcp
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/builtindie",
3
- "version": "0.6.0",
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,18 +23,24 @@
23
23
  "typecheck": "tsc --noEmit"
24
24
  },
25
25
  "dependencies": {
26
- "@modelcontextprotocol/sdk": "^1.17.0",
26
+ "@modelcontextprotocol/sdk": "^1.32.2",
27
+ "@profullstack/affiliates": "0.1.0",
27
28
  "@profullstack/coinpay-onboard": "0.2.0",
28
29
  "@profullstack/footer": "latest",
29
- "@profullstack/hqtui": "^0.10.3",
30
+ "@profullstack/hqtui": "^0.11.1",
30
31
  "@profullstack/leaderboard": "^0.3.1",
31
32
  "@profullstack/openprofile": "^0.2.0",
33
+ "@profullstack/profile": "0.1.6",
34
+ "@profullstack/redeem-credits": "0.1.0",
32
35
  "@profullstack/stack": "0.5.0",
33
- "hono": "^4.13.13",
34
- "zod": "^3.25.0"
36
+ "hono": "^4.13.14",
37
+ "zod": "^4.6.7"
35
38
  },
36
39
  "devDependencies": {
37
- "@types/bun": "^1.4.3",
40
+ "@types/bun": "^1.4.4",
38
41
  "typescript": "^7.0.2"
42
+ },
43
+ "overrides": {
44
+ "zod": "^4.6.7"
39
45
  }
40
46
  }
package/public/style.css CHANGED
@@ -257,3 +257,8 @@ table.earn td.pts { color: var(--gold); font-weight: 700; white-space: nowrap; }
257
257
  @media (max-width: 640px) { table.earn td:nth-child(3) { display: block; padding-top: 0; border: 0; } }
258
258
  .or { text-align: center; margin: .6rem 0; }
259
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
@@ -50,7 +50,7 @@ export class Auth {
50
50
  constructor(private store: Store, private mailer: Mailer, private now = () => Date.now()) {}
51
51
 
52
52
  /** Always resolves the same way, so nobody learns who has an account. */
53
- async sendLink(emailInput: string, next: string | null, base: string, ref: string | null = null): Promise<boolean> {
53
+ async sendLink(emailInput: string, next: string | null, base: string, ref: string | null = null, profile: object | null = null): Promise<boolean> {
54
54
  const email = validEmail(emailInput)
55
55
  if (!email) return false
56
56
  const db = this.store.db
@@ -58,7 +58,7 @@ export class Auth {
58
58
  if (recent >= 5) return true
59
59
  const token = randomBytes(24).toString('base64url')
60
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) VALUES (?, ?, ?, ?, ?, ?)').run(hashKey(token), email, safeNext(next), ref ? String(ref).slice(0, 32) : null, this.now(), this.now() + LINK_MINUTES * 60_000)
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)
62
62
  const url = `${base}/auth/verify?token=${token}`
63
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.`
64
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>`
@@ -70,15 +70,15 @@ export class Auth {
70
70
  return true
71
71
  }
72
72
 
73
- /** One use, then it is gone. Returns the user (made on first sign-in, `created`), where to go, and the invite code it carried. */
74
- consumeLink(token: string): { user: User; next: string | null; created: boolean; ref: 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 {
75
75
  const hash = hashKey(String(token))
76
76
  const db = this.store.db
77
- const row = db.query('SELECT email, next, ref, expires_at FROM magic_links WHERE token_hash = ?').get(hash) as { email: string; next: string | null; ref: 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
78
78
  db.query('DELETE FROM magic_links WHERE token_hash = ? OR expires_at < ?').run(hash, this.now())
79
79
  if (!row || row.expires_at < this.now()) return null
80
80
  const created = !this.store.userByEmail(row.email)
81
- return { user: this.store.findOrCreateUser(row.email), next: row.next, created, ref: row.ref }
81
+ return { user: this.store.findOrCreateUser(row.email), next: row.next, created, ref: row.ref, profile: row.profile ? JSON.parse(row.profile) : null }
82
82
  }
83
83
 
84
84
  createSession(userId: string): string {
package/src/cli.ts CHANGED
@@ -14,6 +14,9 @@
14
14
  * builtindie me your launches, BUILTINDIE_KEY
15
15
  * builtindie invite your invite link and free Featured credits, BUILTINDIE_KEY
16
16
  * builtindie credit <slug> spend a credit: feature a listing you own, BUILTINDIE_KEY
17
+ * builtindie redeem <code> a credit code: Featured days to your balance, BUILTINDIE_KEY
18
+ * builtindie invite-code [note] one of your 3 single-use invite codes, BUILTINDIE_KEY
19
+ * builtindie feature-days <slug> <n> spend Featured days on a listing you own, BUILTINDIE_KEY
17
20
  * builtindie feature <slug> pay to feature it: a CoinPay checkout link (any coin or PayPal), BUILTINDIE_KEY
18
21
  * builtindie karma your karma, history, next free Featured week, BUILTINDIE_KEY
19
22
  * builtindie leaderboard [karma|invites] [--period week|month|all]
@@ -49,6 +52,9 @@ export const HELP = `builtindie ${VERSION}: launch your indie product with one U
49
52
  builtindie me your launches
50
53
  builtindie invite your invite link; each maker it brings in who launches = a free Featured week
51
54
  builtindie credit <slug> spend a credit to feature a listing you own
55
+ builtindie redeem <code> redeem a credit code: Featured days to your balance
56
+ builtindie invite-code [note] make one of your 3 single-use invite codes (both get Featured days)
57
+ builtindie feature-days <slug> <n> spend n Featured days on a listing you own
52
58
  builtindie feature <slug> [--email you@x] pay to feature it: prints a CoinPay checkout link (any coin or PayPal)
53
59
  builtindie karma your karma, history and how far to the next free Featured week
54
60
  builtindie leaderboard [karma|invites] [--period week|month|all]
@@ -243,6 +249,21 @@ export async function main(argv = process.argv.slice(2), out: Out = console, cli
243
249
  show(r, `${rest[0]} is Featured until ${r.featured_until.slice(0, 10)} ★ (${r.credits} credit${r.credits === 1 ? '' : 's'} left)`)
244
250
  return 0
245
251
  }
252
+ case 'redeem': {
253
+ const r = await client.redeem(rest[0] ?? '')
254
+ show(r, `${r.message}\nFeatured days: ${r.featured_days}${r.featured_days ? ' (use them: builtindie feature-days <slug> <n>)' : ''}`)
255
+ return 0
256
+ }
257
+ case 'invite-code': {
258
+ const r = await client.inviteCode(rest.join(' '))
259
+ show(r, `Your invite (works once, for someone new; you both get ${r.bonus_days} Featured days):\n${r.link}\n${r.left} left`)
260
+ return 0
261
+ }
262
+ case 'feature-days': {
263
+ const r = await client.featureDays(rest[0] ?? '', Number(rest[1] ?? 0))
264
+ show(r, `${rest[0]} is Featured until ${r.featured_until.slice(0, 10)} ★ (${r.featured_days} day${r.featured_days === 1 ? '' : 's'} left)`)
265
+ return 0
266
+ }
246
267
  case 'feature': {
247
268
  const r = await client.feature(rest[0] ?? '', str('email'))
248
269
  show(r, `Pay $${r.usd} for ${r.days} days Featured (${r.methods.join(', ')}):\n${r.checkout_url}`)
package/src/client.ts CHANGED
@@ -38,7 +38,7 @@ export type ApiProduct = {
38
38
  award?: { day: string; rank: number } | null
39
39
  }
40
40
 
41
- export type Invites = { link: string; code: string; invited: number; launched: number; credits: number; karma_credits?: number; used: number; feature_days: number }
41
+ export type Invites = { link: string; code: string; invited: number; launched: number; credits: number; karma_credits?: number; used: number; feature_days: number; invite_codes?: { quota: { total: number; used: number; left: number }; bonus_days: number; invites: { id: string; note: string; created_at: string; expires_at: string | null; used: boolean; used_at: string | null }[] } }
42
42
 
43
43
  export type KarmaInfo = { total: number; week: number; per_credit: number; next_credit_in: number; credits: { earned: number; used: number }; events: { action: string; label: string; ref: string; points: number; at: string }[]; how_to_earn: { action: string; points: number; rule: string }[]; leaderboard: string }
44
44
  export type BoardRows = { board: string; period: string; total: number; rows: { rank: number | null; id: string; name: string; value: number; badges: { emoji: string; label: string }[]; url: string }[]; web: string }
@@ -143,6 +143,21 @@ export class BuiltIndieClient {
143
143
  return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/credit`, {}, this.needKey())
144
144
  }
145
145
 
146
+ /** Redeem a credit code: Featured days added to your balance. */
147
+ redeem(code: string): Promise<{ ok: boolean; message: string; featured_days: number }> {
148
+ return this.call('POST', '/api/v1/redeem', { code }, this.needKey())
149
+ }
150
+
151
+ /** Make one of your scarce single-use invite codes; the link is shown once. */
152
+ inviteCode(note = ''): Promise<{ link: string; code: string; expires_at: string | null; left: number; bonus_days: number }> {
153
+ return this.call('POST', '/api/v1/invites', { note }, this.needKey())
154
+ }
155
+
156
+ /** Spend Featured days from your balance on a listing you own. */
157
+ featureDays(slug: string, days: number): Promise<{ featured_until: string; featured_days: number }> {
158
+ return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/feature-days`, { days }, this.needKey())
159
+ }
160
+
146
161
  /** Pay to feature a listing you own: opens a CoinPay checkout session (any coin or PayPal); send the buyer to checkout_url. */
147
162
  feature(slug: string, email?: string): Promise<{ checkout_url: string; checkout_session_id: string; methods: string[]; payment: string; usd: number; days: number }> {
148
163
  return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/feature`, email ? { email } : {}, this.needKey())
package/src/db.ts CHANGED
@@ -257,6 +257,8 @@ export class Store {
257
257
  const has = (table: string, col: string) => (this.db.query(`PRAGMA table_info(${table})`).all() as { name: string }[]).some((c) => c.name === col)
258
258
  if (!has('users', 'display_name')) this.db.exec('ALTER TABLE users ADD COLUMN display_name TEXT')
259
259
  if (!has('magic_links', 'ref')) this.db.exec('ALTER TABLE magic_links ADD COLUMN ref TEXT')
260
+ // The signup form's profile fields ride on the link until it is spent (profile.ts saves them).
261
+ if (!has('magic_links', 'profile')) this.db.exec('ALTER TABLE magic_links ADD COLUMN profile TEXT')
260
262
  // Hosted checkout sessions (0.5.0): the session is how CoinPay's webhook finds the row.
261
263
  for (const col of ['checkout_session_id', 'customer_email', 'account_id', 'payment_id']) if (!has('payments', col)) this.db.exec(`ALTER TABLE payments ADD COLUMN ${col} TEXT`)
262
264
  // Karma (0.6.0): who cast a vote, when they were signed in; review totals beside the vote count.
@@ -479,7 +481,7 @@ export class Store {
479
481
  .run(productId, now, userId)
480
482
  // No invite credit left: a karma credit is the same free week.
481
483
  if (!r.changes && !this.db.query('UPDATE karma_credits SET redeemed_product = ?, redeemed_at = ? WHERE rowid = (SELECT rowid FROM karma_credits WHERE user_id = ? AND redeemed_at IS NULL ORDER BY milestone LIMIT 1)').run(productId, now, userId).changes) return false
482
- this.db.query('UPDATE products SET featured_until = MAX(COALESCE(featured_until, 0), ?) + ? WHERE id = ?').run(now, days * 86400_000, productId)
484
+ this.extendFeatured(productId, days, now)
483
485
  return true
484
486
  })()
485
487
  }
@@ -788,11 +790,16 @@ export class Store {
788
790
  return r ? { ref: r.ref, productId: r.product_id, days: r.days, status: r.status } : null
789
791
  }
790
792
 
793
+ /** The one way a listing gets Featured time: paid weeks, invite and karma credits, and redeemed Featured days. */
794
+ extendFeatured(productId: string, days: number, now = Date.now()): void {
795
+ this.db.query('UPDATE products SET featured_until = MAX(COALESCE(featured_until, 0), ?) + ? WHERE id = ?').run(now, days * 86400_000, productId)
796
+ }
797
+
791
798
  /** Mark a payment settled and extend the product's featured time, exactly once per payment. */
792
799
  settle(ref: string, now = Date.now()): boolean {
793
800
  const r: any = this.db.query("UPDATE payments SET status = 'paid', updated_at = ? WHERE ref = ? AND status != 'paid' RETURNING product_id, days").get(now, ref)
794
801
  if (!r) return false
795
- this.db.query('UPDATE products SET featured_until = MAX(COALESCE(featured_until, 0), ?) + ? WHERE id = ?').run(now, r.days * 86400_000, r.product_id)
802
+ this.extendFeatured(r.product_id, r.days, now)
796
803
  return true
797
804
  }
798
805
 
package/src/html.ts CHANGED
@@ -12,6 +12,7 @@ import { pitFooter } from './pit-footer.js'
12
12
  import { RING_PLACEHOLDER } from './ring.js'
13
13
  import { ringFooter, ringFooterOptions, setRingFooterOptions } from './ring-footer.js'
14
14
  import { icon, SOCIAL_ICONS } from './icons.js'
15
+ import { COLUMNS as PROFILE_COLUMNS, renderProfileFields } from '@profullstack/profile'
15
16
 
16
17
  export const REPO = 'https://github.com/moshcoder/built.indie'
17
18
 
@@ -452,7 +453,7 @@ function pricingHtml(p: Product): string {
452
453
 
453
454
  const SOCIAL_LABELS: Record<string, string> = { x: 'X', github: 'GitHub', linkedin: 'LinkedIn', discord: 'Discord', youtube: 'YouTube', bluesky: 'Bluesky', instagram: 'Instagram', tiktok: 'TikTok', reddit: 'Reddit', telegram: 'Telegram', producthunt: 'Product Hunt', appstore: 'App Store', playstore: 'Google Play', chrome: 'Chrome Web Store' }
454
455
 
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 {
456
+ export function productPage(ctx: Ctx, d: { comments?: CommentView[]; reviews?: ReviewView[]; myReview?: Review | null; selfListing?: boolean; error?: string; product: Product; award: { day: string; rank: number } | null; maker: Maker | null; featuredOk?: boolean; featuredCredit?: boolean; featuredDaysUsed?: boolean; launched?: boolean; canEdit?: boolean; credits?: number; daysBalance?: number; buyerEmail?: string; featureUsd: number; featureDays: number; payments: boolean }): string {
456
457
  const p = d.product
457
458
  const shots = [p.screenshot, ...p.images, p.ogImage].filter((f): f is string => Boolean(f))
458
459
  const rel = p.verifiedAt ? 'noopener' : 'noopener nofollow ugc'
@@ -470,6 +471,7 @@ export function productPage(ctx: Ctx, d: { comments?: CommentView[]; reviews?: R
470
471
  body: `${d.error ? `<p class="note error" role="alert">${esc(d.error)}</p>` : ''}${d.launched && live ? `<div class="note launched"><b>🚀 ${esc(p.name)} is live.</b> Today’s board resets at midnight UTC: share it now. ${p.ownerId ? '' : `Is it yours? <a href="/p/${esc(p.slug)}/claim">Claim it</a> with your badge (or a DNS record) to edit it and get the ✓.`}</div>` : ''}
471
472
  ${d.featuredOk ? '<p class="note">Thanks! Your payment is on its way; the Featured spot starts the moment CoinPay confirms it.</p>' : ''}
472
473
  ${d.featuredCredit ? `<p class="note launched">★ Featured for ${d.featureDays} more days with an invite credit. <a href="/invite">Invite another maker</a> to earn the next one.</p>` : ''}
474
+ ${d.featuredDaysUsed ? `<p class="note launched">★ Featured with your Featured days${p.featuredUntil ? ` until ${new Date(p.featuredUntil).toISOString().slice(0, 10)}` : ''}.</p>` : ''}
473
475
  ${!live ? `<p class="note">${p.status === 'draft' ? 'Draft: only people with the edit link see this.' : 'Taken down.'}</p>` : ''}
474
476
  <section class="product-head">
475
477
  ${logoImg(p, 72)}
@@ -505,9 +507,10 @@ ${commentsHtml(ctx, p, d.comments ?? [])}
505
507
  <pre><code>${esc(badgeSnippet(ctx.origin, p))}</code></pre>
506
508
  <div class="row"><button class="chip" data-copy="${esc(badgeSnippet(ctx.origin, p))}">Copy badge HTML</button><form method="post" action="/p/${esc(p.slug)}/verify"><button class="chip">I added it: check</button></form></div>
507
509
  </details>
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>
510
+ ${d.payments || d.canEdit ? `<details class="card" id="feature"${d.canEdit && (d.credits || d.daysBalance) ? ' open' : ''}><summary>Feature it: ${esc(`$${d.featureUsd}`)} for ${d.featureDays} days, or free with an invite</summary>
509
511
  <p class="muted">Pinned above today’s board on the homepage for ${d.featureDays} days.</p>
510
512
  ${d.canEdit && d.credits ? `<form method="post" action="/p/${esc(p.slug)}/credit" class="row"><button class="btn">${icon('star')} Use a free invite credit</button><span class="muted small">${d.credits} left</span></form>` : `<p class="muted small">Free: ${esc(inviteOffer(d.featureDays, d.featureUsd))} <a href="/invite">Get your invite link</a>.</p>`}
513
+ ${d.canEdit && d.daysBalance ? featureDaysForm(p, d.daysBalance) : ''}
511
514
  ${d.payments ? `<form method="post" action="/p/${esc(p.slug)}/feature" class="row"><label class="sr" for="feature-email">Your email</label><input id="feature-email" name="email" type="email" required autocomplete="email" placeholder="you@example.com" value="${esc(d.buyerEmail ?? '')}"><button class="btn${d.canEdit && d.credits ? ' ghost' : ''}">Pay $${esc(d.featureUsd)}</button></form><p class="muted small">You pick any coin or PayPal on CoinPay's checkout page. The receipt goes to your email.</p>` : ''}
512
515
  </details>` : ''}` : ''}`,
513
516
  })
@@ -670,22 +673,30 @@ ${d.result && !d.result.ok ? `<p class="note error" role="alert">We did not find
670
673
  })
671
674
  }
672
675
 
673
- export function loginPage(ctx: Ctx, d: { next: string | null; sent?: boolean; error?: string; email?: string; invitedBy?: string | null }): string {
676
+ /** The shared profile fields (@profullstack/profile) beside the form's own email box. */
677
+ const PROFILE_FIELDS = PROFILE_COLUMNS.filter((f) => f !== 'email')
678
+
679
+ export function loginPage(ctx: Ctx, d: { next: string | null; sent?: boolean; error?: string; email?: string; invitedBy?: string | null; signup?: boolean; values?: Record<string, string>; errors?: Record<string, string> }): string {
680
+ const signup = Boolean(d.signup) && !d.sent
674
681
  return layout({
675
682
  ctx,
676
- title: 'Sign in · built.indie',
677
- description: 'Sign in with a link by email.',
678
- path: '/login',
683
+ title: signup ? 'Create your account · built.indie' : 'Sign in · built.indie',
684
+ description: signup ? 'Create a built.indie account with a link by email.' : 'Sign in with a link by email.',
685
+ path: signup ? '/signup' : '/login',
679
686
  noindex: true,
680
- body: `<section class="hero narrow"><h1>${d.sent ? 'Check your email' : 'Sign in'}</h1>
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>
687
+ body: `<section class="hero narrow"><h1>${d.sent ? 'Check your email' : signup ? 'Create your account' : 'Sign in'}</h1>
688
+ ${d.sent ? '<p class="lede">If that address can receive mail, a sign-in link is on its way. It works once, for 20 minutes.</p>' : `<p class="lede">No password. We email you a link${signup ? '' : '; a new address makes an account'}. You need one only to claim and edit a listing. Launching is free and needs nothing.</p>
682
689
  ${d.invitedBy ? `<p class="note launched">${esc(d.invitedBy)} invited you. Sign up here, then launch your product: it is free, and it earns them a Featured week.</p>` : ''}
683
690
  ${d.error ? `<p class="note error" role="alert">${esc(d.error)}</p>` : ''}
684
691
  <form method="post" action="/login" class="card">
685
692
  <input type="hidden" name="next" value="${esc(d.next ?? '')}">
693
+ ${signup ? '<input type="hidden" name="mode" value="signup">' : ''}
686
694
  <label for="email">Email</label><input id="email" name="email" type="email" autocomplete="email" required value="${esc(d.email ?? '')}">
687
- <p><button class="btn wide">Email me a link</button></p>
695
+ ${signup ? renderProfileFields({ fields: PROFILE_FIELDS, values: d.values, errors: d.errors }) : ''}
696
+ <p><button class="btn wide">${signup ? 'Create account' : 'Email me a link'}</button></p>
688
697
  </form>
698
+ <p class="muted small">${signup ? `Already a member? <a href="/login${d.next ? `?next=${encodeURIComponent(d.next)}` : ''}">Sign in</a>` : `New here? <a href="/signup${d.next ? `?next=${encodeURIComponent(d.next)}` : ''}">Create an account</a>`}</p>
699
+ ${signup ? '<script src="/vendor-profile.js" defer></script>' : ''}
689
700
  ${ctx.coinpay ? `<p class="or muted small">or</p>${coinpayButton(d.next)}<p class="muted small">One CoinPay account signs you in here and on the rest of the network.</p>` : ''}`}</section>`,
690
701
  })
691
702
  }
@@ -701,7 +712,23 @@ ${k.events.length ? `<h3 class="mini-h">History</h3><ol class="ledger">${k.event
701
712
  </div>`
702
713
  }
703
714
 
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 {
715
+ /** Spend Featured days from the balance on one listing. */
716
+ function featureDaysForm(p: Product, balance: number): string {
717
+ return `<form method="post" action="/p/${esc(p.slug)}/feature-days" class="row"><label class="sr" for="days-${esc(p.slug)}">Days</label><input id="days-${esc(p.slug)}" name="days" type="number" min="1" max="${balance}" value="${balance}" inputmode="numeric" style="inline-size:6em"><button class="chip">${icon('star')} Feature ${esc(p.name)} with your days</button><span class="muted small">${balance} day${balance === 1 ? '' : 's'} left</span></form>`
718
+ }
719
+
720
+ /** "Have a code?": redeem a credit code, see the Featured days it added, spend them on a listing. */
721
+ function featuredDaysCard(d: { products: Product[]; daysBalance?: number; redeemNote?: string; redeemError?: boolean }): string {
722
+ const balance = d.daysBalance ?? 0
723
+ const live = d.products.filter((p) => p.status === 'live')
724
+ return `<div class="card" id="redeem"><h2>${icon('star')} Featured days <span class="karma-total">${balance}</span></h2>
725
+ ${d.redeemNote ? `<p class="note${d.redeemError ? ' error' : ' launched'}" role="status">${esc(d.redeemNote)}</p>` : ''}
726
+ <form method="post" action="/me/redeem" class="row"><label class="sr" for="redeem-code">Credit code</label><input id="redeem-code" name="code" required placeholder="Have a code? CR-XXXX-XXXX-XXXX-XXXX" autocomplete="off" spellcheck="false"><button class="chip">Redeem</button></form>
727
+ <p class="muted small">A code or an invite adds days to pin any live listing you own on the homepage, the same Featured slot a paid week buys.</p>
728
+ ${balance ? (live.length ? live.map((p) => featureDaysForm(p, balance)).join('') : '<p class="muted">You have no live listing yet. <a href="/submit">Launch one</a>, then come back to feature it.</p>') : ''}</div>`
729
+ }
730
+
731
+ export function mePage(ctx: Ctx, d: { user: User; products: Product[]; apiKey?: string; invite: InviteStats; daysBalance?: number; redeemNote?: string; redeemError?: boolean; karma?: KarmaSummary; howToEarn?: { action: string; points: number; rule: string }[]; featureDays?: number; coinpay?: { name: string | null } | null; emailless?: boolean; coinpayNote?: string | null; profile?: object | null; detailsNote?: string | null; detailsError?: boolean; detailsErrors?: Record<string, string> }): string {
705
732
  return layout({
706
733
  ctx,
707
734
  title: 'Your launches · built.indie',
@@ -712,9 +739,13 @@ export function mePage(ctx: Ctx, d: { user: User; products: Product[]; apiKey?:
712
739
  ${d.coinpayNote ? `<p class="note">${esc(d.coinpayNote)}</p>` : ''}
713
740
  <p class="muted">Signed in ${d.emailless ? `with CoinPay${d.coinpay?.name ? ` as ${esc(d.coinpay.name)}` : ''}` : `as ${esc(d.user.email)}`}. <form class="inline" method="post" action="/logout"><button class="linkish">Sign out</button></form></p>
714
741
  ${d.products.length ? `<ol class="board">${d.products.map((p) => `<li class="row-item"><a class="row-main" href="/p/${esc(p.slug)}">${logoImg(p)}<span class="row-text"><b>${esc(p.name)}</b><span class="muted">${p.status === 'draft' ? 'draft' : `▲ ${p.votes}`} · ${p.ownerId === d.user.id ? 'claimed ✓' : 'submitted by you, not claimed'}</span></span></a><a class="chip" href="/p/${esc(p.slug)}/edit">Edit</a>${p.ownerId === d.user.id ? '' : `<a class="chip" href="/p/${esc(p.slug)}/claim">Claim</a>`}</li>`).join('')}</ol>` : '<p class="empty">Nothing yet. <a href="/submit">Launch something</a>, or open a listing that is yours and claim it.</p>'}
742
+ ${featuredDaysCard(d)}
715
743
  ${d.karma ? karmaCard(d.karma, d.howToEarn ?? [], d.featureDays ?? 7) : ''}
716
744
  <div class="card"><h2>${icon('star')} Invites</h2><p class="muted">${d.invite.invited} joined through your link · ${d.invite.launched} launched · <b>${d.invite.credits}</b> free Featured week${d.invite.credits === 1 ? '' : 's'} to use${d.invite.karmaCredits ? ` (${d.invite.karmaCredits} from karma)` : ''}.</p><p><a class="chip" href="/invite">Your invite link and credits</a></p></div>
717
745
  ${ctx.coinpay || d.coinpay ? `<div class="card" id="signin"><h2>Sign in with CoinPay</h2>${d.coinpay ? `<p class="muted">Connected${d.coinpay.name ? ` (${esc(d.coinpay.name)})` : ''}: your CoinPay account signs you in here.</p>` : `<p class="muted">Connect your CoinPay account to sign in with it, here and across the network. We link the account itself, never by email.</p>${coinpayButton('/me', 'Connect CoinPay')}`}</div>` : ''}
746
+ <div class="card" id="details"><h2>Your details</h2><p class="muted small">Private to your account, never shown on a public page.</p>
747
+ ${d.detailsNote ? `<p class="note${d.detailsError ? ' error' : ''}" role="status">${esc(d.detailsNote)}</p>` : ''}
748
+ <form method="post" action="/me/profile">${renderProfileFields({ fields: PROFILE_FIELDS, values: (d.profile ?? {}) as Record<string, string | null>, errors: d.detailsErrors, idPrefix: 'details-' })}<p><button class="chip">Save details</button></p></form></div>
718
749
  <div class="card" id="api"><h2>API key</h2>
719
750
  ${d.apiKey ? `<p class="note"><b>Copy it now</b>, we show it once. Making a new one retires this one.</p><div class="address"><code>${esc(d.apiKey)}</code><button class="copy" data-copy="${esc(d.apiKey)}">Copy</button></div><pre>export BUILTINDIE_KEY=${esc(d.apiKey)}</pre>` : '<p class="muted">For the CLI, the TUI and the MCP server: edit and claim from the terminal or an agent.</p>'}
720
751
  <form method="post" action="/me/key"><button class="chip">${d.apiKey ? 'Make another' : 'Make an API key'}</button></form></div>`,
@@ -767,6 +798,9 @@ curl -X POST ${o}/api/v1/products/&lt;slug&gt;/refetch -H 'authorization: Bearer
767
798
  curl -X POST ${o}/api/v1/products/&lt;slug&gt;/publish -H 'authorization: Bearer bi_…'
768
799
  curl ${o}/api/v1/invites -H 'authorization: Bearer bi_…' # your invite link and credits
769
800
  curl -X POST ${o}/api/v1/products/&lt;slug&gt;/credit -H 'authorization: Bearer bi_…' # spend one: Featured for a week
801
+ curl -X POST ${o}/api/v1/redeem -H 'authorization: Bearer bi_…' -d '{"code":"CR-…"}' # a credit code: Featured days to your balance
802
+ curl -X POST ${o}/api/v1/products/&lt;slug&gt;/feature-days -H 'authorization: Bearer bi_…' -d '{"days":3}' # spend Featured days on a listing
803
+ curl -X POST ${o}/api/v1/invites -H 'authorization: Bearer bi_…' -d '{"note":"for Ada"}' # one of your 3 single-use invite codes (both get Featured days)
770
804
  curl -X POST ${o}/api/v1/products/&lt;slug&gt;/feature -H 'authorization: Bearer bi_…' -d '{"email":"you@…"}' # pay: a CoinPay checkout_url (any coin or PayPal)
771
805
  curl ${o}/leaderboard/invites.json?period=week # top inviters (all|week|month)
772
806
  curl '${o}/api/v1/leaderboard?board=karma&amp;period=week' # top karma (board karma|invites)
@@ -809,7 +843,24 @@ bunx @profullstack/builtindie preview any.site --json</pre></div>
809
843
  }
810
844
 
811
845
  /** 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 {
846
+ export type InviteCodes = {
847
+ quota: { total: number; used: number; left: number }
848
+ invites: { id: string; note: string; createdAt: number; expiresAt: number | null; usedAt: number | null; revokedAt?: number | null }[]
849
+ bonusDays: number
850
+ newLink?: string
851
+ }
852
+
853
+ /** Scarce single-use invite codes: a few each, and both people get Featured days when one is used. */
854
+ function inviteCodesCard(c: InviteCodes): string {
855
+ const state = (i: InviteCodes['invites'][number]) => (i.usedAt ? `used ${new Date(i.usedAt).toISOString().slice(0, 10)}` : i.revokedAt ? 'withdrawn' : i.expiresAt && i.expiresAt < Date.now() ? 'expired' : `open${i.expiresAt ? ` until ${new Date(i.expiresAt).toISOString().slice(0, 10)}` : ''}`)
856
+ return `<div class="card" id="codes"><h2>${icon('mail')} Invite codes <span class="karma-total">${c.quota.left}/${c.quota.total}</span></h2>
857
+ <p class="muted">Your link above is open to anyone. These are not: you get ${c.quota.total}, each works once, for someone new. When they sign up with it, you both get <b>${c.bonusDays} Featured days</b>.</p>
858
+ ${c.newLink ? `<p class="note launched">Your new invite, shown once. Send it to one person:</p><div class="address"><code>${esc(c.newLink)}</code><button class="copy" data-copy="${esc(c.newLink)}">Copy</button></div>` : ''}
859
+ ${c.quota.left > 0 ? `<form method="post" action="/invite/code" class="row"><label class="sr" for="invite-note">Who is it for?</label><input id="invite-note" name="note" maxlength="80" placeholder="Who is it for? (only you see this)"><button class="chip">Make an invite code</button></form>` : '<p class="muted small">All used. Thank you for bringing people in.</p>'}
860
+ ${c.invites.length ? `<ol class="ledger">${c.invites.map((i) => `<li>${esc(i.note || 'invite')} <span class="muted small">${esc(state(i))}</span></li>`).join('')}</ol>` : ''}</div>`
861
+ }
862
+
863
+ export function invitePage(ctx: Ctx, d: { featureDays: number; featureUsd: number; inviters: InviterRow[]; me?: { link: string; stats: InviteStats; name: string; displayName: string | null; listings: Product[]; codes?: InviteCodes; flash?: string; error?: string } }): string {
813
864
  const m = d.me
814
865
  const text = 'I launch my indie products on built.indie: one URL and it pulls in the logo, screenshots and story. Launch yours free:'
815
866
  const shares: [string, string, string][] = m
@@ -846,6 +897,7 @@ ${
846
897
  }</div>`
847
898
  : ''
848
899
  }
900
+ ${m.codes ? inviteCodesCard(m.codes) : ''}
849
901
  <div class="card"><h2>Your name on the board</h2><p class="muted small">Shown as <b>${esc(m.name)}</b>. We never show your email.</p>
850
902
  <form method="post" action="/invite/name" class="row"><label class="sr" for="displayName">Display name</label><input id="displayName" name="displayName" maxlength="40" placeholder="Your name or handle" value="${esc(m.displayName ?? '')}"><button class="chip">Save</button></form></div>`
851
903
  : '<p class="center"><a class="btn" href="/login?next=/invite">Sign in to get your invite link</a></p>'
@@ -862,7 +914,20 @@ ${d.inviters.length ? invitersList(d.inviters) : '<p class="empty">Nobody yet. O
862
914
  }
863
915
 
864
916
  /** Where an invite link lands: who invited you, and one step to launch. */
865
- export function inviteLanding(ctx: Ctx, d: { inviter: string }): string {
917
+ export function inviteLanding(ctx: Ctx, d: { inviter: string | null; bonusDays?: number }): string {
918
+ // A scarce invite code: nobody is named until it is used, and both people get Featured days.
919
+ if (!d.inviter)
920
+ return layout({
921
+ ctx,
922
+ title: "You're invited to built.indie",
923
+ description: 'Launch your indie product with one URL. We fetch the logo, screenshots and story; people upvote; you get a badge. Free.',
924
+ path: '/invite',
925
+ noindex: true,
926
+ body: `<section class="hero"><h1>You're invited.</h1>
927
+ <p class="lede">Someone on built.indie spent one of their few invites on you. Sign up and you both get <b>${d.bonusDays ?? 0} Featured days</b>: your launch pinned on the homepage.</p>
928
+ ${ctx.user ? '<p class="muted">You are already signed in, so this invite is for someone new. Pass it on.</p>' : `<p><a class="btn" href="/signup?next=/me">${icon('rocket')} Accept: sign up</a></p><p class="muted small">Already a member? Invites are for new members; your days come from <a href="/me#redeem">codes</a> and your own invites.</p>`}
929
+ </section>`,
930
+ })
866
931
  return layout({
867
932
  ctx,
868
933
  title: `${d.inviter} invited you to launch on built.indie`,
package/src/mcp.ts CHANGED
@@ -97,6 +97,9 @@ export function createMcpServer(client = new BuiltIndieClient()): McpServer {
97
97
  server.tool('my_products', 'Your launches and claims (needs BUILTINDIE_KEY).', {}, wrap(() => client.me()))
98
98
  server.tool('my_invites', 'Your invite link and credits (needs BUILTINDIE_KEY). Each new member who signs up through the link and launches a listing earns one credit: a free Featured week.', {}, wrap(() => client.invites()))
99
99
  server.tool('feature_with_credit', 'Spend one invite credit to feature a listing you own for a week (needs BUILTINDIE_KEY).', { slug }, wrap((a: { slug: string }) => client.credit(a.slug)))
100
+ server.tool('redeem_code', 'Redeem a credit code (CR-XXXX-XXXX-XXXX-XXXX) for Featured days on your balance (needs BUILTINDIE_KEY).', { code: z.string().min(4).describe('The credit code') }, wrap((a: { code: string }) => client.redeem(a.code)))
101
+ server.tool('create_invite', 'Make one of your 3 single-use invite codes (needs BUILTINDIE_KEY). Someone new who signs up through the link gives you both Featured days. The link is shown once.', { note: z.string().max(80).optional().describe('Who it is for, only you see it') }, wrap((a: { note?: string }) => client.inviteCode(a.note ?? '')))
102
+ server.tool('feature_with_days', 'Spend Featured days from your balance (from codes and invites) on a listing you own (needs BUILTINDIE_KEY).', { slug, days: z.number().int().min(1).describe('How many days') }, wrap((a: { slug: string; days: number }) => client.featureDays(a.slug, a.days)))
100
103
  server.tool('feature_paid', 'Pay to feature a listing you own (needs BUILTINDIE_KEY): returns a CoinPay hosted checkout_url where the buyer picks any coin or PayPal. The receipt goes to email (default: your account email).', { slug, email: z.string().email().optional().describe('Where the receipt goes') }, wrap((a: { slug: string; email?: string }) => client.feature(a.slug, a.email)))
101
104
  server.tool('my_karma', 'Your karma on built.indie (needs BUILTINDIE_KEY): total, this week, recent history, how many karma to the next free Featured week, and the how-to-earn table.', {}, wrap(() => client.karma()))
102
105
  server.tool(
package/src/profile.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The shared Profullstack profile (@profullstack/profile): email, first and last
3
+ * name, company domain, job title and LinkedIn URL, in the package's own
4
+ * `user_profiles` table keyed by users.id. Private to the member: nothing here is
5
+ * shown on a public page.
6
+ */
7
+ import type { Database } from 'bun:sqlite'
8
+ import { createProfileStore, migrate, splitFullName, type ProfileStore } from '@profullstack/profile'
9
+
10
+ /** The store, and `ready` once its table exists (created when the database opens). */
11
+ export function openProfiles(db: Database): { store: ProfileStore; ready: Promise<ProfileStore> } {
12
+ const store = createProfileStore(db, { keyType: 'text' })
13
+ const ready = migrate(store).then(async () => {
14
+ await backfill(db, store)
15
+ return store
16
+ })
17
+ ready.catch((error) => console.error(`[profile] migration failed: ${(error as Error).message}`))
18
+ return { store, ready }
19
+ }
20
+
21
+ /**
22
+ * One-time and idempotent: a display name typed as "First Last" becomes the
23
+ * profile's names. A single word is a handle, not a name, and is left alone, and
24
+ * fillBlanks never overwrites what a member saved.
25
+ */
26
+ async function backfill(db: Database, store: ProfileStore) {
27
+ const rows = db.query(`SELECT u.id, u.email, u.display_name FROM users u
28
+ WHERE instr(trim(u.display_name), ' ') > 0 AND NOT EXISTS (SELECT 1 FROM user_profiles p WHERE p.user_id = u.id)`)
29
+ .all() as { id: string; email: string; display_name: string }[]
30
+ for (const row of rows) {
31
+ const email = row.email.endsWith('.invalid') ? {} : { email: row.email }
32
+ try { await store.fillBlanks(row.id, { ...splitFullName(row.display_name), ...email }) } catch { /* a name the validator refuses is skipped */ }
33
+ }
34
+ }
package/src/server.ts CHANGED
@@ -22,10 +22,12 @@ import { HOW_TO_EARN, SHARE_NETWORKS } from './karma.js'
22
22
  import { IMAGE_TYPES, Media } from './media.js'
23
23
  import { startPitFooterRefresh } from './pit-footer.js'
24
24
  import { RING_PLACEHOLDER, ringBar, startRingRefresh, visitorOnMoshpit } from './ring.js'
25
- import { BuiltIndie, CLAIM_LABEL, CLAIM_PREFIX, InputError, type Config, type Edit } from './service.js'
25
+ import { BuiltIndie, CLAIM_LABEL, CLAIM_PREFIX, INVITE_BONUS_DAYS, INVITES_PER_PERSON, InputError, type Config, type Edit } from './service.js'
26
26
  import { autofetch, FetchError } from './fetch.js'
27
27
  import { COINPAY_ERRORS, coinpayOAuthFromEnv, isPlaceholder, mountCoinPaySignin, PROVIDER as COINPAY, type CoinPayOAuth } from './coinpay-signin.js'
28
28
  import { VERSION } from './version.js'
29
+ import { mountProfileRoutes, PROFILE_ENHANCE_SCRIPT, saveSignupProfile, validateProfile } from '@profullstack/profile'
30
+ import { openProfiles } from './profile.js'
29
31
 
30
32
  export { VERSION }
31
33
 
@@ -36,7 +38,8 @@ const CSP = [
36
38
  "default-src 'none'",
37
39
  "style-src 'self' 'unsafe-inline'",
38
40
  "script-src 'self' https://crawlproof.com",
39
- "img-src 'self' data:",
41
+ // The shared footer's badges: ours is same-origin, pwamart's is not.
42
+ "img-src 'self' data: https://pwamart.com",
40
43
  "connect-src 'self' https://crawlproof.com",
41
44
  'frame-src https://crawlproof.com https://www.youtube-nocookie.com https://player.vimeo.com https://www.loom.com',
42
45
  "manifest-src 'self'",
@@ -58,6 +61,10 @@ export type Deps = { service: BuiltIndie; auth: Auth; adminKey?: string; secureC
58
61
 
59
62
  /** The invite code from /i/<code>, kept until the visitor signs up (it also rides in the magic link). */
60
63
  const REF_COOKIE = 'bi_ref'
64
+ /** A scarce single-use invite code (INV-XXXX-XXXX-XXXX) from /i/<code>, kept until the visitor signs up. */
65
+ const INVITE_COOKIE = 'bi_invite'
66
+ /** An @profullstack/affiliates invite code, as opposed to a member's open invite-link code. */
67
+ const isInviteCode = (code: string | null | undefined): code is string => Boolean(code && /^INV-?[0-9A-Z]{4}-?[0-9A-Z]{4}-?[0-9A-Z]{4}$/i.test(code.trim()))
61
68
  type Env = { Bindings: { ip?: string } }
62
69
 
63
70
  /** A small fixed-window limit per key, in memory: enough to stop a loop, not a fortress. */
@@ -87,7 +94,10 @@ export function createApp(deps: Deps) {
87
94
  const voteLimit = new Limiter(60, 10 * 60_000)
88
95
  const commentLimit = new Limiter(10, 10 * 60_000)
89
96
  const shareLimit = new Limiter(60, 10 * 60_000)
97
+ const redeemLimit = new Limiter(10, 10 * 60_000)
90
98
  const origin = config.origin
99
+ // The shared profile fields (profile.ts); user_profiles is created as the app starts.
100
+ const profiles = openProfiles(store.db)
91
101
 
92
102
  const direct = (c: Context) => visitorOnMoshpit(SELF, c.req.header('host'), c.req.header('x-moshpit-name'))
93
103
  const ip = (c: Context<Env>) => c.env?.ip ?? 'local'
@@ -153,6 +163,14 @@ export function createApp(deps: Deps) {
153
163
  if (!c.res.headers.has('content-security-policy')) for (const [k, v] of Object.entries(SECURITY_HEADERS)) c.header(k, v)
154
164
  })
155
165
 
166
+ // ?ref=<a member's invite code> on any page is their invite link too: the same bi_ref cookie
167
+ // /i/<code> sets, first touch wins, so one sign-up is counted once, in one place (the invites table).
168
+ app.use('*', async (c, next) => {
169
+ const ref = c.req.query('ref')
170
+ if (ref && ref.length <= 40 && !getCookie(c, REF_COOKIE) && store.inviterOf(ref) && !sessionUser(c)) setCookie(c, REF_COOKIE, ref, { httpOnly: true, secure: secure(c), sameSite: 'Lax', path: '/', maxAge: 30 * 86400 })
171
+ await next()
172
+ })
173
+
156
174
  app.get('/health', (c) => c.json({ ok: true, version: VERSION }))
157
175
 
158
176
  // Static files.
@@ -242,7 +260,7 @@ export function createApp(deps: Deps) {
242
260
  const rv = (x: Review): ReviewView => ({ rating: x.rating, body: x.body, at: x.updatedAt, author: who(x.userId), karma: karmaOf.get(x.userId) ?? 0, mine: Boolean(me && x.userId === me.id) })
243
261
  return { comments: comments.map(cv), reviews: reviews.map(rv), myReview: me ? store.review(p.id, me.id) : null }
244
262
  }
245
- const productView = (c: Context<Env>, p: Product, extra: { error?: string } = {}, status = 200) =>
263
+ const productView = async (c: Context<Env>, p: Product, extra: { error?: string } = {}, status = 200) =>
246
264
  page(
247
265
  c,
248
266
  productPage(ctxOf(c), {
@@ -257,6 +275,11 @@ export function createApp(deps: Deps) {
257
275
  maker: p.makerId ? store.maker(p.makerId) : null,
258
276
  featuredOk: c.req.query('featured') === '1',
259
277
  featuredCredit: c.req.query('featured') === 'credit',
278
+ featuredDaysUsed: c.req.query('featured') === 'days',
279
+ daysBalance: await (async () => {
280
+ const u = sessionUser(c)
281
+ return u && store.canEdit(p, u) ? service.featuredDays(u) : 0
282
+ })(),
260
283
  launched: c.req.query('launched') === '1',
261
284
  canEdit: store.canEdit(p, sessionUser(c)),
262
285
  credits: (() => {
@@ -354,12 +377,29 @@ export function createApp(deps: Deps) {
354
377
  return claimView(c, store.product(p.id)!, user, { result })
355
378
  })
356
379
 
380
+ /**
381
+ * A new member: a scarce invite code (bi_invite) gives both people Featured days, and an
382
+ * invite link (bi_ref, /i/<code> or ?ref=) makes their first live listing earn the inviter a credit.
383
+ * Both write the same invites row, first one wins, so a sign-up is counted once.
384
+ */
385
+ const joined = async (c: Context, user: User, carried: string | null) => {
386
+ const parts = (carried ?? '').split(' ').filter(Boolean)
387
+ const invite = parts.find(isInviteCode) ?? getCookie(c, INVITE_COOKIE)
388
+ const ref = parts.find((x) => !isInviteCode(x)) ?? getCookie(c, REF_COOKIE)
389
+ if (invite) await service.acceptInvite(invite, user).catch((e) => console.error(`[invite] accept failed: ${(e as Error).message}`))
390
+ if (ref) store.recordInvite(user.id, ref)
391
+ for (const name of [REF_COOKIE, INVITE_COOKIE]) deleteCookie(c, name, { path: '/' })
392
+ }
393
+
357
394
  // Signing in: an emailed link, which is also sign-up.
358
395
  const invitedBy = (c: Context) => {
359
396
  const inviter = store.inviterOf(getCookie(c, REF_COOKIE) ?? '')
360
397
  return inviter && !sessionUser(c) ? store.publicName(inviter) : null
361
398
  }
362
399
  app.get('/login', (c) => page(c, loginPage(ctxOf(c), { next: safeNext(c.req.query('next')), invitedBy: invitedBy(c), error: COINPAY_ERRORS[c.req.query('error') ?? ''] })))
400
+ // Creating an account asks for the shared profile fields; plain sign-in never does.
401
+ app.get('/signup', (c) => page(c, loginPage(ctxOf(c), { next: safeNext(c.req.query('next')), invitedBy: invitedBy(c), signup: true })))
402
+ app.get('/vendor-profile.js', (c) => c.body(PROFILE_ENHANCE_SCRIPT, 200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'public, max-age=86400' }))
363
403
  // Sign in with CoinPay (coinpay-signin.ts): /api/v1/coinpay/login and /callback.
364
404
  mountCoinPaySignin(app, {
365
405
  store,
@@ -369,26 +409,32 @@ export function createApp(deps: Deps) {
369
409
  secure,
370
410
  sessionUser,
371
411
  // A new member who came through an invite link counts, as with a magic link.
372
- onNewMember: (c, user) => {
373
- const ref = getCookie(c, REF_COOKIE)
374
- if (ref) store.recordInvite(user.id, ref), deleteCookie(c, REF_COOKIE, { path: '/' })
375
- },
412
+ onNewMember: (c, user) => void joined(c, user, null),
376
413
  })
377
414
  app.post('/login', async (c) => {
378
415
  const form = (await c.req.parseBody()) as Record<string, string>
379
416
  const next = safeNext(form.next)
380
- if (!validEmail(form.email)) return page(c, loginPage(ctxOf(c), { next, error: 'That does not look like an email address.', email: form.email }), 400)
381
- if (!crossSite(c) && readLimit.allow(`mail:${ip(c)}`)) await deps.auth.sendLink(form.email ?? '', next, here(c), getCookie(c, REF_COOKIE) ?? null)
417
+ const signup = form.mode === 'signup'
418
+ if (!validEmail(form.email)) return page(c, loginPage(ctxOf(c), { next, error: 'That does not look like an email address.', email: form.email, signup, values: form }), 400)
419
+ // The signup fields are checked before any link exists; they ride on the link and are saved on verify.
420
+ let profile: object | null = null
421
+ if (signup) {
422
+ const check = validateProfile(form)
423
+ if (!check.ok) return page(c, loginPage(ctxOf(c), { next, error: 'Some fields need fixing.', email: form.email, signup, values: form, errors: check.errors }), 400)
424
+ profile = check.profile
425
+ }
426
+ // The invites ride in the link (space-separated), so opening it in another browser still counts.
427
+ if (!crossSite(c) && readLimit.allow(`mail:${ip(c)}`)) await deps.auth.sendLink(form.email ?? '', next, here(c), [getCookie(c, INVITE_COOKIE), getCookie(c, REF_COOKIE)].filter(Boolean).join(' ') || null, profile)
382
428
  return page(c, loginPage(ctxOf(c), { next, sent: true }))
383
429
  })
384
- app.get('/auth/verify', (c) => {
430
+ app.get('/auth/verify', async (c) => {
385
431
  const r = deps.auth.consumeLink(c.req.query('token') ?? '')
386
432
  if (!r) return page(c, messagePage(ctxOf(c), { title: 'That link is used up', message: 'Sign-in links work once, for 20 minutes. Ask for a new one.' }), 400)
387
433
  setCookie(c, SESSION_COOKIE, deps.auth.createSession(r.user.id), { httpOnly: true, secure: secure(c), sameSite: 'Lax', path: '/', maxAge: 90 * 86400 })
388
- // A new member who came through an invite link: their first live listing will earn the inviter a credit.
389
- const ref = r.ref ?? getCookie(c, REF_COOKIE)
390
- if (r.created && ref) store.recordInvite(r.user.id, ref)
391
- if (ref) deleteCookie(c, REF_COOKIE, { path: '/' })
434
+ if (r.created) await joined(c, r.user, r.ref ?? null)
435
+ else for (const name of [REF_COOKIE, INVITE_COOKIE]) deleteCookie(c, name, { path: '/' })
436
+ // The signup form's fields, now that the account exists. A failure never costs the sign-in: /me asks again.
437
+ if (r.profile) await saveSignupProfile(await profiles.ready, r.user.id, r.profile, { email: r.user.email }).catch((error) => console.error(`[auth] signup profile save failed: ${(error as Error).message}`))
392
438
  return c.redirect(r.next ?? '/me')
393
439
  })
394
440
  app.post('/logout', (c) => {
@@ -401,33 +447,98 @@ export function createApp(deps: Deps) {
401
447
  emailless: isPlaceholder(user.email),
402
448
  coinpayNote: ({ connected: 'CoinPay is connected: sign in with it next time.', taken: 'That CoinPay account is already linked to another built.indie member.', welcome: 'Welcome! You signed in with CoinPay.' } as Record<string, string>)[c.req.query('coinpay') ?? ''] ?? null,
403
449
  })
404
- app.get('/me', (c) => {
450
+ app.get('/me', async (c) => {
405
451
  const user = sessionUser(c)
406
- return user ? page(c, mePage(ctxOf(c), { user, products: store.mine(user.id), invite: store.inviteStats(user.id), karma: service.karma.summary(user.id), howToEarn: HOW_TO_EARN, featureDays: config.featureDays, ...signinInfo(c, user) })) : toLogin(c)
452
+ return user ? page(c, mePage(ctxOf(c), { user, products: store.mine(user.id), invite: store.inviteStats(user.id), karma: service.karma.summary(user.id), howToEarn: HOW_TO_EARN, featureDays: config.featureDays, daysBalance: await service.featuredDays(user), ...signinInfo(c, user), profile: await (await profiles.ready).get(user.id), detailsNote: c.req.query('details') === 'saved' ? 'Your details are saved.' : null })) : toLogin(c)
407
453
  })
408
- app.post('/me/key', (c) => {
454
+ // "Have a code?": a credit code (@profullstack/redeem-credits) adds Featured days to the balance.
455
+ app.post('/me/redeem', async (c) => {
409
456
  const user = sessionUser(c)
410
457
  if (!user) return toLogin(c)
411
458
  if (crossSite(c)) return c.redirect('/me')
412
- return page(c, mePage(ctxOf(c), { user, products: store.mine(user.id), apiKey: store.newApiKey(user.id), invite: store.inviteStats(user.id), karma: service.karma.summary(user.id), howToEarn: HOW_TO_EARN, featureDays: config.featureDays, ...signinInfo(c, user) }))
459
+ const code = String(((await c.req.parseBody()) as Record<string, string>).code ?? '')
460
+ let note: string
461
+ let status = 200
462
+ if (!redeemLimit.allow(`redeem:${user.id}`) || !redeemLimit.allow(`redeem-ip:${ip(c)}`)) (note = 'Too many tries. Wait a few minutes.'), (status = 429)
463
+ else
464
+ try {
465
+ note = (await service.redeem(user, code)).message
466
+ } catch (e) {
467
+ note = e instanceof Error ? e.message : String(e)
468
+ status = 400
469
+ }
470
+ return page(c, mePage(ctxOf(c), { user, products: store.mine(user.id), invite: store.inviteStats(user.id), karma: service.karma.summary(user.id), howToEarn: HOW_TO_EARN, featureDays: config.featureDays, daysBalance: await service.featuredDays(user), redeemNote: note, redeemError: status !== 200, ...signinInfo(c, user), profile: await (await profiles.ready).get(user.id) }), status)
471
+ })
472
+ // Spend Featured days from the balance on a listing you own.
473
+ app.post('/p/:slug/feature-days', async (c) => {
474
+ const user = sessionUser(c)
475
+ if (!user) return toLogin(c)
476
+ const p = store.bySlug(c.req.param('slug'))
477
+ if (!p || p.status === 'hidden') return c.notFound()
478
+ if (crossSite(c) || !store.canEdit(p, user)) return c.redirect(`/p/${p.slug}`)
479
+ const days = Number(((await c.req.parseBody()) as Record<string, string>).days ?? 0)
480
+ try {
481
+ await service.spendFeaturedDays(p, user, days)
482
+ return c.redirect(`/p/${p.slug}?featured=days`)
483
+ } catch (e) {
484
+ return productView(c, p, { error: e instanceof Error ? e.message : String(e) }, 400)
485
+ }
486
+ })
487
+ // The shared profile (@profullstack/profile): the /me form, and GET/PUT/PATCH /api/v1/me/profile for a session or an API key.
488
+ app.post('/me/profile', async (c) => {
489
+ const user = sessionUser(c)
490
+ if (!user) return toLogin(c)
491
+ if (crossSite(c)) return c.redirect('/me')
492
+ const form = (await c.req.parseBody()) as Record<string, string>
493
+ const email = isPlaceholder(user.email) ? null : user.email
494
+ const check = validateProfile({ ...form, email }, { required: ['first_name', 'last_name'] })
495
+ if (!check.ok) return page(c, mePage(ctxOf(c), { user, products: store.mine(user.id), invite: store.inviteStats(user.id), karma: service.karma.summary(user.id), howToEarn: HOW_TO_EARN, featureDays: config.featureDays, daysBalance: await service.featuredDays(user), ...signinInfo(c, user), profile: form, detailsNote: 'Some fields need fixing.', detailsError: true, detailsErrors: check.errors }), 400)
496
+ await (await profiles.ready).save(user.id, check.profile)
497
+ return c.redirect('/me?details=saved#details')
498
+ })
499
+ app.post('/me/key', async (c) => {
500
+ const user = sessionUser(c)
501
+ if (!user) return toLogin(c)
502
+ if (crossSite(c)) return c.redirect('/me')
503
+ return page(c, mePage(ctxOf(c), { user, products: store.mine(user.id), apiKey: store.newApiKey(user.id), invite: store.inviteStats(user.id), karma: service.karma.summary(user.id), howToEarn: HOW_TO_EARN, featureDays: config.featureDays, daysBalance: await service.featuredDays(user), ...signinInfo(c, user) }))
413
504
  })
414
505
 
415
506
  // Invites: a link per member; a new member it brings in who launches earns the inviter a free Featured week.
416
507
  app.get('/i/:code', (c) => {
417
508
  const code = c.req.param('code')
509
+ // A scarce invite code: whoever made it stays private until it is used.
510
+ if (isInviteCode(code)) {
511
+ if (!sessionUser(c)) setCookie(c, INVITE_COOKIE, code.trim().toUpperCase(), { httpOnly: true, secure: secure(c), sameSite: 'Lax', path: '/', maxAge: 30 * 86400 })
512
+ return page(c, inviteLanding(ctxOf(c), { inviter: null, bonusDays: INVITE_BONUS_DAYS }))
513
+ }
418
514
  const inviter = store.inviterOf(code)
419
515
  if (!inviter) return c.redirect('/invite')
420
516
  if (!sessionUser(c)) setCookie(c, REF_COOKIE, code, { httpOnly: true, secure: secure(c), sameSite: 'Lax', path: '/', maxAge: 30 * 86400 })
421
517
  return page(c, inviteLanding(ctxOf(c), { inviter: store.publicName(inviter) }))
422
518
  })
423
- const inviteView = async (c: Context<Env>, extra: { flash?: string; error?: string } = {}, status = 200) => {
519
+ const inviteCodes = async (user: User) => ({ quota: await service.affiliates.quota(user.id), invites: await service.affiliates.invites(user.id), bonusDays: INVITE_BONUS_DAYS })
520
+ const inviteView = async (c: Context<Env>, extra: { flash?: string; error?: string; newCode?: string } = {}, status = 200) => {
424
521
  const user = sessionUser(c)
425
522
  const me = user
426
- ? { link: inviteLink(user), stats: store.inviteStats(user.id), name: store.publicName(user.id), displayName: user.displayName, listings: store.mine(user.id).filter((p) => p.status === 'live'), ...extra }
523
+ ? { link: inviteLink(user), stats: store.inviteStats(user.id), name: store.publicName(user.id), displayName: user.displayName, listings: store.mine(user.id).filter((p) => p.status === 'live'), codes: { ...(await inviteCodes(user)), newLink: extra.newCode ? `${origin}/i/${extra.newCode}` : undefined }, ...extra }
427
524
  : undefined
428
525
  return page(c, invitePage(ctxOf(c), { featureDays: config.featureDays, featureUsd: config.featureUsd, inviters: await inviters(10), me }), status)
429
526
  }
430
527
  app.get('/invite', (c) => inviteView(c))
528
+ app.get('/invites', (c) => c.redirect('/invite#codes'))
529
+ // A scarce invite code: three each, single use.
530
+ app.post('/invite/code', async (c) => {
531
+ const user = sessionUser(c)
532
+ if (!user) return toLogin(c)
533
+ if (crossSite(c)) return c.redirect('/invite')
534
+ const note = String(((await c.req.parseBody()) as Record<string, string>).note ?? '').slice(0, 80)
535
+ try {
536
+ const invite = await service.affiliates.createInvite(user.id, note)
537
+ return inviteView(c, { newCode: invite.code })
538
+ } catch (e) {
539
+ return inviteView(c, { error: (e as Error).message === 'no invites left' ? `You have used all ${INVITES_PER_PERSON} invite codes.` : (e as Error).message }, 400)
540
+ }
541
+ })
431
542
  app.post('/invite/name', async (c) => {
432
543
  const user = sessionUser(c)
433
544
  if (!user) return toLogin(c)
@@ -834,18 +945,69 @@ export function createApp(deps: Deps) {
834
945
  const r = await service.claim(p, user)
835
946
  return c.json({ claimed: r.ok, ...(r.method ? { method: r.method } : {}), looked: r.looked, record: claimRecord(p, user), badge: claimBadge(p, user), ...(r.ok ? {} : { markdown: claimMd(p, user) }) })
836
947
  })
837
- app.get('/api/v1/me', (c) => {
948
+ mountProfileRoutes(app, {
949
+ store: profiles.store,
950
+ getUser: (_req: Request, c: Context) => {
951
+ const user = sessionUser(c) ?? apiUser(c)
952
+ return user ? { id: user.id, email: isPlaceholder(user.email) ? null : user.email } : null
953
+ },
954
+ })
955
+ app.get('/api/v1/me', async (c) => {
838
956
  const user = apiUser(c)
839
957
  if (!user) return c.json(NEEDS_ACCOUNT, 401)
840
- return c.json({ email: isPlaceholder(user.email) ? null : user.email, coinpay: Boolean(store.identities(user.id).some((i) => i.provider === COINPAY)), products: store.mine(user.id).map((p) => ({ ...apiProduct(p), owner: p.ownerId === user.id })), invite: apiInvite(user), karma: apiKarma(user) })
958
+ return c.json({ email: isPlaceholder(user.email) ? null : user.email, coinpay: Boolean(store.identities(user.id).some((i) => i.provider === COINPAY)), products: store.mine(user.id).map((p) => ({ ...apiProduct(p), owner: p.ownerId === user.id })), invite: await apiInvite(user), featured_days: await service.featuredDays(user), karma: apiKarma(user) })
841
959
  })
842
- const apiInvite = (user: User) => {
960
+ const apiInvite = async (user: User) => {
843
961
  const s = store.inviteStats(user.id)
844
- return { link: inviteLink(user), code: s.code, invited: s.invited, launched: s.launched, credits: s.credits, karma_credits: s.karmaCredits, used: s.used, feature_days: config.featureDays }
962
+ const codes = await inviteCodes(user)
963
+ return {
964
+ link: inviteLink(user),
965
+ code: s.code,
966
+ invited: s.invited,
967
+ launched: s.launched,
968
+ credits: s.credits,
969
+ karma_credits: s.karmaCredits,
970
+ used: s.used,
971
+ feature_days: config.featureDays,
972
+ // Scarce single-use invite codes: the code itself is shown once, when made.
973
+ invite_codes: { quota: codes.quota, bonus_days: codes.bonusDays, invites: codes.invites.map((i) => ({ id: i.id, note: i.note, created_at: new Date(i.createdAt).toISOString(), expires_at: i.expiresAt ? new Date(i.expiresAt).toISOString() : null, used: Boolean(i.usedAt), used_at: i.usedAt ? new Date(i.usedAt).toISOString() : null })) },
974
+ }
845
975
  }
846
- app.get('/api/v1/invites', (c) => {
976
+ app.get('/api/v1/invites', async (c) => {
977
+ const user = apiUser(c)
978
+ return user ? c.json(await apiInvite(user)) : c.json(NEEDS_ACCOUNT, 401)
979
+ })
980
+ app.post('/api/v1/invites', async (c) => {
981
+ const user = apiUser(c)
982
+ if (!user) return c.json(NEEDS_ACCOUNT, 401)
983
+ try {
984
+ const invite = await service.affiliates.createInvite(user.id, String((await body(c)).note ?? '').slice(0, 80))
985
+ return c.json({ link: `${origin}/i/${invite.code}`, code: invite.code, expires_at: invite.expiresAt ? new Date(invite.expiresAt).toISOString() : null, left: (await service.affiliates.quota(user.id)).left, bonus_days: INVITE_BONUS_DAYS }, 201)
986
+ } catch (e) {
987
+ return c.json({ error: (e as Error).message === 'no invites left' ? `You have used all ${INVITES_PER_PERSON} invite codes.` : (e as Error).message }, 400)
988
+ }
989
+ })
990
+ app.post('/api/v1/redeem', async (c) => {
847
991
  const user = apiUser(c)
848
- return user ? c.json(apiInvite(user)) : c.json(NEEDS_ACCOUNT, 401)
992
+ if (!user) return c.json(NEEDS_ACCOUNT, 401)
993
+ if (!redeemLimit.allow(`redeem:${user.id}`) || !redeemLimit.allow(`redeem-ip:${ip(c)}`)) return c.json({ error: 'too many tries; wait a few minutes' }, 429)
994
+ try {
995
+ const r = await service.redeem(user, String((await body(c)).code ?? ''))
996
+ return c.json({ ok: true, message: r.message, featured_days: r.days })
997
+ } catch (e) {
998
+ return apiError(c, e)
999
+ }
1000
+ })
1001
+ app.post('/api/v1/products/:slug/feature-days', async (c) => {
1002
+ const user = apiUser(c)
1003
+ const p = owned(c)
1004
+ if (!user || !p) return c.json(NEEDS_KEY, 401)
1005
+ try {
1006
+ const after = await service.spendFeaturedDays(p, user, Number((await body(c)).days ?? 0))
1007
+ return c.json({ featured_until: new Date(after.featuredUntil!).toISOString(), featured_days: await service.featuredDays(user) })
1008
+ } catch (e) {
1009
+ return apiError(c, e)
1010
+ }
849
1011
  })
850
1012
  app.post('/api/v1/products/:slug/credit', (c) => {
851
1013
  const user = apiUser(c)
package/src/service.ts CHANGED
@@ -10,6 +10,9 @@
10
10
  * verify the maker put our badge on their site: a followed link back
11
11
  * feature pay on CoinPay's hosted checkout (any coin or PayPal) to sit on top for a week
12
12
  * vote, comment, review, share what members do on a listing; each pays karma (karma.ts)
13
+ * redeem a credit code (@profullstack/redeem-credits) adds Featured days to a member's balance
14
+ * invites 3 single-use invite codes each (@profullstack/affiliates); a new member who
15
+ * joins through one gives both of them INVITE_BONUS_DAYS Featured days
13
16
  *
14
17
  * Products are described in the LogicSRC open specs: each one is an OpenSite
15
18
  * record (logicsrc.com/docs/opensite) and an OpenProfile.md with Kind
@@ -26,6 +29,23 @@ import { Karma, SHARE_NETWORKS } from './karma.js'
26
29
  import { cleanTags } from './tags.js'
27
30
  import { validEmail } from './auth.js'
28
31
  import { CoinPay, CoinPayError, readWebhook, verifyWebhook } from '@profullstack/coinpay-onboard'
32
+ import { Credits, sqliteStore as creditStore } from '@profullstack/redeem-credits'
33
+ import { Affiliates, sqliteStore as affiliateStore } from '@profullstack/affiliates'
34
+
35
+ /** The unit a credit code grants: days a listing sits in the Featured slot. */
36
+ export const FEATURED_DAYS = 'featured_days'
37
+ /** Single-use invite codes each member can hand out. Scarce on purpose: an invite should mean something. */
38
+ export const INVITES_PER_PERSON = 3
39
+ /** Featured days both people get when an invite code is used by someone new. */
40
+ export const INVITE_BONUS_DAYS = 2
41
+ /** What a refused credit code means, in words. */
42
+ const REDEEM_REASONS: Record<string, string> = {
43
+ invalid: 'That code is not valid.',
44
+ expired: 'That code has expired.',
45
+ used_up: 'That code has been used.',
46
+ already_redeemed: 'You already redeemed that code.',
47
+ revoked: 'That code was withdrawn.',
48
+ }
29
49
 
30
50
  export class InputError extends Error {
31
51
  constructor(message: string, readonly field?: string) {
@@ -80,8 +100,57 @@ const list = (v: string[] | string | undefined) => (Array.isArray(v) ? v : Strin
80
100
 
81
101
  export class BuiltIndie {
82
102
  readonly karma: Karma
103
+ /** Redeemable credit codes: Featured days, in the app's own database (credit_* tables). */
104
+ readonly credits: Credits
105
+ /** Scarce invite codes (aff_* tables). The open per-member link stays the app's own invite program. */
106
+ readonly affiliates: Affiliates
83
107
  constructor(readonly store: Store, readonly media: Media, readonly config: Config) {
84
108
  this.karma = new Karma(store, config.karmaPerCredit ?? 500)
109
+ this.credits = new Credits(creditStore(store.db as never))
110
+ this.affiliates = new Affiliates(affiliateStore(store.db as never), { defaultQuota: INVITES_PER_PERSON })
111
+ }
112
+
113
+ /** Featured days this member holds (from credit codes and invite bonuses), not yet spent on a listing. */
114
+ featuredDays(user: Pick<User, 'id'>): Promise<number> {
115
+ return this.credits.balance(user.id, FEATURED_DAYS)
116
+ }
117
+
118
+ /** Redeem a credit code for the signed-in member. Returns what it added, in words; a refused code throws. */
119
+ async redeem(user: Pick<User, 'id'>, code: string): Promise<{ message: string; days: number }> {
120
+ if (!code.trim()) throw new InputError('Enter a code.', 'code')
121
+ const r = await this.credits.redeem(code, user.id)
122
+ if (!r.ok) throw new InputError(REDEEM_REASONS[r.reason] ?? 'That code did not work.', 'code')
123
+ const g = r.grant
124
+ const what = g.unit === FEATURED_DAYS ? `${g.amount} Featured day${g.amount === 1 ? '' : 's'}` : `${g.amount} ${g.unit}`
125
+ const message = g.every === 'month' ? `Added ${what} every month${g.months ? ` for ${g.months} months` : ''}.` : `Added ${what}.`
126
+ return { message: `${message} Use ${g.amount === 1 ? 'it' : 'them'} on any live listing you own.`, days: await this.featuredDays(user) }
127
+ }
128
+
129
+ /** Spend Featured days from the balance on a live listing: the same featured_until a paid week extends. */
130
+ async spendFeaturedDays(p: Product, user: User, days: number): Promise<Product> {
131
+ if (p.status !== 'live') throw new InputError('Only a live listing can be featured.')
132
+ if (!this.store.canEdit(p, user)) throw new InputError('You can only feature a listing you submitted or own.')
133
+ const want = Math.floor(Number(days))
134
+ if (!Number.isFinite(want) || want < 1) throw new InputError('Pick at least one day.', 'days')
135
+ const r = await this.credits.spend(user.id, FEATURED_DAYS, want, `featured ${p.slug}`)
136
+ if (!r.ok) throw new InputError(r.balance ? `You have ${r.balance} Featured day${r.balance === 1 ? '' : 's'} left.` : 'You have no Featured days. Redeem a code or use an invite.', 'days')
137
+ this.store.extendFeatured(p.id, want)
138
+ return this.store.product(p.id)!
139
+ }
140
+
141
+ /**
142
+ * Someone new signed up through a scarce invite code: the code is spent, both get
143
+ * INVITE_BONUS_DAYS Featured days, and the invite counts in the invite program
144
+ * (the same invites row an invite link makes, so a sign-up is never counted twice).
145
+ */
146
+ async acceptInvite(code: string, user: User): Promise<{ ok: boolean; reason?: string; inviterId?: string }> {
147
+ const r = await this.affiliates.redeemInvite(code, user.id)
148
+ if (!r.ok) return { ok: false, reason: r.reason }
149
+ const bonus = { unit: FEATURED_DAYS, amount: INVITE_BONUS_DAYS } as const
150
+ await this.credits.grant(user.id, bonus, `invited by ${r.inviterId}`)
151
+ await this.credits.grant(r.inviterId, bonus, `invited ${user.id}`)
152
+ this.store.recordInvite(user.id, this.store.inviteCode(r.inviterId))
153
+ return { ok: true, inviterId: r.inviterId }
85
154
  }
86
155
 
87
156
  /** A product page's public address. */
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.6.0'
1
+ export const VERSION = '0.7.1'