feat: /subscribe pricing page + Pro nav link + fix checkout token scope
All checks were successful
Release / Test backend (push) Successful in 40s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 42s
Release / Docker / backend (push) Successful in 2m35s
Release / Docker / runner (push) Successful in 2m38s
Release / Docker / ui (push) Successful in 2m12s
Release / Gitea Release (push) Successful in 41s

- Add /subscribe route with hero, benefits list, and pricing cards
  (annual featured with Save 33% badge, monthly secondary)
- Add 'Pro' link in nav for non-Pro users
- Add 'See plans' link in profile subscription section
- i18n keys across en/fr/id/pt/ru for all subscribe strings

Note: checkout still requires POLAR_API_TOKEN with checkouts:write scope.
Regenerate the token at polar.sh to fix the 502 error on subscribe buttons.
This commit is contained in:
Admin
2026-04-04 20:12:24 +05:00
parent 18f490f790
commit c4a0256f6e
31 changed files with 1180 additions and 2 deletions

View File

@@ -407,6 +407,15 @@
>
{m.nav_catalogue()}
</a>
{#if !data.isPro}
<a
href="/subscribe"
class="hidden sm:inline-flex items-center gap-1 text-sm font-semibold transition-colors {page.url.pathname.startsWith('/subscribe') ? 'text-(--color-brand)' : 'text-(--color-brand) opacity-70 hover:opacity-100'}"
>
<svg class="w-3.5 h-3.5 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>
Pro
</a>
{/if}
<div class="ml-auto flex items-center gap-2">
<!-- Theme dropdown (desktop) -->
<div class="hidden sm:block relative">

View File

@@ -375,7 +375,7 @@
</div>
<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>
<p class="text-sm text-(--color-muted) mb-4">{m.profile_upgrade_desc()} <a href="/subscribe" class="text-(--color-brand) hover:underline">See plans →</a></p>
{#if checkoutError}
<p class="text-sm text-(--color-danger) mb-3">{checkoutError}</p>
{/if}

View File

@@ -0,0 +1,5 @@
// Data is inherited from the root layout (user, isPro).
// This file exists only to ensure the route is treated as a page by SvelteKit.
export const load = async () => {
return {};
};

View File

@@ -0,0 +1,162 @@
<script lang="ts">
import type { LayoutData } from '../$types';
import * as m from '$lib/paraglide/messages.js';
// Data flows from root layout (user, isPro)
let { data }: { data: LayoutData } = $props();
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;
}
}
const benefits = [
{ icon: 'M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3', label: () => m.subscribe_benefit_audio() },
{ icon: 'M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z', label: () => m.subscribe_benefit_voices() },
{ icon: 'M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129', label: () => m.subscribe_benefit_translation() },
{ icon: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4', label: () => m.subscribe_benefit_downloads() },
];
</script>
<svelte:head>
<title>{m.subscribe_page_title()}</title>
</svelte:head>
<div class="min-h-[calc(100vh-3.5rem)] flex flex-col items-center justify-center px-4 py-16">
<!-- Hero -->
<div class="text-center mb-12 max-w-xl">
<div class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-(--color-brand)/15 border border-(--color-brand)/30 text-(--color-brand) text-xs font-semibold uppercase tracking-wider mb-5">
<svg class="w-3.5 h-3.5 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>
Pro
</div>
<h1 class="text-4xl sm:text-5xl font-bold text-(--color-text) tracking-tight mb-4">
{m.subscribe_heading()}
</h1>
<p class="text-lg text-(--color-muted)">
{m.subscribe_subheading()}
</p>
</div>
<!-- Benefits list -->
<ul class="mb-12 space-y-3 w-full max-w-sm">
{#each benefits as b}
<li class="flex items-center gap-3 text-sm text-(--color-text)">
<span class="shrink-0 w-8 h-8 rounded-full bg-(--color-brand)/15 text-(--color-brand) flex items-center justify-center">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d={b.icon}/>
</svg>
</span>
{b.label()}
</li>
{/each}
</ul>
{#if data.isPro}
<!-- Already Pro -->
<div class="w-full max-w-sm bg-(--color-surface-2) border border-(--color-brand)/40 rounded-xl p-6 text-center">
<p class="text-base font-semibold text-(--color-text) mb-1">{m.subscribe_already_pro()}</p>
<a
href="https://polar.sh/libnovel/portal"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 mt-3 text-sm font-medium text-(--color-brand) hover:underline"
>
{m.subscribe_manage()}
<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>
</a>
</div>
{:else if !data.user}
<!-- Not logged in -->
<div class="w-full max-w-sm bg-(--color-surface-2) border border-(--color-border) rounded-xl p-6 text-center">
<p class="text-sm text-(--color-muted) mb-4">{m.subscribe_login_prompt()}</p>
<a
href="/login?next=/subscribe"
class="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors"
>
{m.subscribe_login_cta()}
</a>
</div>
{:else}
<!-- Pricing cards -->
{#if checkoutError}
<p class="text-sm text-(--color-danger) mb-4 text-center">{checkoutError}</p>
{/if}
<div class="w-full max-w-sm grid gap-4">
<!-- Annual card (featured) -->
<div class="relative bg-(--color-surface-2) border-2 border-(--color-brand) rounded-xl p-6">
<div class="absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-0.5 bg-(--color-brand) text-(--color-surface) text-xs font-bold rounded-full tracking-wide uppercase">
{m.subscribe_annual_save()}
</div>
<div class="flex items-baseline justify-between mb-5">
<span class="text-base font-semibold text-(--color-text)">{m.subscribe_annual_label()}</span>
<div class="text-right">
<span class="text-3xl font-bold text-(--color-text)">{m.subscribe_annual_price()}</span>
<span class="text-sm text-(--color-muted) ml-1">{m.subscribe_annual_period()}</span>
</div>
</div>
<button
type="button"
onclick={() => startCheckout('annual')}
disabled={checkoutLoading !== null}
class="w-full flex items-center justify-center gap-2 py-2.5 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 === '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>
{/if}
{m.subscribe_cta_annual()}
</button>
</div>
<!-- Monthly card -->
<div class="bg-(--color-surface-2) border border-(--color-border) rounded-xl p-6">
<div class="flex items-baseline justify-between mb-5">
<span class="text-base font-semibold text-(--color-text)">{m.subscribe_monthly_label()}</span>
<div class="text-right">
<span class="text-3xl font-bold text-(--color-text)">{m.subscribe_monthly_price()}</span>
<span class="text-sm text-(--color-muted) ml-1">{m.subscribe_monthly_period()}</span>
</div>
</div>
<button
type="button"
onclick={() => startCheckout('monthly')}
disabled={checkoutLoading !== null}
class="w-full flex items-center justify-center gap-2 py-2.5 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 === '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>
{/if}
{m.subscribe_cta_monthly()}
</button>
</div>
</div>
{/if}
<p class="mt-8 text-xs text-(--color-muted) text-center max-w-xs">
Payments processed securely by <a href="https://polar.sh" target="_blank" rel="noopener noreferrer" class="underline hover:text-(--color-text)">Polar</a>. Cancel anytime.
</p>
</div>