Compare commits

...

6 Commits

Author SHA1 Message Date
Admin
23ae1ed500 feat(payments): lock checkout email via Polar server-side checkout sessions
Some checks failed
CI / UI (pull_request) Failing after 23s
CI / Backend (push) Successful in 27s
CI / Backend (pull_request) Successful in 53s
CI / UI (push) Failing after 25s
Release / Test backend (push) Successful in 28s
Release / Check ui (push) Failing after 30s
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 42s
Release / Docker / backend (push) Successful in 1m50s
Release / Docker / runner (push) Successful in 3m47s
Release / Gitea Release (push) Has been skipped
Replace static Polar checkout links with a server-side POST /api/checkout
route that creates a checkout session with customer_external_id = user ID
and customer_email locked (not editable). Adds loading/error states and
a post-checkout success banner on the profile page.
2026-03-31 23:36:53 +05:00
Admin
e7cb460f9b fix(payments): point manage subscription to org customer portal
Some checks failed
CI / Backend (pull_request) Successful in 32s
CI / UI (pull_request) Failing after 28s
CI / Backend (push) Successful in 26s
CI / UI (push) Failing after 25s
Release / Check ui (push) Failing after 16s
Release / Docker / ui (push) Has been skipped
Release / Test backend (push) Successful in 47s
Release / Docker / caddy (push) Successful in 50s
Release / Docker / runner (push) Successful in 2m0s
Release / Docker / backend (push) Successful in 2m59s
Release / Gitea Release (push) Has been skipped
2026-03-31 23:26:57 +05:00
Admin
392248e8a6 fix(payments): update Polar checkout links to use checkout link IDs
Some checks failed
CI / Backend (pull_request) Successful in 25s
CI / UI (pull_request) Failing after 16s
CI / UI (push) Failing after 17s
CI / Backend (push) Successful in 41s
Release / Test backend (push) Successful in 40s
Release / Docker / caddy (push) Successful in 54s
Release / Docker / backend (push) Successful in 1m55s
Release / Docker / runner (push) Successful in 2m52s
Release / Docker / ui (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
Release / Check ui (push) Has been cancelled
2026-03-31 23:25:26 +05:00
Admin
68ea2d2808 feat(payments): fix Polar webhook + pre-fill checkout email
Some checks failed
CI / Backend (pull_request) Successful in 26s
CI / UI (pull_request) Failing after 24s
CI / Backend (push) Successful in 26s
Release / Check ui (push) Failing after 16s
Release / Docker / ui (push) Has been skipped
CI / UI (push) Failing after 31s
Release / Test backend (push) Successful in 41s
Release / Docker / caddy (push) Successful in 33s
Release / Docker / runner (push) Successful in 2m58s
Release / Docker / backend (push) Successful in 3m43s
Release / Gitea Release (push) Has been skipped
- Fix customer email path: was data.customer_email, is actually
  data.customer.email per Polar v1 API schema
- Add resolveUser() helper: tries polar_customer_id → email → external_id
- Add subscription.active and subscription.canceled event handling
- Handle order.created for fast-path pro upgrade on purchase
- Profile page: fetch user email + polarCustomerId from PocketBase
- Profile page: pre-fill ?customer_email= on checkout links
- Profile page: link to polar.sh/purchases for existing customers
2026-03-31 23:11:34 +05:00
Admin
7b1df9b592 fix(infra): fix libretranslate healthcheck; fix scrollbar-none css
All checks were successful
CI / Backend (push) Successful in 25s
Release / Test backend (push) Successful in 24s
CI / UI (push) Successful in 51s
Release / Check ui (push) Successful in 29s
CI / UI (pull_request) Successful in 27s
CI / Backend (pull_request) Successful in 44s
Release / Docker / caddy (push) Successful in 54s
Release / Docker / ui (push) Successful in 2m4s
Release / Docker / runner (push) Successful in 2m56s
Release / Docker / backend (push) Successful in 3m23s
Release / Gitea Release (push) Successful in 12s
2026-03-31 22:36:19 +05:00
Admin
f4089fe111 fix(admin): add layout guard and redirect /admin to /admin/scrape
All checks were successful
CI / Backend (push) Successful in 45s
CI / UI (push) Successful in 56s
Release / Check ui (push) Successful in 26s
Release / Test backend (push) Successful in 39s
CI / Backend (pull_request) Successful in 24s
Release / Docker / caddy (push) Successful in 48s
CI / UI (pull_request) Successful in 40s
Release / Docker / runner (push) Successful in 2m52s
Release / Docker / backend (push) Successful in 3m27s
Release / Docker / ui (push) Successful in 3m38s
Release / Gitea Release (push) Successful in 14s
- Add +layout.server.ts to enforce admin role check at layout level,
  preventing 404 on /admin and protecting all sub-routes centrally
- Add +page.server.ts to redirect /admin → /admin/scrape (was 404)
2026-03-31 22:33:39 +05:00
9 changed files with 334 additions and 40 deletions

View File

@@ -29,7 +29,7 @@ services:
- libretranslate_models:/home/libretranslate/.local/share/argos-translate
- libretranslate_db:/app/db
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:5000/languages || exit 1"]
test: ["CMD", "python3", "-c", "import urllib.request,sys; urllib.request.urlopen('http://localhost:5000/languages'); sys.exit(0)"]
interval: 30s
timeout: 10s
retries: 5

View File

@@ -147,6 +147,15 @@ html {
margin: 2em 0;
}
/* ── Hide scrollbars (used on horizontal carousels) ────────────────── */
.scrollbar-none {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE / Edge legacy */
}
.scrollbar-none::-webkit-scrollbar {
display: none; /* Chrome / Safari / WebKit */
}
/* ── Navigation progress bar ───────────────────────────────────────── */
@keyframes progress-bar {
0% { width: 0%; opacity: 1; }

View File

@@ -9,12 +9,16 @@
* Product IDs (Polar dashboard):
* Monthly : 1376fdf5-b6a9-492b-be70-7c905131c0f9
* Annual : b6190307-79aa-4905-80c8-9ed941378d21
*
* Webhook event data shapes (Polar v1 API):
* subscription.* → data.customer_id, data.product_id, data.status, data.customer.email
* order.created → data.customer_id, data.product_id, data.customer.email, data.billing_reason
*/
import { createHmac, timingSafeEqual } from 'node:crypto';
import { env } from '$env/dynamic/private';
import { log } from '$lib/server/logger';
import { getUserById, getUserByPolarCustomerId, patchUser } from '$lib/server/pocketbase';
import { getUserByPolarCustomerId, patchUser } from '$lib/server/pocketbase';
export const POLAR_PRO_PRODUCT_IDS = new Set([
'1376fdf5-b6a9-492b-be70-7c905131c0f9', // monthly
@@ -55,41 +59,69 @@ export function verifyPolarWebhook(rawBody: string, signatureHeader: string): bo
// ─── Subscription event handler ───────────────────────────────────────────────
interface PolarCustomer {
email?: string;
external_id?: string; // our app_users.id if set on the customer
}
interface PolarSubscription {
id: string;
status: string; // "active" | "canceled" | "past_due" | "unpaid" | "incomplete" | ...
status: string; // "active" | "canceled" | "past_due" | "unpaid" | ...
product_id: string;
customer_id: string;
customer_email?: string;
user_id?: string; // Polar user id (not our user id)
customer?: PolarCustomer; // nested object — email lives here
}
/**
* Resolve the app_user for a Polar customer.
* Priority: polar_customer_id → email → customer.external_id (our user ID)
*/
async function resolveUser(customer_id: string, customer?: PolarCustomer) {
const { getUserByEmail, getUserById } = await import('$lib/server/pocketbase');
// 1. By stored polar_customer_id (fastest on repeat events)
const byCustomerId = await getUserByPolarCustomerId(customer_id).catch(() => null);
if (byCustomerId) return byCustomerId;
// 2. By email (most common first-time path)
const email = customer?.email;
if (email) {
const byEmail = await getUserByEmail(email).catch(() => null);
if (byEmail) return byEmail;
}
// 3. By external_id = our user ID (if set via Polar API on customer creation)
const externalId = customer?.external_id;
if (externalId) {
const byId = await getUserById(externalId).catch(() => null);
if (byId) return byId;
}
return null;
}
/**
* Handle a Polar subscription event.
* Finds the matching app_user by email and updates role + polar fields.
* Finds the matching app_user and updates role + polar fields.
*/
export async function handleSubscriptionEvent(
eventType: string,
subscription: PolarSubscription
): Promise<void> {
const { id: subId, status, product_id, customer_id, customer_email } = subscription;
const { id: subId, status, product_id, customer_id, customer } = subscription;
log.info('polar', 'subscription event', { eventType, subId, status, product_id, customer_email });
log.info('polar', 'subscription event', {
eventType, subId, status, product_id,
customer_email: customer?.email
});
if (!customer_email) {
log.warn('polar', 'subscription event missing customer_email — cannot match user', { subId });
return;
}
// Find user by their polar_customer_id first (faster on repeat events), then by email
let user = await getUserByPolarCustomerId(customer_id).catch(() => null);
if (!user) {
const { getUserByEmail } = await import('$lib/server/pocketbase');
user = await getUserByEmail(customer_email).catch(() => null);
}
const user = await resolveUser(customer_id, customer);
if (!user) {
log.warn('polar', 'no app_user found for polar customer', { customer_email, customer_id });
log.warn('polar', 'no app_user found for polar customer', {
customer_email: customer?.email,
customer_id
});
return;
}
@@ -103,5 +135,60 @@ export async function handleSubscriptionEvent(
polar_subscription_id: isActive ? subId : ''
});
log.info('polar', 'user role updated', { userId: user.id, username: user.username, newRole, status });
log.info('polar', 'user role updated', {
userId: user.id, username: user.username, newRole, status
});
}
// ─── Order event handler ──────────────────────────────────────────────────────
interface PolarOrder {
id: string;
status: string;
billing_reason: string; // "purchase" | "subscription_create" | "subscription_cycle" | "subscription_update"
product_id: string | null;
customer_id: string;
subscription_id: string | null;
customer?: PolarCustomer;
}
/**
* Handle order.created — used for initial subscription purchases.
* We only act on subscription_create billing_reason to avoid double-processing
* (subscription.active will also fire, but this ensures we catch edge cases).
*/
export async function handleOrderCreated(order: PolarOrder): Promise<void> {
const { id: orderId, billing_reason, product_id, customer_id, customer } = order;
log.info('polar', 'order.created', { orderId, billing_reason, product_id, customer_email: customer?.email });
// Only handle new subscription purchases here; renewals are handled by subscription.updated
if (billing_reason !== 'purchase' && billing_reason !== 'subscription_create') {
log.debug('polar', 'order.created — skipping non-purchase billing_reason', { billing_reason });
return;
}
if (!product_id || !POLAR_PRO_PRODUCT_IDS.has(product_id)) {
log.debug('polar', 'order.created — product not a pro product', { product_id });
return;
}
const user = await resolveUser(customer_id, customer);
if (!user) {
log.warn('polar', 'order.created — no app_user found', {
customer_email: customer?.email, customer_id
});
return;
}
// Only upgrade if not already pro/admin — subscription.active will do a full sync too
if (user.role !== 'pro' && user.role !== 'admin') {
await patchUser(user.id, {
role: 'pro',
polar_customer_id: customer_id
});
log.info('polar', 'order.created — user upgraded to pro', {
userId: user.id, username: user.username
});
}
}

View File

@@ -0,0 +1,8 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
if (locals.user?.role !== 'admin') {
redirect(302, '/');
}
};

View File

@@ -0,0 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
redirect(302, '/admin/scrape');
};

View File

@@ -0,0 +1,106 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/private';
import { log } from '$lib/server/logger';
import { getUserByUsername } from '$lib/server/pocketbase';
const POLAR_API_BASE = 'https://api.polar.sh';
const PRICE_IDS: Record<string, string> = {
monthly: '9c0eea36-4f4a-4fd6-970b-d176588d4771',
annual: '5a5be04e-f252-4a30-8f8b-858b40ec33e4'
};
/**
* POST /api/checkout
* Body: { product: 'monthly' | 'annual' }
*
* Creates a Polar server-side checkout session with:
* - external_customer_id = locals.user.id (so webhooks can match back to us)
* - customer_email locked to the logged-in user's email (email field disabled in UI)
* - allow_discount_codes: true
* - success_url redirects to /profile?subscribed=1
*
* Returns: { url: string }
*/
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) error(401, 'Not authenticated');
const apiToken = env.POLAR_API_TOKEN;
if (!apiToken) {
log.error('checkout', 'POLAR_API_TOKEN not set');
error(500, 'Checkout unavailable');
}
let product: string;
try {
const body = await request.json() as { product?: unknown };
product = String(body?.product ?? '');
} catch {
error(400, 'Invalid request body');
}
const priceId = PRICE_IDS[product];
if (!priceId) {
error(400, `Unknown product: ${product}. Use 'monthly' or 'annual'.`);
}
// Fetch the user's email from PocketBase (not in the auth token)
let email: string | null = null;
try {
const record = await getUserByUsername(locals.user.username);
email = record?.email ?? null;
} catch (e) {
log.warn('checkout', 'failed to fetch user email (non-fatal)', { err: String(e) });
}
// Create a server-side checkout session on Polar
// https://docs.polar.sh/api-reference/checkouts/create
const payload = {
product_price_id: priceId,
allow_discount_codes: true,
success_url: 'https://libnovel.cc/profile?subscribed=1',
customer_external_id: locals.user.id,
...(email ? { customer_email: email } : {})
};
log.info('checkout', 'creating polar checkout session', {
userId: locals.user.id,
product,
email: email ?? '(none)'
});
const res = await fetch(`${POLAR_API_BASE}/v1/checkouts/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiToken}`
},
body: JSON.stringify(payload)
});
if (!res.ok) {
const text = await res.text().catch(() => '');
log.error('checkout', 'polar checkout creation failed', {
status: res.status,
body: text.slice(0, 500)
});
error(502, 'Failed to create checkout session');
}
const data = await res.json() as { url?: string; id?: string };
const checkoutUrl = data?.url;
if (!checkoutUrl) {
log.error('checkout', 'polar response missing url', { data: JSON.stringify(data).slice(0, 200) });
error(502, 'Invalid checkout response from Polar');
}
log.info('checkout', 'checkout session created', {
userId: locals.user.id,
checkoutId: data?.id,
product
});
return json({ url: checkoutUrl });
};

View File

@@ -1,12 +1,20 @@
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { verifyPolarWebhook, handleSubscriptionEvent } from '$lib/server/polar';
import { verifyPolarWebhook, handleSubscriptionEvent, handleOrderCreated } from '$lib/server/polar';
/**
* POST /api/webhooks/polar
*
* Receives Polar subscription lifecycle events and syncs user roles in PocketBase.
* Signature is verified via HMAC-SHA256 before any processing.
*
* Handled events:
* subscription.created — new subscription (status may be "active" or "trialing")
* subscription.active — subscription became active (e.g. after payment)
* subscription.updated — catch-all: cancellations, renewals, plan changes
* subscription.canceled — cancel_at_period_end=true, still active until period end
* subscription.revoked — access ended, downgrade to free
* order.created — purchase / subscription_create: fast-path upgrade
*/
export const POST: RequestHandler = async ({ request }) => {
const rawBody = await request.text();
@@ -30,14 +38,15 @@ export const POST: RequestHandler = async ({ request }) => {
try {
switch (type) {
case 'subscription.created':
case 'subscription.active':
case 'subscription.updated':
case 'subscription.canceled':
case 'subscription.revoked':
await handleSubscriptionEvent(type, data as unknown as Parameters<typeof handleSubscriptionEvent>[1]);
await handleSubscriptionEvent(type, data as Parameters<typeof handleSubscriptionEvent>[1]);
break;
case 'order.created':
// One-time purchases — no role change needed for now
log.info('polar', 'order.created (no action)', { orderId: data.id });
await handleOrderCreated(data as Parameters<typeof handleOrderCreated>[0]);
break;
default:

View File

@@ -10,24 +10,31 @@ export const load: PageServerLoad = async ({ locals }) => {
}
let sessions: Awaited<ReturnType<typeof listUserSessions>> = [];
try {
sessions = await listUserSessions(locals.user.id);
} catch (e) {
log.warn('profile', 'listUserSessions failed (non-fatal)', { err: String(e) });
}
let email: string | null = null;
let polarCustomerId: string | null = null;
// Fetch avatar — MinIO first, fall back to OAuth provider picture
let avatarUrl: string | null = null;
try {
const record = await getUserByUsername(locals.user.username);
avatarUrl = await resolveAvatarUrl(locals.user.id, record?.avatar_url);
email = record?.email ?? null;
polarCustomerId = record?.polar_customer_id ?? null;
} catch (e) {
log.warn('profile', 'avatar fetch failed (non-fatal)', { err: String(e) });
}
try {
sessions = await listUserSessions(locals.user.id);
} catch (e) {
log.warn('profile', 'listUserSessions failed (non-fatal)', { err: String(e) });
}
return {
user: locals.user,
avatarUrl,
email,
polarCustomerId,
sessions: sessions.map((s) => ({
id: s.id,
user_agent: s.user_agent,

View File

@@ -4,12 +4,46 @@
import type { PageData, ActionData } from './$types';
import { audioStore } from '$lib/audio.svelte';
import { browser } from '$app/environment';
import { page } from '$app/state';
import type { Voice } from '$lib/types';
import * as m from '$lib/paraglide/messages.js';
let { data, form }: { data: PageData; form: ActionData } = $props();
// ── Polar checkout ───────────────────────────────────────────────────────────
// Customer portal: always link to the org portal
const manageUrl = `https://polar.sh/libnovel/portal`;
let checkoutLoading = $state<'monthly' | 'annual' | null>(null);
let checkoutError = $state('');
async function startCheckout(product: 'monthly' | 'annual') {
checkoutLoading = product;
checkoutError = '';
try {
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ product })
});
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { message?: string };
checkoutError = body.message ?? `Checkout failed (${res.status}). Please try again.`;
return;
}
const { url } = await res.json() as { url: string };
window.location.href = url;
} catch {
checkoutError = 'Network error. Please try again.';
} finally {
checkoutLoading = null;
}
}
// ── Avatar ───────────────────────────────────────────────────────────────────
// Show a welcome banner when Polar redirects back with ?subscribed=1
const justSubscribed = $derived(browser && page.url.searchParams.get('subscribed') === '1');
let avatarUrl = $state<string | null>(untrack(() => data.avatarUrl ?? null));
let avatarUploading = $state(false);
let avatarError = $state('');
@@ -218,6 +252,17 @@
<div class="max-w-2xl mx-auto space-y-6 pb-12">
<!-- ── Post-checkout success banner ──────────────────────────────────────── -->
{#if justSubscribed}
<div class="rounded-xl bg-(--color-brand)/10 border border-(--color-brand)/40 px-5 py-4 flex items-start gap-3">
<svg class="w-5 h-5 text-(--color-brand) shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
<div>
<p class="text-sm font-semibold text-(--color-brand)">Welcome to Pro!</p>
<p class="text-sm text-(--color-muted) mt-0.5">Your subscription is being activated. Refresh the page in a moment if the Pro badge doesn't appear yet.</p>
</div>
</div>
{/if}
<!-- ── Profile header ──────────────────────────────────────────────────────── -->
<div class="flex items-center gap-5 pt-2">
<div class="relative shrink-0">
@@ -287,17 +332,34 @@
<div class="mt-5 pt-5 border-t border-(--color-border)">
<p class="text-sm font-medium text-(--color-text) mb-1">{m.profile_upgrade_heading()}</p>
<p class="text-sm text-(--color-muted) mb-4">{m.profile_upgrade_desc()}</p>
{#if checkoutError}
<p class="text-sm text-(--color-danger) mb-3">{checkoutError}</p>
{/if}
<div class="flex flex-wrap gap-3">
<a href="https://buy.polar.sh/libnovel/1376fdf5-b6a9-492b-be70-7c905131c0f9" target="_blank" rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors">
<svg class="w-4 h-4 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
<button
type="button"
onclick={() => startCheckout('monthly')}
disabled={checkoutLoading !== null}
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors disabled:opacity-60 disabled:cursor-wait">
{#if checkoutLoading === 'monthly'}
<svg class="w-4 h-4 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/></svg>
{:else}
<svg class="w-4 h-4 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
{/if}
{m.profile_upgrade_monthly()}
</a>
<a href="https://buy.polar.sh/libnovel/b6190307-79aa-4905-80c8-9ed941378d21" target="_blank" rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-(--color-brand) text-(--color-brand) font-semibold text-sm hover:bg-(--color-brand)/10 transition-colors">
{m.profile_upgrade_annual()}
<span class="text-xs font-bold px-1.5 py-0.5 rounded bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30">33%</span>
</a>
</button>
<button
type="button"
onclick={() => startCheckout('annual')}
disabled={checkoutLoading !== null}
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-(--color-brand) text-(--color-brand) font-semibold text-sm hover:bg-(--color-brand)/10 transition-colors disabled:opacity-60 disabled:cursor-wait">
{#if checkoutLoading === 'annual'}
<svg class="w-4 h-4 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/></svg>
{:else}
{m.profile_upgrade_annual()}
<span class="text-xs font-bold px-1.5 py-0.5 rounded bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30">33%</span>
{/if}
</button>
</div>
</div>
</section>
@@ -307,7 +369,7 @@
<p class="text-sm font-medium text-(--color-text)">{m.profile_pro_active()}</p>
<p class="text-sm text-(--color-muted) mt-0.5">{m.profile_pro_perks()}</p>
</div>
<a href="https://polar.sh/libnovel" target="_blank" rel="noopener noreferrer"
<a href={manageUrl} target="_blank" rel="noopener noreferrer"
class="shrink-0 inline-flex items-center gap-1.5 text-sm font-medium text-(--color-brand) hover:underline">
{m.profile_manage_subscription()}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>