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.tsxBlame1195 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.
cf27bebClaude11 *
12 * 2026 polish: scoped under `.spons-`. Tier cards render with $/mo, optional
13 * description / benefits list, and a gradient "Become a sponsor" CTA. Every
14 * form action, validation rule, and POST handler is preserved verbatim.
08420cdClaude15 */
16
17import { Hono } from "hono";
18import { and, desc, eq, isNull, sql } from "drizzle-orm";
19import { db } from "../db";
20import {
21 sponsorships,
22 sponsorshipTiers,
23 users,
24} from "../db/schema";
25import { Layout } from "../views/layout";
9b776daccantynz-alt26import { SettingsNav, settingsNavStyles } from "../views/components";
08420cdClaude27import { softAuth, requireAuth } from "../middleware/auth";
28import type { AuthEnv } from "../middleware/auth";
29
30const sponsors = new Hono<AuthEnv>();
31sponsors.use("*", softAuth);
32
33function formatCents(cents: number): string {
34 if (cents === 0) return "Any amount";
35 const dollars = (cents / 100).toFixed(2);
36 return `$${dollars}`;
37}
38
cf27bebClaude39/* ─────────────────────────────────────────────────────────────────────────
40 * Scoped CSS — every selector prefixed `.spons-` so this surface can't leak.
41 * ───────────────────────────────────────────────────────────────────── */
42const sponsStyles = `
eed4684Claude43 .spons-wrap { max-width: 1320px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
cf27bebClaude44
45 .spons-hero {
46 position: relative;
47 margin-bottom: var(--space-5);
48 padding: var(--space-5) var(--space-6);
49 background: var(--bg-elevated);
50 border: 1px solid var(--border);
51 border-radius: 16px;
52 overflow: hidden;
53 }
54 .spons-hero::before {
55 content: '';
56 position: absolute;
57 top: 0; left: 0; right: 0;
58 height: 2px;
6fd5915Claude59 background: linear-gradient(90deg, transparent 0%, #ff7eb6 30%, #5b6ee8 70%, transparent 100%);
cf27bebClaude60 opacity: 0.7;
61 pointer-events: none;
62 }
63 .spons-hero-orb {
64 position: absolute;
65 inset: -20% -10% auto auto;
66 width: 380px; height: 380px;
6fd5915Claude67 background: radial-gradient(circle, rgba(255,126,182,0.18), rgba(91,110,232,0.12) 45%, transparent 70%);
cf27bebClaude68 filter: blur(80px);
69 opacity: 0.7;
70 pointer-events: none;
71 z-index: 0;
72 }
73 .spons-hero-inner {
74 position: relative;
75 z-index: 1;
76 display: flex;
77 align-items: flex-end;
78 justify-content: space-between;
79 gap: var(--space-4);
80 flex-wrap: wrap;
81 }
82 .spons-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
83 .spons-eyebrow {
84 font-size: 12px;
85 color: var(--text-muted);
86 margin-bottom: var(--space-2);
87 letter-spacing: 0.02em;
88 display: inline-flex;
89 align-items: center;
90 gap: 8px;
91 text-transform: uppercase;
92 font-family: var(--font-mono);
93 font-weight: 600;
94 }
95 .spons-eyebrow-pill {
96 display: inline-flex;
97 align-items: center;
98 justify-content: center;
99 width: 18px; height: 18px;
100 border-radius: 6px;
101 background: rgba(255,126,182,0.14);
102 color: #ffb7d1;
103 box-shadow: inset 0 0 0 1px rgba(255,126,182,0.35);
104 }
105 .spons-title {
106 font-size: clamp(28px, 4vw, 40px);
107 font-family: var(--font-display);
108 font-weight: 800;
109 letter-spacing: -0.028em;
110 line-height: 1.05;
111 margin: 0 0 var(--space-2);
112 color: var(--text-strong);
113 }
114 .spons-title-grad {
6fd5915Claude115 background-image: linear-gradient(135deg, #ff7eb6 0%, #5b6ee8 50%, #5f8fa0 100%);
cf27bebClaude116 -webkit-background-clip: text;
117 background-clip: text;
118 -webkit-text-fill-color: transparent;
119 color: transparent;
120 }
121 .spons-sub {
122 font-size: 15px;
123 color: var(--text-muted);
124 margin: 0;
125 line-height: 1.55;
126 max-width: 620px;
127 }
128 .spons-sub a { color: var(--accent); text-decoration: none; }
129 .spons-sub a:hover { text-decoration: underline; }
130
131 .spons-banner {
132 margin-bottom: var(--space-4);
133 padding: 10px 14px;
134 border-radius: 10px;
135 font-size: 13.5px;
136 border: 1px solid var(--border);
137 background: rgba(255,255,255,0.025);
138 color: var(--text);
139 display: flex;
140 align-items: center;
141 gap: 10px;
142 }
143 .spons-banner.is-ok { border-color: rgba(52,211,153,0.40); background: rgba(52,211,153,0.08); color: #bbf7d0; }
144 .spons-banner-dot { width: 8px; height: 8px; border-radius: 9999px; background: currentColor; flex-shrink: 0; }
145
146 /* ─── Status card ─── */
147 .spons-status {
148 position: relative;
149 margin-bottom: var(--space-5);
150 padding: var(--space-5);
151 background: var(--bg-elevated);
152 border: 1px solid var(--border);
153 border-radius: 14px;
154 overflow: hidden;
155 }
156 .spons-status.is-on {
157 border-color: rgba(255,126,182,0.32);
6fd5915Claude158 background: linear-gradient(135deg, rgba(255,126,182,0.08) 0%, rgba(22,27,34,0) 60%), var(--bg-elevated);
cf27bebClaude159 }
160 .spons-status.is-empty {
161 border-color: rgba(251,191,36,0.30);
6fd5915Claude162 background: linear-gradient(135deg, rgba(251,191,36,0.06) 0%, rgba(22,27,34,0) 60%), var(--bg-elevated);
cf27bebClaude163 }
164 .spons-status-row { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; }
165 .spons-status-mark {
166 flex-shrink: 0;
167 width: 52px; height: 52px;
168 border-radius: 14px;
169 display: flex;
170 align-items: center;
171 justify-content: center;
172 color: #fff;
6fd5915Claude173 background: linear-gradient(135deg, #ff7eb6 0%, #5b6ee8 100%);
cf27bebClaude174 box-shadow: 0 8px 20px -8px rgba(255,126,182,0.50), inset 0 1px 0 rgba(255,255,255,0.18);
175 }
176 .spons-status.is-empty .spons-status-mark {
177 background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
178 color: #1a1206;
179 box-shadow: 0 8px 20px -8px rgba(251,191,36,0.55), inset 0 1px 0 rgba(255,255,255,0.18);
180 }
181 .spons-status-text { flex: 1; min-width: 220px; }
182 .spons-status-headline {
183 margin: 0 0 4px;
184 font-family: var(--font-display);
185 font-size: 18px;
186 font-weight: 700;
187 letter-spacing: -0.018em;
188 color: var(--text-strong);
189 }
190 .spons-status-desc { margin: 0; font-size: 13.5px; color: var(--text-muted); line-height: 1.5; }
191
192 .spons-total {
193 text-align: right;
194 }
195 .spons-total-label {
196 font-family: var(--font-mono);
197 font-size: 10.5px;
198 font-weight: 700;
199 text-transform: uppercase;
200 letter-spacing: 0.14em;
201 color: var(--text-muted);
202 }
203 .spons-total-num {
204 font-family: var(--font-display);
205 font-size: 28px;
206 font-weight: 800;
207 letter-spacing: -0.022em;
208 line-height: 1;
209 color: var(--text-strong);
210 margin-top: 2px;
6fd5915Claude211 background-image: linear-gradient(135deg, #ff7eb6 0%, #5b6ee8 100%);
cf27bebClaude212 -webkit-background-clip: text;
213 background-clip: text;
214 -webkit-text-fill-color: transparent;
215 color: transparent;
216 }
217
218 /* ─── Tier card grid ─── */
219 .spons-tiers {
220 display: grid;
221 grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
222 gap: var(--space-3);
223 margin-bottom: var(--space-5);
224 }
225 .spons-tier {
226 position: relative;
227 display: flex;
228 flex-direction: column;
229 padding: var(--space-5);
230 background: var(--bg-elevated);
231 border: 1px solid var(--border);
232 border-radius: 14px;
233 overflow: hidden;
234 transition: border-color 140ms ease, transform 140ms ease;
235 }
236 .spons-tier:hover { border-color: rgba(255,126,182,0.35); transform: translateY(-2px); }
237 .spons-tier::before {
238 content: '';
239 position: absolute;
240 top: 0; left: 0; right: 0;
241 height: 2px;
6fd5915Claude242 background: linear-gradient(90deg, transparent, #ff7eb6 30%, #5b6ee8 70%, transparent);
cf27bebClaude243 opacity: 0.55;
244 }
245 .spons-tier-name {
246 margin: 0 0 var(--space-2);
247 font-family: var(--font-display);
248 font-size: 17px;
249 font-weight: 700;
250 color: var(--text-strong);
251 letter-spacing: -0.012em;
252 }
253 .spons-tier-price {
254 display: flex;
255 align-items: baseline;
256 gap: 4px;
257 margin-bottom: var(--space-3);
258 }
259 .spons-tier-price-num {
260 font-family: var(--font-display);
261 font-size: 30px;
262 font-weight: 800;
263 letter-spacing: -0.025em;
6fd5915Claude264 background-image: linear-gradient(135deg, #ff7eb6 0%, #5b6ee8 50%, #5f8fa0 100%);
cf27bebClaude265 -webkit-background-clip: text;
266 background-clip: text;
267 -webkit-text-fill-color: transparent;
268 color: transparent;
269 line-height: 1;
270 }
271 .spons-tier-price-unit {
272 font-size: 13px;
273 color: var(--text-muted);
274 font-weight: 500;
275 }
276 .spons-tier-desc {
277 font-size: 13px;
278 color: var(--text-muted);
279 line-height: 1.55;
280 flex: 1;
281 margin: 0 0 var(--space-4);
282 }
283 .spons-tier-benefits {
284 margin: 0 0 var(--space-4);
285 padding: 0;
286 list-style: none;
287 display: flex;
288 flex-direction: column;
289 gap: 6px;
290 }
291 .spons-tier-benefits li {
292 display: flex;
293 align-items: flex-start;
294 gap: 7px;
295 font-size: 12.5px;
296 color: var(--text);
297 line-height: 1.45;
298 }
299 .spons-tier-benefits li svg { flex-shrink: 0; margin-top: 2px; color: #6ee7b7; }
300 .spons-tier-select {
301 padding: 8px 10px;
302 font-size: 12.5px;
303 color: var(--text);
304 background: var(--bg);
305 border: 1px solid var(--border-strong);
306 border-radius: 8px;
307 margin-bottom: 8px;
308 font-family: inherit;
309 outline: none;
310 transition: border-color 120ms ease, box-shadow 120ms ease;
311 width: 100%;
312 box-sizing: border-box;
313 }
314 .spons-tier-select:focus {
6fd5915Claude315 border-color: var(--border-focus, rgba(91,110,232,0.55));
316 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
cf27bebClaude317 }
318
319 /* ─── Buttons ─── */
320 .spons-btn {
321 display: inline-flex;
322 align-items: center;
323 justify-content: center;
324 gap: 6px;
325 padding: 10px 18px;
326 border-radius: 10px;
327 font-size: 13px;
328 font-weight: 600;
329 text-decoration: none;
330 border: 1px solid transparent;
331 cursor: pointer;
332 font-family: inherit;
333 line-height: 1;
334 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
335 }
336 .spons-btn-primary {
6fd5915Claude337 background: linear-gradient(135deg, #ff7eb6 0%, #5b6ee8 60%, #5f8fa0 100%);
cf27bebClaude338 color: #fff;
339 box-shadow: 0 6px 18px -4px rgba(255,126,182,0.45), inset 0 1px 0 rgba(255,255,255,0.20);
340 }
341 .spons-btn-primary:hover {
342 transform: translateY(-1px);
343 box-shadow: 0 10px 24px -6px rgba(255,126,182,0.55), inset 0 1px 0 rgba(255,255,255,0.22);
344 color: #fff;
345 text-decoration: none;
346 }
347 .spons-btn-ghost {
348 background: rgba(255,255,255,0.025);
349 color: var(--text);
350 border-color: var(--border-strong);
351 }
352 .spons-btn-ghost:hover {
6fd5915Claude353 background: rgba(91,110,232,0.06);
354 border-color: rgba(91,110,232,0.45);
cf27bebClaude355 color: var(--text-strong);
356 text-decoration: none;
357 }
358 .spons-btn-danger {
359 background: transparent;
360 color: #fecaca;
361 border-color: rgba(248,113,113,0.40);
362 }
363 .spons-btn-danger:hover {
364 background: rgba(248,113,113,0.10);
365 border-color: rgba(248,113,113,0.65);
366 color: #fee2e2;
367 text-decoration: none;
368 }
369 .spons-btn-sm { padding: 6px 11px; font-size: 12px; }
370 .spons-btn-block { width: 100%; }
371
372 /* ─── Section card ─── */
373 .spons-section {
374 margin-bottom: var(--space-5);
375 background: var(--bg-elevated);
376 border: 1px solid var(--border);
377 border-radius: 14px;
378 overflow: hidden;
379 }
380 .spons-section-head {
381 padding: var(--space-4) var(--space-5);
382 border-bottom: 1px solid var(--border);
383 }
384 .spons-section-title {
385 margin: 0;
386 font-family: var(--font-display);
387 font-size: 16px;
388 font-weight: 700;
389 letter-spacing: -0.018em;
390 color: var(--text-strong);
391 display: flex;
392 align-items: center;
393 gap: 10px;
394 }
395 .spons-section-icon {
396 display: inline-flex;
397 align-items: center;
398 justify-content: center;
399 width: 26px; height: 26px;
400 border-radius: 8px;
401 background: rgba(255,126,182,0.12);
402 color: #ffb7d1;
403 box-shadow: inset 0 0 0 1px rgba(255,126,182,0.28);
404 flex-shrink: 0;
405 }
406 .spons-section-sub {
407 margin: 6px 0 0 36px;
408 font-size: 12.5px;
409 color: var(--text-muted);
410 line-height: 1.5;
411 }
412 .spons-section-body { padding: var(--space-4) var(--space-5); }
413
414 /* ─── Activity / recent sponsors rows ─── */
415 .spons-row {
416 display: flex;
417 align-items: center;
418 justify-content: space-between;
419 gap: var(--space-3);
420 padding: 12px 0;
421 border-top: 1px solid var(--border);
422 }
423 .spons-row:first-child { border-top: none; padding-top: 4px; }
424 .spons-row:last-child { padding-bottom: 4px; }
425 .spons-row-name { font-weight: 600; color: var(--text-strong); font-size: 13.5px; }
426 .spons-row-name a { color: inherit; text-decoration: none; }
427 .spons-row-name a:hover { color: var(--accent); }
428 .spons-row-note { margin-left: 8px; font-size: 13px; color: var(--text-muted); font-style: italic; }
429 .spons-row-kind { margin-left: 8px; font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; font-family: var(--font-mono); }
430 .spons-row-meta {
431 font-size: 12.5px;
432 color: var(--text-muted);
433 white-space: nowrap;
434 font-variant-numeric: tabular-nums;
435 }
436
437 /* ─── Empty state ─── */
438 .spons-empty {
439 padding: var(--space-6);
440 text-align: center;
441 background: var(--bg-elevated);
442 border: 1px dashed var(--border-strong);
443 border-radius: 14px;
444 position: relative;
445 overflow: hidden;
446 }
447 .spons-empty-orb {
448 position: absolute;
449 inset: -30% auto auto -10%;
450 width: 260px; height: 260px;
6fd5915Claude451 background: radial-gradient(circle, rgba(255,126,182,0.16), rgba(91,110,232,0.08) 45%, transparent 70%);
cf27bebClaude452 filter: blur(60px);
453 opacity: 0.8;
454 pointer-events: none;
455 }
456 .spons-empty-mark {
457 position: relative;
458 z-index: 1;
459 margin: 0 auto var(--space-3);
460 width: 52px; height: 52px;
461 border-radius: 14px;
6fd5915Claude462 background: linear-gradient(135deg, rgba(255,126,182,0.18), rgba(91,110,232,0.12));
cf27bebClaude463 box-shadow: inset 0 0 0 1px rgba(255,126,182,0.30);
464 display: flex;
465 align-items: center;
466 justify-content: center;
467 color: #ffb7d1;
468 }
469 .spons-empty-title {
470 position: relative;
471 z-index: 1;
472 margin: 0 0 6px;
473 font-family: var(--font-display);
474 font-size: 17px;
475 font-weight: 700;
476 color: var(--text-strong);
477 letter-spacing: -0.018em;
478 }
479 .spons-empty-body {
480 position: relative;
481 z-index: 1;
482 margin: 0 auto;
483 max-width: 460px;
484 font-size: 13.5px;
485 color: var(--text-muted);
486 line-height: 1.55;
487 }
488 .spons-empty .spons-empty-form {
489 position: relative;
490 z-index: 1;
491 margin-top: var(--space-4);
492 display: flex;
493 gap: 8px;
494 justify-content: center;
495 flex-wrap: wrap;
496 align-items: center;
497 }
498 .spons-empty .spons-input-inline {
499 padding: 9px 12px;
500 font-size: 13px;
501 color: var(--text);
502 background: var(--bg);
503 border: 1px solid var(--border-strong);
504 border-radius: 8px;
505 outline: none;
506 font-family: var(--font-mono);
507 transition: border-color 120ms ease, box-shadow 120ms ease;
508 width: 220px;
509 max-width: 100%;
510 }
511 .spons-empty .spons-input-inline:focus {
512 border-color: var(--border-focus, rgba(255,126,182,0.55));
513 box-shadow: 0 0 0 3px rgba(255,126,182,0.18);
514 }
515
516 /* ─── Form ─── */
517 .spons-form { padding: var(--space-5); }
518 .spons-form-group { margin-bottom: var(--space-4); }
519 .spons-form-label {
520 display: block;
521 font-family: var(--font-mono);
522 font-size: 11.5px;
523 font-weight: 700;
524 text-transform: uppercase;
525 letter-spacing: 0.12em;
526 color: var(--text-muted);
527 margin-bottom: 6px;
528 }
529 .spons-input, .spons-textarea {
530 width: 100%;
531 padding: 9px 12px;
532 font-size: 13.5px;
533 color: var(--text);
534 background: var(--bg);
535 border: 1px solid var(--border-strong);
536 border-radius: 8px;
537 outline: none;
538 font-family: var(--font-mono);
539 transition: border-color 120ms ease, box-shadow 120ms ease;
540 box-sizing: border-box;
541 }
542 .spons-input:focus, .spons-textarea:focus {
543 border-color: var(--border-focus, rgba(255,126,182,0.55));
544 box-shadow: 0 0 0 3px rgba(255,126,182,0.18);
545 }
546 .spons-textarea { font-family: inherit; }
547 .spons-form-hint { margin-top: 6px; font-size: 12px; color: var(--text-muted); }
548`;
549
550/* Icons */
551const HeartIcon = () => (
552 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
553 <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
554 </svg>
555);
556const CheckIcon = () => (
557 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
558 <polyline points="20 6 9 17 4 12" />
559 </svg>
560);
561const TierIcon = () => (
562 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
563 <polygon points="12 2 15 8.5 22 9.3 17 14.1 18.2 21 12 17.8 5.8 21 7 14.1 2 9.3 9 8.5 12 2" />
564 </svg>
565);
566const PlusIcon = () => (
567 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
568 <line x1="12" y1="5" x2="12" y2="19" />
569 <line x1="5" y1="12" x2="19" y2="12" />
570 </svg>
571);
572const ActivityIcon = () => (
573 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
574 <polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
575 </svg>
576);
577
08420cdClaude578// ---------- Public sponsor page ----------
579
580sponsors.get("/sponsors/:username", async (c) => {
581 const user = c.get("user");
582 const targetName = c.req.param("username");
583 const [target] = await db
584 .select()
585 .from(users)
586 .where(eq(users.username, targetName))
587 .limit(1);
588 if (!target) return c.notFound();
589
590 const tiers = await db
591 .select()
592 .from(sponsorshipTiers)
593 .where(
594 and(
595 eq(sponsorshipTiers.maintainerId, target.id),
596 eq(sponsorshipTiers.isActive, true)
597 )
598 )
599 .orderBy(sponsorshipTiers.monthlyCents);
600
601 const recentPublic = await db
602 .select({
603 id: sponsorships.id,
604 amountCents: sponsorships.amountCents,
605 createdAt: sponsorships.createdAt,
606 note: sponsorships.note,
607 sponsorName: users.username,
608 })
609 .from(sponsorships)
610 .innerJoin(users, eq(sponsorships.sponsorId, users.id))
611 .where(
612 and(
613 eq(sponsorships.maintainerId, target.id),
614 eq(sponsorships.isPublic, true),
615 isNull(sponsorships.cancelledAt)
616 )
617 )
618 .orderBy(desc(sponsorships.createdAt))
619 .limit(20);
620
cf27bebClaude621 const thanks = c.req.query("thanks");
622
623 const statusVariant: "is-on" | "is-empty" =
624 tiers.length === 0 ? "is-empty" : "is-on";
625 const statusHead =
626 tiers.length === 0
627 ? `No sponsorship tiers yet`
628 : `Sponsorship enabled · ${tiers.length} tier${tiers.length === 1 ? "" : "s"}`;
629 const statusDesc =
630 tiers.length === 0
631 ? `${targetName} hasn't published any tiers yet — you can still sponsor a custom amount below.`
632 : `Pick a tier or contribute a one-time amount. ${recentPublic.length} recent sponsor${recentPublic.length === 1 ? "" : "s"}.`;
633
08420cdClaude634 return c.html(
635 <Layout title={`Sponsor ${targetName}`} user={user}>
cf27bebClaude636 <div class="spons-wrap">
637 <section class="spons-hero">
638 <div class="spons-hero-orb" aria-hidden="true" />
639 <div class="spons-hero-inner">
640 <div class="spons-hero-text">
641 <div class="spons-eyebrow">
642 <span class="spons-eyebrow-pill" aria-hidden="true">
643 <HeartIcon />
644 </span>
645 Sponsor · <a href={`/${targetName}`} style="color:var(--accent);text-decoration:none">{targetName}</a>
08420cdClaude646 </div>
cf27bebClaude647 <h1 class="spons-title">
648 <span class="spons-title-grad">Back {targetName}.</span>
649 </h1>
650 <p class="spons-sub">
651 Support {targetName}'s open-source work on Gluecron. Pick a tier
652 or contribute a one-time amount — every dollar lands directly
653 with the maintainer.
654 </p>
655 </div>
656 </div>
657 </section>
658
659 {thanks && (
660 <div class="spons-banner is-ok" role="status">
661 <span class="spons-banner-dot" aria-hidden="true" />
662 Thank you for supporting {targetName}.
663 </div>
664 )}
665
666 <section class={`spons-status ${statusVariant}`}>
667 <div class="spons-status-row">
668 <span class="spons-status-mark" aria-hidden="true">
669 <HeartIcon />
670 </span>
671 <div class="spons-status-text">
672 <h2 class="spons-status-headline">{statusHead}</h2>
673 <p class="spons-status-desc">{statusDesc}</p>
674 </div>
675 </div>
676 </section>
677
678 {tiers.length === 0 ? (
679 <div class="spons-empty">
680 <div class="spons-empty-orb" aria-hidden="true" />
681 <div class="spons-empty-mark" aria-hidden="true">
682 <TierIcon />
683 </div>
684 <h3 class="spons-empty-title">No published tiers yet</h3>
685 <p class="spons-empty-body">
686 {targetName} hasn't set up sponsorship tiers, but you can still
687 support them directly with a one-time amount.
688 </p>
689 {user ? (
690 <form
691 method="post"
692 action={`/sponsors/${targetName}`}
693 class="spons-empty-form"
08420cdClaude694 >
cf27bebClaude695 <input
696 type="number"
697 name="amount_cents"
698 placeholder="Amount in cents (e.g. 500 = $5)"
699 min="100"
700 required
701 aria-label="Sponsorship amount in cents"
702 class="spons-input-inline"
703 />
704 <button type="submit" class="spons-btn spons-btn-primary">
705 <HeartIcon /> Sponsor (one-time)
706 </button>
707 </form>
708 ) : (
709 <div class="spons-empty-form">
08420cdClaude710 <a
711 href={`/login?next=/sponsors/${targetName}`}
cf27bebClaude712 class="spons-btn spons-btn-primary"
08420cdClaude713 >
cf27bebClaude714 <HeartIcon /> Sign in to sponsor
08420cdClaude715 </a>
cf27bebClaude716 </div>
717 )}
718 </div>
08420cdClaude719 ) : (
cf27bebClaude720 <div class="spons-tiers">
721 {tiers.map((t) => (
722 <form
723 method="post"
724 action={`/sponsors/${targetName}`}
725 class="spons-tier"
726 >
727 <input type="hidden" name="tier_id" value={t.id} />
728 <h3 class="spons-tier-name">{t.name}</h3>
729 <div class="spons-tier-price">
730 <span class="spons-tier-price-num">
731 {formatCents(t.monthlyCents)}
08420cdClaude732 </span>
cf27bebClaude733 {t.monthlyCents > 0 && (
734 <span class="spons-tier-price-unit">/ month</span>
735 )}
736 </div>
737 {t.description && (
738 <p class="spons-tier-desc">{t.description}</p>
08420cdClaude739 )}
cf27bebClaude740 <ul class="spons-tier-benefits">
741 <li>
742 <CheckIcon />
743 <span>Listed as a sponsor on {targetName}'s profile</span>
744 </li>
745 <li>
746 <CheckIcon />
747 <span>Direct line to thank the maintainer</span>
748 </li>
749 {t.oneTimeAllowed && (
750 <li>
751 <CheckIcon />
752 <span>One-time contribution allowed</span>
753 </li>
754 )}
755 </ul>
756 {user ? (
757 <>
758 <select name="kind" class="spons-tier-select" aria-label="Sponsorship kind">
759 <option value="monthly">Monthly</option>
760 {t.oneTimeAllowed && (
761 <option value="one_time">One-time</option>
762 )}
763 </select>
764 <button
765 type="submit"
766 class="spons-btn spons-btn-primary spons-btn-block"
767 >
768 <HeartIcon /> Become a sponsor
769 </button>
770 </>
771 ) : (
772 <a
773 href={`/login?next=/sponsors/${targetName}`}
774 class="spons-btn spons-btn-ghost spons-btn-block"
775 >
776 Sign in to sponsor
777 </a>
778 )}
779 </form>
780 ))}
781 </div>
08420cdClaude782 )}
cf27bebClaude783
784 <section class="spons-section">
785 <header class="spons-section-head">
786 <h3 class="spons-section-title">
787 <span class="spons-section-icon" aria-hidden="true">
788 <ActivityIcon />
789 </span>
790 Recent sponsors
791 </h3>
792 <p class="spons-section-sub">
793 People who've publicly backed {targetName} recently.
794 </p>
795 </header>
796 <div class="spons-section-body">
797 {recentPublic.length === 0 ? (
798 <div class="spons-empty">
799 <div class="spons-empty-orb" aria-hidden="true" />
800 <div class="spons-empty-mark" aria-hidden="true">
801 <HeartIcon />
802 </div>
803 <h4 class="spons-empty-title">Be the first to sponsor</h4>
804 <p class="spons-empty-body">
805 No public sponsors yet — pick a tier above and your name will
806 show up here.
807 </p>
808 </div>
809 ) : (
810 recentPublic.map((s) => (
811 <div class="spons-row">
812 <div>
813 <span class="spons-row-name">
814 <a href={`/${s.sponsorName}`}>{s.sponsorName}</a>
815 </span>
816 {s.note && <span class="spons-row-note">"{s.note}"</span>}
817 </div>
818 <div class="spons-row-meta">
819 {formatCents(s.amountCents)} ·{" "}
820 {new Date(s.createdAt).toLocaleDateString()}
821 </div>
822 </div>
823 ))
824 )}
825 </div>
826 </section>
08420cdClaude827 </div>
cf27bebClaude828 <style dangerouslySetInnerHTML={{ __html: sponsStyles }} />
08420cdClaude829 </Layout>
830 );
831});
832
833// Record a sponsorship
834sponsors.post("/sponsors/:username", requireAuth, async (c) => {
835 const user = c.get("user")!;
836 const targetName = c.req.param("username");
837 const [target] = await db
838 .select()
839 .from(users)
840 .where(eq(users.username, targetName))
841 .limit(1);
842 if (!target) return c.notFound();
843 if (target.id === user.id) {
844 return c.redirect(`/sponsors/${targetName}`);
845 }
846 const body = await c.req.parseBody();
847 const tierId = body.tier_id ? String(body.tier_id) : null;
848 let amountCents = 0;
849 let kind = String(body.kind || "one_time");
850 if (kind !== "monthly" && kind !== "one_time") kind = "one_time";
851
852 if (tierId) {
853 const [tier] = await db
854 .select()
855 .from(sponsorshipTiers)
856 .where(eq(sponsorshipTiers.id, tierId))
857 .limit(1);
858 if (!tier || tier.maintainerId !== target.id) {
859 return c.redirect(`/sponsors/${targetName}`);
860 }
861 amountCents = tier.monthlyCents;
862 } else {
863 amountCents = Math.max(0, parseInt(String(body.amount_cents || "0"), 10));
864 }
865 if (amountCents <= 0 && !tierId) {
866 return c.redirect(`/sponsors/${targetName}`);
867 }
868
869 await db.insert(sponsorships).values({
870 sponsorId: user.id,
871 maintainerId: target.id,
872 tierId: tierId || null,
873 amountCents,
874 kind,
875 note: body.note ? String(body.note).slice(0, 200) : null,
876 isPublic: body.is_public !== "0",
877 });
878 return c.redirect(`/sponsors/${targetName}?thanks=1`);
879});
880
881// ---------- Maintainer settings ----------
882
883sponsors.get("/settings/sponsors", requireAuth, async (c) => {
884 const user = c.get("user")!;
885 const [tiers, activity] = await Promise.all([
886 db
887 .select()
888 .from(sponsorshipTiers)
889 .where(eq(sponsorshipTiers.maintainerId, user.id))
890 .orderBy(sponsorshipTiers.monthlyCents),
891 db
892 .select({
893 id: sponsorships.id,
894 amountCents: sponsorships.amountCents,
895 kind: sponsorships.kind,
896 createdAt: sponsorships.createdAt,
897 sponsorName: users.username,
898 })
899 .from(sponsorships)
900 .innerJoin(users, eq(sponsorships.sponsorId, users.id))
901 .where(eq(sponsorships.maintainerId, user.id))
902 .orderBy(desc(sponsorships.createdAt))
903 .limit(50),
904 ]);
905 const total = activity.reduce((sum, s) => sum + s.amountCents, 0);
cf27bebClaude906
907 const statusVariant: "is-on" | "is-empty" =
908 tiers.length === 0 ? "is-empty" : "is-on";
909 const statusHead =
910 tiers.length === 0
911 ? "Sponsorship not configured"
912 : `Sponsorship enabled · ${tiers.length} tier${tiers.length === 1 ? "" : "s"}`;
913 const statusDesc =
914 tiers.length === 0
915 ? "Your public sponsor page renders an empty state. Add a tier below to start accepting support."
916 : `Your public page is live at /sponsors/${user.username}. ${activity.length} contribution${activity.length === 1 ? "" : "s"} recorded.`;
917
08420cdClaude918 return c.html(
919 <Layout title="Sponsorship settings" user={user}>
9b776daccantynz-alt920 <style dangerouslySetInnerHTML={{ __html: settingsNavStyles }} />
921 <div class="settings-shell" style="max-width:1500px;margin:0 auto;padding:var(--space-4)">
922 <SettingsNav active="sponsors" />
923 <div class="spons-wrap settings-content" style="max-width:none;margin:0;padding:0">
cf27bebClaude924 <section class="spons-hero">
925 <div class="spons-hero-orb" aria-hidden="true" />
926 <div class="spons-hero-inner">
927 <div class="spons-hero-text">
928 <div class="spons-eyebrow">
929 <span class="spons-eyebrow-pill" aria-hidden="true">
930 <HeartIcon />
931 </span>
932 Sponsorship · {user.username}
933 </div>
934 <h1 class="spons-title">
935 <span class="spons-title-grad">Tiers + activity.</span>
936 </h1>
937 <p class="spons-sub">
938 Your public sponsor page is at{" "}
939 <a href={`/sponsors/${user.username}`}>
940 /sponsors/{user.username}
941 </a>
942 . Publish tiers, retire them, and watch contributions roll in.
943 </p>
944 </div>
945 </div>
946 </section>
08420cdClaude947
cf27bebClaude948 <section class={`spons-status ${statusVariant}`}>
949 <div class="spons-status-row">
950 <span class="spons-status-mark" aria-hidden="true">
951 <HeartIcon />
952 </span>
953 <div class="spons-status-text">
954 <h2 class="spons-status-headline">{statusHead}</h2>
955 <p class="spons-status-desc">{statusDesc}</p>
956 </div>
957 <div class="spons-total">
958 <div class="spons-total-label">Total received</div>
959 <div class="spons-total-num">{formatCents(total)}</div>
960 </div>
08420cdClaude961 </div>
cf27bebClaude962 </section>
963
964 <section class="spons-section">
965 <header class="spons-section-head">
966 <h3 class="spons-section-title">
967 <span class="spons-section-icon" aria-hidden="true">
968 <TierIcon />
969 </span>
970 Tiers
971 </h3>
972 <p class="spons-section-sub">
973 Published tiers appear on your public sponsor page. Retire any
974 tier with the button on its card.
975 </p>
976 </header>
977 <div class="spons-section-body">
978 {tiers.length === 0 ? (
979 <div class="spons-empty">
980 <div class="spons-empty-orb" aria-hidden="true" />
981 <div class="spons-empty-mark" aria-hidden="true">
982 <TierIcon />
08420cdClaude983 </div>
cf27bebClaude984 <h4 class="spons-empty-title">No tiers published yet</h4>
985 <p class="spons-empty-body">
986 Add a tier below — start with a low one ($5/mo) and a higher
987 one with perks. People sponsor more often when the choice is
988 easy.
989 </p>
08420cdClaude990 </div>
cf27bebClaude991 ) : (
992 <div class="spons-tiers">
993 {tiers.map((t) => (
994 <div class="spons-tier">
995 <h3 class="spons-tier-name">{t.name}</h3>
996 <div class="spons-tier-price">
997 <span class="spons-tier-price-num">
998 {formatCents(t.monthlyCents)}
999 </span>
1000 {t.monthlyCents > 0 && (
1001 <span class="spons-tier-price-unit">/ month</span>
1002 )}
1003 </div>
1004 <p class="spons-tier-desc">
1005 {t.description || "No description set."}
1006 </p>
1007 <form
1008 method="post"
1009 action={`/settings/sponsors/tiers/${t.id}/delete`}
1010 onsubmit="return confirm('Retire this tier?')"
1011 >
1012 <button
1013 type="submit"
1014 class="spons-btn spons-btn-danger spons-btn-sm spons-btn-block"
1015 >
1016 Retire tier
1017 </button>
1018 </form>
1019 </div>
1020 ))}
08420cdClaude1021 </div>
cf27bebClaude1022 )}
1023 </div>
1024 </section>
1025
1026 <section class="spons-section">
1027 <header class="spons-section-head">
1028 <h3 class="spons-section-title">
1029 <span class="spons-section-icon" aria-hidden="true">
1030 <PlusIcon />
1031 </span>
1032 Add a tier
1033 </h3>
1034 <p class="spons-section-sub">
1035 Give it a name, a short description, and a monthly amount in
1036 cents.
1037 </p>
1038 </header>
1039 <form
1040 method="post"
1041 action="/settings/sponsors/tiers/new"
1042 class="spons-form"
1043 >
1044 <div class="spons-form-group">
1045 <label class="spons-form-label" for="spons-name">Name</label>
1046 <input
1047 type="text"
1048 id="spons-name"
1049 name="name"
1050 required
1051 aria-label="Tier name"
1052 class="spons-input"
1053 placeholder="Silver supporter"
1054 />
1055 </div>
1056 <div class="spons-form-group">
1057 <label class="spons-form-label" for="spons-desc">Description</label>
1058 <textarea
1059 id="spons-desc"
1060 name="description"
1061 rows={2}
1062 class="spons-textarea"
1063 placeholder="What does this tier get them?"
1064 />
1065 </div>
1066 <div class="spons-form-group">
1067 <label class="spons-form-label" for="spons-cents">
1068 Monthly amount (cents)
1069 </label>
1070 <input
1071 type="number"
1072 id="spons-cents"
1073 name="monthly_cents"
1074 min="0"
1075 placeholder="500 = $5/mo"
1076 required
1077 aria-label="Monthly amount in cents"
1078 class="spons-input"
1079 />
1080 <div class="spons-form-hint">
1081 Tip: 500 = $5/mo, 2500 = $25/mo, 10000 = $100/mo.
08420cdClaude1082 </div>
1083 </div>
cf27bebClaude1084 <button type="submit" class="spons-btn spons-btn-primary">
1085 <PlusIcon /> Add tier
1086 </button>
1087 </form>
1088 </section>
1089
1090 <section class="spons-section">
1091 <header class="spons-section-head">
1092 <h3 class="spons-section-title">
1093 <span class="spons-section-icon" aria-hidden="true">
1094 <ActivityIcon />
1095 </span>
1096 Recent activity
1097 </h3>
1098 <p class="spons-section-sub">
1099 Latest 50 contributions (monthly + one-time).
1100 </p>
1101 </header>
1102 <div class="spons-section-body">
1103 {activity.length === 0 ? (
1104 <div class="spons-empty">
1105 <div class="spons-empty-orb" aria-hidden="true" />
1106 <div class="spons-empty-mark" aria-hidden="true">
1107 <ActivityIcon />
1108 </div>
1109 <h4 class="spons-empty-title">No sponsors yet</h4>
1110 <p class="spons-empty-body">
1111 Once people sponsor you, every contribution lands here with
1112 the kind (monthly/one-time) and amount.
1113 </p>
1114 </div>
1115 ) : (
1116 activity.map((a) => (
1117 <div class="spons-row">
1118 <div>
1119 <span class="spons-row-name">
1120 <a href={`/${a.sponsorName}`}>{a.sponsorName}</a>
1121 </span>
1122 <span class="spons-row-kind">{a.kind}</span>
1123 </div>
1124 <div class="spons-row-meta">
1125 {formatCents(a.amountCents)} ·{" "}
1126 {new Date(a.createdAt).toLocaleDateString()}
1127 </div>
1128 </div>
1129 ))
1130 )}
1131 </div>
1132 </section>
08420cdClaude1133 </div>
9b776daccantynz-alt1134 </div>
cf27bebClaude1135 <style dangerouslySetInnerHTML={{ __html: sponsStyles }} />
08420cdClaude1136 </Layout>
1137 );
1138});
1139
1140sponsors.post("/settings/sponsors/tiers/new", requireAuth, async (c) => {
1141 const user = c.get("user")!;
1142 const body = await c.req.parseBody();
1143 const name = String(body.name || "").trim();
1144 if (!name) return c.redirect("/settings/sponsors");
1145 const monthlyCents = Math.max(
1146 0,
1147 parseInt(String(body.monthly_cents || "0"), 10)
1148 );
1149 await db.insert(sponsorshipTiers).values({
1150 maintainerId: user.id,
1151 name,
1152 description: String(body.description || ""),
1153 monthlyCents,
1154 });
1155 return c.redirect("/settings/sponsors");
1156});
1157
1158sponsors.post(
1159 "/settings/sponsors/tiers/:id/delete",
1160 requireAuth,
1161 async (c) => {
1162 const user = c.get("user")!;
1163 const id = c.req.param("id");
1164 await db
1165 .update(sponsorshipTiers)
1166 .set({ isActive: false })
1167 .where(
1168 and(
1169 eq(sponsorshipTiers.id, id),
1170 eq(sponsorshipTiers.maintainerId, user.id)
1171 )
1172 );
1173 return c.redirect("/settings/sponsors");
1174 }
1175);
1176
1177// Handy stat helper for other pages
1178export async function sponsorshipTotalForUser(
1179 userId: string
1180): Promise<number> {
1181 try {
1182 const [r] = await db
1183 .select({ n: sql<number>`coalesce(sum(${sponsorships.amountCents}), 0)::int` })
1184 .from(sponsorships)
1185 .where(eq(sponsorships.maintainerId, userId));
1186 return Number(r?.n || 0);
1187 } catch {
1188 return 0;
1189 }
1190}
1191
1192/** Test-only hook. */
1193export const __internal = { formatCents };
1194
1195export default sponsors;