Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit891efbeunknown_key

Add shareable AI hours-saved OG image card at /share/:username

Add shareable AI hours-saved OG image card at /share/:username

- New route file src/routes/share.tsx with two endpoints:
  - GET /share/hours-saved?user=:username — returns 1200x630 SVG card
    as image/svg+xml suitable for og:image embed (no canvas/sharp)
  - GET /share/:username — HTML page with og:image, og:title,
    og:description, twitter:card meta tags and a pre-filled Twitter
    share button; falls back to platform-wide aggregate for unknown users
- Hours formula (all-time): AI-merged PRs x1.5h + AI reviews x0.5h
  + CI heals x0.3h; sources: audit_log(auto_merge.merged),
  pr_comments(isAiReview=true), gate_runs(repairSucceeded=true)
- Registered in src/app.tsx before the web catch-all
- Added "Share your AI stats" link to /billing/usage sublinks bar

https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
Claude committed on June 6, 2026Parent: af39632
3 files changed+6760891efbee7e57d8a1ddd348c65c69363c2f45a57a
3 changed files+676−0
Modifiedsrc/app.tsx+7−0View fileUnifiedSplit
170170import healthScoreRoutes from "./routes/health-score";
171171import hotFilesRoutes from "./routes/hot-files";
172172import developerProgramRoutes from "./routes/developer-program";
173import shareRoutes from "./routes/share";
173174import { authRateLimit, gitRateLimit, searchRateLimit } from "./middleware/rate-limit";
174175import { csrfToken, csrfProtect } from "./middleware/csrf";
175176import { noCache } from "./middleware/no-cache";
696697// `/:owner` user-profile route.
697698app.route("/", playgroundRoutes);
698699
700// Shareable AI hours-saved OG image card + landing page.
701// /share/hours-saved?user=:username → 1200×630 SVG (og:image)
702// /share/:username → HTML page with og:image meta + Twitter share
703// Mounted BEFORE webRoutes so /share/* doesn't fall through to the /:owner profile route.
704app.route("/", shareRoutes);
705
699706// Web UI (catch-all, must be last)
700707app.route("/", webRoutes);
701708
Modifiedsrc/routes/billing-usage.tsx+4−0View fileUnifiedSplit
371371 <a href="/settings/billing">Plans + payment</a>
372372 <a href="/billing/usage" class="is-current">AI usage</a>
373373 <a href="/settings/agents">Agents</a>
374 <a href={`/share/${user.username}`} style="display:inline-flex;align-items:center;gap:6px;color:#00ff88;border-color:rgba(0,255,136,0.30);background:rgba(0,255,136,0.06)">
375 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
376 Share your AI stats
377 </a>
374378 </div>
375379
376380 {saved && (
Addedsrc/routes/share.tsx+665−0View fileUnifiedSplit
1/**
2 * Shareable "AI hours saved" cards — viral growth lever for Twitter / LinkedIn.
3 *
4 * GET /share/hours-saved?user=:username
5 * Returns a 1200×630 SVG OG image card showing how many hours the user
6 * (or the platform globally) has saved with AI tooling.
7 *
8 * GET /share/:username
9 * HTML landing page with proper OG meta tags (og:image points to the
10 * SVG endpoint above), a live stat display, and a "Share on Twitter"
11 * button with pre-filled tweet text.
12 *
13 * Hours-saved formula (all-time, per user):
14 * - AI-merged PRs × 1.5 h (source: auditLog action="auto_merge.merged",
15 * joined to pull_requests where authorId = user)
16 * - AI reviews × 0.5 h (source: pr_comments where isAiReview=true and
17 * the PR's repository is owned by the user)
18 * - CI heals × 0.3 h (source: gate_runs where repairSucceeded=true and
19 * repositoryId in user's repos)
20 *
21 * For missing / unauthenticated users the SVG and page fall back to
22 * platform-wide aggregate stats.
23 *
24 * No new npm dependencies — pure SVG text, no canvas / sharp / puppeteer.
25 */
26
27import { Hono } from "hono";
28import { eq, and, sql, inArray } from "drizzle-orm";
29import { db } from "../db";
30import {
31 users,
32 pullRequests,
33 prComments,
34 gateRuns,
35 repositories,
36 auditLog,
37} from "../db/schema";
38import { Layout } from "../views/layout";
39import { softAuth } from "../middleware/auth";
40import type { AuthEnv } from "../middleware/auth";
41
42const share = new Hono<AuthEnv>();
43share.use("*", softAuth);
44
45// ─── Hours computation ────────────────────────────────────────────────────
46
47interface HoursSaved {
48 aiMergedPrs: number;
49 aiReviews: number;
50 ciHeals: number;
51 totalHours: number;
52}
53
54/** Compute hours saved for a specific user (all-time). */
55async function computeHoursForUser(userId: string): Promise<HoursSaved> {
56 // Repos owned by the user — needed for CI heals and AI-review lookups.
57 const userRepos = await db
58 .select({ id: repositories.id })
59 .from(repositories)
60 .where(eq(repositories.ownerId, userId));
61
62 const repoIds = userRepos.map((r) => r.id);
63
64 // 1. AI-merged PRs: audit_log rows where action='auto_merge.merged' and
65 // the associated PR was authored by the user.
66 let aiMergedPrs = 0;
67 try {
68 const [row] = await db
69 .select({ n: sql<number>`count(*)::int` })
70 .from(auditLog)
71 .where(
72 and(
73 eq(auditLog.action, "auto_merge.merged"),
74 eq(auditLog.userId, userId)
75 )
76 );
77 aiMergedPrs = Number(row?.n ?? 0);
78 } catch {
79 aiMergedPrs = 0;
80 }
81
82 // 2. AI reviews: pr_comments with isAiReview=true on PRs in user's repos.
83 let aiReviews = 0;
84 try {
85 if (repoIds.length > 0) {
86 const [row] = await db
87 .select({ n: sql<number>`count(*)::int` })
88 .from(prComments)
89 .innerJoin(
90 pullRequests,
91 eq(prComments.pullRequestId, pullRequests.id)
92 )
93 .where(
94 and(
95 eq(prComments.isAiReview, true),
96 inArray(pullRequests.repositoryId, repoIds)
97 )
98 );
99 aiReviews = Number(row?.n ?? 0);
100 }
101 } catch {
102 aiReviews = 0;
103 }
104
105 // 3. CI heals: gate_runs where repairSucceeded=true on user's repos.
106 let ciHeals = 0;
107 try {
108 if (repoIds.length > 0) {
109 const [row] = await db
110 .select({ n: sql<number>`count(*)::int` })
111 .from(gateRuns)
112 .where(
113 and(
114 eq(gateRuns.repairSucceeded, true),
115 inArray(gateRuns.repositoryId, repoIds)
116 )
117 );
118 ciHeals = Number(row?.n ?? 0);
119 }
120 } catch {
121 ciHeals = 0;
122 }
123
124 const totalHours =
125 aiMergedPrs * 1.5 + aiReviews * 0.5 + ciHeals * 0.3;
126
127 return { aiMergedPrs, aiReviews, ciHeals, totalHours };
128}
129
130/** Platform-wide aggregate hours saved (for fallback / anonymous). */
131async function computeGlobalHours(): Promise<HoursSaved> {
132 let aiMergedPrs = 0;
133 let aiReviews = 0;
134 let ciHeals = 0;
135
136 try {
137 const [row] = await db
138 .select({ n: sql<number>`count(*)::int` })
139 .from(auditLog)
140 .where(eq(auditLog.action, "auto_merge.merged"));
141 aiMergedPrs = Number(row?.n ?? 0);
142 } catch {
143 aiMergedPrs = 0;
144 }
145
146 try {
147 const [row] = await db
148 .select({ n: sql<number>`count(*)::int` })
149 .from(prComments)
150 .where(eq(prComments.isAiReview, true));
151 aiReviews = Number(row?.n ?? 0);
152 } catch {
153 aiReviews = 0;
154 }
155
156 try {
157 const [row] = await db
158 .select({ n: sql<number>`count(*)::int` })
159 .from(gateRuns)
160 .where(eq(gateRuns.repairSucceeded, true));
161 ciHeals = Number(row?.n ?? 0);
162 } catch {
163 ciHeals = 0;
164 }
165
166 const totalHours =
167 aiMergedPrs * 1.5 + aiReviews * 0.5 + ciHeals * 0.3;
168
169 return { aiMergedPrs, aiReviews, ciHeals, totalHours };
170}
171
172/** Format a number to one decimal place, dropping ".0" when it's clean. */
173function fmtHours(n: number): string {
174 if (n === 0) return "0";
175 const s = n.toFixed(1);
176 return s.endsWith(".0") ? s.slice(0, -2) : s;
177}
178
179// ─── SVG OG image ────────────────────────────────────────────────────────
180
181/**
182 * GET /share/hours-saved?user=:username
183 * Returns a 1200×630 SVG card suitable for use as an OG image.
184 */
185share.get("/share/hours-saved", async (c) => {
186 const username = c.req.query("user") ?? "";
187 let stats: HoursSaved;
188 let displayName = username || "the community";
189 let atName = username ? `@${username}` : "gluecron.com";
190 let isGlobal = !username;
191
192 if (username) {
193 const [found] = await db
194 .select()
195 .from(users)
196 .where(eq(users.username, username))
197 .limit(1);
198
199 if (found) {
200 stats = await computeHoursForUser(found.id);
201 } else {
202 // Unknown user — fall back to global
203 stats = await computeGlobalHours();
204 displayName = "the community";
205 atName = "gluecron.com";
206 isGlobal = true;
207 }
208 } else {
209 stats = await computeGlobalHours();
210 }
211
212 const hoursStr = fmtHours(stats.totalHours);
213 const label = isGlobal
214 ? "hours saved with AI — platform-wide"
215 : "hours saved with AI";
216
217 const svg = buildOgSvg({ hoursStr, label, atName, stats });
218
219 c.header("Content-Type", "image/svg+xml; charset=utf-8");
220 c.header("Cache-Control", "public, max-age=300, s-maxage=900");
221 return c.body(svg);
222});
223
224/** Build the 1200×630 SVG string. Pure function — no I/O. */
225function buildOgSvg({
226 hoursStr,
227 label,
228 atName,
229 stats,
230}: {
231 hoursStr: string;
232 label: string;
233 atName: string;
234 stats: HoursSaved;
235}): string {
236 // Estimate text width for the giant number so we can center it.
237 // Each digit ≈ 95px wide at font-size 160; decimal point ≈ 32px.
238 const charWidths: Record<string, number> = {
239 "0": 95, "1": 70, "2": 95, "3": 95, "4": 95,
240 "5": 95, "6": 95, "7": 85, "8": 95, "9": 95,
241 ".": 32,
242 };
243 const numWidth = [...hoursStr].reduce(
244 (w, ch) => w + (charWidths[ch] ?? 90),
245 0
246 );
247 const numX = Math.round(600 - numWidth / 2);
248
249 const pill = (x: number, y: number, n: number, text: string) =>
250 `<g transform="translate(${x},${y})">
251 <rect x="0" y="0" width="240" height="46" rx="10" fill="rgba(0,255,136,0.08)" stroke="rgba(0,255,136,0.22)" stroke-width="1"/>
252 <text x="20" y="30" font-family="'Courier New',monospace" font-size="14" fill="#00ff88" font-weight="700">${n.toLocaleString()}</text>
253 <text x="60" y="30" font-family="'Courier New',monospace" font-size="14" fill="#8b949e">${text}</text>
254 </g>`;
255
256 return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
257 <defs>
258 <linearGradient id="bg-grad" x1="0" y1="0" x2="1" y2="1">
259 <stop offset="0%" stop-color="#0d1117"/>
260 <stop offset="100%" stop-color="#0a0e14"/>
261 </linearGradient>
262 <linearGradient id="num-grad" x1="0" y1="0" x2="1" y2="1">
263 <stop offset="0%" stop-color="#00ff88"/>
264 <stop offset="100%" stop-color="#00e5ff"/>
265 </linearGradient>
266 <filter id="glow">
267 <feGaussianBlur stdDeviation="6" result="blur"/>
268 <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
269 </filter>
270 <!-- radial glow orb behind the number -->
271 <radialGradient id="orb-grad" cx="50%" cy="50%" r="50%">
272 <stop offset="0%" stop-color="rgba(0,255,136,0.10)"/>
273 <stop offset="100%" stop-color="rgba(0,255,136,0)"/>
274 </radialGradient>
275 </defs>
276
277 <!-- Background -->
278 <rect width="1200" height="630" fill="url(#bg-grad)"/>
279
280 <!-- Subtle grid lines -->
281 <line x1="0" y1="1" x2="1200" y2="1" stroke="#30363d" stroke-width="1"/>
282 <line x1="0" y1="629" x2="1200" y2="629" stroke="#30363d" stroke-width="1"/>
283
284 <!-- Top accent bar -->
285 <rect x="0" y="0" width="1200" height="3" fill="url(#num-grad)" opacity="0.85"/>
286
287 <!-- Gluecron logo / wordmark -->
288 <text x="60" y="72" font-family="'Courier New',monospace" font-size="22" font-weight="700" fill="#00ff88" letter-spacing="0.18em">GLUECRON</text>
289 <text x="222" y="72" font-family="'Courier New',monospace" font-size="14" fill="#444c56">· AI-native git platform</text>
290
291 <!-- Orb glow behind number -->
292 <ellipse cx="600" cy="310" rx="320" ry="180" fill="url(#orb-grad)"/>
293
294 <!-- Giant hours number -->
295 <text
296 x="${numX}"
297 y="340"
298 font-family="'Courier New',monospace"
299 font-size="160"
300 font-weight="700"
301 fill="url(#num-grad)"
302 filter="url(#glow)"
303 >${hoursStr}</text>
304
305 <!-- Label below number -->
306 <text x="600" y="400" text-anchor="middle" font-family="'Courier New',monospace" font-size="26" fill="#e6edf3" letter-spacing="0.01em">${label}</text>
307
308 <!-- Breakdown pills -->
309 ${pill(60, 450, stats.aiMergedPrs, "auto-merged PRs × 1.5h")}
310 ${pill(330, 450, stats.aiReviews, "AI reviews × 0.5h")}
311 ${pill(600, 450, stats.ciHeals, "CI heals × 0.3h")}
312
313 <!-- Bottom footer -->
314 <line x1="60" y1="548" x2="1140" y2="548" stroke="#21262d" stroke-width="1"/>
315 <text x="60" y="580" font-family="'Courier New',monospace" font-size="18" fill="#8b949e">gluecron.com</text>
316 <text x="1140" y="580" text-anchor="end" font-family="'Courier New',monospace" font-size="18" fill="#8b949e">${escSvg(atName)}</text>
317</svg>`;
318}
319
320/** Escape special XML/SVG characters in text content. */
321function escSvg(s: string): string {
322 return s
323 .replace(/&/g, "&amp;")
324 .replace(/</g, "&lt;")
325 .replace(/>/g, "&gt;")
326 .replace(/"/g, "&quot;")
327 .replace(/'/g, "&#39;");
328}
329
330// ─── Shareable HTML page ──────────────────────────────────────────────────
331
332const shareStyles = `
333 .share-wrap { max-width: 860px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
334
335 .share-hero {
336 position: relative;
337 margin-bottom: var(--space-5);
338 padding: clamp(32px,5vw,56px) clamp(24px,4vw,48px);
339 background: var(--bg-elevated);
340 border: 1px solid var(--border);
341 border-radius: 18px;
342 overflow: hidden;
343 text-align: center;
344 }
345 .share-hero::before {
346 content: '';
347 position: absolute; top: 0; left: 0; right: 0; height: 3px;
348 background: linear-gradient(90deg, transparent 0%, #00ff88 30%, #00e5ff 70%, transparent 100%);
349 opacity: 0.85; pointer-events: none;
350 }
351 .share-orb {
352 position: absolute;
353 inset: -20% 10% auto 10%;
354 width: 80%; height: 300px;
355 background: radial-gradient(ellipse, rgba(0,255,136,0.10), rgba(0,229,255,0.06) 50%, transparent 80%);
356 filter: blur(60px); opacity: 0.8;
357 pointer-events: none; z-index: 0;
358 }
359 .share-hero-inner { position: relative; z-index: 1; }
360
361 .share-eyebrow {
362 font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.16em;
363 text-transform: uppercase; color: var(--text-muted); font-weight: 600;
364 margin-bottom: var(--space-3);
365 }
366 .share-eyebrow-dot {
367 display: inline-block; width: 8px; height: 8px; border-radius: 9999px;
368 background: #00ff88; box-shadow: 0 0 0 3px rgba(0,255,136,0.18);
369 margin-right: 8px; vertical-align: middle;
370 }
371
372 .share-hours-num {
373 font-family: var(--font-mono);
374 font-size: clamp(64px, 14vw, 120px);
375 font-weight: 700;
376 line-height: 1;
377 background: linear-gradient(135deg, #00ff88 0%, #00e5ff 100%);
378 -webkit-background-clip: text; background-clip: text;
379 -webkit-text-fill-color: transparent; color: transparent;
380 letter-spacing: -0.03em;
381 margin: 0 0 var(--space-2);
382 }
383 .share-hours-label {
384 font-size: clamp(16px, 3vw, 22px);
385 color: var(--text);
386 font-weight: 600;
387 margin: 0 0 var(--space-4);
388 letter-spacing: -0.01em;
389 }
390
391 .share-pills {
392 display: flex; flex-wrap: wrap; justify-content: center;
393 gap: var(--space-2); margin-bottom: var(--space-4);
394 }
395 .share-pill {
396 display: inline-flex; align-items: center; gap: 6px;
397 padding: 6px 14px; border-radius: 9999px;
398 background: rgba(0,255,136,0.08);
399 border: 1px solid rgba(0,255,136,0.22);
400 font-family: var(--font-mono); font-size: 13px; color: #e6edf3;
401 }
402 .share-pill-num { color: #00ff88; font-weight: 700; }
403
404 .share-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--space-3); }
405 .share-btn {
406 display: inline-flex; align-items: center; gap: 8px;
407 padding: 12px 22px; border-radius: 10px; font-size: 14px; font-weight: 600;
408 text-decoration: none; cursor: pointer; border: none; transition: opacity 150ms ease;
409 }
410 .share-btn:hover { opacity: 0.85; }
411 .share-btn-twitter {
412 background: #1d9bf0; color: #fff;
413 }
414 .share-btn-copy {
415 background: rgba(0,255,136,0.12);
416 border: 1px solid rgba(0,255,136,0.30);
417 color: #00ff88;
418 }
419 .share-btn-copy:hover { background: rgba(0,255,136,0.20); }
420
421 .share-preview-wrap {
422 margin-bottom: var(--space-5);
423 border: 1px solid var(--border);
424 border-radius: 14px;
425 overflow: hidden;
426 background: var(--bg-elevated);
427 }
428 .share-preview-head {
429 padding: 12px 20px; border-bottom: 1px solid var(--border);
430 font-size: 12px; color: var(--text-muted); font-weight: 500;
431 display: flex; align-items: center; gap: 8px;
432 }
433 .share-preview-dot { width: 8px; height: 8px; border-radius: 9999px; background: var(--border-strong); }
434 .share-preview-img { display: block; width: 100%; }
435
436 .share-breakdown {
437 margin-bottom: var(--space-5);
438 background: var(--bg-elevated);
439 border: 1px solid var(--border);
440 border-radius: 14px;
441 overflow: hidden;
442 }
443 .share-breakdown-head {
444 padding: 14px 20px; border-bottom: 1px solid var(--border);
445 font-size: 13px; font-weight: 700; color: var(--text-strong);
446 }
447 .share-breakdown-body { padding: 0; }
448 .share-stat-row {
449 display: flex; align-items: center; justify-content: space-between;
450 padding: 14px 20px; border-bottom: 1px solid var(--border-subtle);
451 font-size: 13.5px;
452 }
453 .share-stat-row:last-child { border-bottom: 0; }
454 .share-stat-label { color: var(--text-muted); }
455 .share-stat-val { font-family: var(--font-mono); font-weight: 600; color: var(--text-strong); }
456 .share-stat-hrs { color: #00ff88; margin-left: 8px; font-size: 12px; }
457
458 .share-foot {
459 text-align: center; font-size: 12.5px; color: var(--text-muted);
460 padding-top: var(--space-4); border-top: 1px solid var(--border);
461 }
462 .share-foot a { color: var(--accent); text-decoration: none; }
463 .share-foot a:hover { text-decoration: underline; }
464`;
465
466/**
467 * GET /share/:username
468 * HTML page with OG meta tags + stat display + Twitter share button.
469 */
470share.get("/share/:username", async (c) => {
471 const username = c.req.param("username");
472 const user = c.get("user");
473
474 let targetUser: typeof users.$inferSelect | null = null;
475 let stats: HoursSaved;
476 let isGlobal = false;
477
478 const [found] = await db
479 .select()
480 .from(users)
481 .where(eq(users.username, username))
482 .limit(1);
483
484 if (found) {
485 targetUser = found;
486 stats = await computeHoursForUser(found.id);
487 } else {
488 // Unknown user — show global stats with a note
489 stats = await computeGlobalHours();
490 isGlobal = true;
491 }
492
493 const hoursStr = fmtHours(stats.totalHours);
494 const ogImageUrl = `/share/hours-saved?user=${encodeURIComponent(username)}`;
495
496 const ogTitle = isGlobal
497 ? `The Gluecron community saved ${hoursStr} hours with AI`
498 : `I saved ${hoursStr} hours with Gluecron AI`;
499
500 const ogDesc =
501 "AI review, auto-merge, and spec-to-PR — all automatic. Try Gluecron free.";
502
503 // Pre-filled tweet text
504 const tweetText = isGlobal
505 ? `The @gluecron community has saved ${hoursStr} hours with AI review, auto-merge, and CI healing. This is what AI-native git looks like. gluecron.com/share/${username}`
506 : `I've saved ${hoursStr} hours using @gluecron's AI review, auto-merge, and CI healing. This is what AI-native git looks like. gluecron.com/share/${username}`;
507
508 const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweetText)}`;
509 const shareUrl = `https://gluecron.com/share/${username}`;
510
511 return c.html(
512 <Layout
513 title={ogTitle + " — Gluecron"}
514 user={user}
515 ogTitle={ogTitle}
516 ogDescription={ogDesc}
517 ogType="website"
518 twitterCard="summary_large_image"
519 >
520 <style dangerouslySetInnerHTML={{ __html: shareStyles }} />
521 {/* og:image injected inline since Layout doesn't support it yet */}
522 <script
523 dangerouslySetInnerHTML={{
524 __html: `
525 (function(){
526 var m = document.createElement('meta');
527 m.setAttribute('property','og:image');
528 m.setAttribute('content','${ogImageUrl}');
529 document.head.appendChild(m);
530 var tw = document.createElement('meta');
531 tw.setAttribute('name','twitter:image');
532 tw.setAttribute('content','${ogImageUrl}');
533 document.head.appendChild(tw);
534 })();
535 `,
536 }}
537 />
538 <div class="share-wrap">
539 {/* Hero card */}
540 <section class="share-hero">
541 <div class="share-orb" aria-hidden="true" />
542 <div class="share-hero-inner">
543 <div class="share-eyebrow">
544 <span class="share-eyebrow-dot" aria-hidden="true" />
545 {isGlobal
546 ? "Platform-wide · all-time · AI impact"
547 : `@${username} · all-time · AI impact`}
548 </div>
549
550 <div class="share-hours-num">{hoursStr}</div>
551 <p class="share-hours-label">
552 {isGlobal
553 ? "hours saved with AI — platform-wide"
554 : "hours saved with AI"}
555 </p>
556
557 <div class="share-pills">
558 <span class="share-pill">
559 <span class="share-pill-num">{stats.aiMergedPrs.toLocaleString()}</span>
560 auto-merged PRs
561 </span>
562 <span class="share-pill">
563 <span class="share-pill-num">{stats.aiReviews.toLocaleString()}</span>
564 AI reviews
565 </span>
566 <span class="share-pill">
567 <span class="share-pill-num">{stats.ciHeals.toLocaleString()}</span>
568 CI heals
569 </span>
570 </div>
571
572 <div class="share-actions">
573 <a
574 href={twitterUrl}
575 target="_blank"
576 rel="noopener noreferrer"
577 class="share-btn share-btn-twitter"
578 >
579 <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
580 <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.747l7.73-8.835L1.254 2.25H8.08l4.259 5.63 5.905-5.63zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
581 </svg>
582 Share on X / Twitter
583 </a>
584 <button
585 class="share-btn share-btn-copy"
586 onclick={`navigator.clipboard.writeText('${shareUrl}').then(()=>this.textContent='Copied!').catch(()=>{})`}
587 >
588 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
589 <rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
590 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
591 </svg>
592 Copy link
593 </button>
594 </div>
595 </div>
596 </section>
597
598 {/* OG image preview */}
599 <div class="share-preview-wrap">
600 <div class="share-preview-head">
601 <span class="share-preview-dot" aria-hidden="true" />
602 <span class="share-preview-dot" aria-hidden="true" />
603 <span class="share-preview-dot" aria-hidden="true" />
604 OG image preview — how this link appears when shared
605 </div>
606 <img
607 src={ogImageUrl}
608 alt={`OG image card: ${hoursStr} hours saved`}
609 class="share-preview-img"
610 loading="lazy"
611 />
612 </div>
613
614 {/* Breakdown table */}
615 <section class="share-breakdown">
616 <div class="share-breakdown-head">How hours are calculated</div>
617 <div class="share-breakdown-body">
618 <div class="share-stat-row">
619 <span class="share-stat-label">AI-merged pull requests</span>
620 <span class="share-stat-val">
621 {stats.aiMergedPrs.toLocaleString()}
622 <span class="share-stat-hrs">× 1.5h = {fmtHours(stats.aiMergedPrs * 1.5)}h</span>
623 </span>
624 </div>
625 <div class="share-stat-row">
626 <span class="share-stat-label">AI code reviews</span>
627 <span class="share-stat-val">
628 {stats.aiReviews.toLocaleString()}
629 <span class="share-stat-hrs">× 0.5h = {fmtHours(stats.aiReviews * 0.5)}h</span>
630 </span>
631 </div>
632 <div class="share-stat-row">
633 <span class="share-stat-label">CI heals (auto-repair)</span>
634 <span class="share-stat-val">
635 {stats.ciHeals.toLocaleString()}
636 <span class="share-stat-hrs">× 0.3h = {fmtHours(stats.ciHeals * 0.3)}h</span>
637 </span>
638 </div>
639 <div class="share-stat-row">
640 <span class="share-stat-label" style="font-weight:700;color:var(--text-strong)">Total hours saved</span>
641 <span class="share-stat-val" style="color:#00ff88;font-size:16px">{hoursStr}h</span>
642 </div>
643 </div>
644 </section>
645
646 <p class="share-foot">
647 {isGlobal ? (
648 <>
649 Platform-wide stats. Want your personal card?{" "}
650 <a href="/register">Sign up free</a> and visit{" "}
651 <a href="/share/{your-username}">/share/{"{your-username}"}</a>.
652 </>
653 ) : (
654 <>
655 Share your stats · <a href="/billing/usage">View AI usage dashboard</a> ·{" "}
656 <a href="/">Back to Gluecron</a>
657 </>
658 )}
659 </p>
660 </div>
661 </Layout>
662 );
663});
664
665export default share;
0666