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.tsxBlame434 lines · 2 contributors
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
e1c0fbecopilot-swe-agent[bot]99 aria-label="Sponsorship amount in cents"
08420cdClaude100 style="width:60%"
101 />{" "}
102 <button type="submit" class="btn btn-primary">
103 Sponsor (one-time)
104 </button>
105 </form>
106 ) : (
107 <a href={`/login?next=/sponsors/${targetName}`}>
108 Sign in to sponsor
109 </a>
110 )}
111 </div>
112 ) : (
113 <div
114 style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin-bottom:20px"
115 >
116 {tiers.map((t) => (
117 <form
001af43Claude118 method="post"
08420cdClaude119 action={`/sponsors/${targetName}`}
120 class="panel"
121 style="padding:16px;display:flex;flex-direction:column;gap:8px"
122 >
123 <input type="hidden" name="tier_id" value={t.id} />
124 <div style="font-weight:700;font-size:16px">{t.name}</div>
125 <div style="font-size:22px;color:var(--accent);font-weight:700">
126 {formatCents(t.monthlyCents)}
127 {t.monthlyCents > 0 && (
128 <span style="font-size:13px;color:var(--text-muted);font-weight:400">
129 /mo
130 </span>
131 )}
132 </div>
133 <div
134 style="font-size:13px;color:var(--text-muted);flex:1"
135 >
136 {t.description || "\u2014"}
137 </div>
138 {user ? (
139 <>
140 <select name="kind" style="font-size:13px">
141 <option value="monthly">Monthly</option>
142 {t.oneTimeAllowed && (
143 <option value="one_time">One-time</option>
144 )}
145 </select>
146 <button type="submit" class="btn btn-primary">
147 Sponsor
148 </button>
149 </>
150 ) : (
151 <a
152 href={`/login?next=/sponsors/${targetName}`}
153 class="btn"
154 style="text-align:center"
155 >
156 Sign in to sponsor
157 </a>
158 )}
159 </form>
160 ))}
161 </div>
162 )}
163
164 <h3>Recent sponsors</h3>
165 <div class="panel">
166 {recentPublic.length === 0 ? (
167 <div class="panel-empty">Be the first to sponsor.</div>
168 ) : (
169 recentPublic.map((s) => (
170 <div class="panel-item" style="justify-content:space-between">
171 <div>
172 <a href={`/${s.sponsorName}`}>
173 <strong>{s.sponsorName}</strong>
174 </a>
175 {s.note && (
176 <span
177 style="margin-left:8px;font-size:13px;color:var(--text-muted)"
178 >
179 "{s.note}"
180 </span>
181 )}
182 </div>
183 <div
184 style="font-size:12px;color:var(--text-muted);white-space:nowrap"
185 >
186 {formatCents(s.amountCents)} ·{" "}
187 {new Date(s.createdAt).toLocaleDateString()}
188 </div>
189 </div>
190 ))
191 )}
192 </div>
193 </Layout>
194 );
195});
196
197// Record a sponsorship
198sponsors.post("/sponsors/:username", requireAuth, async (c) => {
199 const user = c.get("user")!;
200 const targetName = c.req.param("username");
201 const [target] = await db
202 .select()
203 .from(users)
204 .where(eq(users.username, targetName))
205 .limit(1);
206 if (!target) return c.notFound();
207 if (target.id === user.id) {
208 return c.redirect(`/sponsors/${targetName}`);
209 }
210 const body = await c.req.parseBody();
211 const tierId = body.tier_id ? String(body.tier_id) : null;
212 let amountCents = 0;
213 let kind = String(body.kind || "one_time");
214 if (kind !== "monthly" && kind !== "one_time") kind = "one_time";
215
216 if (tierId) {
217 const [tier] = await db
218 .select()
219 .from(sponsorshipTiers)
220 .where(eq(sponsorshipTiers.id, tierId))
221 .limit(1);
222 if (!tier || tier.maintainerId !== target.id) {
223 return c.redirect(`/sponsors/${targetName}`);
224 }
225 amountCents = tier.monthlyCents;
226 } else {
227 amountCents = Math.max(0, parseInt(String(body.amount_cents || "0"), 10));
228 }
229 if (amountCents <= 0 && !tierId) {
230 return c.redirect(`/sponsors/${targetName}`);
231 }
232
233 await db.insert(sponsorships).values({
234 sponsorId: user.id,
235 maintainerId: target.id,
236 tierId: tierId || null,
237 amountCents,
238 kind,
239 note: body.note ? String(body.note).slice(0, 200) : null,
240 isPublic: body.is_public !== "0",
241 });
242 return c.redirect(`/sponsors/${targetName}?thanks=1`);
243});
244
245// ---------- Maintainer settings ----------
246
247sponsors.get("/settings/sponsors", requireAuth, async (c) => {
248 const user = c.get("user")!;
249 const [tiers, activity] = await Promise.all([
250 db
251 .select()
252 .from(sponsorshipTiers)
253 .where(eq(sponsorshipTiers.maintainerId, user.id))
254 .orderBy(sponsorshipTiers.monthlyCents),
255 db
256 .select({
257 id: sponsorships.id,
258 amountCents: sponsorships.amountCents,
259 kind: sponsorships.kind,
260 createdAt: sponsorships.createdAt,
261 sponsorName: users.username,
262 })
263 .from(sponsorships)
264 .innerJoin(users, eq(sponsorships.sponsorId, users.id))
265 .where(eq(sponsorships.maintainerId, user.id))
266 .orderBy(desc(sponsorships.createdAt))
267 .limit(50),
268 ]);
269 const total = activity.reduce((sum, s) => sum + s.amountCents, 0);
270 return c.html(
271 <Layout title="Sponsorship settings" user={user}>
272 <h2>Sponsorship</h2>
273 <p style="color:var(--text-muted);margin-bottom:16px">
274 Your public sponsor page is at{" "}
275 <a href={`/sponsors/${user.username}`}>/sponsors/{user.username}</a>.
276 </p>
277
278 <div class="panel" style="padding:16px;margin-bottom:20px">
279 <div style="font-size:12px;color:var(--text-muted)">
280 Total received
281 </div>
282 <div style="font-size:24px;font-weight:700">
283 {formatCents(total)}
284 </div>
285 </div>
286
287 <h3>Tiers</h3>
288 <div class="panel" style="margin-bottom:20px">
289 {tiers.length === 0 ? (
290 <div class="panel-empty">
291 No tiers yet. Add one below to start accepting support.
292 </div>
293 ) : (
294 tiers.map((t) => (
295 <div class="panel-item" style="justify-content:space-between">
296 <div>
297 <div style="font-weight:600">{t.name}</div>
298 <div
299 style="font-size:12px;color:var(--text-muted);margin-top:2px"
300 >
301 {formatCents(t.monthlyCents)}/mo ·{" "}
302 {t.description || "no description"}
303 </div>
304 </div>
305 <form
001af43Claude306 method="post"
08420cdClaude307 action={`/settings/sponsors/tiers/${t.id}/delete`}
308 onsubmit="return confirm('Retire this tier?')"
309 >
310 <button type="submit" class="btn btn-sm btn-danger">
311 Retire
312 </button>
313 </form>
314 </div>
315 ))
316 )}
317 </div>
318
319 <h3>Add a tier</h3>
320 <form
001af43Claude321 method="post"
08420cdClaude322 action="/settings/sponsors/tiers/new"
323 class="panel"
324 style="padding:16px"
325 >
326 <div class="form-group">
327 <label>Name</label>
e1c0fbecopilot-swe-agent[bot]328 <input type="text" name="name" required aria-label="Tier name" style="width:100%" />
08420cdClaude329 </div>
330 <div class="form-group">
331 <label>Description</label>
9daaf0cClaude332 <textarea name="description" rows={2} style="width:100%" />
08420cdClaude333 </div>
334 <div class="form-group">
335 <label>Monthly amount (cents)</label>
336 <input
337 type="number"
338 name="monthly_cents"
339 min="0"
340 placeholder="500 = $5/mo"
341 required
e1c0fbecopilot-swe-agent[bot]342 aria-label="Monthly amount in cents"
08420cdClaude343 />
344 </div>
345 <button type="submit" class="btn btn-primary">
346 Add tier
347 </button>
348 </form>
349
350 <h3 style="margin-top:24px">Recent activity</h3>
351 <div class="panel">
352 {activity.length === 0 ? (
353 <div class="panel-empty">No sponsors yet.</div>
354 ) : (
355 activity.map((a) => (
356 <div class="panel-item" style="justify-content:space-between">
357 <div>
358 <a href={`/${a.sponsorName}`}>{a.sponsorName}</a>
359 <span
360 style="margin-left:8px;font-size:12px;color:var(--text-muted)"
361 >
362 {a.kind}
363 </span>
364 </div>
365 <div
366 style="font-size:12px;color:var(--text-muted);white-space:nowrap"
367 >
368 {formatCents(a.amountCents)} ·{" "}
369 {new Date(a.createdAt).toLocaleDateString()}
370 </div>
371 </div>
372 ))
373 )}
374 </div>
375 </Layout>
376 );
377});
378
379sponsors.post("/settings/sponsors/tiers/new", requireAuth, async (c) => {
380 const user = c.get("user")!;
381 const body = await c.req.parseBody();
382 const name = String(body.name || "").trim();
383 if (!name) return c.redirect("/settings/sponsors");
384 const monthlyCents = Math.max(
385 0,
386 parseInt(String(body.monthly_cents || "0"), 10)
387 );
388 await db.insert(sponsorshipTiers).values({
389 maintainerId: user.id,
390 name,
391 description: String(body.description || ""),
392 monthlyCents,
393 });
394 return c.redirect("/settings/sponsors");
395});
396
397sponsors.post(
398 "/settings/sponsors/tiers/:id/delete",
399 requireAuth,
400 async (c) => {
401 const user = c.get("user")!;
402 const id = c.req.param("id");
403 await db
404 .update(sponsorshipTiers)
405 .set({ isActive: false })
406 .where(
407 and(
408 eq(sponsorshipTiers.id, id),
409 eq(sponsorshipTiers.maintainerId, user.id)
410 )
411 );
412 return c.redirect("/settings/sponsors");
413 }
414);
415
416// Handy stat helper for other pages
417export async function sponsorshipTotalForUser(
418 userId: string
419): Promise<number> {
420 try {
421 const [r] = await db
422 .select({ n: sql<number>`coalesce(sum(${sponsorships.amountCents}), 0)::int` })
423 .from(sponsorships)
424 .where(eq(sponsorships.maintainerId, userId));
425 return Number(r?.n || 0);
426 } catch {
427 return 0;
428 }
429}
430
431/** Test-only hook. */
432export const __internal = { formatCents };
433
434export default sponsors;