@profullstack/builtindie 0.1.4 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/OpenStack.md +1 -1
- package/README.md +33 -4
- package/package.json +16 -7
- package/public/app.js +58 -0
- package/public/style.css +113 -11
- package/src/auth.ts +10 -6
- package/src/browse.ts +243 -0
- package/src/cli.ts +131 -10
- package/src/client.ts +96 -5
- package/src/coinpay-signin.ts +118 -0
- package/src/db.ts +406 -9
- package/src/fetch.ts +15 -7
- package/src/html.ts +561 -82
- package/src/karma.ts +179 -0
- package/src/leaderboard.ts +59 -0
- package/src/mcp.ts +55 -4
- package/src/pit-footer.ts +39 -0
- package/src/profile.ts +34 -0
- package/src/ring-footer.ts +53 -0
- package/src/ring.ts +5 -1
- package/src/server.ts +607 -48
- package/src/service.ts +307 -74
- package/src/tags.ts +92 -0
- package/src/tui.ts +28 -6
- package/src/version.ts +1 -1
package/src/browse.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browse and search: one query over every live launch, with facets.
|
|
3
|
+
*
|
|
4
|
+
* A launch's tags are whatever its page or its maker said (often nothing), so
|
|
5
|
+
* we also file each one under a fixed set of categories, read from its tags,
|
|
6
|
+
* name, tagline and description. Every listing lands somewhere, and the same
|
|
7
|
+
* twenty words mean the same thing everywhere.
|
|
8
|
+
*
|
|
9
|
+
* Facets work the usual way: values within a facet are OR'd, facets are AND'd,
|
|
10
|
+
* and each facet counts what you would get by changing only that facet.
|
|
11
|
+
*/
|
|
12
|
+
import type { Product } from './db.js'
|
|
13
|
+
|
|
14
|
+
export type Category = { key: string; label: string; words: string[] }
|
|
15
|
+
|
|
16
|
+
export const CATEGORIES: Category[] = [
|
|
17
|
+
{ key: 'developer-tools', label: 'Developer tools', words: ['developer', 'developers', 'devtools', 'dev tools', 'api', 'sdk', 'cli', 'code', 'coding', 'github', 'npm', 'open source', 'programming', 'terminal', 'mcp', 'cicd', 'ci/cd', 'git'] },
|
|
18
|
+
{ key: 'ai', label: 'AI', words: ['ai', 'llm', 'llms', 'gpt', 'agent', 'agents', 'agentic', 'machine learning', 'artificial intelligence', 'chatbot', 'claude', 'openai', 'inference', 'gpu'] },
|
|
19
|
+
{ key: 'crypto', label: 'Crypto & web3', words: ['crypto', 'cryptocurrency', 'bitcoin', 'blockchain', 'web3', 'usdc', 'ethereum', 'solana', 'defi', 'nft', 'multisig', 'escrow', 'stablecoin'] },
|
|
20
|
+
{ key: 'finance', label: 'Finance & payments', words: ['finance', 'payment', 'payments', 'invoice', 'invoicing', 'billing', 'bank', 'banking', 'money', 'tax', 'taxes', 'accounting', 'stocks', 'trading', 'invest', 'investing', 'payment gateway'] },
|
|
21
|
+
{ key: 'security', label: 'Security & privacy', words: ['security', 'cybersecurity', 'malware', 'vulnerability', 'vulnerabilities', 'threat', 'threats', 'privacy', 'encryption', 'encrypted', 'scanner', 'intrusion', 'firewall', 'vpn', 'backdoor', 'exposure management'] },
|
|
22
|
+
{ key: 'productivity', label: 'Productivity', words: ['productivity', 'utilities', 'utility', 'workflow', 'automation', 'automate', 'notes', 'calendar', 'todo', 'bookmarks', 'sync', 'pdf'] },
|
|
23
|
+
{ key: 'marketing', label: 'Marketing & SEO', words: ['marketing', 'seo', 'newsletter', 'newsletters', 'social media', 'ads', 'advertising', 'outreach', 'growth', 'analytics', 'leads', 'programmatic seo', 'affiliate', 'webring'] },
|
|
24
|
+
{ key: 'media', label: 'Video & streaming', words: ['entertainment', 'movies', 'movie', 'video', 'videos', 'streaming', 'tv', 'iptv', 'torrent', 'torrents', 'film', 'films', 'youtube', 'ebooks', 'podcast', 'podcasts'] },
|
|
25
|
+
{ key: 'music', label: 'Music & audio', words: ['music', 'audio', 'songs', 'radio', 'playlist', 'playlists', 'genre', 'genres', 'bands', 'concerts'] },
|
|
26
|
+
{ key: 'news', label: 'News & publishing', words: ['news', 'blog', 'blogs', 'blogging', 'rss', 'feeds', 'journalism', 'publishing', 'magazine', 'content automation'] },
|
|
27
|
+
{ key: 'education', label: 'Education', words: ['education', 'learn', 'learning', 'course', 'courses', 'training', 'tutorial', 'tutorials', 'school', 'students', 'kids'] },
|
|
28
|
+
{ key: 'business', label: 'Business & SaaS', words: ['business', 'saas', 'b2b', 'crm', 'startup', 'startups', 'enterprise', 'small business', 'compliance', 'license', 'government'] },
|
|
29
|
+
{ key: 'jobs', label: 'Jobs & freelance', words: ['jobs', 'job', 'hiring', 'freelance', 'freelancers', 'gig', 'gig economy', 'remote work', 'career', 'resume', 'recruiting'] },
|
|
30
|
+
{ key: 'shopping', label: 'Shopping & deals', words: ['shopping', 'coupons', 'coupon', 'deals', 'discounts', 'promo codes', 'ecommerce', 'e-commerce', 'savings', 'marketplace'] },
|
|
31
|
+
{ key: 'social', label: 'Social & community', words: ['social', 'community', 'communities', 'chat', 'forum', 'forums', 'messaging', 'messenger', 'dating', 'friends', 'sms'] },
|
|
32
|
+
{ key: 'games', label: 'Games', words: ['game', 'games', 'gaming', 'esports', 'puzzle', 'play both sides', 'rpg', 'operatives', 'multiplayer', 'arcade'] },
|
|
33
|
+
{ key: 'sports', label: 'Sports', words: ['sports', 'sport', 'youth-sports', 'athlete', 'athletes', 'league', 'leagues', 'nba', 'nfl', 'mlb', 'nhl', 'soccer', 'football', 'basketball', 'baseball', 'hockey', 'highlight reel', 'highlights', 'fixtures'] },
|
|
34
|
+
{ key: 'design', label: 'Design & images', words: ['design', 'designer', 'image', 'images', 'photo', 'photos', 'background', 'backgrounds', 'icons', 'logo', 'logos', 'graphics', 'illustration', 'art', 'tattoo'] },
|
|
35
|
+
{ key: 'lifestyle', label: 'Lifestyle & health', words: ['lifestyle', 'health', 'fitness', 'food', 'recipe', 'recipes', 'travel', 'family', 'ancestry', 'genealogy', 'home', 'pets', 'cannabis', 'weed', 'delivery', 'cigars', 'cigar', 'tattoo', 'detailing', 'bbq'] },
|
|
36
|
+
{ key: 'data', label: 'Data & directories', words: ['directory', 'directories', 'dataset', 'datasets', 'database', 'databases', 'search engine', 'listings', 'index', 'scraper', 'lookup'] },
|
|
37
|
+
{ key: 'hosting', label: 'Hosting & infrastructure', words: ['hosting', 'server', 'servers', 'vps', 'dns', 'domains', 'domain names', 'cloud', 'seedbox', 'bare metal', 'cdn', 'deploy', 'deployment', 'infrastructure'] },
|
|
38
|
+
]
|
|
39
|
+
const OTHER: Category = { key: 'other', label: 'Other', words: [] }
|
|
40
|
+
export const CATEGORY_LABELS: Record<string, string> = Object.fromEntries([...CATEGORIES, OTHER].map((c) => [c.key, c.label]))
|
|
41
|
+
|
|
42
|
+
const escRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')
|
|
43
|
+
const MATCHERS = CATEGORIES.map((c) => ({ key: c.key, re: new RegExp(`(?:^|[^a-z0-9])(?:${c.words.map(escRe).join('|')})(?=$|[^a-z0-9])`, 'g') }))
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Up to three categories for a launch, best first. Tags and the tagline say
|
|
47
|
+
* what it is; the description only breaks ties, so a passing mention of
|
|
48
|
+
* "payments" in a long paragraph does not file a music site under finance.
|
|
49
|
+
*/
|
|
50
|
+
export function categorize(p: Pick<Product, 'tags' | 'name' | 'tagline' | 'description'>): string[] {
|
|
51
|
+
const strong = ` ${[...p.tags, p.name, p.tagline].join(' · ').toLowerCase()} `
|
|
52
|
+
const weak = ` ${p.description.toLowerCase()} `
|
|
53
|
+
const scored = MATCHERS.map((m) => ({ key: m.key, score: 3 * (strong.match(m.re)?.length ?? 0) + Math.min(2, weak.match(m.re)?.length ?? 0) }))
|
|
54
|
+
.filter((s) => s.score > 0)
|
|
55
|
+
.sort((a, b) => b.score - a.score)
|
|
56
|
+
// A lone weak hit is noise unless it is all we have.
|
|
57
|
+
const keep = scored.filter((s, i) => i === 0 || s.score >= 2).slice(0, 3)
|
|
58
|
+
return keep.length ? keep.map((s) => s.key) : [OTHER.key]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Things a launch has, each one a filter. */
|
|
62
|
+
export const HAS: Record<string, { label: string; test: (p: Product, now: number) => boolean }> = {
|
|
63
|
+
verified: { label: '✓ Links back (verified)', test: (p) => Boolean(p.verifiedAt) },
|
|
64
|
+
claimed: { label: 'Claimed by its owner', test: (p) => Boolean(p.ownerId) },
|
|
65
|
+
featured: { label: '★ Featured now', test: (p, now) => Boolean(p.featuredUntil && p.featuredUntil > now) },
|
|
66
|
+
video: { label: 'Has a video', test: (p) => p.videos.length > 0 },
|
|
67
|
+
screenshots: { label: 'Has screenshots', test: (p) => Boolean(p.screenshot || p.images.length) },
|
|
68
|
+
llms: { label: 'llms.txt', test: (p) => Boolean(p.llms) },
|
|
69
|
+
opensite: { label: 'OpenSite', test: (p) => p.record?.source === 'declared' },
|
|
70
|
+
openprofile: { label: 'OpenProfile', test: (p) => Boolean(p.profile) },
|
|
71
|
+
openstack: { label: 'OpenStack (built with)', test: (p) => Boolean(p.extras.stack) },
|
|
72
|
+
opensaas: { label: 'OpenSaaS (pricing)', test: (p) => Boolean(p.extras.pricing) },
|
|
73
|
+
maker: { label: 'Names its maker', test: (p) => Boolean(p.makerName || p.makerId) },
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const DAY = 86400_000
|
|
77
|
+
export const LAUNCHED: Record<string, { label: string; ms: number }> = {
|
|
78
|
+
today: { label: 'Last 24 hours', ms: DAY },
|
|
79
|
+
week: { label: 'Past week', ms: 7 * DAY },
|
|
80
|
+
month: { label: 'Past month', ms: 30 * DAY },
|
|
81
|
+
year: { label: 'Past year', ms: 365 * DAY },
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const SORTS = { relevance: 'Best match', top: 'Most upvoted', new: 'Newest', name: 'Name A–Z' } as const
|
|
85
|
+
export type Sort = keyof typeof SORTS
|
|
86
|
+
|
|
87
|
+
export type Query = { q: string; category: string[]; tag: string[]; stack: string[]; has: string[]; maker: string[]; launched: string | null; sort: Sort; page: number }
|
|
88
|
+
export type FacetName = 'category' | 'tag' | 'stack' | 'has' | 'maker' | 'launched'
|
|
89
|
+
export type FacetValue = { value: string; label: string; count: number }
|
|
90
|
+
export type Facets = Record<FacetName, FacetValue[]>
|
|
91
|
+
export type Chip = { facet: FacetName | 'q'; value: string; label: string }
|
|
92
|
+
export type Result = { query: Query; total: number; products: Product[]; facets: Facets; chips: Chip[]; categories: Map<string, string[]>; limit: number }
|
|
93
|
+
|
|
94
|
+
/** The order facets appear in, with their headings. */
|
|
95
|
+
export const FACETS: { name: FacetName; label: string; multi: boolean; top?: number }[] = [
|
|
96
|
+
{ name: 'category', label: 'Category', multi: true },
|
|
97
|
+
{ name: 'tag', label: 'Tags', multi: true, top: 30 },
|
|
98
|
+
{ name: 'launched', label: 'Launched', multi: false },
|
|
99
|
+
{ name: 'has', label: 'Has', multi: true },
|
|
100
|
+
{ name: 'stack', label: 'Built with', multi: true, top: 20 },
|
|
101
|
+
{ name: 'maker', label: 'Maker', multi: true, top: 20 },
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
const clean = (v: string, max = 60) => v.trim().slice(0, max)
|
|
105
|
+
const listOf = (p: URLSearchParams, k: string, split: boolean) =>
|
|
106
|
+
[...new Set(p.getAll(k).flatMap((v) => (split ? v.split(',') : [v])).map((v) => clean(v)).filter(Boolean))].slice(0, 20)
|
|
107
|
+
|
|
108
|
+
/** A query from a URL's parameters (pages and the API take the same ones). */
|
|
109
|
+
export function parseQuery(params: URLSearchParams | Record<string, string | string[] | undefined>): Query {
|
|
110
|
+
const p = params instanceof URLSearchParams ? params : new URLSearchParams(Object.entries(params).flatMap(([k, v]) => (v === undefined ? [] : (Array.isArray(v) ? v : [v]).map((x) => [k, String(x)]))))
|
|
111
|
+
const q = clean(p.get('q') ?? '', 80)
|
|
112
|
+
const sort = (p.get('sort') ?? '') as Sort
|
|
113
|
+
const launched = p.get('launched') ?? ''
|
|
114
|
+
return {
|
|
115
|
+
q,
|
|
116
|
+
category: listOf(p, 'category', true).filter((c) => c in CATEGORY_LABELS),
|
|
117
|
+
tag: listOf(p, 'tag', true).map((t) => t.toLowerCase()),
|
|
118
|
+
stack: listOf(p, 'stack', false),
|
|
119
|
+
has: listOf(p, 'has', true).filter((h) => h in HAS),
|
|
120
|
+
maker: listOf(p, 'maker', false),
|
|
121
|
+
launched: launched in LAUNCHED ? launched : null,
|
|
122
|
+
sort: sort in SORTS && (sort !== 'relevance' || q) ? sort : q ? 'relevance' : 'top',
|
|
123
|
+
page: Math.min(500, Math.max(1, Number(p.get('page')) || 1)),
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The query as URL parameters; defaults left out so links stay short. */
|
|
128
|
+
export function queryString(q: Query): string {
|
|
129
|
+
const p = new URLSearchParams()
|
|
130
|
+
if (q.q) p.set('q', q.q)
|
|
131
|
+
for (const k of ['category', 'tag', 'has', 'stack', 'maker'] as const) for (const v of q[k]) p.append(k, v)
|
|
132
|
+
if (q.launched) p.set('launched', q.launched)
|
|
133
|
+
if (q.sort !== (q.q ? 'relevance' : 'top')) p.set('sort', q.sort)
|
|
134
|
+
if (q.page > 1) p.set('page', String(q.page))
|
|
135
|
+
return p.toString()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The same query with one facet value switched on or off (back to page 1). */
|
|
139
|
+
export function toggle(q: Query, facet: FacetName, value: string): Query {
|
|
140
|
+
const next: Query = { ...q, page: 1 }
|
|
141
|
+
if (facet === 'launched') next.launched = q.launched === value ? null : value
|
|
142
|
+
else next[facet] = q[facet].includes(value) ? q[facet].filter((v) => v !== value) : [...q[facet], value]
|
|
143
|
+
return next
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export const isEmpty = (q: Query) => !q.q && !q.launched && !q.category.length && !q.tag.length && !q.stack.length && !q.has.length && !q.maker.length
|
|
147
|
+
|
|
148
|
+
const words = (s: string) => s.toLowerCase().split(/[^a-z0-9+#.]+/).filter((w) => w.length >= 2)
|
|
149
|
+
|
|
150
|
+
export function search(all: Product[], query: Query, o: { limit?: number; now?: number } = {}): Result {
|
|
151
|
+
const now = o.now ?? Date.now()
|
|
152
|
+
const limit = o.limit ?? 30
|
|
153
|
+
const categories = new Map(all.map((p) => [p.id, categorize(p)]))
|
|
154
|
+
const stackOf = (p: Product) => [...new Set((p.extras.stack?.items ?? []).map((i) => i.name))]
|
|
155
|
+
|
|
156
|
+
// Words: every one must appear somewhere; where it appears decides the score.
|
|
157
|
+
// A term matches at the start of a word: "crypt" finds crypto, "ai" does not find email.
|
|
158
|
+
const terms = words(query.q).map((t) => ({ t, re: new RegExp(`(?:^|[^a-z0-9])${escRe(t)}`) }))
|
|
159
|
+
const scores = new Map<string, number>()
|
|
160
|
+
const textMatch = (p: Product) => {
|
|
161
|
+
if (!terms.length) return true
|
|
162
|
+
const name = p.name.toLowerCase()
|
|
163
|
+
const fields = [
|
|
164
|
+
[name, 8],
|
|
165
|
+
[p.tags.join(' ').toLowerCase(), 5],
|
|
166
|
+
[(categories.get(p.id) ?? []).map((c) => CATEGORY_LABELS[c]).join(' ').toLowerCase(), 4],
|
|
167
|
+
[p.domain.toLowerCase(), 4],
|
|
168
|
+
[p.tagline.toLowerCase(), 3],
|
|
169
|
+
[`${p.makerName} ${stackOf(p).join(' ')}`.toLowerCase(), 2],
|
|
170
|
+
[p.description.toLowerCase(), 1],
|
|
171
|
+
] as const
|
|
172
|
+
let score = 0
|
|
173
|
+
for (const { re } of terms) {
|
|
174
|
+
const hit = fields.reduce((best, [text, w]) => (re.test(text) ? Math.max(best, w) : best), 0)
|
|
175
|
+
if (!hit) return false
|
|
176
|
+
score += hit
|
|
177
|
+
}
|
|
178
|
+
if (name === query.q.toLowerCase()) score += 20
|
|
179
|
+
else if (name.startsWith(terms[0]!.t)) score += 6
|
|
180
|
+
scores.set(p.id, score)
|
|
181
|
+
return true
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const tests: Record<FacetName, (p: Product) => boolean> = {
|
|
185
|
+
category: (p) => !query.category.length || query.category.some((c) => categories.get(p.id)!.includes(c)),
|
|
186
|
+
tag: (p) => !query.tag.length || query.tag.some((t) => p.tags.includes(t)),
|
|
187
|
+
stack: (p) => !query.stack.length || stackOf(p).some((s) => query.stack.includes(s)),
|
|
188
|
+
has: (p) => query.has.every((h) => HAS[h]!.test(p, now)),
|
|
189
|
+
maker: (p) => !query.maker.length || query.maker.includes(p.makerName),
|
|
190
|
+
launched: (p) => !query.launched || Boolean(p.launchedAt && p.launchedAt >= now - LAUNCHED[query.launched]!.ms),
|
|
191
|
+
}
|
|
192
|
+
const base = all.filter(textMatch)
|
|
193
|
+
const names = Object.keys(tests) as FacetName[]
|
|
194
|
+
const passes = (p: Product, except?: FacetName) => names.every((n) => n === except || tests[n](p))
|
|
195
|
+
|
|
196
|
+
// Each facet counts over everything the other facets allow.
|
|
197
|
+
const tally = (except: FacetName, valuesOf: (p: Product) => string[]) => {
|
|
198
|
+
const n = new Map<string, number>()
|
|
199
|
+
for (const p of base) if (passes(p, except)) for (const v of valuesOf(p)) n.set(v, (n.get(v) ?? 0) + 1)
|
|
200
|
+
return n
|
|
201
|
+
}
|
|
202
|
+
const ranked = (n: Map<string, number>, label: (v: string) => string, top = 100, selected: string[] = []) =>
|
|
203
|
+
[...n]
|
|
204
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
205
|
+
.filter(([v], i) => i < top || selected.includes(v))
|
|
206
|
+
.map(([value, count]) => ({ value, label: label(value), count }))
|
|
207
|
+
const withSelected = (values: FacetValue[], selected: string[], label: (v: string) => string) => [...values, ...selected.filter((s) => !values.some((v) => v.value === s)).map((s) => ({ value: s, label: label(s), count: 0 }))]
|
|
208
|
+
|
|
209
|
+
const hasCounts = new Map<string, number>()
|
|
210
|
+
for (const p of base) if (passes(p, 'has')) for (const [k, h] of Object.entries(HAS)) if (query.has.filter((x) => x !== k).every((x) => HAS[x]!.test(p, now)) && h.test(p, now)) hasCounts.set(k, (hasCounts.get(k) ?? 0) + 1)
|
|
211
|
+
const launchedCounts = new Map<string, number>()
|
|
212
|
+
for (const p of base) if (passes(p, 'launched')) for (const [k, l] of Object.entries(LAUNCHED)) if (p.launchedAt && p.launchedAt >= now - l.ms) launchedCounts.set(k, (launchedCounts.get(k) ?? 0) + 1)
|
|
213
|
+
|
|
214
|
+
const facets: Facets = {
|
|
215
|
+
category: withSelected(ranked(tally('category', (p) => categories.get(p.id)!), (v) => CATEGORY_LABELS[v] ?? v), query.category, (v) => CATEGORY_LABELS[v] ?? v),
|
|
216
|
+
tag: withSelected(ranked(tally('tag', (p) => p.tags), (v) => v, 30, query.tag), query.tag, (v) => v),
|
|
217
|
+
stack: withSelected(ranked(tally('stack', stackOf), (v) => v, 20, query.stack), query.stack, (v) => v),
|
|
218
|
+
maker: withSelected(ranked(tally('maker', (p) => (p.makerName ? [p.makerName] : [])), (v) => v, 20, query.maker), query.maker, (v) => v),
|
|
219
|
+
has: Object.keys(HAS).filter((k) => hasCounts.get(k) || query.has.includes(k)).map((k) => ({ value: k, label: HAS[k]!.label, count: hasCounts.get(k) ?? 0 })),
|
|
220
|
+
launched: Object.keys(LAUNCHED).filter((k) => launchedCounts.get(k) || query.launched === k).map((k) => ({ value: k, label: LAUNCHED[k]!.label, count: launchedCounts.get(k) ?? 0 })),
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const matched = base.filter((p) => passes(p))
|
|
224
|
+
const by: Record<Sort, (a: Product, b: Product) => number> = {
|
|
225
|
+
relevance: (a, b) => (scores.get(b.id) ?? 0) - (scores.get(a.id) ?? 0) || b.votes - a.votes,
|
|
226
|
+
top: (a, b) => b.votes - a.votes || (b.launchedAt ?? 0) - (a.launchedAt ?? 0),
|
|
227
|
+
new: (a, b) => (b.launchedAt ?? 0) - (a.launchedAt ?? 0),
|
|
228
|
+
name: (a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }),
|
|
229
|
+
}
|
|
230
|
+
matched.sort(by[query.sort])
|
|
231
|
+
|
|
232
|
+
const chips: Chip[] = [
|
|
233
|
+
...(query.q ? [{ facet: 'q' as const, value: query.q, label: `“${query.q}”` }] : []),
|
|
234
|
+
...query.category.map((v) => ({ facet: 'category' as const, value: v, label: CATEGORY_LABELS[v] ?? v })),
|
|
235
|
+
...query.tag.map((v) => ({ facet: 'tag' as const, value: v, label: `#${v}` })),
|
|
236
|
+
...(query.launched ? [{ facet: 'launched' as const, value: query.launched, label: LAUNCHED[query.launched]!.label }] : []),
|
|
237
|
+
...query.has.map((v) => ({ facet: 'has' as const, value: v, label: HAS[v]!.label })),
|
|
238
|
+
...query.stack.map((v) => ({ facet: 'stack' as const, value: v, label: `Built with ${v}` })),
|
|
239
|
+
...query.maker.map((v) => ({ facet: 'maker' as const, value: v, label: `By ${v}` })),
|
|
240
|
+
]
|
|
241
|
+
const start = (query.page - 1) * limit
|
|
242
|
+
return { query, total: matched.length, products: matched.slice(start, start + limit), facets, chips, categories, limit }
|
|
243
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -7,9 +7,23 @@
|
|
|
7
7
|
* builtindie show <slug>
|
|
8
8
|
* builtindie vote <slug>
|
|
9
9
|
* builtindie preview <url> what we would read from a site, nothing saved
|
|
10
|
-
* builtindie
|
|
11
|
-
* builtindie
|
|
10
|
+
* builtindie tags [tag] every tag, or the launches with one
|
|
11
|
+
* builtindie search [words] [--category k] [--tag t] [--has h] [--sort top|new|name]
|
|
12
|
+
* builtindie categories every category and tag, with counts
|
|
13
|
+
* builtindie claim <slug> prove you own the domain (the badge or DNS TXT), BUILTINDIE_KEY
|
|
12
14
|
* builtindie me your launches, BUILTINDIE_KEY
|
|
15
|
+
* builtindie invite your invite link and free Featured credits, BUILTINDIE_KEY
|
|
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
|
|
20
|
+
* builtindie feature <slug> pay to feature it: a CoinPay checkout link (any coin or PayPal), BUILTINDIE_KEY
|
|
21
|
+
* builtindie karma your karma, history, next free Featured week, BUILTINDIE_KEY
|
|
22
|
+
* builtindie leaderboard [karma|invites] [--period week|month|all]
|
|
23
|
+
* builtindie comment <slug> "text" comment on a launch (--reply <id>), BUILTINDIE_KEY
|
|
24
|
+
* builtindie comments <slug> read the comments
|
|
25
|
+
* builtindie review <slug> <1-5> "text" review a launch, BUILTINDIE_KEY
|
|
26
|
+
* builtindie reviews <slug> read the reviews
|
|
13
27
|
* builtindie edit <slug> --tagline … (BUILTINDIE_KEY)
|
|
14
28
|
* builtindie refetch <slug> (BUILTINDIE_KEY)
|
|
15
29
|
* builtindie mcp MCP server on stdio
|
|
@@ -27,10 +41,28 @@ export const HELP = `builtindie ${VERSION}: launch your indie product with one U
|
|
|
27
41
|
builtindie show <slug>
|
|
28
42
|
builtindie vote <slug>
|
|
29
43
|
builtindie preview <url> what we read from a site; nothing is saved
|
|
30
|
-
builtindie
|
|
31
|
-
builtindie
|
|
32
|
-
|
|
44
|
+
builtindie tags [tag] every tag with its count, or the launches tagged <tag>
|
|
45
|
+
builtindie search [words] search; narrow with --category developer-tools --tag ai
|
|
46
|
+
[--has verified,video] [--stack Bun] [--maker "Name"] [--launched today|week|month|year]
|
|
47
|
+
[--sort relevance|top|new|name] [--page N] [--facets]
|
|
48
|
+
builtindie categories every category and tag, with how many launches each
|
|
49
|
+
builtindie claim <slug> [--md] your badge for the homepage (or the DNS TXT record) that proves
|
|
50
|
+
it is yours, then checks both; --md prints the steps as Markdown
|
|
51
|
+
to paste to an AI with DNS or site access
|
|
33
52
|
builtindie me your launches
|
|
53
|
+
builtindie invite your invite link; each maker it brings in who launches = a free Featured week
|
|
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
|
|
58
|
+
builtindie feature <slug> [--email you@x] pay to feature it: prints a CoinPay checkout link (any coin or PayPal)
|
|
59
|
+
builtindie karma your karma, history and how far to the next free Featured week
|
|
60
|
+
builtindie leaderboard [karma|invites] [--period week|month|all]
|
|
61
|
+
builtindie comment <slug> "text" comment on a launch (+2 karma); --reply <comment id> to answer one
|
|
62
|
+
builtindie comments <slug> read a launch's comments
|
|
63
|
+
builtindie uncomment <id> delete your comment (its karma goes back)
|
|
64
|
+
builtindie review <slug> <1-5> "text" stars and words (+5 karma for 30+ characters)
|
|
65
|
+
builtindie reviews <slug> read a launch's reviews
|
|
34
66
|
builtindie edit <slug> [--name --tagline --description --tags a,b --video URL]
|
|
35
67
|
builtindie refetch <slug> read the homepage again
|
|
36
68
|
builtindie publish <slug> put a draft live
|
|
@@ -54,7 +86,7 @@ export function parseArgs(argv: string[]): { words: string[]; flags: Record<stri
|
|
|
54
86
|
if (a.startsWith('--')) {
|
|
55
87
|
const [k, v] = a.slice(2).split(/=(.*)/s) as [string, string | undefined]
|
|
56
88
|
if (v !== undefined) flags[k] = v
|
|
57
|
-
else if (argv[i + 1] !== undefined && !argv[i + 1]!.startsWith('--') && !['json', 'draft', 'md'].includes(k)) flags[k] = argv[++i]!
|
|
89
|
+
else if (argv[i + 1] !== undefined && !argv[i + 1]!.startsWith('--') && !['json', 'draft', 'md', 'facets'].includes(k)) flags[k] = argv[++i]!
|
|
58
90
|
else flags[k] = true
|
|
59
91
|
} else words.push(a)
|
|
60
92
|
}
|
|
@@ -135,8 +167,26 @@ export async function main(argv = process.argv.slice(2), out: Out = console, cli
|
|
|
135
167
|
return 0
|
|
136
168
|
}
|
|
137
169
|
case 'search': {
|
|
138
|
-
const
|
|
139
|
-
|
|
170
|
+
const many = (k: string) => (typeof flags[k] === 'string' ? String(flags[k]).split(',').map((v) => v.trim()).filter(Boolean) : undefined)
|
|
171
|
+
const r = await client.search(rest.join(' '), { category: many('category'), tag: many('tag'), has: many('has'), stack: many('stack'), maker: typeof flags.maker === 'string' ? [flags.maker] : undefined, launched: str('launched'), sort: str('sort'), page: Number(str('page') ?? 1) || 1, limit: Number(str('limit') ?? 30) || 30 })
|
|
172
|
+
const facets = flags.facets && r.facets ? `\n\n${Object.entries(r.facets).filter(([, v]) => v.length).map(([k, v]) => `${k}: ${v.map((f) => `${f.value} (${f.count})`).join(', ')}`).join('\n')}` : ''
|
|
173
|
+
show(r, `${r.products.map((p) => line(p)).join('\n') || 'Nothing matches.'}\n\n${r.total} match${r.total === 1 ? '' : 'es'}${r.pages > 1 ? `, page ${r.page} of ${r.pages}` : ''} · ${r.web}${facets}`)
|
|
174
|
+
return 0
|
|
175
|
+
}
|
|
176
|
+
case 'categories': {
|
|
177
|
+
const r = await client.categories()
|
|
178
|
+
show(r, `${r.categories.map((c) => `${String(c.count).padStart(4)} ${c.label} (${c.key})`).join('\n')}\n\nTags: ${r.tags.map((t) => `${t.tag} (${t.count})`).join(', ')}`)
|
|
179
|
+
return 0
|
|
180
|
+
}
|
|
181
|
+
case 'tags':
|
|
182
|
+
case 'tag': {
|
|
183
|
+
if (rest.length) {
|
|
184
|
+
const r = await client.tagged(rest.join(' '), Number(str('limit') ?? 30))
|
|
185
|
+
show(r, r.products.map((p, i) => line(p, i + 1)).join('\n') || `Nothing tagged ${r.tag} yet.`)
|
|
186
|
+
} else {
|
|
187
|
+
const r = await client.tags()
|
|
188
|
+
show(r, r.tags.map((t) => `${String(t.count).padStart(4)} ${t.tag}`).join('\n') || 'No tags yet.')
|
|
189
|
+
}
|
|
140
190
|
return 0
|
|
141
191
|
}
|
|
142
192
|
case 'show': {
|
|
@@ -179,8 +229,8 @@ export async function main(argv = process.argv.slice(2), out: Out = console, cli
|
|
|
179
229
|
return 0
|
|
180
230
|
}
|
|
181
231
|
const r = await client.claim(slug)
|
|
182
|
-
if (r.claimed) return show(r, `${slug} is yours
|
|
183
|
-
show(r, `
|
|
232
|
+
if (r.claimed) return show(r, `${slug} is yours ✓${r.method === 'badge' ? ' (we found your badge)' : r.method === 'dns' ? ' (we found your DNS record)' : ''}. Edit it: builtindie edit ${slug} --tagline "…"`), 0
|
|
233
|
+
show(r, `Prove it either way, then run builtindie claim ${slug} again:\n\n1. Easiest: put this badge on your homepage and deploy (its id is yours; it also earns the followed link and the ✓):\n\n ${r.badge}\n\n2. Or add this DNS record (wait a minute for it to show):\n\n type TXT\n name ${r.record.name}\n value ${r.record.value}\n\n(not found yet at ${r.looked.join(', ')})`)
|
|
184
234
|
return 1
|
|
185
235
|
}
|
|
186
236
|
case 'me': {
|
|
@@ -188,6 +238,77 @@ export async function main(argv = process.argv.slice(2), out: Out = console, cli
|
|
|
188
238
|
show(r, `${r.email}\n${r.products.map((p) => `${line(p)} ${p.owner ? 'claimed ✓' : p.status === 'draft' ? 'draft' : 'not claimed'}`).join('\n') || 'No launches yet.'}`)
|
|
189
239
|
return 0
|
|
190
240
|
}
|
|
241
|
+
case 'invite':
|
|
242
|
+
case 'invites': {
|
|
243
|
+
const r = await client.invites()
|
|
244
|
+
show(r, `Your invite link: ${r.link}\n${r.invited} joined · ${r.launched} launched · ${r.credits} free Featured week${r.credits === 1 ? '' : 's'} to use (${r.feature_days} days each)${r.credits ? '\nUse one: builtindie credit <slug>' : ''}`)
|
|
245
|
+
return 0
|
|
246
|
+
}
|
|
247
|
+
case 'credit': {
|
|
248
|
+
const r = await client.credit(rest[0] ?? '')
|
|
249
|
+
show(r, `${rest[0]} is Featured until ${r.featured_until.slice(0, 10)} ★ (${r.credits} credit${r.credits === 1 ? '' : 's'} left)`)
|
|
250
|
+
return 0
|
|
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
|
+
}
|
|
267
|
+
case 'feature': {
|
|
268
|
+
const r = await client.feature(rest[0] ?? '', str('email'))
|
|
269
|
+
show(r, `Pay $${r.usd} for ${r.days} days Featured (${r.methods.join(', ')}):\n${r.checkout_url}`)
|
|
270
|
+
return 0
|
|
271
|
+
}
|
|
272
|
+
case 'karma': {
|
|
273
|
+
const r = await client.karma()
|
|
274
|
+
show(r, `${r.total} karma (${r.week >= 0 ? '+' : ''}${r.week} this week) · ${r.next_credit_in} to your next free Featured week · ${r.credits.earned} earned from karma\n\n${r.events.map((e) => `${(e.points > 0 ? '+' : '') + e.points}`.padStart(4) + ` ${e.label} ${e.at.slice(0, 10)}`).join('\n') || 'No karma yet. How to earn:\n' + r.how_to_earn.map((h) => ` +${h.points} ${h.action}: ${h.rule}`).join('\n')}`)
|
|
275
|
+
return 0
|
|
276
|
+
}
|
|
277
|
+
case 'leaderboard':
|
|
278
|
+
case 'board': {
|
|
279
|
+
const r = await client.leaderboard(rest[0] ?? 'karma', str('period') ?? 'week', Number(str('limit') ?? 25))
|
|
280
|
+
show(r, `${r.board === 'invites' ? 'Top inviters' : 'Top karma'} (${r.period})\n${r.rows.map((x) => `${String(x.rank ?? '').padStart(3)}. ${String(x.value).padStart(5)} ${x.name} ${x.badges.map((b) => b.emoji).join('')}`).join('\n') || 'Nobody yet.'}\n${r.web}`)
|
|
281
|
+
return 0
|
|
282
|
+
}
|
|
283
|
+
case 'comment': {
|
|
284
|
+
const [slug, ...text] = rest
|
|
285
|
+
if (!slug || !text.length) throw new Error('builtindie comment <slug> "text" [--reply <comment id>]')
|
|
286
|
+
const r = await client.comment(slug, text.join(' '), str('reply'))
|
|
287
|
+
show(r, `Posted: ${r.comment.url} (you have ${r.karma} karma)`)
|
|
288
|
+
return 0
|
|
289
|
+
}
|
|
290
|
+
case 'comments': {
|
|
291
|
+
const r = await client.comments(rest[0] ?? '')
|
|
292
|
+
show(r, r.comments.map((x) => `${x.parent ? ' ↳ ' : ''}${x.deleted ? '[deleted]' : `${x.author} (${x.karma} karma)${x.maker ? ' [maker]' : ''} ${x.at.slice(0, 10)} #${x.id}\n${x.parent ? ' ' : ' '}${x.body}`}`).join('\n') || 'No comments yet.')
|
|
293
|
+
return 0
|
|
294
|
+
}
|
|
295
|
+
case 'uncomment': {
|
|
296
|
+
const r = await client.deleteComment(rest[0] ?? '')
|
|
297
|
+
show(r, 'Deleted.')
|
|
298
|
+
return 0
|
|
299
|
+
}
|
|
300
|
+
case 'review': {
|
|
301
|
+
const [slug, stars, ...text] = rest
|
|
302
|
+
if (!slug || !stars) throw new Error('builtindie review <slug> <1-5> "text"')
|
|
303
|
+
const r = await client.review(slug, Number(stars), text.join(' '))
|
|
304
|
+
show(r, `${'★'.repeat(r.review.rating)} saved. ${slug} is rated ${r.product.rating} from ${r.product.count}. You have ${r.karma} karma.`)
|
|
305
|
+
return 0
|
|
306
|
+
}
|
|
307
|
+
case 'reviews': {
|
|
308
|
+
const r = await client.reviews(rest[0] ?? '')
|
|
309
|
+
show(r, `${r.rating === null ? 'No reviews yet.' : `★ ${r.rating} from ${r.count}`}\n${r.reviews.map((x) => `${'★'.repeat(x.rating)} ${x.author} (${x.karma} karma) ${x.at.slice(0, 10)}\n ${x.body}`).join('\n')}`)
|
|
310
|
+
return 0
|
|
311
|
+
}
|
|
191
312
|
case 'verify': {
|
|
192
313
|
const r = await client.verify(rest[0] ?? '')
|
|
193
314
|
show(r, r.verified ? 'Verified ✓: we found the badge.' : 'Not found yet: add the badge HTML to your homepage and deploy.')
|
package/src/client.ts
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
* BUILTINDIE_URL points it at another instance; BUILTINDIE_KEY (bi_…) is your
|
|
4
4
|
* account's API key (built.indie/me), for claiming and editing your products.
|
|
5
5
|
*/
|
|
6
|
+
export type SearchFilters = Partial<{ category: string[]; tag: string[]; has: string[]; stack: string[]; maker: string[]; launched: string; sort: string; page: number; limit: number }>
|
|
7
|
+
export type FacetValue = { value: string; label: string; count: number }
|
|
8
|
+
export type SearchResult = { total: number; page: number; pages: number; products: (ApiProduct & { categories: string[] })[]; facets?: Record<string, FacetValue[]>; web: string }
|
|
9
|
+
|
|
6
10
|
export type ApiProduct = {
|
|
7
11
|
slug: string
|
|
8
12
|
name: string
|
|
@@ -12,6 +16,8 @@ export type ApiProduct = {
|
|
|
12
16
|
domain: string
|
|
13
17
|
status: string
|
|
14
18
|
votes: number
|
|
19
|
+
reviews?: number
|
|
20
|
+
rating?: number | null
|
|
15
21
|
tags: string[]
|
|
16
22
|
logo: string | null
|
|
17
23
|
card_image: string | null
|
|
@@ -32,6 +38,13 @@ export type ApiProduct = {
|
|
|
32
38
|
award?: { day: string; rank: number } | null
|
|
33
39
|
}
|
|
34
40
|
|
|
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
|
+
|
|
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
|
+
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 }
|
|
45
|
+
export type ApiComment = { id: string; parent: string | null; body: string | null; deleted: boolean; author: string | null; karma: number; maker: boolean; at: string; url: string; mine?: boolean }
|
|
46
|
+
export type ApiReview = { rating: number; body: string; author: string; karma: number; at: string }
|
|
47
|
+
|
|
35
48
|
export type Launched = { product: ApiProduct; claim_url?: string; existing?: boolean }
|
|
36
49
|
export type ClaimRecord = { type: string; name: string; value: string }
|
|
37
50
|
|
|
@@ -71,8 +84,27 @@ export class BuiltIndieClient {
|
|
|
71
84
|
return this.call('GET', `/api/v1/products?window=${encodeURIComponent(window)}&limit=${limit}`)
|
|
72
85
|
}
|
|
73
86
|
|
|
74
|
-
|
|
75
|
-
return this.call('GET',
|
|
87
|
+
tags(): Promise<{ tags: { tag: string; count: number }[] }> {
|
|
88
|
+
return this.call('GET', '/api/v1/tags')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
tagged(tag: string, limit = 30): Promise<{ tag: string; products: ApiProduct[] }> {
|
|
92
|
+
return this.call('GET', `/api/v1/products?tag=${encodeURIComponent(tag)}&limit=${limit}`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Search with filters; the answer carries the facets (counts per category, tag, stack…) to narrow further. */
|
|
96
|
+
search(q: string, filters: SearchFilters = {}): Promise<SearchResult> {
|
|
97
|
+
const p = new URLSearchParams()
|
|
98
|
+
if (q) p.set('q', q)
|
|
99
|
+
for (const k of ['category', 'tag', 'has', 'stack', 'maker'] as const) for (const v of filters[k] ?? []) p.append(k, v)
|
|
100
|
+
for (const k of ['launched', 'sort'] as const) if (filters[k]) p.set(k, filters[k]!)
|
|
101
|
+
if (filters.page) p.set('page', String(filters.page))
|
|
102
|
+
if (filters.limit) p.set('limit', String(filters.limit))
|
|
103
|
+
return this.call('GET', `/api/v1/search?${p}`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
categories(): Promise<{ categories: { key: string; label: string; count: number; page: string }[]; tags: { tag: string; count: number; page: string }[] }> {
|
|
107
|
+
return this.call('GET', '/api/v1/categories')
|
|
76
108
|
}
|
|
77
109
|
|
|
78
110
|
product(slug: string): Promise<ApiProduct> {
|
|
@@ -89,18 +121,48 @@ export class BuiltIndieClient {
|
|
|
89
121
|
return k
|
|
90
122
|
}
|
|
91
123
|
|
|
92
|
-
claimRecord(slug: string): Promise<{ owner: boolean; record: ClaimRecord; markdown: string }> {
|
|
124
|
+
claimRecord(slug: string): Promise<{ owner: boolean; record: ClaimRecord; badge: string; markdown: string }> {
|
|
93
125
|
return this.call('GET', `/api/v1/products/${encodeURIComponent(slug)}/claim`, undefined, this.needKey())
|
|
94
126
|
}
|
|
95
127
|
|
|
96
|
-
claim(slug: string): Promise<{ claimed: boolean; looked: string[]; record: ClaimRecord; markdown?: string }> {
|
|
128
|
+
claim(slug: string): Promise<{ claimed: boolean; method?: 'dns' | 'badge'; looked: string[]; record: ClaimRecord; badge: string; markdown?: string }> {
|
|
97
129
|
return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/claim`, {}, this.needKey())
|
|
98
130
|
}
|
|
99
131
|
|
|
100
|
-
me(): Promise<{ email: string; products: (ApiProduct & { owner: boolean })[] }> {
|
|
132
|
+
me(): Promise<{ email: string; products: (ApiProduct & { owner: boolean })[]; invite: Invites }> {
|
|
101
133
|
return this.call('GET', '/api/v1/me', undefined, this.needKey())
|
|
102
134
|
}
|
|
103
135
|
|
|
136
|
+
/** Your invite link and credits: each new member it brings in who launches earns a free Featured week. */
|
|
137
|
+
invites(): Promise<Invites> {
|
|
138
|
+
return this.call('GET', '/api/v1/invites', undefined, this.needKey())
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Spend one invite credit to feature a listing you own. */
|
|
142
|
+
credit(slug: string): Promise<{ featured_until: string; credits: number }> {
|
|
143
|
+
return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/credit`, {}, this.needKey())
|
|
144
|
+
}
|
|
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
|
+
|
|
161
|
+
/** Pay to feature a listing you own: opens a CoinPay checkout session (any coin or PayPal); send the buyer to checkout_url. */
|
|
162
|
+
feature(slug: string, email?: string): Promise<{ checkout_url: string; checkout_session_id: string; methods: string[]; payment: string; usd: number; days: number }> {
|
|
163
|
+
return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/feature`, email ? { email } : {}, this.needKey())
|
|
164
|
+
}
|
|
165
|
+
|
|
104
166
|
edit(slug: string, patch: Record<string, unknown>, key?: string): Promise<ApiProduct> {
|
|
105
167
|
return this.call('PATCH', `/api/v1/products/${encodeURIComponent(slug)}`, patch, this.needKey(key))
|
|
106
168
|
}
|
|
@@ -109,6 +171,35 @@ export class BuiltIndieClient {
|
|
|
109
171
|
return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/${action}`, {}, this.needKey(key))
|
|
110
172
|
}
|
|
111
173
|
|
|
174
|
+
/** Your karma: total, this week, history, and how far to the next free Featured week. */
|
|
175
|
+
karma(): Promise<KarmaInfo> {
|
|
176
|
+
return this.call('GET', '/api/v1/karma', undefined, this.needKey())
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
leaderboard(board = 'karma', period = 'week', limit = 25): Promise<BoardRows> {
|
|
180
|
+
return this.call('GET', `/api/v1/leaderboard?board=${encodeURIComponent(board)}&period=${encodeURIComponent(period)}&limit=${limit}`)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
comments(slug: string): Promise<{ slug: string; comments: ApiComment[] }> {
|
|
184
|
+
return this.call('GET', `/api/v1/products/${encodeURIComponent(slug)}/comments`)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
comment(slug: string, body: string, parent?: string): Promise<{ comment: ApiComment; karma: number }> {
|
|
188
|
+
return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/comments`, { body, ...(parent ? { parent } : {}) }, this.needKey())
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
deleteComment(id: string): Promise<{ deleted: boolean }> {
|
|
192
|
+
return this.call('DELETE', `/api/v1/comments/${encodeURIComponent(id)}`, undefined, this.needKey())
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
reviews(slug: string): Promise<{ slug: string; rating: number | null; count: number; reviews: ApiReview[] }> {
|
|
196
|
+
return this.call('GET', `/api/v1/products/${encodeURIComponent(slug)}/reviews`)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
review(slug: string, rating: number, body: string): Promise<{ review: { rating: number; body: string }; product: { rating: number | null; count: number }; karma: number }> {
|
|
200
|
+
return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/reviews`, { rating, body }, this.needKey())
|
|
201
|
+
}
|
|
202
|
+
|
|
112
203
|
verify(slug: string): Promise<{ verified: boolean }> {
|
|
113
204
|
return this.call('POST', `/api/v1/products/${encodeURIComponent(slug)}/verify`, {})
|
|
114
205
|
}
|