@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 +9 -0
- package/package.json +12 -6
- package/public/style.css +5 -0
- package/src/auth.ts +6 -6
- package/src/cli.ts +21 -0
- package/src/client.ts +16 -1
- package/src/db.ts +9 -2
- package/src/html.ts +77 -12
- package/src/mcp.ts +3 -0
- package/src/profile.ts +34 -0
- package/src/server.ts +188 -26
- package/src/service.ts +69 -0
- package/src/version.ts +1 -1
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.
|
|
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.
|
|
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.
|
|
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.
|
|
34
|
-
"zod": "^
|
|
36
|
+
"hono": "^4.13.14",
|
|
37
|
+
"zod": "^4.6.7"
|
|
35
38
|
},
|
|
36
39
|
"devDependencies": {
|
|
37
|
-
"@types/bun": "^1.4.
|
|
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,
|
|
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,
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/<slug>/refetch -H 'authorization: Bearer
|
|
|
767
798
|
curl -X POST ${o}/api/v1/products/<slug>/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/<slug>/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/<slug>/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/<slug>/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&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
|
|
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
|
-
|
|
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
|
-
|
|
381
|
-
if (!
|
|
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
|
-
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1
|
+
export const VERSION = '0.7.1'
|