Skip to main content

Build an eLearning Platform with Gamification

Build a complete online learning platform with courses, progress tracking, gamification, and payments. This tutorial covers everything from setup to a production-ready application.

Time: 30 minutes Difficulty: Intermediate Blocks Used: Auth, Onboarding, Files, University, Rewards, Sales


What You'll Build​

A fully-featured eLearning platform with:

  • User Authentication - Registration, login, SSO, password reset
  • Course Catalog - Browse and search available courses
  • Video Lessons - Stream video content with progress tracking
  • Quizzes - Test knowledge with interactive assessments
  • Gamification - Earn badges, points, and climb leaderboards
  • Payments - Purchase courses or subscribe for access
  • Progress Dashboard - Track completion and achievements
┌─────────────────────────────────────────────────────────────────┐
│ eLearning Platform │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Auth │ │University│ │ Rewards │ │ Sales │ │
│ │ │ │ │ │ │ │ │ │
│ │ Users │ │ Courses │ │ Badges │ │ Plans │ │
│ │ SSO │ │ Lessons │ │ Points │ │ Stripe │ │
│ │ MFA │ │ Quizzes │ │ Boards │ │ Invoices│ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ ┌─────────┐ ┌─────────┐ │
│ │Onboarding│ │ Files │ │
│ │ │ │ │ │
│ │ Welcome │ │ Videos │ │
│ │ Emails │ │ PDFs │ │
│ │ Flows │ │ Images │ │
│ └─────────┘ └─────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘

Prerequisites​

Before starting, make sure you have:

  • ✅ A 23blocks account (Sign up)
  • ✅ Basic knowledge of JavaScript/TypeScript
  • ✅ Node.js 18+ installed
  • ✅ A Stripe account (for payments)

Part 1: Project Setup​

1.1 Create Your App​

  1. Go to app.23blocks.com
  2. Click "Create App"
  3. Name it: my-elearning-platform
  4. Click "Create"

1.2 Add Required Blocks​

Navigate to your app's Blocks tab and add these blocks:

BlockPurpose
AuthUser accounts, login, SSO
OnboardingWelcome flows, activation emails
FilesVideo storage, course materials
UniversityCourses, lessons, progress
RewardsBadges, points, gamification
SalesSubscriptions, payments

1.3 Get Your Credentials​

From your app's API Keys tab, copy:

APP_ID=app_xxxxxxxxxxxx
API_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxxxx
BASE_URL=https://api.23blocks.com/v1

1.4 Initialize Your Project​

# Create a new project (React example)
npx create-react-app my-elearning-app --template typescript
cd my-elearning-app

# Install the SDK
npm install @23blocks/sdk

# Create environment file
cat > .env << EOF
REACT_APP_BLOCKS_URL=https://api.23blocks.com/v1
REACT_APP_BLOCKS_APP_ID=app_xxxxxxxxxxxx
REACT_APP_BLOCKS_API_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxxxx
EOF

1.5 Create the Client​

Create src/lib/blocks.ts:

import { create23BlocksClient } from '@23blocks/sdk';

export const blocks = create23BlocksClient({
urls: { authentication: process.env.REACT_APP_BLOCKS_URL! },
appId: process.env.REACT_APP_BLOCKS_APP_ID!,
apiKey: process.env.REACT_APP_BLOCKS_API_KEY!,
});

// For authenticated requests (after login)
export const createAuthenticatedClient = (sessionToken: string) => {
return create23BlocksClient({
urls: { authentication: process.env.REACT_APP_BLOCKS_URL! },
appId: process.env.REACT_APP_BLOCKS_APP_ID!,
sessionToken,
});
};

Part 2: User Authentication​

2.1 Configure the Auth Block​

In your dashboard, go to App → Blocks → Auth → Settings:

SettingValue
Password minimum length8
Require uppercaseYes
Require numberYes
Session duration7 days
Allow registrationYes

2.2 Registration Component​

Create src/components/auth/Register.tsx:

import { useState } from 'react';
import { blocks } from '../../lib/blocks';

interface RegisterForm {
email: string;
password: string;
firstName: string;
lastName: string;
}

export function Register() {
const [form, setForm] = useState<RegisterForm>({
email: '',
password: '',
firstName: '',
lastName: '',
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);

try {
const user = await blocks.auth.register({
email: form.email,
password: form.password,
profile: {
firstName: form.firstName,
lastName: form.lastName,
},
});

// Redirect to onboarding
window.location.href = '/onboarding';
} catch (err: any) {
setError(err.message || 'Registration failed');
} finally {
setLoading(false);
}
};

return (
<form onSubmit={handleSubmit} className="space-y-4">
<h2 className="text-2xl font-bold">Create Your Account</h2>

{error && (
<div className="bg-red-100 text-red-700 p-3 rounded">
{error}
</div>
)}

<div className="grid grid-cols-2 gap-4">
<input
type="text"
placeholder="First Name"
value={form.firstName}
onChange={(e) => setForm({ ...form, firstName: e.target.value })}
className="border p-2 rounded"
required
/>
<input
type="text"
placeholder="Last Name"
value={form.lastName}
onChange={(e) => setForm({ ...form, lastName: e.target.value })}
className="border p-2 rounded"
required
/>
</div>

<input
type="email"
placeholder="Email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
className="border p-2 rounded w-full"
required
/>

<input
type="password"
placeholder="Password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
className="border p-2 rounded w-full"
required
/>

<button
type="submit"
disabled={loading}
className="bg-blue-600 text-white px-4 py-2 rounded w-full"
>
{loading ? 'Creating Account...' : 'Sign Up'}
</button>
</form>
);
}

2.3 Login Component​

Create src/components/auth/Login.tsx:

import { useState } from 'react';
import { blocks } from '../../lib/blocks';

export function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);

try {
const session = await blocks.auth.login({ email, password });

// Store the session token
localStorage.setItem('session_token', session.token);
localStorage.setItem('user', JSON.stringify(session.user));

// Redirect to dashboard
window.location.href = '/dashboard';
} catch (err: any) {
setError(err.message || 'Login failed');
} finally {
setLoading(false);
}
};

return (
<form onSubmit={handleSubmit} className="space-y-4">
<h2 className="text-2xl font-bold">Welcome Back</h2>

{error && (
<div className="bg-red-100 text-red-700 p-3 rounded">
{error}
</div>
)}

<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="border p-2 rounded w-full"
required
/>

<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="border p-2 rounded w-full"
required
/>

<button
type="submit"
disabled={loading}
className="bg-blue-600 text-white px-4 py-2 rounded w-full"
>
{loading ? 'Signing In...' : 'Sign In'}
</button>

<a href="/forgot-password" className="text-blue-600 text-sm">
Forgot password?
</a>
</form>
);
}

2.4 Auth Context​

Create src/contexts/AuthContext.tsx:

import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { createAuthenticatedClient } from '../lib/blocks';

interface User {
id: string;
email: string;
profile: {
firstName: string;
lastName: string;
};
}

interface AuthContextType {
user: User | null;
client: ReturnType<typeof createAuthenticatedClient> | null;
loading: boolean;
logout: () => void;
}

const AuthContext = createContext<AuthContextType | null>(null);

export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [client, setClient] = useState<ReturnType<typeof createAuthenticatedClient> | null>(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
const token = localStorage.getItem('session_token');
const userData = localStorage.getItem('user');

if (token && userData) {
setUser(JSON.parse(userData));
setClient(createAuthenticatedClient(token));
}
setLoading(false);
}, []);

const logout = () => {
localStorage.removeItem('session_token');
localStorage.removeItem('user');
setUser(null);
setClient(null);
window.location.href = '/login';
};

return (
<AuthContext.Provider value={{ user, client, loading, logout }}>
{children}
</AuthContext.Provider>
);
}

export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within AuthProvider');
return context;
};

Part 3: Course Catalog (University Block)​

3.1 Configure the University Block​

In your dashboard, go to App → Blocks → University → Settings:

SettingValue
Progress trackingEnabled
Completion certificatesEnabled
Quiz passing score70%

3.2 Create Sample Courses (Dashboard)​

In the University block dashboard, create:

Course 1: JavaScript Fundamentals

  • Lesson 1: Variables and Data Types
  • Lesson 2: Functions and Scope
  • Lesson 3: Arrays and Objects
  • Quiz: JavaScript Basics

Course 2: React Essentials

  • Lesson 1: Components and JSX
  • Lesson 2: State and Props
  • Lesson 3: Hooks
  • Quiz: React Fundamentals

3.3 Course Catalog Component​

Create src/components/courses/CourseCatalog.tsx:

import { useEffect, useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { blocks } from '../../lib/blocks';

interface Course {
id: string;
title: string;
description: string;
thumbnail: string;
duration: string;
lessonsCount: number;
price: number;
instructor: {
name: string;
avatar: string;
};
}

export function CourseCatalog() {
const [courses, setCourses] = useState<Course[]>([]);
const [loading, setLoading] = useState(true);
const { client } = useAuth();

useEffect(() => {
loadCourses();
}, []);

const loadCourses = async () => {
try {
const response = await blocks.university.courses.list({
status: 'published',
limit: 20,
});
setCourses(response.data);
} catch (error) {
console.error('Failed to load courses:', error);
} finally {
setLoading(false);
}
};

if (loading) {
return <div className="text-center py-10">Loading courses...</div>;
}

return (
<div className="max-w-6xl mx-auto px-4">
<h1 className="text-3xl font-bold mb-8">Explore Courses</h1>

<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{courses.map((course) => (
<CourseCard key={course.id} course={course} />
))}
</div>
</div>
);
}

function CourseCard({ course }: { course: Course }) {
return (
<a
href={`/courses/${course.id}`}
className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow"
>
<img
src={course.thumbnail}
alt={course.title}
className="w-full h-48 object-cover"
/>
<div className="p-4">
<h3 className="font-bold text-lg mb-2">{course.title}</h3>
<p className="text-gray-600 text-sm mb-4 line-clamp-2">
{course.description}
</p>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500">
{course.lessonsCount} lessons • {course.duration}
</span>
<span className="font-bold text-green-600">
${course.price}
</span>
</div>
</div>
</a>
);
}

3.4 Course Player Component​

Create src/components/courses/CoursePlayer.tsx:

import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';

interface Lesson {
id: string;
title: string;
type: 'video' | 'text' | 'quiz';
duration: string;
completed: boolean;
videoUrl?: string;
content?: string;
}

interface CourseDetails {
id: string;
title: string;
lessons: Lesson[];
progress: number;
}

export function CoursePlayer() {
const { courseId } = useParams<{ courseId: string }>();
const { client } = useAuth();
const [course, setCourse] = useState<CourseDetails | null>(null);
const [currentLesson, setCurrentLesson] = useState<Lesson | null>(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
loadCourse();
}, [courseId]);

const loadCourse = async () => {
if (!client || !courseId) return;

try {
const response = await client.university.courses.get(courseId, {
include: ['lessons', 'progress'],
});
setCourse(response.data);
setCurrentLesson(response.data.lessons[0]);
} catch (error) {
console.error('Failed to load course:', error);
} finally {
setLoading(false);
}
};

const markLessonComplete = async (lessonId: string) => {
if (!client || !courseId) return;

try {
await client.university.progress.complete(courseId, lessonId);

// Update local state
setCourse((prev) => {
if (!prev) return prev;
return {
...prev,
lessons: prev.lessons.map((l) =>
l.id === lessonId ? { ...l, completed: true } : l
),
progress: prev.progress + (100 / prev.lessons.length),
};
});

// Award points for completion
await client.rewards.points.award({
action: 'lesson_complete',
points: 10,
metadata: { lessonId, courseId },
});
} catch (error) {
console.error('Failed to mark lesson complete:', error);
}
};

if (loading || !course) {
return <div className="text-center py-10">Loading course...</div>;
}

return (
<div className="flex h-screen">
{/* Sidebar - Lesson List */}
<aside className="w-80 bg-gray-100 overflow-y-auto">
<div className="p-4">
<h2 className="font-bold text-lg mb-4">{course.title}</h2>

{/* Progress Bar */}
<div className="mb-4">
<div className="flex justify-between text-sm mb-1">
<span>Progress</span>
<span>{Math.round(course.progress)}%</span>
</div>
<div className="h-2 bg-gray-300 rounded-full">
<div
className="h-2 bg-green-500 rounded-full transition-all"
style={{ width: `${course.progress}%` }}
/>
</div>
</div>

{/* Lesson List */}
<ul className="space-y-2">
{course.lessons.map((lesson, index) => (
<li key={lesson.id}>
<button
onClick={() => setCurrentLesson(lesson)}
className={`w-full text-left p-3 rounded-lg flex items-center gap-3 ${
currentLesson?.id === lesson.id
? 'bg-blue-100 text-blue-700'
: 'hover:bg-gray-200'
}`}
>
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-sm ${
lesson.completed
? 'bg-green-500 text-white'
: 'bg-gray-300'
}`}>
{lesson.completed ? '✓' : index + 1}
</span>
<div>
<div className="font-medium">{lesson.title}</div>
<div className="text-xs text-gray-500">{lesson.duration}</div>
</div>
</button>
</li>
))}
</ul>
</div>
</aside>

{/* Main Content */}
<main className="flex-1 overflow-y-auto">
{currentLesson && (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4">{currentLesson.title}</h1>

{currentLesson.type === 'video' && (
<div className="aspect-video bg-black rounded-lg mb-4">
<video
src={currentLesson.videoUrl}
controls
className="w-full h-full"
onEnded={() => markLessonComplete(currentLesson.id)}
/>
</div>
)}

{currentLesson.type === 'text' && (
<div
className="prose max-w-none mb-4"
dangerouslySetInnerHTML={{ __html: currentLesson.content || '' }}
/>
)}

{!currentLesson.completed && (
<button
onClick={() => markLessonComplete(currentLesson.id)}
className="bg-green-600 text-white px-6 py-2 rounded-lg"
>
Mark as Complete
</button>
)}
</div>
)}
</main>
</div>
);
}

Part 4: Gamification (Rewards Block)​

4.1 Configure the Rewards Block​

In your dashboard, create these reward items:

Badges:

BadgeTriggerPoints
First StepsComplete first lesson50
Quick LearnerComplete 5 lessons in a day100
Course MasterComplete a course200
Quiz WizardScore 100% on a quiz150
Streak Champion7-day learning streak300

Point Actions:

ActionPoints
lesson_complete10
quiz_pass25
course_complete100
daily_login5

4.2 Leaderboard Component​

Create src/components/rewards/Leaderboard.tsx:

import { useEffect, useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';

interface LeaderboardEntry {
rank: number;
userId: string;
userName: string;
avatar: string;
points: number;
badges: number;
}

export function Leaderboard() {
const { client, user } = useAuth();
const [entries, setEntries] = useState<LeaderboardEntry[]>([]);
const [userRank, setUserRank] = useState<LeaderboardEntry | null>(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
loadLeaderboard();
}, [client]);

const loadLeaderboard = async () => {
if (!client) return;

try {
const response = await client.rewards.leaderboard.get({
period: 'all_time',
limit: 10,
});
setEntries(response.data.entries);
setUserRank(response.data.currentUser);
} catch (error) {
console.error('Failed to load leaderboard:', error);
} finally {
setLoading(false);
}
};

if (loading) {
return <div className="text-center py-10">Loading leaderboard...</div>;
}

return (
<div className="max-w-2xl mx-auto">
<h2 className="text-2xl font-bold mb-6">Leaderboard</h2>

{/* Top 3 */}
<div className="flex justify-center gap-4 mb-8">
{entries.slice(0, 3).map((entry, index) => (
<div
key={entry.userId}
className={`text-center ${index === 0 ? 'order-2' : index === 1 ? 'order-1' : 'order-3'}`}
>
<div className={`relative ${index === 0 ? 'scale-110' : ''}`}>
<img
src={entry.avatar}
alt={entry.userName}
className="w-16 h-16 rounded-full mx-auto border-4"
style={{ borderColor: index === 0 ? 'gold' : index === 1 ? 'silver' : '#cd7f32' }}
/>
<span className={`absolute -bottom-2 left-1/2 -translate-x-1/2 w-6 h-6 rounded-full flex items-center justify-center text-white text-sm font-bold ${
index === 0 ? 'bg-yellow-500' : index === 1 ? 'bg-gray-400' : 'bg-orange-700'
}`}>
{entry.rank}
</span>
</div>
<div className="mt-4">
<div className="font-medium">{entry.userName}</div>
<div className="text-sm text-gray-500">{entry.points.toLocaleString()} pts</div>
</div>
</div>
))}
</div>

{/* Full List */}
<div className="bg-white rounded-lg shadow overflow-hidden">
{entries.map((entry) => (
<div
key={entry.userId}
className={`flex items-center gap-4 p-4 border-b ${
entry.userId === user?.id ? 'bg-blue-50' : ''
}`}
>
<span className="w-8 text-center font-bold text-gray-500">
{entry.rank}
</span>
<img
src={entry.avatar}
alt={entry.userName}
className="w-10 h-10 rounded-full"
/>
<div className="flex-1">
<div className="font-medium">{entry.userName}</div>
<div className="text-xs text-gray-500">{entry.badges} badges</div>
</div>
<div className="font-bold text-blue-600">
{entry.points.toLocaleString()} pts
</div>
</div>
))}
</div>

{/* Current User (if not in top 10) */}
{userRank && userRank.rank > 10 && (
<div className="mt-4 p-4 bg-blue-50 rounded-lg">
<div className="text-sm text-gray-500 mb-2">Your Rank</div>
<div className="flex items-center gap-4">
<span className="w-8 text-center font-bold">#{userRank.rank}</span>
<div className="flex-1 font-medium">{userRank.userName}</div>
<div className="font-bold text-blue-600">
{userRank.points.toLocaleString()} pts
</div>
</div>
</div>
)}
</div>
);
}

4.3 Badges Display Component​

Create src/components/rewards/BadgesDisplay.tsx:

import { useEffect, useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';

interface Badge {
id: string;
name: string;
description: string;
icon: string;
earnedAt: string | null;
progress?: {
current: number;
target: number;
};
}

export function BadgesDisplay() {
const { client } = useAuth();
const [badges, setBadges] = useState<Badge[]>([]);
const [loading, setLoading] = useState(true);

useEffect(() => {
loadBadges();
}, [client]);

const loadBadges = async () => {
if (!client) return;

try {
const response = await client.rewards.badges.list({
include: ['progress'],
});
setBadges(response.data);
} catch (error) {
console.error('Failed to load badges:', error);
} finally {
setLoading(false);
}
};

if (loading) {
return <div className="text-center py-10">Loading badges...</div>;
}

const earnedBadges = badges.filter((b) => b.earnedAt);
const lockedBadges = badges.filter((b) => !b.earnedAt);

return (
<div className="max-w-4xl mx-auto">
<h2 className="text-2xl font-bold mb-6">Your Badges</h2>

{/* Earned Badges */}
<div className="mb-8">
<h3 className="text-lg font-semibold mb-4 text-green-600">
Earned ({earnedBadges.length})
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{earnedBadges.map((badge) => (
<div
key={badge.id}
className="bg-white rounded-lg p-4 text-center shadow-md border-2 border-green-200"
>
<div className="text-4xl mb-2">{badge.icon}</div>
<div className="font-bold">{badge.name}</div>
<div className="text-xs text-gray-500">{badge.description}</div>
<div className="text-xs text-green-600 mt-2">
Earned {new Date(badge.earnedAt!).toLocaleDateString()}
</div>
</div>
))}
</div>
</div>

{/* Locked Badges */}
<div>
<h3 className="text-lg font-semibold mb-4 text-gray-500">
Locked ({lockedBadges.length})
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{lockedBadges.map((badge) => (
<div
key={badge.id}
className="bg-gray-100 rounded-lg p-4 text-center opacity-60"
>
<div className="text-4xl mb-2 grayscale">{badge.icon}</div>
<div className="font-bold">{badge.name}</div>
<div className="text-xs text-gray-500">{badge.description}</div>

{badge.progress && (
<div className="mt-2">
<div className="h-1 bg-gray-300 rounded-full">
<div
className="h-1 bg-blue-500 rounded-full"
style={{
width: `${(badge.progress.current / badge.progress.target) * 100}%`
}}
/>
</div>
<div className="text-xs mt-1">
{badge.progress.current}/{badge.progress.target}
</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
);
}

Part 5: Payments (Sales Block)​

5.1 Configure Stripe Integration​

  1. Go to App → Blocks → Sales → Settings
  2. Add your Stripe API keys (these are Stripe's keys with Stripe's own test/live modes — not your 23blocks keys, which are always live):
    • Publishable Key: pk_test_xxxxx
    • Secret Key: sk_test_xxxxx

5.2 Create Subscription Plans​

In the Sales block dashboard, create:

PlanPriceFeatures
Free$0/mo3 free courses, basic quizzes
Pro$19/moAll courses, certificates, priority support
Team$49/moPro features + 5 seats, team analytics

5.3 Pricing Component​

Create src/components/sales/Pricing.tsx:

import { useEffect, useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { blocks } from '../../lib/blocks';

interface Plan {
id: string;
name: string;
price: number;
interval: 'month' | 'year';
features: string[];
highlighted?: boolean;
}

export function Pricing() {
const { client, user } = useAuth();
const [plans, setPlans] = useState<Plan[]>([]);
const [currentPlan, setCurrentPlan] = useState<string | null>(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
loadPlans();
}, [client]);

const loadPlans = async () => {
try {
const response = await blocks.sales.plans.list();
setPlans(response.data);

if (client) {
const subscription = await client.sales.subscriptions.current();
setCurrentPlan(subscription?.planId || null);
}
} catch (error) {
console.error('Failed to load plans:', error);
} finally {
setLoading(false);
}
};

const handleSubscribe = async (planId: string) => {
if (!client) {
window.location.href = '/login?redirect=/pricing';
return;
}

try {
const checkout = await client.sales.checkout.create({
planId,
successUrl: `${window.location.origin}/subscription/success`,
cancelUrl: `${window.location.origin}/pricing`,
});

// Redirect to Stripe Checkout
window.location.href = checkout.url;
} catch (error) {
console.error('Failed to create checkout:', error);
}
};

if (loading) {
return <div className="text-center py-10">Loading plans...</div>;
}

return (
<div className="max-w-5xl mx-auto px-4 py-12">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold mb-4">Choose Your Plan</h1>
<p className="text-gray-600">
Start learning today. Upgrade or cancel anytime.
</p>
</div>

<div className="grid md:grid-cols-3 gap-8">
{plans.map((plan) => (
<div
key={plan.id}
className={`bg-white rounded-2xl shadow-lg p-8 ${
plan.highlighted ? 'ring-2 ring-blue-500 scale-105' : ''
}`}
>
{plan.highlighted && (
<div className="bg-blue-500 text-white text-sm font-medium px-3 py-1 rounded-full inline-block mb-4">
Most Popular
</div>
)}

<h3 className="text-xl font-bold">{plan.name}</h3>

<div className="my-4">
<span className="text-4xl font-bold">${plan.price}</span>
<span className="text-gray-500">/{plan.interval}</span>
</div>

<ul className="space-y-3 mb-8">
{plan.features.map((feature, i) => (
<li key={i} className="flex items-center gap-2">
<span className="text-green-500">✓</span>
{feature}
</li>
))}
</ul>

<button
onClick={() => handleSubscribe(plan.id)}
disabled={currentPlan === plan.id}
className={`w-full py-3 rounded-lg font-medium ${
currentPlan === plan.id
? 'bg-gray-200 text-gray-500 cursor-not-allowed'
: plan.highlighted
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-gray-900 text-white hover:bg-gray-800'
}`}
>
{currentPlan === plan.id ? 'Current Plan' : 'Get Started'}
</button>
</div>
))}
</div>
</div>
);
}

Part 6: File Uploads (Files Block)​

6.1 Video Upload Component​

Create src/components/admin/VideoUpload.tsx:

import { useState, useCallback } from 'react';
import { useAuth } from '../../contexts/AuthContext';

export function VideoUpload({ onUploadComplete }: { onUploadComplete: (url: string) => void }) {
const { client } = useAuth();
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);

const handleUpload = useCallback(async (file: File) => {
if (!client) return;

setUploading(true);
setError(null);
setProgress(0);

try {
const response = await client.files.upload({
file,
folder: 'course-videos',
public: false,
onProgress: (percent) => setProgress(percent),
});

onUploadComplete(response.url);
} catch (err: any) {
setError(err.message || 'Upload failed');
} finally {
setUploading(false);
}
}, [client, onUploadComplete]);

const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('video/')) {
handleUpload(file);
} else {
setError('Please upload a video file');
}
}, [handleUpload]);

return (
<div
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
className={`border-2 border-dashed rounded-lg p-8 text-center ${
uploading ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
}`}
>
{uploading ? (
<div>
<div className="text-lg mb-4">Uploading... {progress}%</div>
<div className="h-2 bg-gray-200 rounded-full max-w-xs mx-auto">
<div
className="h-2 bg-blue-500 rounded-full transition-all"
style={{ width: `${progress}%` }}
/>
</div>
</div>
) : (
<div>
<div className="text-4xl mb-4">📹</div>
<div className="text-lg mb-2">Drop your video here</div>
<div className="text-gray-500 mb-4">or</div>
<label className="bg-blue-600 text-white px-4 py-2 rounded cursor-pointer">
Choose File
<input
type="file"
accept="video/*"
className="hidden"
onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0])}
/>
</label>
</div>
)}

{error && (
<div className="mt-4 text-red-600">{error}</div>
)}
</div>
);
}

Part 7: Putting It All Together​

7.1 App Router​

Create src/App.tsx:

import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './contexts/AuthContext';

// Auth
import { Login } from './components/auth/Login';
import { Register } from './components/auth/Register';

// Main
import { CourseCatalog } from './components/courses/CourseCatalog';
import { CoursePlayer } from './components/courses/CoursePlayer';
import { Dashboard } from './components/dashboard/Dashboard';

// Rewards
import { Leaderboard } from './components/rewards/Leaderboard';
import { BadgesDisplay } from './components/rewards/BadgesDisplay';

// Sales
import { Pricing } from './components/sales/Pricing';

function PrivateRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();

if (loading) {
return <div className="flex items-center justify-center h-screen">Loading...</div>;
}

return user ? <>{children}</> : <Navigate to="/login" />;
}

function App() {
return (
<AuthProvider>
<BrowserRouter>
<Routes>
{/* Public Routes */}
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/courses" element={<CourseCatalog />} />
<Route path="/pricing" element={<Pricing />} />

{/* Private Routes */}
<Route path="/dashboard" element={
<PrivateRoute><Dashboard /></PrivateRoute>
} />
<Route path="/courses/:courseId" element={
<PrivateRoute><CoursePlayer /></PrivateRoute>
} />
<Route path="/leaderboard" element={
<PrivateRoute><Leaderboard /></PrivateRoute>
} />
<Route path="/badges" element={
<PrivateRoute><BadgesDisplay /></PrivateRoute>
} />

{/* Default */}
<Route path="/" element={<Navigate to="/courses" />} />
</Routes>
</BrowserRouter>
</AuthProvider>
);
}

export default App;

7.2 User Dashboard​

Create src/components/dashboard/Dashboard.tsx:

import { useEffect, useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';

interface DashboardData {
enrolledCourses: number;
completedCourses: number;
totalPoints: number;
badges: number;
streak: number;
recentActivity: Array<{
id: string;
type: string;
title: string;
date: string;
}>;
}

export function Dashboard() {
const { client, user } = useAuth();
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
loadDashboard();
}, [client]);

const loadDashboard = async () => {
if (!client) return;

try {
// Fetch data from multiple blocks in parallel
const [courses, rewards, activity] = await Promise.all([
client.university.enrollments.stats(),
client.rewards.user.stats(),
client.university.activity.recent({ limit: 5 }),
]);

setData({
enrolledCourses: courses.enrolled,
completedCourses: courses.completed,
totalPoints: rewards.points,
badges: rewards.badgesCount,
streak: rewards.streak,
recentActivity: activity.data,
});
} catch (error) {
console.error('Failed to load dashboard:', error);
} finally {
setLoading(false);
}
};

if (loading || !data) {
return <div className="text-center py-10">Loading dashboard...</div>;
}

return (
<div className="max-w-6xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">
Welcome back, {user?.profile.firstName}!
</h1>

{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
<StatCard label="Enrolled" value={data.enrolledCourses} icon="📚" />
<StatCard label="Completed" value={data.completedCourses} icon="✅" />
<StatCard label="Points" value={data.totalPoints.toLocaleString()} icon="⭐" />
<StatCard label="Badges" value={data.badges} icon="🏆" />
<StatCard label="Day Streak" value={data.streak} icon="🔥" />
</div>

{/* Continue Learning */}
<section className="mb-8">
<h2 className="text-xl font-bold mb-4">Continue Learning</h2>
{/* Add enrolled courses list here */}
</section>

{/* Recent Activity */}
<section>
<h2 className="text-xl font-bold mb-4">Recent Activity</h2>
<div className="bg-white rounded-lg shadow">
{data.recentActivity.map((item) => (
<div key={item.id} className="p-4 border-b last:border-b-0 flex items-center gap-4">
<span className="text-2xl">
{item.type === 'lesson_complete' ? '📖' :
item.type === 'badge_earned' ? '🏆' :
item.type === 'quiz_passed' ? '✅' : '📝'}
</span>
<div className="flex-1">
<div className="font-medium">{item.title}</div>
<div className="text-sm text-gray-500">
{new Date(item.date).toLocaleDateString()}
</div>
</div>
</div>
))}
</div>
</section>
</div>
);
}

function StatCard({ label, value, icon }: { label: string; value: string | number; icon: string }) {
return (
<div className="bg-white rounded-lg shadow p-4 text-center">
<div className="text-3xl mb-2">{icon}</div>
<div className="text-2xl font-bold">{value}</div>
<div className="text-gray-500 text-sm">{label}</div>
</div>
);
}

Part 8: Testing Your Application​

8.1 Test User Flow​

  1. Register a new user

    • Visit /register
    • Create an account
    • Check email for verification
  2. Browse courses

    • Visit /courses
    • View course details
  3. Enroll and learn

    • Subscribe to a plan
    • Enroll in a course
    • Complete lessons
    • Take quizzes
  4. Check gamification

    • View earned badges
    • Check leaderboard position
    • Track points

8.2 Test Checklist​

  • User can register and login
  • Course catalog loads correctly
  • Video lessons play
  • Progress is tracked
  • Badges are awarded
  • Points accumulate
  • Leaderboard updates
  • Payment flow works
  • Subscription is active

Next Steps​

Congratulations! You've built a complete eLearning platform. Here are ideas for extending it:

Feature Ideas​

FeatureBlocks to Add
Discussion forumsContent + Real Time
Live classesReal Time
Instructor dashboardCompanies (roles)
Mobile appSame blocks, React Native
Advanced searchSearch
AI recommendationsAI/Jarvis

Production Checklist​

  • Confirm your live API keys (pk_live_*/sk_live_*) are configured
  • Configure custom domain
  • Set up monitoring
  • Enable backup policies
  • Configure webhooks for events
  • Add error tracking (Sentry, etc.)

Resources​