🪻 distributed transcription service thistle.dunkirk.sh
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: nice billing page

+299 -4
+206 -4
src/components/user-settings.ts
··· 27 27 last_used_at: number | null; 28 28 } 29 29 30 + interface Subscription { 31 + id: string; 32 + status: string; 33 + current_period_start: number | null; 34 + current_period_end: number | null; 35 + cancel_at_period_end: number; 36 + canceled_at: number | null; 37 + } 38 + 30 39 type SettingsPage = "account" | "sessions" | "passkeys" | "billing" | "danger"; 31 40 32 41 @customElement("user-settings") ··· 34 43 @state() user: User | null = null; 35 44 @state() sessions: Session[] = []; 36 45 @state() passkeys: Passkey[] = []; 46 + @state() subscription: Subscription | null = null; 37 47 @state() loading = true; 38 48 @state() loadingSessions = true; 39 49 @state() loadingPasskeys = true; 50 + @state() loadingSubscription = true; 40 51 @state() error = ""; 41 52 @state() showDeleteConfirm = false; 42 53 @state() currentPage: SettingsPage = "account"; ··· 191 202 color: white; 192 203 } 193 204 205 + .btn-affirmative { 206 + background: var(--primary); 207 + color: white; 208 + border-color: var(--primary); 209 + } 210 + 211 + .btn-affirmative:hover:not(:disabled) { 212 + background: transparent; 213 + color: var(--primary); 214 + } 215 + 216 + .btn-success { 217 + background: var(--success); 218 + color: white; 219 + border-color: var(--success); 220 + } 221 + 222 + .btn-success:hover:not(:disabled) { 223 + background: transparent; 224 + color: var(--success); 225 + } 226 + 194 227 .btn-small { 195 228 padding: 0.5rem 1rem; 196 229 font-size: 0.875rem; ··· 415 448 this.passkeySupported = isPasskeySupported(); 416 449 await this.loadUser(); 417 450 await this.loadSessions(); 451 + await this.loadSubscription(); 418 452 if (this.passkeySupported) { 419 453 await this.loadPasskeys(); 420 454 } ··· 461 495 } 462 496 } 463 497 498 + async loadSubscription() { 499 + try { 500 + const response = await fetch("/api/billing/subscription"); 501 + 502 + if (response.ok) { 503 + const data = await response.json(); 504 + this.subscription = data.subscription; 505 + } 506 + } finally { 507 + this.loadingSubscription = false; 508 + } 509 + } 510 + 464 511 async handleAddPasskey() { 465 512 this.addingPasskey = true; 466 513 this.error = ""; ··· 673 720 } 674 721 675 722 const { url } = await response.json(); 676 - window.location.href = url; 723 + window.open(url, "_blank"); 677 724 } catch { 678 725 this.error = "Failed to create checkout session"; 679 726 } finally { ··· 681 728 } 682 729 } 683 730 731 + async handleOpenPortal() { 732 + this.loading = true; 733 + this.error = ""; 734 + 735 + try { 736 + const response = await fetch("/api/billing/portal", { 737 + method: "POST", 738 + headers: { "Content-Type": "application/json" }, 739 + }); 740 + 741 + if (!response.ok) { 742 + const data = await response.json(); 743 + this.error = data.error || "Failed to open customer portal"; 744 + return; 745 + } 746 + 747 + const { url } = await response.json(); 748 + window.open(url, "_blank"); 749 + } catch { 750 + this.error = "Failed to open customer portal"; 751 + } finally { 752 + this.loading = false; 753 + } 754 + } 755 + 684 756 generateRandomAvatar() { 685 757 // Generate a random string for the avatar 686 758 const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; ··· 1077 1149 } 1078 1150 1079 1151 renderBillingPage() { 1152 + if (this.loadingSubscription) { 1153 + return html` 1154 + <div class="content-inner"> 1155 + <div class="section"> 1156 + <div class="loading">Loading subscription...</div> 1157 + </div> 1158 + </div> 1159 + `; 1160 + } 1161 + 1162 + const hasActiveSubscription = this.subscription && ( 1163 + this.subscription.status === "active" || 1164 + this.subscription.status === "trialing" 1165 + ); 1166 + 1167 + if (this.subscription && !hasActiveSubscription) { 1168 + // Has a subscription but it's not active (canceled, expired, etc.) 1169 + const statusColor = 1170 + this.subscription.status === "canceled" ? "var(--accent)" : 1171 + "var(--secondary)"; 1172 + 1173 + return html` 1174 + <div class="content-inner"> 1175 + <div class="section"> 1176 + <h2 class="section-title">Subscription</h2> 1177 + 1178 + <div class="field-group"> 1179 + <label class="field-label">Status</label> 1180 + <div style="display: flex; align-items: center; gap: 0.75rem;"> 1181 + <span style=" 1182 + display: inline-block; 1183 + padding: 0.25rem 0.75rem; 1184 + border-radius: 4px; 1185 + background: ${statusColor}; 1186 + color: var(--white); 1187 + font-size: 0.875rem; 1188 + font-weight: 600; 1189 + text-transform: uppercase; 1190 + "> 1191 + ${this.subscription.status} 1192 + </span> 1193 + </div> 1194 + </div> 1195 + 1196 + ${this.subscription.canceled_at ? html` 1197 + <div class="field-group"> 1198 + <label class="field-label">Canceled At</label> 1199 + <div class="field-value" style="color: var(--accent);"> 1200 + ${this.formatDate(this.subscription.canceled_at)} 1201 + </div> 1202 + </div> 1203 + ` : ""} 1204 + 1205 + <div class="field-group" style="margin-top: 2rem;"> 1206 + <button 1207 + class="btn btn-success" 1208 + @click=${this.handleCreateCheckout} 1209 + ?disabled=${this.loading} 1210 + > 1211 + ${this.loading ? "Loading..." : "Activate Your Subscription"} 1212 + </button> 1213 + <p class="field-description" style="margin-top: 0.75rem;"> 1214 + Reactivate your subscription to unlock unlimited transcriptions. 1215 + </p> 1216 + </div> 1217 + 1218 + ${this.error ? html`<p class="error" style="margin-top: 1rem;">${this.error}</p>` : ""} 1219 + </div> 1220 + </div> 1221 + `; 1222 + } 1223 + 1224 + if (hasActiveSubscription) { 1225 + return html` 1226 + <div class="content-inner"> 1227 + <div class="section"> 1228 + <h2 class="section-title">Subscription</h2> 1229 + 1230 + <div class="field-group"> 1231 + <label class="field-label">Status</label> 1232 + <div style="display: flex; align-items: center; gap: 0.75rem;"> 1233 + <span style=" 1234 + display: inline-block; 1235 + padding: 0.25rem 0.75rem; 1236 + border-radius: 4px; 1237 + background: var(--success); 1238 + color: var(--white); 1239 + font-size: 0.875rem; 1240 + font-weight: 600; 1241 + text-transform: uppercase; 1242 + "> 1243 + ${this.subscription.status} 1244 + </span> 1245 + ${this.subscription.cancel_at_period_end ? html` 1246 + <span style="color: var(--accent); font-size: 0.875rem;"> 1247 + (Cancels at end of period) 1248 + </span> 1249 + ` : ""} 1250 + </div> 1251 + </div> 1252 + 1253 + ${this.subscription.current_period_start && this.subscription.current_period_end ? html` 1254 + <div class="field-group"> 1255 + <label class="field-label">Current Period</label> 1256 + <div class="field-value"> 1257 + ${this.formatDate(this.subscription.current_period_start)} - 1258 + ${this.formatDate(this.subscription.current_period_end)} 1259 + </div> 1260 + </div> 1261 + ` : ""} 1262 + 1263 + <div class="field-group" style="margin-top: 2rem;"> 1264 + <button 1265 + class="btn btn-affirmative" 1266 + @click=${this.handleOpenPortal} 1267 + ?disabled=${this.loading} 1268 + > 1269 + ${this.loading ? "Loading..." : "Manage Subscription"} 1270 + </button> 1271 + <p class="field-description" style="margin-top: 0.75rem;"> 1272 + Opens the customer portal where you can update payment methods, view invoices, and manage your subscription. 1273 + </p> 1274 + </div> 1275 + 1276 + ${this.error ? html`<p class="error" style="margin-top: 1rem;">${this.error}</p>` : ""} 1277 + </div> 1278 + </div> 1279 + `; 1280 + } 1281 + 1080 1282 return html` 1081 1283 <div class="content-inner"> 1082 1284 <div class="section"> 1083 1285 <h2 class="section-title">Billing & Subscription</h2> 1084 1286 <p class="field-description" style="margin-bottom: 1.5rem;"> 1085 - Manage your subscription and billing information. 1287 + Activate your subscription to unlock unlimited transcriptions. Note: We currently offer a single subscription tier. 1086 1288 </p> 1087 1289 <button 1088 - class="btn btn-affirmative" 1290 + class="btn btn-success" 1089 1291 @click=${this.handleCreateCheckout} 1090 1292 ?disabled=${this.loading} 1091 1293 > 1092 - ${this.loading ? "Loading..." : "Subscribe to Premium"} 1294 + ${this.loading ? "Loading..." : "Activate Your Subscription"} 1093 1295 </button> 1094 1296 ${this.error ? html`<p class="error" style="margin-top: 1rem;">${this.error}</p>` : ""} 1095 1297 </div>
+81
src/index.ts
··· 683 683 } 684 684 }, 685 685 }, 686 + "/api/billing/subscription": { 687 + GET: async (req) => { 688 + const sessionId = getSessionFromRequest(req); 689 + if (!sessionId) { 690 + return Response.json({ error: "Not authenticated" }, { status: 401 }); 691 + } 692 + const user = getUserBySession(sessionId); 693 + if (!user) { 694 + return Response.json({ error: "Invalid session" }, { status: 401 }); 695 + } 696 + 697 + try { 698 + // Get subscription from database 699 + const subscription = db.query<{ 700 + id: string; 701 + status: string; 702 + current_period_start: number | null; 703 + current_period_end: number | null; 704 + cancel_at_period_end: number; 705 + canceled_at: number | null; 706 + }>( 707 + "SELECT id, status, current_period_start, current_period_end, cancel_at_period_end, canceled_at FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1", 708 + ).get(user.id); 709 + 710 + if (!subscription) { 711 + return Response.json({ subscription: null }); 712 + } 713 + 714 + return Response.json({ subscription }); 715 + } catch (error) { 716 + console.error("Failed to fetch subscription:", error); 717 + return Response.json( 718 + { error: "Failed to fetch subscription" }, 719 + { status: 500 }, 720 + ); 721 + } 722 + }, 723 + }, 724 + "/api/billing/portal": { 725 + POST: async (req) => { 726 + const sessionId = getSessionFromRequest(req); 727 + if (!sessionId) { 728 + return Response.json({ error: "Not authenticated" }, { status: 401 }); 729 + } 730 + const user = getUserBySession(sessionId); 731 + if (!user) { 732 + return Response.json({ error: "Invalid session" }, { status: 401 }); 733 + } 734 + 735 + try { 736 + const { polar } = await import("./lib/polar"); 737 + 738 + // Get subscription to find customer ID 739 + const subscription = db.query<{ 740 + customer_id: string; 741 + }>( 742 + "SELECT customer_id FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1", 743 + ).get(user.id); 744 + 745 + if (!subscription || !subscription.customer_id) { 746 + return Response.json( 747 + { error: "No subscription found" }, 748 + { status: 404 }, 749 + ); 750 + } 751 + 752 + // Create customer portal session 753 + const session = await polar.customerSessions.create({ 754 + customerId: subscription.customer_id, 755 + }); 756 + 757 + return Response.json({ url: session.customerPortalUrl }); 758 + } catch (error) { 759 + console.error("Failed to create portal session:", error); 760 + return Response.json( 761 + { error: "Failed to create portal session" }, 762 + { status: 500 }, 763 + ); 764 + } 765 + }, 766 + }, 686 767 "/api/webhooks/polar": { 687 768 POST: async (req) => { 688 769 try {
+12
src/styles/buttons.css
··· 28 28 color: var(--primary); 29 29 } 30 30 31 + /* Success/positive actions (subscribe, activate) */ 32 + .btn-success { 33 + background: var(--success); 34 + color: white; 35 + border-color: var(--success); 36 + } 37 + 38 + .btn-success:hover:not(:disabled) { 39 + background: transparent; 40 + color: var(--success); 41 + } 42 + 31 43 /* Neutral actions (cancel, close) */ 32 44 .btn-neutral { 33 45 background: transparent;