🪻 distributed transcription service thistle.dunkirk.sh

feat: nice billing page

dunkirk.sh 86bf3894 7bf0148a

verified
Changed files
+299 -4
src
+206 -4
src/components/user-settings.ts
···
last_used_at: number | null;
}
+
interface Subscription {
+
id: string;
+
status: string;
+
current_period_start: number | null;
+
current_period_end: number | null;
+
cancel_at_period_end: number;
+
canceled_at: number | null;
+
}
+
type SettingsPage = "account" | "sessions" | "passkeys" | "billing" | "danger";
@customElement("user-settings")
···
@state() user: User | null = null;
@state() sessions: Session[] = [];
@state() passkeys: Passkey[] = [];
+
@state() subscription: Subscription | null = null;
@state() loading = true;
@state() loadingSessions = true;
@state() loadingPasskeys = true;
+
@state() loadingSubscription = true;
@state() error = "";
@state() showDeleteConfirm = false;
@state() currentPage: SettingsPage = "account";
···
color: white;
}
+
.btn-affirmative {
+
background: var(--primary);
+
color: white;
+
border-color: var(--primary);
+
}
+
+
.btn-affirmative:hover:not(:disabled) {
+
background: transparent;
+
color: var(--primary);
+
}
+
+
.btn-success {
+
background: var(--success);
+
color: white;
+
border-color: var(--success);
+
}
+
+
.btn-success:hover:not(:disabled) {
+
background: transparent;
+
color: var(--success);
+
}
+
.btn-small {
padding: 0.5rem 1rem;
font-size: 0.875rem;
···
this.passkeySupported = isPasskeySupported();
await this.loadUser();
await this.loadSessions();
+
await this.loadSubscription();
if (this.passkeySupported) {
await this.loadPasskeys();
}
···
}
}
+
async loadSubscription() {
+
try {
+
const response = await fetch("/api/billing/subscription");
+
+
if (response.ok) {
+
const data = await response.json();
+
this.subscription = data.subscription;
+
}
+
} finally {
+
this.loadingSubscription = false;
+
}
+
}
+
async handleAddPasskey() {
this.addingPasskey = true;
this.error = "";
···
}
const { url } = await response.json();
-
window.location.href = url;
+
window.open(url, "_blank");
} catch {
this.error = "Failed to create checkout session";
} finally {
···
}
}
+
async handleOpenPortal() {
+
this.loading = true;
+
this.error = "";
+
+
try {
+
const response = await fetch("/api/billing/portal", {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
});
+
+
if (!response.ok) {
+
const data = await response.json();
+
this.error = data.error || "Failed to open customer portal";
+
return;
+
}
+
+
const { url } = await response.json();
+
window.open(url, "_blank");
+
} catch {
+
this.error = "Failed to open customer portal";
+
} finally {
+
this.loading = false;
+
}
+
}
+
generateRandomAvatar() {
// Generate a random string for the avatar
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
···
renderBillingPage() {
+
if (this.loadingSubscription) {
+
return html`
+
<div class="content-inner">
+
<div class="section">
+
<div class="loading">Loading subscription...</div>
+
</div>
+
</div>
+
`;
+
}
+
+
const hasActiveSubscription = this.subscription && (
+
this.subscription.status === "active" ||
+
this.subscription.status === "trialing"
+
);
+
+
if (this.subscription && !hasActiveSubscription) {
+
// Has a subscription but it's not active (canceled, expired, etc.)
+
const statusColor =
+
this.subscription.status === "canceled" ? "var(--accent)" :
+
"var(--secondary)";
+
+
return html`
+
<div class="content-inner">
+
<div class="section">
+
<h2 class="section-title">Subscription</h2>
+
+
<div class="field-group">
+
<label class="field-label">Status</label>
+
<div style="display: flex; align-items: center; gap: 0.75rem;">
+
<span style="
+
display: inline-block;
+
padding: 0.25rem 0.75rem;
+
border-radius: 4px;
+
background: ${statusColor};
+
color: var(--white);
+
font-size: 0.875rem;
+
font-weight: 600;
+
text-transform: uppercase;
+
">
+
${this.subscription.status}
+
</span>
+
</div>
+
</div>
+
+
${this.subscription.canceled_at ? html`
+
<div class="field-group">
+
<label class="field-label">Canceled At</label>
+
<div class="field-value" style="color: var(--accent);">
+
${this.formatDate(this.subscription.canceled_at)}
+
</div>
+
</div>
+
` : ""}
+
+
<div class="field-group" style="margin-top: 2rem;">
+
<button
+
class="btn btn-success"
+
@click=${this.handleCreateCheckout}
+
?disabled=${this.loading}
+
>
+
${this.loading ? "Loading..." : "Activate Your Subscription"}
+
</button>
+
<p class="field-description" style="margin-top: 0.75rem;">
+
Reactivate your subscription to unlock unlimited transcriptions.
+
</p>
+
</div>
+
+
${this.error ? html`<p class="error" style="margin-top: 1rem;">${this.error}</p>` : ""}
+
</div>
+
</div>
+
`;
+
}
+
+
if (hasActiveSubscription) {
+
return html`
+
<div class="content-inner">
+
<div class="section">
+
<h2 class="section-title">Subscription</h2>
+
+
<div class="field-group">
+
<label class="field-label">Status</label>
+
<div style="display: flex; align-items: center; gap: 0.75rem;">
+
<span style="
+
display: inline-block;
+
padding: 0.25rem 0.75rem;
+
border-radius: 4px;
+
background: var(--success);
+
color: var(--white);
+
font-size: 0.875rem;
+
font-weight: 600;
+
text-transform: uppercase;
+
">
+
${this.subscription.status}
+
</span>
+
${this.subscription.cancel_at_period_end ? html`
+
<span style="color: var(--accent); font-size: 0.875rem;">
+
(Cancels at end of period)
+
</span>
+
` : ""}
+
</div>
+
</div>
+
+
${this.subscription.current_period_start && this.subscription.current_period_end ? html`
+
<div class="field-group">
+
<label class="field-label">Current Period</label>
+
<div class="field-value">
+
${this.formatDate(this.subscription.current_period_start)} -
+
${this.formatDate(this.subscription.current_period_end)}
+
</div>
+
</div>
+
` : ""}
+
+
<div class="field-group" style="margin-top: 2rem;">
+
<button
+
class="btn btn-affirmative"
+
@click=${this.handleOpenPortal}
+
?disabled=${this.loading}
+
>
+
${this.loading ? "Loading..." : "Manage Subscription"}
+
</button>
+
<p class="field-description" style="margin-top: 0.75rem;">
+
Opens the customer portal where you can update payment methods, view invoices, and manage your subscription.
+
</p>
+
</div>
+
+
${this.error ? html`<p class="error" style="margin-top: 1rem;">${this.error}</p>` : ""}
+
</div>
+
</div>
+
`;
+
}
+
return html`
<div class="content-inner">
<div class="section">
<h2 class="section-title">Billing & Subscription</h2>
<p class="field-description" style="margin-bottom: 1.5rem;">
-
Manage your subscription and billing information.
+
Activate your subscription to unlock unlimited transcriptions. Note: We currently offer a single subscription tier.
</p>
<button
-
class="btn btn-affirmative"
+
class="btn btn-success"
@click=${this.handleCreateCheckout}
?disabled=${this.loading}
-
${this.loading ? "Loading..." : "Subscribe to Premium"}
+
${this.loading ? "Loading..." : "Activate Your Subscription"}
</button>
${this.error ? html`<p class="error" style="margin-top: 1rem;">${this.error}</p>` : ""}
</div>
+81
src/index.ts
···
}
},
},
+
"/api/billing/subscription": {
+
GET: async (req) => {
+
const sessionId = getSessionFromRequest(req);
+
if (!sessionId) {
+
return Response.json({ error: "Not authenticated" }, { status: 401 });
+
}
+
const user = getUserBySession(sessionId);
+
if (!user) {
+
return Response.json({ error: "Invalid session" }, { status: 401 });
+
}
+
+
try {
+
// Get subscription from database
+
const subscription = db.query<{
+
id: string;
+
status: string;
+
current_period_start: number | null;
+
current_period_end: number | null;
+
cancel_at_period_end: number;
+
canceled_at: number | null;
+
}>(
+
"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",
+
).get(user.id);
+
+
if (!subscription) {
+
return Response.json({ subscription: null });
+
}
+
+
return Response.json({ subscription });
+
} catch (error) {
+
console.error("Failed to fetch subscription:", error);
+
return Response.json(
+
{ error: "Failed to fetch subscription" },
+
{ status: 500 },
+
);
+
}
+
},
+
},
+
"/api/billing/portal": {
+
POST: async (req) => {
+
const sessionId = getSessionFromRequest(req);
+
if (!sessionId) {
+
return Response.json({ error: "Not authenticated" }, { status: 401 });
+
}
+
const user = getUserBySession(sessionId);
+
if (!user) {
+
return Response.json({ error: "Invalid session" }, { status: 401 });
+
}
+
+
try {
+
const { polar } = await import("./lib/polar");
+
+
// Get subscription to find customer ID
+
const subscription = db.query<{
+
customer_id: string;
+
}>(
+
"SELECT customer_id FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1",
+
).get(user.id);
+
+
if (!subscription || !subscription.customer_id) {
+
return Response.json(
+
{ error: "No subscription found" },
+
{ status: 404 },
+
);
+
}
+
+
// Create customer portal session
+
const session = await polar.customerSessions.create({
+
customerId: subscription.customer_id,
+
});
+
+
return Response.json({ url: session.customerPortalUrl });
+
} catch (error) {
+
console.error("Failed to create portal session:", error);
+
return Response.json(
+
{ error: "Failed to create portal session" },
+
{ status: 500 },
+
);
+
}
+
},
+
},
"/api/webhooks/polar": {
POST: async (req) => {
try {
+12
src/styles/buttons.css
···
color: var(--primary);
}
+
/* Success/positive actions (subscribe, activate) */
+
.btn-success {
+
background: var(--success);
+
color: white;
+
border-color: var(--success);
+
}
+
+
.btn-success:hover:not(:disabled) {
+
background: transparent;
+
color: var(--success);
+
}
+
/* Neutral actions (cancel, close) */
.btn-neutral {
background: transparent;