feat(billing): Polar.sh Pro subscription integration
Some checks failed
CI / UI (push) Successful in 1m36s
CI / Backend (push) Successful in 59s
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 33s
CI / Backend (pull_request) Successful in 44s
CI / UI (pull_request) Successful in 34s
Release / Docker / runner (push) Successful in 2m44s
Release / Docker / ui (push) Successful in 2m45s
Release / Docker / backend (push) Successful in 3m35s
Release / Docker / caddy (push) Successful in 1m8s
Release / Gitea Release (push) Failing after 2s
Some checks failed
CI / UI (push) Successful in 1m36s
CI / Backend (push) Successful in 59s
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 33s
CI / Backend (pull_request) Successful in 44s
CI / UI (pull_request) Successful in 34s
Release / Docker / runner (push) Successful in 2m44s
Release / Docker / ui (push) Successful in 2m45s
Release / Docker / backend (push) Successful in 3m35s
Release / Docker / caddy (push) Successful in 1m8s
Release / Gitea Release (push) Failing after 2s
- Webhook handler verifies HMAC-SHA256 sig and updates user role on
subscription.created / subscription.updated / subscription.revoked
- Audio endpoint gated: free users limited to 3 chapters/day via Valkey
counter; returns 402 {error:'pro_required'} when limit reached
- Translation proxy endpoint enforces 402 for non-pro users
- AudioPlayer.svelte surfaces 402 via onProRequired callback + upgrade banner
- Chapter page shows lock icon + upgrade prompts for gated translation langs
- Profile page: subscription section shows Pro badge + manage link (active)
or monthly/annual checkout buttons (free); isPro resolved fresh from DB
- i18n: 13 new profile_subscription_* keys across all 5 locales
This commit is contained in:
69
ui/src/routes/api/translation/[slug]/[n]/+server.ts
Normal file
69
ui/src/routes/api/translation/[slug]/[n]/+server.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
const SUPPORTED_LANGS = new Set(['ru', 'id', 'pt', 'fr']);
|
||||
|
||||
/**
|
||||
* POST /api/translation/[slug]/[n]?lang=<lang>
|
||||
* Proxy to backend translation enqueue endpoint.
|
||||
* Enforces Pro gate — free users cannot enqueue translations.
|
||||
*
|
||||
* GET /api/translation/[slug]/[n]?lang=<lang>
|
||||
* Proxy to backend translation fetch (no gate — already gated at page.server.ts).
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
const { slug, n } = params;
|
||||
const lang = url.searchParams.get('lang') ?? '';
|
||||
const res = await backendFetch(
|
||||
`/api/translation/${encodeURIComponent(slug)}/${n}?lang=${lang}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
return new Response(null, { status: res.status });
|
||||
}
|
||||
const data = await res.json();
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ params, url, locals }) => {
|
||||
const { slug, n } = params;
|
||||
const chapter = parseInt(n, 10);
|
||||
const lang = url.searchParams.get('lang') ?? '';
|
||||
|
||||
if (!slug || !chapter || chapter < 1) error(400, 'Invalid slug or chapter');
|
||||
if (!SUPPORTED_LANGS.has(lang)) error(400, 'Unsupported language');
|
||||
|
||||
// ── Pro gate ──────────────────────────────────────────────────────────────
|
||||
if (!locals.isPro) {
|
||||
log.info('polar', 'translation blocked for free user', {
|
||||
userId: locals.user?.id,
|
||||
slug,
|
||||
chapter,
|
||||
lang
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'pro_required' }),
|
||||
{ status: 402, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const res = await backendFetch(
|
||||
`/api/translation/${encodeURIComponent(slug)}/${chapter}?lang=${lang}`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
log.error('translation', 'backend translation enqueue failed', { slug, chapter, lang, status: res.status, body: text });
|
||||
error(res.status as Parameters<typeof error>[0], text || 'Translation enqueue failed');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
};
|
||||
23
ui/src/routes/api/translation/status/[slug]/[n]/+server.ts
Normal file
23
ui/src/routes/api/translation/status/[slug]/[n]/+server.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
/**
|
||||
* GET /api/translation/status/[slug]/[n]?lang=<lang>
|
||||
* Proxies the translation status check to the backend.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
const { slug, n } = params;
|
||||
const lang = url.searchParams.get('lang') ?? '';
|
||||
const res = await backendFetch(
|
||||
`/api/translation/status/${encodeURIComponent(slug)}/${n}?lang=${lang}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
return new Response(JSON.stringify({ status: 'idle' }), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
const data = await res.json();
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user