Back to Projects

LingoMaster

Next.jsTypeScriptReactDrizzle ORMMySQLClerkStripeTailwind CSSRadix UIZustandReact AdminElevenLabs
IMAGE — Landing/marketing page with hero mascot and 'Get Started' CTA
IMAGE — Course selection page showing language flags (Spanish, French, Italian, etc.)
IMAGE — Main learn page with unit banner and zigzag lesson buttons showing progress
IMAGE — Active lesson/quiz with SELECT challenge — image option cards with audio
IMAGE — Active lesson/quiz with ASSIST challenge — question bubble with text options
IMAGE — Correct answer feedback with green highlight and sound effect indicator
IMAGE — Wrong answer feedback with red highlight and hearts decrement
IMAGE — Lesson completion screen with confetti, XP earned, and hearts remaining
IMAGE — 'No hearts left' popup modal with upgrade to Pro option
IMAGE — Exit confirmation popup with sad mascot
IMAGE — Shop page showing 'Refill hearts' and 'Unlimited hearts (Pro)' options
IMAGE — Leaderboard page with top 10 users ranked by XP
IMAGE — Quests page with milestone progress bars (20, 50, 100, 500, 1000 XP)
IMAGE — Admin dashboard (React Admin) showing courses/units/lessons/challenges CRUD
IMAGE — Mobile responsive view with slide-out sidebar navigation
VIDEO
VIDEO
VIDEO
VIDEO
VIDEO
VIDEO
VIDEO
VIDEO
VIDEO
Landing Page1 / 24

Overview

A gamified language learning platform inspired by Duolingo, featuring a hearts system, XP points, leaderboards, quests, and a shop — all built around an interactive lesson engine with AI-generated audio, sound effects, and keyboard shortcuts. Includes a Pro tier with unlimited hearts via Stripe subscriptions and a full admin dashboard using React Admin for content management. Built with Next.js 14 Server Actions, Drizzle ORM, MySQL, and Clerk authentication.

Key Features

Gamification
  • Interactive quiz engine with two challenge types — SELECT (image+audio cards in grid) and ASSIST (text-based question bubble with options)
  • Hearts system with 5 hearts max — lose a heart on wrong answers, regain hearts through practice or the shop
  • XP points system — earn 10 XP per correct answer, track total points across all courses
  • Leaderboard showing top 10 users ranked by XP with avatars and usernames
  • Quest milestones at 20, 50, 100, 500, and 1000 XP with progress bar tracking
  • Shop system to exchange 10 points for a heart refill or upgrade to Pro for unlimited hearts
  • Pro tier via Stripe subscription ($20/month) — unlimited hearts, infinity icon display, and manage subscription via Stripe billing portal
  • Practice mode for completed lessons — regain hearts and points without penalty, cannot lose hearts
  • Lesson completion celebration with react-confetti animation and result cards showing XP and hearts
  • Zigzag lesson path with circular progress indicators, animated 'Start' badges, and lock/unlock states
  • No hearts popup redirecting to shop, exit confirmation popup with sad mascot, practice mode info popup
Lessons & Audio
  • AI-generated voice audio using ElevenLabs for challenge options — plays on card click with per-option audio sources
  • Sound effects for correct answers, wrong answers, and lesson completion using react-use audio hooks
  • Keyboard shortcuts (keys 1-4) to select challenge options without clicking
  • Admin dashboard using React Admin with full CRUD for courses, units, lessons, challenges, and challenge options
Payments & Pro
  • Authentication using Clerk with modal sign-in/sign-up and automatic redirect to learning page
Admin & Infrastructure
  • ORM using Drizzle with MySQL — 8 tables with cascade deletes and relational queries
  • Mobile responsive design with collapsible slide-out sidebar navigation

Architecture Overview

How the system is structured from frontend to external services.

01

Frontend

Next.js 14 (App Router)React 18Tailwind CSSRadix UI / ShadcnZustand

Server Components for data-fetching pages (learn, shop, leaderboard, quests). Client Components for the interactive quiz engine with audio playback, keyboard shortcuts, and confetti animations. Zustand manages modal state (exit, hearts, practice). Custom button component with 13 variants for gamified UI.

02

Backend & API

Next.js Server ActionsDrizzle ORMClerk

Server Actions handle all mutations — challenge progress, hearts management, course switching, and Stripe checkout. React cache() wraps all database queries for request-level deduplication. Clerk provides authentication with modal-based sign-in/sign-up. Admin check compares Clerk userId against ADMIN_ID env variable.

03

Database

MySQLDrizzle ORM

Eight tables: courses, units, lessons, challenges, challenge_options, challenge_progress, user_progress, and user_subscription. Ordered content via position integers with cascade deletes. Relational queries with nested eager loading for lessons with challenges, options, and user-specific progress.

04

External Services

ClerkStripeReact AdminElevenLabs

Clerk handles authentication with public/protected route middleware. Stripe processes Pro subscriptions ($20/month) with webhook-driven subscription lifecycle. ElevenLabs generates AI voice audio for challenge options. React Admin provides a full CRUD dashboard backed by REST API routes for content management.

Data Model

Core entities and how they relate to each other.

Course

Has many Units (cascade delete). Has many UserProgress records (as activeCourse)

A language course (e.g., Spanish, French) — each course has a title and flag image displayed on the course selection page

id (int, auto-increment, PK)titleimageSrc (flag image)

Unit

Belongs to Course (cascade delete). Has many Lessons

A thematic section within a course (e.g., 'Nouns', 'Verbs') — ordered by position with a title and description shown on the unit banner

id (int, PK)titledescriptioncourseIdorder (int, position)

Lesson

Belongs to Unit (cascade delete). Has many Challenges

An individual lesson within a unit — displayed as a button on the zigzag path with lock/unlock, progress, and completion states

id (int, PK)titleunitIdorder (int, position)

Challenge

Belongs to Lesson (cascade delete). Has many ChallengeOptions and ChallengeProgress records

A single question within a lesson — supports SELECT (image+audio grid) and ASSIST (text question bubble) types, ordered within the lesson

id (int, PK)lessonIdtype (enum: SELECT | ASSIST)question (text)order (int)

ChallengeOption

Belongs to Challenge (cascade delete)

An answer option for a challenge — can include an image (for SELECT type), audio clip (ElevenLabs), and a correct/incorrect flag

id (int, PK)challengeIdtextcorrect (boolean)imageSrc (optional)audioSrc (optional)

ChallengeProgress

Belongs to Challenge (cascade delete). References User by Clerk userId

Tracks whether a specific user has completed a specific challenge — used to calculate lesson completion percentage and practice mode detection

id (int, PK)userId (varchar)challengeIdcompleted (boolean, default: false)

UserProgress

One-to-one with User (Clerk userId as PK). Belongs to Course (activeCourse, cascade delete)

Central gamification state per user — tracks active course, hearts (max 5), XP points, and profile info synced from Clerk

userId (varchar, PK)userNameuserImageSrcactiveCourseIdhearts (int, default: 5)points (int, default: 0)

UserSubscription

One-to-one with User (Clerk userId). isActive computed as stripeCurrentPeriodEnd + 1 day > now

Stripe Pro subscription record — tracks Stripe customer, subscription, and price IDs with period end for active status calculation

id (int, PK)userId (unique)stripeCustomerId (unique)stripeSubscriptionId (unique)stripePriceIdstripeCurrentPeriodEnd

Key Flows

Step-by-step walkthrough of the main user and system flows.

Lesson Quiz Flow

  1. 1User clicks a lesson button on the learn page (must be unlocked — all previous lessons completed)
  2. 2Server fetches the lesson with challenges, options, and user-specific progress via Drizzle relational query
  3. 3If lesson is already 100% complete, practice modal is shown (hearts/points cannot be lost)
  4. 4Quiz renders the first challenge — SELECT shows image/audio cards, ASSIST shows a question bubble with text options
  5. 5User selects an option (click or keyboard shortcut 1-4) and clicks 'Check'
  6. 6Correct: server action upsertChallengeProgress creates a progress record, adds +10 XP (+1 heart in practice mode), plays correct sound
  7. 7Wrong: server action reduceHearts decrements hearts by 1 (skipped for Pro subscribers and practice mode), plays wrong sound
  8. 8If hearts reach 0: hearts modal opens with 'Get unlimited hearts' redirect to shop
  9. 9After all challenges: confetti animation, finish sound auto-plays, result cards show total XP and remaining hearts

Stripe Pro Subscription

  1. 1User clicks 'Upgrade' in the shop page
  2. 2Server action createStripeUrl checks for existing Stripe customer
  3. 3If new: creates a Stripe Checkout session for 'Duolingo Pro - Unlimited Hearts' at $20/month with success/cancel URLs
  4. 4If existing subscriber: creates a Stripe Billing Portal session to manage subscription
  5. 5User completes payment on Stripe's hosted checkout page
  6. 6Stripe sends checkout.session.completed webhook to /api/webhooks/stripe
  7. 7Webhook handler inserts a UserSubscription record with Stripe IDs and period end timestamp
  8. 8On renewal: invoice.payment_succeeded webhook updates stripePriceId and stripeCurrentPeriodEnd
  9. 9isActive check: stripeCurrentPeriodEnd + 1 day > Date.now() — gives 1-day grace period

Hearts & Shop Economy

  1. 1Users start with 5 hearts — each wrong answer costs 1 heart (unless Pro or practice mode)
  2. 2At 0 hearts, user cannot start new lessons — hearts modal directs to shop
  3. 3Shop option 1: Refill hearts — costs 10 XP points, resets hearts to 5 (disabled if hearts already 5 or not enough points)
  4. 4Shop option 2: Upgrade to Pro — redirects to Stripe checkout for unlimited hearts subscription
  5. 5Practice mode: replaying completed lessons regains +1 heart per correct answer (max 5) and +10 XP, no heart loss on wrong answers
  6. 6Pro subscribers: hearts display shows infinity icon, reduceHearts returns early with 'subscription' flag, hearts never decrease

Admin Content Management

  1. 1Admin user (Clerk userId matches ADMIN_ID env variable) navigates to /admin
  2. 2React Admin loads with ra-data-simple-rest provider pointing to /api
  3. 3Admin manages 5 resources: Courses, Units, Lessons, Challenges, Challenge Options
  4. 4Each resource has List, Create, and Edit views with form validation
  5. 5Challenges support SELECT and ASSIST types via dropdown. Challenge Options support image/audio URLs and correct flag
  6. 6All API routes check getIsAdmin() before executing — returns 401 for non-admin users
  7. 7Changes are immediately reflected in the learning content via Drizzle ORM

Challenges I Solved

01

Gamification State Machine

The Problem

The hearts, XP, and practice mode systems interact in complex ways — wrong answers in practice shouldn't cost hearts, Pro users should never lose hearts, and practice should award hearts. Getting these edge cases right across multiple server actions is error-prone.

How I Solved It

Each server action (upsertChallengeProgress, reduceHearts, refillHearts) checks the full context: existing challenge progress (practice detection), active subscription (Pro status), and current hearts count before mutating state. Practice mode is detected by checking if a ChallengeProgress record already exists for the challenge.

02

Interactive Quiz Engine with Audio & Keyboard

The Problem

The quiz needs to handle multiple input methods (click and keyboard), play audio clips per option, manage complex visual states (default, selected, correct, wrong), and support two different challenge layouts — all while keeping the UI responsive.

How I Solved It

Built with react-use hooks: useAudio for per-option audio playback, useKey for keyboard shortcuts (1-4 mapped to options), and useAudio for correct/wrong/finish sound effects. Card component manages its own audio state and visual variant based on the quiz's global status. Separate layouts for SELECT (grid) and ASSIST (single column with question bubble).

03

Lesson Progress & Ordering Logic

The Problem

Lessons must be locked/unlocked based on completion of previous lessons, and completion requires ALL challenges in a lesson to have progress records. The zigzag path needs to show correct state for every lesson button.

How I Solved It

The getUnits query uses Drizzle's relational loading with nested lessons → challenges → challengeProgress (filtered by userId). Each lesson is normalized with a completed boolean (true only if every challenge has a completed progress record). Lesson buttons are locked if any previous lesson in the unit is incomplete.

04

Stripe Subscription Lifecycle

The Problem

Subscription state must be accurate — new purchases, renewals, and expired subscriptions all need different handling. The webhook must be idempotent and the subscription check needs a grace period for failed renewals.

How I Solved It

Webhook handles two events: checkout.session.completed (new subscription insert) and invoice.payment_succeeded (renewal update). The isActive calculation adds a 1-day buffer to stripeCurrentPeriodEnd to handle timezone edge cases and give a grace period. The createStripeUrl action intelligently routes existing customers to the billing portal instead of creating a new checkout.

Summary

LingoMaster demonstrates gamification design with a hearts/XP economy, interactive quiz engine with audio and keyboard support, Stripe subscription integration with webhook lifecycle management, and a full admin CMS via React Admin. The project showcases handling complex state interactions between practice mode, Pro subscriptions, and the hearts system — all built with Next.js 14 Server Actions, Drizzle ORM, and Clerk authentication.