Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

sponsors.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

sponsors.tsxBlame432 lines · 1 contributor
08420cdClaude1/**
2 * Block I6 — Sponsors.
3 *
4 * GET /sponsors/:username — public sponsor page
5 * GET /settings/sponsors — maintain your own tiers + activity
6 * POST /settings/sponsors/tiers/new — publish a tier
7 * POST /settings/sponsors/tiers/:id/delete — retire a tier
8 * POST /sponsors/:username — record a sponsorship
9 *
10 * Payment rails are out of scope — this captures intent + thank-you notes.
11 */
12
13import { Hono } from "hono";
14import { and, desc, eq, isNull, sql } from "drizzle-orm";
15import { db } from "../db";
16import {
17 sponsorships,
18 sponsorshipTiers,
19 users,
20} from "../db/schema";
21import { Layout } from "../views/layout";
22import { softAuth, requireAuth } from "../middleware/auth";
23import type { AuthEnv } from "../middleware/auth";
24
25const sponsors = new Hono<AuthEnv>();
26sponsors.use("*", softAuth);
27
28function formatCents(cents: number): string {
29 if (cents === 0) return "Any amount";
30 const dollars = (cents / 100).toFixed(2);
31 return `$${dollars}`;
32}
33
34// ---------- Public sponsor page ----------
35
36sponsors.get("/sponsors/:username", async (c) => {
37 const user = c.get("user");
38 const targetName = c.req.param("username");
39 const [target] = await db
40 .select()
41 .from(users)
42 .where(eq(users.username, targetName))
43 .limit(1);
44 if (!target) return c.notFound();
45
46 const tiers = await db
47 .select()
48 .from(sponsorshipTiers)
49 .where(
50 and(
51 eq(sponsorshipTiers.maintainerId, target.id),
52 eq(sponsorshipTiers.isActive, true)
53 )
54 )
55 .orderBy(sponsorshipTiers.monthlyCents);
56
57 const recentPublic = await db
58 .select({
59 id: sponsorships.id,
60 amountCents: sponsorships.amountCents,
61 createdAt: sponsorships.createdAt,
62 note: sponsorships.note,
63 sponsorName: users.username,
64 })
65 .from(sponsorships)
66 .innerJoin(users, eq(sponsorships.sponsorId, users.id))
67 .where(
68 and(
69 eq(sponsorships.maintainerId, target.id),
70 eq(sponsorships.isPublic, true),
71 isNull(sponsorships.cancelledAt)
72 )
73 )
74 .orderBy(desc(sponsorships.createdAt))
75 .limit(20);
76
77 return c.html(
78 <Layout title={`Sponsor ${targetName}`} user={user}>
79 <h2>Sponsor {targetName}</h2>
80 <p
81 style="font-size:14px;color:var(--text-muted);margin:8px 0 20px"
82 >
83 Support {targetName}'s open-source work on Gluecron.
84 </p>
85
86 {tiers.length === 0 ? (
87 <div class="panel" style="padding:16px">
88 <p style="margin-bottom:12px">
89 {targetName} hasn't published any sponsorship tiers yet.
90 </p>
91 {user ? (
001af43Claude92 <form method="post" action={`/sponsors/${targetName}`}>
08420cdClaude93 <input
94 type="number"
95 name="amount_cents"
96 placeholder="Amount in cents (e.g. 500 = $5)"
97 min="100"
98 required
99 style="width:60%"
100 />{" "}
101 <button type="submit" class="btn btn-primary">
102 Sponsor (one-time)
103 </button>
104 </form>
105 ) : (
106 <a href={`/login?next=/sponsors/${targetName}`}>
107 Sign in to sponsor
108 </a>
109 )}
110 </div>
111 ) : (
112 <div
113 style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin-bottom:20px"
114 >
115 {tiers.map((t) => (
116 <form
001af43Claude117 method="post"
08420cdClaude118 action={`/sponsors/${targetName}`}
119 class="panel"
120 style="padding:16px;display:flex;flex-direction:column;gap:8px"
121 >
122 <input type="hidden" name="tier_id" value={t.id} />
123 <div style="font-weight:700;font-size:16px">{t.name}</div>
124 <div style="font-size:22px;color:var(--accent);font-weight:700">
125 {formatCents(t.monthlyCents)}
126 {t.monthlyCents > 0 && (
127 <span style="font-size:13px;color:var(--text-muted);font-weight:400">
128 /mo
129 </span>
130 )}
131 </div>
132 <div
133 style="font-size:13px;color:var(--text-muted);flex:1"
134 >
135 {t.description || "\u2014"}
136 </div>
137 {user ? (
138 <>
139 <select name="kind" style="font-size:13px">
140 <option value="monthly">Monthly</option>
141 {t.oneTimeAllowed && (
142 <option value="one_time">One-time</option>
143 )}
144 </select>
145 <button type="submit" class="btn btn-primary">
146 Sponsor
147 </button>
148 </>
149 ) : (
150 <a
151 href={`/login?next=/sponsors/${targetName}`}
152 class="btn"
153 style="text-align:center"
154 >
155 Sign in to sponsor
156 </a>
157 )}
158 </form>
159 ))}
160 </div>
161 )}
162
163 <h3>Recent sponsors</h3>
164 <div class="panel">
165 {recentPublic.length === 0 ? (
166 <div class="panel-empty">Be the first to sponsor.</div>
167 ) : (
168 recentPublic.map((s) => (
169 <div class="panel-item" style="justify-content:space-between">
170 <div>
171 <a href={`/${s.sponsorName}`}>
172 <strong>{s.sponsorName}</strong>
173 </a>
174 {s.note && (
175 <span
176 style="margin-left:8px;font-size:13px;color:var(--text-muted)"
177 >
178 "{s.note}"
179 </span>
180 )}
181 </div>
182 <div
183 style="font-size:12px;color:var(--text-muted);white-space:nowrap"
184 >
185 {formatCents(s.amountCents)} ·{" "}
186 {new Date(s.createdAt).toLocaleDateString()}
187 </div>
188 </div>
189 ))
190 )}
191 </div>
192 </Layout>
193 );
194});
195
196// Record a sponsorship
197sponsors.post("/sponsors/:username", requireAuth, async (c) => {
198 const user = c.get("user")!;
199 const targetName = c.req.param("username");
200 const [target] = await db
201 .select()
202 .from(users)
203 .where(eq(users.username, targetName))
204 .limit(1);
205 if (!target) return c.notFound();
206 if (target.id === user.id) {
207 return c.redirect(`/sponsors/${targetName}`);
208 }
209 const body = await c.req.parseBody();
210 const tierId = body.tier_id ? String(body.tier_id) : null;
211 let amountCents = 0;
212 let kind = String(body.kind || "one_time");
213 if (kind !== "monthly" && kind !== "one_time") kind = "one_time";
214
215 if (tierId) {
216 const [tier] = await db
217 .select()
218 .from(sponsorshipTiers)
219 .where(eq(sponsorshipTiers.id, tierId))
220 .limit(1);
221 if (!tier || tier.maintainerId !== target.id) {
222 return c.redirect(`/sponsors/${targetName}`);
223 }
224 amountCents = tier.monthlyCents;
225 } else {
226 amountCents = Math.max(0, parseInt(String(body.amount_cents || "0"), 10));
227 }
228 if (amountCents <= 0 && !tierId) {
229 return c.redirect(`/sponsors/${targetName}`);
230 }
231
232 await db.insert(sponsorships).values({
233 sponsorId: user.id,
234 maintainerId: target.id,
235 tierId: tierId || null,
236 amountCents,
237 kind,
238 note: body.note ? String(body.note).slice(0, 200) : null,
239 isPublic: body.is_public !== "0",
240 });
241 return c.redirect(`/sponsors/${targetName}?thanks=1`);
242});
243
244// ---------- Maintainer settings ----------
245
246sponsors.get("/settings/sponsors", requireAuth, async (c) => {
247 const user = c.get("user")!;
248 const [tiers, activity] = await Promise.all([
249 db
250 .select()
251 .from(sponsorshipTiers)
252 .where(eq(sponsorshipTiers.maintainerId, user.id))
253 .orderBy(sponsorshipTiers.monthlyCents),
254 db
255 .select({
256 id: sponsorships.id,
257 amountCents: sponsorships.amountCents,
258 kind: sponsorships.kind,
259 createdAt: sponsorships.createdAt,
260 sponsorName: users.username,
261 })
262 .from(sponsorships)
263 .innerJoin(users, eq(sponsorships.sponsorId, users.id))
264 .where(eq(sponsorships.maintainerId, user.id))
265 .orderBy(desc(sponsorships.createdAt))
266 .limit(50),
267 ]);
268 const total = activity.reduce((sum, s) => sum + s.amountCents, 0);
269 return c.html(
270 <Layout title="Sponsorship settings" user={user}>
271 <h2>Sponsorship</h2>
272 <p style="color:var(--text-muted);margin-bottom:16px">
273 Your public sponsor page is at{" "}
274 <a href={`/sponsors/${user.username}`}>/sponsors/{user.username}</a>.
275 </p>
276
277 <div class="panel" style="padding:16px;margin-bottom:20px">
278 <div style="font-size:12px;color:var(--text-muted)">
279 Total received
280 </div>
281 <div style="font-size:24px;font-weight:700">
282 {formatCents(total)}
283 </div>
284 </div>
285
286 <h3>Tiers</h3>
287 <div class="panel" style="margin-bottom:20px">
288 {tiers.length === 0 ? (
289 <div class="panel-empty">
290 No tiers yet. Add one below to start accepting support.
291 </div>
292 ) : (
293 tiers.map((t) => (
294 <div class="panel-item" style="justify-content:space-between">
295 <div>
296 <div style="font-weight:600">{t.name}</div>
297 <div
298 style="font-size:12px;color:var(--text-muted);margin-top:2px"
299 >
300 {formatCents(t.monthlyCents)}/mo ·{" "}
301 {t.description || "no description"}
302 </div>
303 </div>
304 <form
001af43Claude305 method="post"
08420cdClaude306 action={`/settings/sponsors/tiers/${t.id}/delete`}
307 onsubmit="return confirm('Retire this tier?')"
308 >
309 <button type="submit" class="btn btn-sm btn-danger">
310 Retire
311 </button>
312 </form>
313 </div>
314 ))
315 )}
316 </div>
317
318 <h3>Add a tier</h3>
319 <form
001af43Claude320 method="post"
08420cdClaude321 action="/settings/sponsors/tiers/new"
322 class="panel"
323 style="padding:16px"
324 >
325 <div class="form-group">
326 <label>Name</label>
327 <input type="text" name="name" required style="width:100%" />
328 </div>
329 <div class="form-group">
330 <label>Description</label>
9daaf0cClaude331 <textarea name="description" rows={2} style="width:100%" />
08420cdClaude332 </div>
333 <div class="form-group">
334 <label>Monthly amount (cents)</label>
335 <input
336 type="number"
337 name="monthly_cents"
338 min="0"
339 placeholder="500 = $5/mo"
340 required
341 />
342 </div>
343 <button type="submit" class="btn btn-primary">
344 Add tier
345 </button>
346 </form>
347
348 <h3 style="margin-top:24px">Recent activity</h3>
349 <div class="panel">
350 {activity.length === 0 ? (
351 <div class="panel-empty">No sponsors yet.</div>
352 ) : (
353 activity.map((a) => (
354 <div class="panel-item" style="justify-content:space-between">
355 <div>
356 <a href={`/${a.sponsorName}`}>{a.sponsorName}</a>
357 <span
358 style="margin-left:8px;font-size:12px;color:var(--text-muted)"
359 >
360 {a.kind}
361 </span>
362 </div>
363 <div
364 style="font-size:12px;color:var(--text-muted);white-space:nowrap"
365 >
366 {formatCents(a.amountCents)} ·{" "}
367 {new Date(a.createdAt).toLocaleDateString()}
368 </div>
369 </div>
370 ))
371 )}
372 </div>
373 </Layout>
374 );
375});
376
377sponsors.post("/settings/sponsors/tiers/new", requireAuth, async (c) => {
378 const user = c.get("user")!;
379 const body = await c.req.parseBody();
380 const name = String(body.name || "").trim();
381 if (!name) return c.redirect("/settings/sponsors");
382 const monthlyCents = Math.max(
383 0,
384 parseInt(String(body.monthly_cents || "0"), 10)
385 );
386 await db.insert(sponsorshipTiers).values({
387 maintainerId: user.id,
388 name,
389 description: String(body.description || ""),
390 monthlyCents,
391 });
392 return c.redirect("/settings/sponsors");
393});
394
395sponsors.post(
396 "/settings/sponsors/tiers/:id/delete",
397 requireAuth,
398 async (c) => {
399 const user = c.get("user")!;
400 const id = c.req.param("id");
401 await db
402 .update(sponsorshipTiers)
403 .set({ isActive: false })
404 .where(
405 and(
406 eq(sponsorshipTiers.id, id),
407 eq(sponsorshipTiers.maintainerId, user.id)
408 )
409 );
410 return c.redirect("/settings/sponsors");
411 }
412);
413
414// Handy stat helper for other pages
415export async function sponsorshipTotalForUser(
416 userId: string
417): Promise<number> {
418 try {
419 const [r] = await db
420 .select({ n: sql<number>`coalesce(sum(${sponsorships.amountCents}), 0)::int` })
421 .from(sponsorships)
422 .where(eq(sponsorships.maintainerId, userId));
423 return Number(r?.n || 0);
424 } catch {
425 return 0;
426 }
427}
428
429/** Test-only hook. */
430export const __internal = { formatCents };
431
432export default sponsors;