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:
@@ -7,6 +7,7 @@ export const load = async ({ params, cookies }) => {
|
||||
const exerciseId = params.exerciseId || '';
|
||||
|
||||
if (!courseId || !exerciseId) {
|
||||
console.log('courseId', courseId, 'exerciseId', exerciseId);
|
||||
return { courseId, exerciseId, exercise: null, submissions: [], mySubmissions: [] };
|
||||
}
|
||||
|
||||
@@ -31,6 +32,7 @@ export const load = async ({ params, cookies }) => {
|
||||
const mySubmissions = mySubmissionData;
|
||||
|
||||
const submissions = Array.isArray(overview.allSubmissions) ? overview.allSubmissions : [];
|
||||
console.log('submissions', submissions.length, 'mySubmissions', mySubmissions.length);
|
||||
|
||||
return {
|
||||
courseId,
|
||||
|
||||
@@ -2,19 +2,38 @@
|
||||
import { get } from 'svelte/store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import LockIcon from '@lucide/svelte/icons/lock';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { ExercisePage } from '$features/course/pages';
|
||||
import { questionnaireMetaData, reset } from '$features/course/components/exercise/store';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { isOrgStudent } from '$lib/utils/store/app';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { hydrateExercisePageData } from '$features/course/utils/exercise-page-utils';
|
||||
import { getStudentContentLockReason } from '$features/ai-assistant/utils/content-ask-ai-bar';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
|
||||
let { data = $bindable() } = $props();
|
||||
|
||||
const path = `/courses/${data.courseId}/lessons`;
|
||||
const isLockedForStudent = $derived($isOrgStudent && data.exercise?.isUnlocked === false);
|
||||
const isCourseReady = $derived(courseApi.course?.id === data.courseId);
|
||||
|
||||
const contentLockReason = $derived(
|
||||
getStudentContentLockReason(courseApi.course, data.exerciseId, ContentType.Exercise)
|
||||
);
|
||||
|
||||
const isLockedForStudent = $derived.by(() => {
|
||||
if (!$isOrgStudent) return false;
|
||||
|
||||
if (data.exercise?.isUnlocked === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!data.exercise || contentLockReason !== null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!data.exercise || isLockedForStudent) return;
|
||||
@@ -32,14 +51,10 @@
|
||||
</script>
|
||||
|
||||
<Page.Root class="mx-auto flex w-full px-3 sm:w-[90%] sm:px-4 lg:max-w-5xl">
|
||||
{#if isLockedForStudent}
|
||||
<Empty
|
||||
title={$t('course.navItem.lessons.content_locked_title')}
|
||||
description={$t('course.navItem.lessons.content_locked_description')}
|
||||
icon={LockIcon}
|
||||
variant="page"
|
||||
class="text-center"
|
||||
/>
|
||||
{#if $isOrgStudent && !isCourseReady}
|
||||
<div class="flex justify-center py-16">
|
||||
<Spinner />
|
||||
</div>
|
||||
{:else}
|
||||
<ExercisePage
|
||||
exerciseId={data.exerciseId}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user