feat: add sequential progression and video watch lesson completion

Gate student content by completion order, track per-video watch progress
with seek enforcement, and auto-complete lessons at the configured
threshold. Show ring progress in the lesson header, keep watch progress
on rewatch, and sync progression locks client-side so the next item
unlocks after completion.
This commit is contained in:
Rotimi Best
2026-06-11 14:48:34 +01:00
parent c8154409f5
commit 52d9bf18c3
94 changed files with 3499 additions and 564 deletions
@@ -2,23 +2,25 @@ import { Polar } from '@polar-sh/sdk';
import { TOKEN_PACK } from '@cio/utils/plans';
import { dev } from '$app/environment';
import { env } from '$env/dynamic/private';
import type { RequestEvent } from './$types';
export const GET = async ({ url, locals }: RequestEvent) => {
const { user, profile, organizations } = locals;
if (!user || !profile) {
return new Response('Unauthorized', { status: 401 });
}
const orgId = url.searchParams.get('orgId');
const org = organizations?.find((candidate) => candidate.id === orgId);
if (!org?.siteName) {
return new Response('You are not a member of this organization', { status: 403 });
}
export const GET = async ({ url }: { url: URL }) => {
const quantityRaw = Number(url.searchParams.get('quantity') ?? '1');
const quantity = Math.min(100, Math.max(1, Number.isFinite(quantityRaw) ? Math.trunc(quantityRaw) : 1));
let metadata: { orgId?: string; orgSlug?: string; triggeredBy?: string };
try {
metadata = JSON.parse(url.searchParams.get('metadata') ?? '{}');
} catch {
return new Response('Invalid metadata JSON', { status: 400 });
}
if (!metadata.orgId || !metadata.orgSlug) {
return new Response('Missing orgId / orgSlug in metadata', { status: 400 });
}
const productId = env.POLAR_TOKEN_PACK_PRODUCT_ID;
if (!productId) {
@@ -35,6 +37,7 @@ export const GET = async ({ url }: { url: URL }) => {
});
const totalPriceCents = TOKEN_PACK.PRICE_USD_CENTS * quantity;
const customerEmail = (profile.email ?? user.email)?.replace('@test.com', '[email protected]');
const checkout = await polar.checkouts.create({
products: [productId],
@@ -47,14 +50,14 @@ export const GET = async ({ url }: { url: URL }) => {
}
]
},
customerEmail: url.searchParams.get('customerEmail') ?? undefined,
customerName: url.searchParams.get('customerName') ?? undefined,
successUrl: `${url.origin}/org/${metadata.orgSlug}/settings/ai-credits?tokens=success`,
customerEmail,
customerName: profile.fullname || undefined,
successUrl: `${url.origin}/org/${org.siteName}/settings/ai-credits?tokens=success`,
metadata: {
kind: 'token_pack',
orgId: metadata.orgId,
orgSlug: metadata.orgSlug,
triggeredBy: metadata.triggeredBy ?? '',
orgId: org.id,
orgSlug: org.siteName,
triggeredBy: profile.id,
tokensPerUnit: String(TOKEN_PACK.TOKENS_PER_UNIT),
quantity: String(quantity)
}
@@ -1,9 +1,28 @@
import { CustomerPortal } from '@polar-sh/sveltekit';
import { dev } from '$app/environment';
import { env } from '$env/dynamic/private';
import type { RequestEvent } from './$types';
export const GET = CustomerPortal({
accessToken: env.POLAR_ACCESS_TOKEN!,
getCustomerId: async (e) => e.url.searchParams.get('customerId')!,
server: dev ? 'sandbox' : 'production'
});
export const GET = async (event: RequestEvent) => {
const { user, organizations } = event.locals;
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
const orgId = event.url.searchParams.get('orgId');
const org = organizations?.find((candidate) => candidate.id === orgId);
const customerId = org?.plan?.customerId;
if (!customerId) {
return new Response('No billing customer found for this organization', { status: 404 });
}
const handler = CustomerPortal({
accessToken: env.POLAR_ACCESS_TOKEN!,
getCustomerId: async () => customerId,
server: dev ? 'sandbox' : 'production'
});
return handler(event);
};
@@ -1,16 +1,58 @@
import { Polar } from '@polar-sh/sdk';
import { PLANS } from '@cio/utils/plans';
import { dev } from '$app/environment';
import { env } from '$env/dynamic/private';
import { Checkout } from '@polar-sh/sveltekit';
import type { RequestEvent } from './$types';
export const GET = ({ ...params }) => {
const metadata = JSON.parse(params.url.searchParams.get('metadata') ?? '{}');
const successUrl = `/org/${metadata.orgSlug}?upgrade=true&confirmation=true`;
const SUBSCRIPTION_PRODUCT_IDS = new Set(
Object.values(PLANS)
.flatMap((plan) => {
const cta = plan.CTA as { PRODUCT_ID?: string; PRODUCT_ID_YEARLY?: string };
return [cta.PRODUCT_ID, cta.PRODUCT_ID_YEARLY];
})
.filter((id): id is string => Boolean(id))
);
const request = Checkout({
export const GET = async ({ url, locals }: RequestEvent) => {
const { user, profile, organizations } = locals;
if (!user || !profile) {
return new Response('Unauthorized', { status: 401 });
}
const orgId = url.searchParams.get('orgId');
const org = organizations?.find((candidate) => candidate.id === orgId);
if (!org?.siteName || !org.member) {
return new Response('You are not a member of this organization', { status: 403 });
}
const productId = url.searchParams.get('products');
if (!productId || !SUBSCRIPTION_PRODUCT_IDS.has(productId)) {
return new Response('Unknown product', { status: 400 });
}
if (!env.POLAR_ACCESS_TOKEN) {
return new Response('POLAR_ACCESS_TOKEN is not configured', { status: 500 });
}
const polar = new Polar({
accessToken: env.POLAR_ACCESS_TOKEN,
successUrl,
server: dev ? 'sandbox' : 'production'
});
return request(params);
const checkout = await polar.checkouts.create({
products: [productId],
customerEmail: profile.email ?? user.email,
customerName: profile.fullname || undefined,
successUrl: `${url.origin}/org/${org.siteName}?upgrade=true&confirmation=true`,
metadata: {
orgId: org.id,
orgSlug: org.siteName,
triggeredBy: String(org.member.id)
}
});
return Response.redirect(checkout.url, 303);
};