🪻 distributed transcription service thistle.dunkirk.sh

feat: add inital polar integration

dunkirk.sh 7bf0148a b978d464

verified
+843 -35
+13
.env.example
··· 20 20 # Origin - full URL of your app 21 21 # Must match exactly where users access your app 22 22 # ORIGIN=https://thistle.app 23 + 24 + # Polar.sh payment stuff 25 + # Get your access token from https://polar.sh/settings (or sandbox.polar.sh for testing) 26 + POLAR_ACCESS_TOKEN=XXX 27 + # Get product ID from your Polar dashboard (create a product first) 28 + POLAR_PRODUCT_ID=3f1ab9f9-d573-49d4-ac0a-a78bfb06c347 29 + # Redirect URL after successful checkout (use {CHECKOUT_ID} placeholder) 30 + POLAR_SUCCESS_URL=http://localhost:3000/checkout?checkout_id={CHECKOUT_ID} 31 + # Webhook secret for verifying Polar webhook signatures (get from Polar dashboard) 32 + POLAR_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxx 33 + 34 + # Environment (set to 'production' in production) 35 + NODE_ENV=development
+11
bun.lock
··· 5 5 "": { 6 6 "name": "inky", 7 7 "dependencies": { 8 + "@polar-sh/sdk": "^0.41.5", 8 9 "@simplewebauthn/browser": "^13.2.2", 9 10 "@simplewebauthn/server": "^13.2.2", 10 11 "eventsource-client": "^1.2.0", ··· 73 74 74 75 "@peculiar/x509": ["@peculiar/x509@1.14.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.5.0", "@peculiar/asn1-csr": "^2.5.0", "@peculiar/asn1-ecc": "^2.5.0", "@peculiar/asn1-pkcs9": "^2.5.0", "@peculiar/asn1-rsa": "^2.5.0", "@peculiar/asn1-schema": "^2.5.0", "@peculiar/asn1-x509": "^2.5.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-Yc4PDxN3OrxUPiXgU63c+ZRXKGE8YKF2McTciYhUHFtHVB0KMnjeFSU0qpztGhsp4P0uKix4+J2xEpIEDu8oXg=="], 75 76 77 + "@polar-sh/sdk": ["@polar-sh/sdk@0.41.5", "", { "dependencies": { "standardwebhooks": "^1.0.0", "zod": "^3.25.65 || ^4.0.0" } }, "sha512-E+VoVV+WvebZKmj+KZ/fj1byBZbG7J8hHyzYD9kktvAToigPM19sywo2tFCHeb44aWGCVACMOP8r31e6je7dxA=="], 78 + 76 79 "@simplewebauthn/browser": ["@simplewebauthn/browser@13.2.2", "", {}, "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA=="], 77 80 78 81 "@simplewebauthn/server": ["@simplewebauthn/server@13.2.2", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.3.10", "@peculiar/asn1-ecc": "^2.3.8", "@peculiar/asn1-rsa": "^2.3.8", "@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-x509": "^2.3.8", "@peculiar/x509": "^1.13.0" } }, "sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA=="], 79 82 80 83 "@simplewebauthn/types": ["@simplewebauthn/types@12.0.0", "", {}, "sha512-q6y8MkoV8V8jB4zzp18Uyj2I7oFp2/ONL8c3j8uT06AOWu3cIChc1au71QYHrP2b+xDapkGTiv+9lX7xkTlAsA=="], 84 + 85 + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], 81 86 82 87 "@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="], 83 88 ··· 99 104 100 105 "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], 101 106 107 + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], 108 + 102 109 "is-standalone-pwa": ["is-standalone-pwa@0.1.1", "", {}, "sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g=="], 103 110 104 111 "lit": ["lit@3.3.1", "", { "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", "lit-html": "^3.3.0" } }, "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA=="], ··· 115 122 116 123 "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], 117 124 125 + "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], 126 + 118 127 "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 119 128 120 129 "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], ··· 126 135 "ua-parser-js": ["ua-parser-js@2.0.6", "", { "dependencies": { "detect-europe-js": "^0.1.2", "is-standalone-pwa": "^0.1.1", "ua-is-frozen": "^0.1.2" }, "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-EmaxXfltJaDW75SokrY4/lXMrVyXomE/0FpIIqP2Ctic93gK7rlme55Cwkz8l3YZ6gqf94fCU7AnIkidd/KXPg=="], 127 136 128 137 "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 138 + 139 + "zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], 129 140 130 141 "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], 131 142 }
+28 -26
package.json
··· 1 1 { 2 - "name": "thistle", 3 - "module": "src/index.ts", 4 - "type": "module", 5 - "private": true, 6 - "scripts": { 7 - "dev": "bun run src/index.ts --hot", 8 - "clean": "rm -rf transcripts uploads thistle.db", 9 - "test": "bun test", 10 - "test:integration": "bun test src/index.test.ts" 11 - }, 12 - "devDependencies": { 13 - "@biomejs/biome": "^2.3.2", 14 - "@simplewebauthn/types": "^12.0.0", 15 - "@types/bun": "latest" 16 - }, 17 - "peerDependencies": { 18 - "typescript": "^5" 19 - }, 20 - "dependencies": { 21 - "@simplewebauthn/browser": "^13.2.2", 22 - "@simplewebauthn/server": "^13.2.2", 23 - "eventsource-client": "^1.2.0", 24 - "lit": "^3.3.1", 25 - "nanoid": "^5.1.6", 26 - "ua-parser-js": "^2.0.6" 27 - } 2 + "name": "thistle", 3 + "module": "src/index.ts", 4 + "type": "module", 5 + "private": true, 6 + "scripts": { 7 + "dev": "bun run src/index.ts --hot", 8 + "clean": "rm -rf transcripts uploads thistle.db", 9 + "test": "bun test", 10 + "test:integration": "bun test src/index.test.ts", 11 + "ngrok": "ngrok http 3000 --domain casual-renewing-reptile.ngrok-free.app" 12 + }, 13 + "devDependencies": { 14 + "@biomejs/biome": "^2.3.2", 15 + "@simplewebauthn/types": "^12.0.0", 16 + "@types/bun": "latest" 17 + }, 18 + "peerDependencies": { 19 + "typescript": "^5" 20 + }, 21 + "dependencies": { 22 + "@polar-sh/sdk": "^0.41.5", 23 + "@simplewebauthn/browser": "^13.2.2", 24 + "@simplewebauthn/server": "^13.2.2", 25 + "eventsource-client": "^1.2.0", 26 + "lit": "^3.3.1", 27 + "nanoid": "^5.1.6", 28 + "ua-parser-js": "^2.0.6" 29 + } 28 30 }
+209 -7
src/components/admin-users.ts
··· 10 10 transcription_count: number; 11 11 last_login: number | null; 12 12 created_at: number; 13 + subscription_status: string | null; 14 + subscription_id: string | null; 13 15 } 14 16 15 17 @customElement("admin-users") ··· 19 21 @state() isLoading = true; 20 22 @state() error: string | null = null; 21 23 @state() currentUserEmail: string | null = null; 24 + @state() revokingSubscriptions = new Set<number>(); 22 25 23 26 static override styles = css` 24 27 :host { ··· 196 199 opacity: 0.5; 197 200 cursor: not-allowed; 198 201 } 202 + 203 + .revoke-btn { 204 + background: transparent; 205 + border: 2px solid var(--accent); 206 + color: var(--accent); 207 + padding: 0.5rem 1rem; 208 + border-radius: 4px; 209 + cursor: pointer; 210 + font-size: 0.875rem; 211 + font-weight: 600; 212 + transition: all 0.2s; 213 + } 214 + 215 + .revoke-btn:hover:not(:disabled) { 216 + background: var(--accent); 217 + color: var(--white); 218 + } 219 + 220 + .revoke-btn:disabled { 221 + opacity: 0.5; 222 + cursor: not-allowed; 223 + } 224 + 225 + .subscription-badge { 226 + background: var(--primary); 227 + color: var(--white); 228 + padding: 0.25rem 0.5rem; 229 + border-radius: 4px; 230 + font-size: 0.75rem; 231 + font-weight: 600; 232 + text-transform: uppercase; 233 + } 234 + 235 + .subscription-badge.active { 236 + background: var(--primary); 237 + color: var(--white); 238 + } 239 + 240 + .subscription-badge.none { 241 + background: var(--secondary); 242 + color: var(--paynes-gray); 243 + } 199 244 `; 200 245 201 246 override async connectedCallback() { ··· 297 342 } 298 343 } 299 344 300 - private async handleDelete(userId: number, email: string) { 345 + @state() deleteState: { 346 + id: number; 347 + type: "user" | "revoke"; 348 + clicks: number; 349 + timeout: number | null; 350 + } | null = null; 351 + 352 + private handleDeleteClick(userId: number, email: string, event: Event) { 353 + event.stopPropagation(); 354 + 355 + // If this is a different item or timeout expired, reset 301 356 if ( 302 - !confirm( 303 - `Are you sure you want to delete user ${email}? This will delete all their transcriptions and cannot be undone.`, 304 - ) 357 + !this.deleteState || 358 + this.deleteState.id !== userId || 359 + this.deleteState.type !== "user" 305 360 ) { 361 + // Clear any existing timeout 362 + if (this.deleteState?.timeout) { 363 + clearTimeout(this.deleteState.timeout); 364 + } 365 + 366 + // Set first click 367 + const timeout = window.setTimeout(() => { 368 + this.deleteState = null; 369 + }, 1000); 370 + 371 + this.deleteState = { id: userId, type: "user", clicks: 1, timeout }; 306 372 return; 307 373 } 308 374 375 + // Increment clicks 376 + const newClicks = this.deleteState.clicks + 1; 377 + 378 + // Clear existing timeout 379 + if (this.deleteState.timeout) { 380 + clearTimeout(this.deleteState.timeout); 381 + } 382 + 383 + // Third click - actually delete 384 + if (newClicks === 3) { 385 + this.deleteState = null; 386 + this.performDeleteUser(userId, email); 387 + return; 388 + } 389 + 390 + // Second click - reset timeout 391 + const timeout = window.setTimeout(() => { 392 + this.deleteState = null; 393 + }, 1000); 394 + 395 + this.deleteState = { id: userId, type: "user", clicks: newClicks, timeout }; 396 + } 397 + 398 + private async performDeleteUser(userId: number, email: string) { 309 399 try { 310 400 const response = await fetch(`/api/admin/users/${userId}`, { 311 401 method: "DELETE", ··· 323 413 } 324 414 } 325 415 416 + private handleRevokeClick(userId: number, email: string, subscriptionId: string, event: Event) { 417 + event.stopPropagation(); 418 + 419 + // If this is a different item or timeout expired, reset 420 + if ( 421 + !this.deleteState || 422 + this.deleteState.id !== userId || 423 + this.deleteState.type !== "revoke" 424 + ) { 425 + // Clear any existing timeout 426 + if (this.deleteState?.timeout) { 427 + clearTimeout(this.deleteState.timeout); 428 + } 429 + 430 + // Set first click 431 + const timeout = window.setTimeout(() => { 432 + this.deleteState = null; 433 + }, 1000); 434 + 435 + this.deleteState = { id: userId, type: "revoke", clicks: 1, timeout }; 436 + return; 437 + } 438 + 439 + // Increment clicks 440 + const newClicks = this.deleteState.clicks + 1; 441 + 442 + // Clear existing timeout 443 + if (this.deleteState.timeout) { 444 + clearTimeout(this.deleteState.timeout); 445 + } 446 + 447 + // Third click - actually revoke 448 + if (newClicks === 3) { 449 + this.deleteState = null; 450 + this.performRevokeSubscription(userId, email, subscriptionId); 451 + return; 452 + } 453 + 454 + // Second click - reset timeout 455 + const timeout = window.setTimeout(() => { 456 + this.deleteState = null; 457 + }, 1000); 458 + 459 + this.deleteState = { id: userId, type: "revoke", clicks: newClicks, timeout }; 460 + } 461 + 462 + private async performRevokeSubscription(userId: number, email: string, subscriptionId: string) { 463 + this.revokingSubscriptions.add(userId); 464 + this.requestUpdate(); 465 + 466 + try { 467 + const response = await fetch(`/api/admin/users/${userId}/subscription`, { 468 + method: "DELETE", 469 + headers: { "Content-Type": "application/json" }, 470 + body: JSON.stringify({ subscriptionId }), 471 + }); 472 + 473 + if (!response.ok) { 474 + const data = await response.json(); 475 + throw new Error(data.error || "Failed to revoke subscription"); 476 + } 477 + 478 + await this.loadUsers(); 479 + alert(`Subscription revoked for ${email}`); 480 + } catch (error) { 481 + console.error("Failed to revoke subscription:", error); 482 + alert(`Failed to revoke subscription: ${error instanceof Error ? error.message : "Unknown error"}`); 483 + this.revokingSubscriptions.delete(userId); 484 + } 485 + } 486 + 487 + private getDeleteButtonText(userId: number, type: "user" | "revoke"): string { 488 + if ( 489 + !this.deleteState || 490 + this.deleteState.id !== userId || 491 + this.deleteState.type !== type 492 + ) { 493 + return type === "user" ? "Delete User" : "Revoke Subscription"; 494 + } 495 + 496 + if (this.deleteState.clicks === 1) { 497 + return "Are you sure?"; 498 + } 499 + 500 + if (this.deleteState.clicks === 2) { 501 + return "Final warning!"; 502 + } 503 + 504 + return type === "user" ? "Delete User" : "Revoke Subscription"; 505 + } 506 + 326 507 private handleCardClick(userId: number, event: Event) { 327 - // Don't open modal if clicking on delete button or role select 508 + // Don't open modal if clicking on delete button, revoke button, or role select 328 509 if ( 329 510 (event.target as HTMLElement).closest(".delete-btn") || 511 + (event.target as HTMLElement).closest(".revoke-btn") || 330 512 (event.target as HTMLElement).closest(".role-select") 331 513 ) { 332 514 return; ··· 409 591 <div class="meta-value">${u.transcription_count}</div> 410 592 </div> 411 593 <div class="meta-item"> 594 + <div class="meta-label">Subscription</div> 595 + <div class="meta-value"> 596 + ${u.subscription_status 597 + ? html`<span class="subscription-badge ${u.subscription_status.toLowerCase()}">${u.subscription_status}</span>` 598 + : html`<span class="subscription-badge none">None</span>` 599 + } 600 + </div> 601 + </div> 602 + <div class="meta-item"> 412 603 <div class="meta-label">Last Login</div> 413 604 <div class="meta-value timestamp"> 414 605 ${this.formatTimestamp(u.last_login)} ··· 431 622 <option value="user">User</option> 432 623 <option value="admin">Admin</option> 433 624 </select> 434 - <button class="delete-btn" @click=${() => this.handleDelete(u.id, u.email)}> 435 - Delete User 625 + <button 626 + class="revoke-btn" 627 + ?disabled=${!u.subscription_status || !u.subscription_id || this.revokingSubscriptions.has(u.id)} 628 + @click=${(e: Event) => { 629 + if (u.subscription_id) { 630 + this.handleRevokeClick(u.id, u.email, u.subscription_id, e); 631 + } 632 + }} 633 + > 634 + ${this.revokingSubscriptions.has(u.id) ? "Revoking..." : this.getDeleteButtonText(u.id, "revoke")} 635 + </button> 636 + <button class="delete-btn" @click=${(e: Event) => this.handleDeleteClick(u.id, u.email, e)}> 637 + ${this.getDeleteButtonText(u.id, "user")} 436 638 </button> 437 639 </div> 438 640 </div>
+271
src/components/checkout-success.ts
··· 1 + import { css, html, LitElement } from "lit"; 2 + import { customElement, state } from "lit/decorators.js"; 3 + 4 + declare global { 5 + interface Window { 6 + confetti: (options: { 7 + particleCount?: number; 8 + spread?: number; 9 + startVelocity?: number; 10 + decay?: number; 11 + scalar?: number; 12 + origin?: { x?: number; y?: number }; 13 + }) => void; 14 + } 15 + } 16 + 17 + @customElement("checkout-success") 18 + export class CheckoutSuccess extends LitElement { 19 + @state() checkoutId: string | null = null; 20 + @state() loading = true; 21 + @state() error = ""; 22 + 23 + static override styles = css` 24 + :host { 25 + display: block; 26 + max-width: 48rem; 27 + margin: 0 auto; 28 + padding: 2rem; 29 + } 30 + 31 + .success-container { 32 + text-align: center; 33 + padding: 3rem 2rem; 34 + } 35 + 36 + .success-icon { 37 + font-size: 5rem; 38 + margin-bottom: 1.5rem; 39 + animation: bounce 0.6s ease-out; 40 + } 41 + 42 + @keyframes bounce { 43 + 0%, 100% { transform: translateY(0); } 44 + 50% { transform: translateY(-20px); } 45 + } 46 + 47 + h1 { 48 + color: var(--text); 49 + margin-bottom: 1rem; 50 + font-size: 2.5rem; 51 + } 52 + 53 + .message { 54 + color: var(--text); 55 + opacity: 0.8; 56 + margin-bottom: 2rem; 57 + line-height: 1.8; 58 + font-size: 1.125rem; 59 + } 60 + 61 + .highlight { 62 + color: var(--accent); 63 + font-weight: 600; 64 + } 65 + 66 + .features { 67 + background: var(--background); 68 + border: 1px solid var(--secondary); 69 + border-radius: 12px; 70 + padding: 2rem; 71 + margin: 2rem 0; 72 + text-align: left; 73 + } 74 + 75 + .features h2 { 76 + color: var(--text); 77 + font-size: 1.25rem; 78 + margin: 0 0 1.5rem 0; 79 + text-align: center; 80 + } 81 + 82 + .feature-list { 83 + list-style: none; 84 + padding: 0; 85 + margin: 0; 86 + display: grid; 87 + gap: 1rem; 88 + } 89 + 90 + .feature-item { 91 + display: flex; 92 + align-items: center; 93 + gap: 0.75rem; 94 + color: var(--text); 95 + font-size: 1rem; 96 + } 97 + 98 + .feature-icon { 99 + font-size: 1.5rem; 100 + flex-shrink: 0; 101 + } 102 + 103 + .checkout-id { 104 + background: var(--background); 105 + border: 1px solid var(--secondary); 106 + border-radius: 8px; 107 + padding: 1rem; 108 + margin: 2rem 0; 109 + font-family: monospace; 110 + font-size: 0.875rem; 111 + color: var(--text); 112 + opacity: 0.6; 113 + word-break: break-all; 114 + } 115 + 116 + .actions { 117 + display: flex; 118 + gap: 1rem; 119 + justify-content: center; 120 + flex-wrap: wrap; 121 + margin-top: 2rem; 122 + } 123 + 124 + .btn { 125 + padding: 0.75rem 1.5rem; 126 + border-radius: 6px; 127 + font-size: 1rem; 128 + font-weight: 500; 129 + cursor: pointer; 130 + transition: all 0.2s; 131 + font-family: inherit; 132 + border: 2px solid transparent; 133 + text-decoration: none; 134 + display: inline-block; 135 + } 136 + 137 + .btn-affirmative { 138 + background: var(--primary); 139 + color: white; 140 + border-color: var(--primary); 141 + } 142 + 143 + .btn-affirmative:hover { 144 + background: var(--gunmetal); 145 + border-color: var(--gunmetal); 146 + } 147 + 148 + .btn-neutral { 149 + background: transparent; 150 + color: var(--text); 151 + border-color: var(--secondary); 152 + } 153 + 154 + .btn-neutral:hover { 155 + border-color: var(--primary); 156 + color: var(--primary); 157 + } 158 + 159 + .error { 160 + color: var(--accent); 161 + text-align: center; 162 + padding: 2rem; 163 + } 164 + 165 + .loading { 166 + text-align: center; 167 + color: var(--text); 168 + padding: 2rem; 169 + } 170 + 171 + @media (max-width: 768px) { 172 + h1 { 173 + font-size: 2rem; 174 + } 175 + 176 + .message { 177 + font-size: 1rem; 178 + } 179 + } 180 + `; 181 + 182 + override connectedCallback() { 183 + super.connectedCallback(); 184 + const params = new URLSearchParams(window.location.search); 185 + this.checkoutId = params.get("checkout_id"); 186 + this.loading = false; 187 + 188 + // Trigger confetti after a short delay 189 + setTimeout(() => this.fireConfetti(), 300); 190 + } 191 + 192 + fireConfetti() { 193 + if (!window.confetti) return; 194 + 195 + const count = 200; 196 + const defaults = { 197 + origin: { y: 0.7 }, 198 + }; 199 + 200 + const fire = (particleRatio: number, opts: object) => { 201 + window.confetti({ 202 + ...defaults, 203 + ...opts, 204 + particleCount: Math.floor(count * particleRatio), 205 + }); 206 + }; 207 + 208 + fire(0.25, { 209 + spread: 26, 210 + startVelocity: 55, 211 + }); 212 + 213 + fire(0.2, { 214 + spread: 60, 215 + }); 216 + 217 + fire(0.35, { 218 + spread: 100, 219 + decay: 0.91, 220 + scalar: 0.8, 221 + }); 222 + 223 + fire(0.1, { 224 + spread: 120, 225 + startVelocity: 25, 226 + decay: 0.92, 227 + scalar: 1.2, 228 + }); 229 + 230 + fire(0.1, { 231 + spread: 120, 232 + startVelocity: 45, 233 + }); 234 + } 235 + 236 + override render() { 237 + if (this.loading) { 238 + return html`<div class="loading">Loading...</div>`; 239 + } 240 + 241 + if (this.error) { 242 + return html`<div class="error">${this.error}</div>`; 243 + } 244 + 245 + return html` 246 + <div class="success-container"> 247 + <div class="success-icon">🎉</div> 248 + <h1>Thanks for purchasing a subscription for thistle!</h1> 249 + <p class="message"> 250 + 251 + ${ 252 + this.checkoutId 253 + ? html` 254 + <div class="checkout-id"> 255 + Checkout ID: ${this.checkoutId} 256 + </div> 257 + ` 258 + : "" 259 + } 260 + Go checkout your classes and try recording a lecture! 261 + </p> 262 + 263 + <div class="actions"> 264 + <a href="/classes" class="btn btn-affirmative"> 265 + Lets go!!! 266 + </a> 267 + </div> 268 + </div> 269 + `; 270 + } 271 + }
+56 -1
src/components/user-settings.ts
··· 27 27 last_used_at: number | null; 28 28 } 29 29 30 - type SettingsPage = "account" | "sessions" | "passkeys" | "danger"; 30 + type SettingsPage = "account" | "sessions" | "passkeys" | "billing" | "danger"; 31 31 32 32 @customElement("user-settings") 33 33 export class UserSettings extends LitElement { ··· 656 656 } 657 657 } 658 658 659 + async handleCreateCheckout() { 660 + this.loading = true; 661 + this.error = ""; 662 + 663 + try { 664 + const response = await fetch("/api/billing/checkout", { 665 + method: "POST", 666 + headers: { "Content-Type": "application/json" }, 667 + }); 668 + 669 + if (!response.ok) { 670 + const data = await response.json(); 671 + this.error = data.error || "Failed to create checkout session"; 672 + return; 673 + } 674 + 675 + const { url } = await response.json(); 676 + window.location.href = url; 677 + } catch { 678 + this.error = "Failed to create checkout session"; 679 + } finally { 680 + this.loading = false; 681 + } 682 + } 683 + 659 684 generateRandomAvatar() { 660 685 // Generate a random string for the avatar 661 686 const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; ··· 1051 1076 `; 1052 1077 } 1053 1078 1079 + renderBillingPage() { 1080 + return html` 1081 + <div class="content-inner"> 1082 + <div class="section"> 1083 + <h2 class="section-title">Billing & Subscription</h2> 1084 + <p class="field-description" style="margin-bottom: 1.5rem;"> 1085 + Manage your subscription and billing information. 1086 + </p> 1087 + <button 1088 + class="btn btn-affirmative" 1089 + @click=${this.handleCreateCheckout} 1090 + ?disabled=${this.loading} 1091 + > 1092 + ${this.loading ? "Loading..." : "Subscribe to Premium"} 1093 + </button> 1094 + ${this.error ? html`<p class="error" style="margin-top: 1rem;">${this.error}</p>` : ""} 1095 + </div> 1096 + </div> 1097 + `; 1098 + } 1099 + 1054 1100 renderDangerPage() { 1055 1101 return html` 1056 1102 <div class="content-inner"> ··· 1108 1154 Sessions 1109 1155 </button> 1110 1156 <button 1157 + class="tab ${this.currentPage === "billing" ? "active" : ""}" 1158 + @click=${() => { 1159 + this.currentPage = "billing"; 1160 + }} 1161 + > 1162 + Billing 1163 + </button> 1164 + <button 1111 1165 class="tab ${this.currentPage === "danger" ? "active" : ""}" 1112 1166 @click=${() => { 1113 1167 this.currentPage = "danger"; ··· 1119 1173 1120 1174 ${this.currentPage === "account" ? this.renderAccountPage() : ""} 1121 1175 ${this.currentPage === "sessions" ? this.renderSessionsPage() : ""} 1176 + ${this.currentPage === "billing" ? this.renderBillingPage() : ""} 1122 1177 ${this.currentPage === "danger" ? this.renderDangerPage() : ""} 1123 1178 </div> 1124 1179
+24
src/db/schema.ts
··· 180 180 ALTER TABLE class_waitlist DROP COLUMN section; 181 181 `, 182 182 }, 183 + { 184 + version: 6, 185 + name: "Add subscriptions table for Polar integration", 186 + sql: ` 187 + -- Subscriptions table 188 + CREATE TABLE IF NOT EXISTS subscriptions ( 189 + id TEXT PRIMARY KEY, 190 + user_id INTEGER NOT NULL, 191 + customer_id TEXT NOT NULL, 192 + status TEXT NOT NULL, 193 + current_period_start INTEGER, 194 + current_period_end INTEGER, 195 + cancel_at_period_end BOOLEAN DEFAULT 0, 196 + canceled_at INTEGER, 197 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 198 + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 199 + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 200 + ); 201 + 202 + CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id); 203 + CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status); 204 + CREATE INDEX IF NOT EXISTS idx_subscriptions_customer_id ON subscriptions(customer_id); 205 + `, 206 + }, 183 207 ]; 184 208 185 209 function getCurrentVersion(): number {
+181
src/index.ts
··· 66 66 WhisperServiceManager, 67 67 } from "./lib/transcription"; 68 68 import adminHTML from "./pages/admin.html"; 69 + import checkoutHTML from "./pages/checkout.html"; 69 70 import classHTML from "./pages/class.html"; 70 71 import classesHTML from "./pages/classes.html"; 71 72 import indexHTML from "./pages/index.html"; ··· 129 130 routes: { 130 131 "/": indexHTML, 131 132 "/admin": adminHTML, 133 + "/checkout": checkoutHTML, 132 134 "/settings": settingsHTML, 133 135 "/transcribe": transcribeHTML, 134 136 "/classes": classesHTML, ··· 631 633 } 632 634 }, 633 635 }, 636 + "/api/billing/checkout": { 637 + POST: async (req) => { 638 + const sessionId = getSessionFromRequest(req); 639 + if (!sessionId) { 640 + return Response.json({ error: "Not authenticated" }, { status: 401 }); 641 + } 642 + const user = getUserBySession(sessionId); 643 + if (!user) { 644 + return Response.json({ error: "Invalid session" }, { status: 401 }); 645 + } 646 + 647 + try { 648 + const { polar } = await import("./lib/polar"); 649 + 650 + const productId = process.env.POLAR_PRODUCT_ID; 651 + if (!productId) { 652 + return Response.json( 653 + { error: "Product not configured" }, 654 + { status: 500 }, 655 + ); 656 + } 657 + 658 + const successUrl = process.env.POLAR_SUCCESS_URL; 659 + if (!successUrl) { 660 + return Response.json( 661 + { error: "Success URL not configured" }, 662 + { status: 500 }, 663 + ); 664 + } 665 + 666 + const checkout = await polar.checkouts.create({ 667 + products: [productId], 668 + successUrl, 669 + customerEmail: user.email, 670 + customerName: user.name ?? undefined, 671 + metadata: { 672 + userId: user.id.toString(), 673 + }, 674 + }); 675 + 676 + return Response.json({ url: checkout.url }); 677 + } catch (error) { 678 + console.error("Failed to create checkout:", error); 679 + return Response.json( 680 + { error: "Failed to create checkout session" }, 681 + { status: 500 }, 682 + ); 683 + } 684 + }, 685 + }, 686 + "/api/webhooks/polar": { 687 + POST: async (req) => { 688 + try { 689 + const { validateEvent } = await import("@polar-sh/sdk/webhooks"); 690 + 691 + // Get raw body as string 692 + const rawBody = await req.text(); 693 + const headers = Object.fromEntries(req.headers.entries()); 694 + 695 + // Validate webhook signature 696 + const webhookSecret = process.env.POLAR_WEBHOOK_SECRET; 697 + if (!webhookSecret) { 698 + console.error("[Webhook] POLAR_WEBHOOK_SECRET not configured"); 699 + return Response.json({ error: "Webhook secret not configured" }, { status: 500 }); 700 + } 701 + 702 + const event = validateEvent(rawBody, headers, webhookSecret); 703 + 704 + console.log(`[Webhook] Received event: ${event.type}`); 705 + 706 + // Handle different event types 707 + switch (event.type) { 708 + case "subscription.updated": { 709 + const { id, status, customerId, metadata } = event.data; 710 + const userId = metadata?.userId 711 + ? Number.parseInt(metadata.userId as string, 10) 712 + : null; 713 + 714 + if (!userId) { 715 + console.warn("[Webhook] No userId in subscription metadata"); 716 + break; 717 + } 718 + 719 + // Upsert subscription 720 + db.run( 721 + `INSERT INTO subscriptions (id, user_id, customer_id, status, current_period_start, current_period_end, cancel_at_period_end, canceled_at, updated_at) 722 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now')) 723 + ON CONFLICT(id) DO UPDATE SET 724 + status = excluded.status, 725 + current_period_start = excluded.current_period_start, 726 + current_period_end = excluded.current_period_end, 727 + cancel_at_period_end = excluded.cancel_at_period_end, 728 + canceled_at = excluded.canceled_at, 729 + updated_at = strftime('%s', 'now')`, 730 + [ 731 + id, 732 + userId, 733 + customerId, 734 + status, 735 + event.data.currentPeriodStart 736 + ? Math.floor(new Date(event.data.currentPeriodStart).getTime() / 1000) 737 + : null, 738 + event.data.currentPeriodEnd 739 + ? Math.floor(new Date(event.data.currentPeriodEnd).getTime() / 1000) 740 + : null, 741 + event.data.cancelAtPeriodEnd ? 1 : 0, 742 + event.data.canceledAt 743 + ? Math.floor(new Date(event.data.canceledAt).getTime() / 1000) 744 + : null, 745 + ], 746 + ); 747 + 748 + console.log(`[Webhook] Updated subscription ${id} for user ${userId}`); 749 + break; 750 + } 751 + 752 + default: 753 + console.log(`[Webhook] Unhandled event type: ${event.type}`); 754 + } 755 + 756 + return Response.json({ received: true }); 757 + } catch (error) { 758 + console.error("[Webhook] Error processing webhook:", error); 759 + return Response.json( 760 + { error: "Webhook processing failed" }, 761 + { status: 400 }, 762 + ); 763 + } 764 + }, 765 + }, 634 766 "/api/transcriptions/:id/stream": { 635 767 GET: async (req) => { 636 768 const sessionId = getSessionFromRequest(req); ··· 1193 1325 1194 1326 updateUserRole(userId, role); 1195 1327 return Response.json({ success: true }); 1328 + } catch (error) { 1329 + return handleError(error); 1330 + } 1331 + }, 1332 + }, 1333 + "/api/admin/users/:id/subscription": { 1334 + DELETE: async (req) => { 1335 + try { 1336 + requireAdmin(req); 1337 + const userId = Number.parseInt(req.params.id, 10); 1338 + if (Number.isNaN(userId)) { 1339 + return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1340 + } 1341 + 1342 + const body = await req.json(); 1343 + const { subscriptionId } = body as { subscriptionId: string }; 1344 + 1345 + if (!subscriptionId) { 1346 + return Response.json( 1347 + { error: "Subscription ID required" }, 1348 + { status: 400 }, 1349 + ); 1350 + } 1351 + 1352 + try { 1353 + const { polar } = await import("./lib/polar"); 1354 + await polar.subscriptions.revoke({ id: subscriptionId }); 1355 + console.log( 1356 + `[Admin] Revoked subscription ${subscriptionId} for user ${userId}`, 1357 + ); 1358 + return Response.json({ 1359 + success: true, 1360 + message: "Subscription revoked successfully", 1361 + }); 1362 + } catch (error) { 1363 + console.error( 1364 + `[Admin] Failed to revoke subscription ${subscriptionId}:`, 1365 + error, 1366 + ); 1367 + return Response.json( 1368 + { 1369 + error: 1370 + error instanceof Error 1371 + ? error.message 1372 + : "Failed to revoke subscription", 1373 + }, 1374 + { status: 500 }, 1375 + ); 1376 + } 1196 1377 } catch (error) { 1197 1378 return handleError(error); 1198 1379 }
+6 -1
src/lib/auth.ts
··· 366 366 role: UserRole; 367 367 last_login: number | null; 368 368 transcription_count: number; 369 + subscription_status: string | null; 370 + subscription_id: string | null; 369 371 } 370 372 371 373 export function getAllUsersWithStats(): UserWithStats[] { ··· 379 381 u.created_at, 380 382 u.role, 381 383 u.last_login, 382 - COUNT(t.id) as transcription_count 384 + COUNT(DISTINCT t.id) as transcription_count, 385 + s.status as subscription_status, 386 + s.id as subscription_id 383 387 FROM users u 384 388 LEFT JOIN transcriptions t ON u.id = t.user_id 389 + LEFT JOIN subscriptions s ON u.id = s.user_id AND s.status IN ('active', 'trialing', 'past_due') 385 390 GROUP BY u.id 386 391 ORDER BY u.created_at DESC`, 387 392 )
+8
src/lib/polar.ts
··· 1 + import { Polar } from "@polar-sh/sdk"; 2 + 3 + const isDevelopment = process.env.NODE_ENV !== "production"; 4 + 5 + export const polar = new Polar({ 6 + accessToken: process.env.POLAR_ACCESS_TOKEN ?? "", 7 + server: isDevelopment ? "sandbox" : "production", 8 + });
+35
src/pages/checkout.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + 4 + <head> 5 + <meta charset="UTF-8"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <title>Success! - Thistle</title> 8 + <link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png"> 9 + <link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png"> 10 + <link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png"> 11 + <link rel="manifest" href="../../public/favicon/site.webmanifest"> 12 + <link rel="stylesheet" href="../styles/main.css"> 13 + <script src="https://cdn.jsdelivr.net/npm/@tsparticles/confetti@3.0.3/tsparticles.confetti.bundle.min.js"></script> 14 + </head> 15 + 16 + <body> 17 + <header> 18 + <div class="header-content"> 19 + <a href="/" class="site-title"> 20 + <img src="../../public/favicon/favicon-32x32.png" alt="Thistle logo"> 21 + <span>Thistle</span> 22 + </a> 23 + <auth-component></auth-component> 24 + </div> 25 + </header> 26 + 27 + <main> 28 + <checkout-success></checkout-success> 29 + </main> 30 + 31 + <script type="module" src="../components/auth.ts"></script> 32 + <script type="module" src="../components/checkout-success.ts"></script> 33 + </body> 34 + 35 + </html>
+1
src/styles/main.css
··· 6 6 --gunmetal: #2d3142ff; 7 7 --paynes-gray: #4f5d75ff; 8 8 --silver: #bfc0c0ff; 9 + --white: #ffffffff; 9 10 --off-white: #fcf6f1; 10 11 --coral: #ef8354ff; 11 12 --success-green: #16a34a;