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