├── .eslintrc.json ├── app ├── favicon.ico ├── (main) │ ├── courses │ │ ├── loading.tsx │ │ ├── page.tsx │ │ ├── card.tsx │ │ └── list.tsx │ ├── learn │ │ ├── loading.tsx │ │ ├── header.tsx │ │ ├── unit-banner.tsx │ │ ├── unit.tsx │ │ ├── page.tsx │ │ └── lesson-button.tsx │ ├── quests │ │ ├── loading.tsx │ │ └── page.tsx │ ├── shop │ │ ├── loading.tsx │ │ ├── page.tsx │ │ └── items.tsx │ ├── leaderboard │ │ ├── loading.tsx │ │ └── page.tsx │ └── layout.tsx ├── lesson │ ├── layout.tsx │ ├── question-bubble.tsx │ ├── page.tsx │ ├── header.tsx │ ├── [lessonId] │ │ └── page.tsx │ ├── challenge.tsx │ ├── result-card.tsx │ ├── footer.tsx │ ├── card.tsx │ └── quiz.tsx ├── admin │ ├── course │ │ ├── list.tsx │ │ ├── create.tsx │ │ └── edit.tsx │ ├── page.tsx │ ├── lesson │ │ ├── list.tsx │ │ ├── edit.tsx │ │ └── create.tsx │ ├── unit │ │ ├── list.tsx │ │ ├── create.tsx │ │ └── edit.tsx │ ├── challengeOption │ │ ├── list.tsx │ │ ├── edit.tsx │ │ └── create.tsx │ ├── challenge │ │ ├── list.tsx │ │ ├── edit.tsx │ │ └── create.tsx │ └── app.tsx ├── (marketing) │ ├── layout.tsx │ ├── header.tsx │ ├── footer.tsx │ └── page.tsx ├── api │ ├── units │ │ ├── route.ts │ │ └── [unitId] │ │ │ └── route.ts │ ├── courses │ │ ├── route.ts │ │ └── [courseId] │ │ │ └── route.ts │ ├── lessons │ │ ├── route.ts │ │ └── [lessonId] │ │ │ └── route.ts │ ├── challenges │ │ ├── route.ts │ │ └── [challengeId] │ │ │ └── route.ts │ ├── challengeOptions │ │ ├── route.ts │ │ └── [challengeOptionId] │ │ │ └── route.ts │ └── webhooks │ │ └── stripe │ │ └── route.ts ├── layout.tsx ├── buttons │ └── page.tsx └── globals.css ├── public ├── correct.wav ├── es_boy.mp3 ├── es_girl.mp3 ├── es_man.mp3 ├── finish.mp3 ├── es_robot.mp3 ├── es_woman.mp3 ├── es_zombie.mp3 ├── incorrect.wav ├── it.svg ├── fr.svg ├── vercel.svg ├── jp.svg ├── heart.svg ├── next.svg ├── quests.svg ├── mascot.svg ├── points.svg ├── mascot_sad.svg ├── mascot_bad.svg ├── woman.svg ├── leaderboard.svg └── man.svg ├── postcss.config.js ├── lib ├── stripe.ts ├── admin.ts └── utils.ts ├── components ├── feed-wrapper.tsx ├── mobile-header.tsx ├── sticky-wrapper.tsx ├── mobile-sidebar.tsx ├── ui │ ├── separator.tsx │ ├── progress.tsx │ ├── sonner.tsx │ ├── avatar.tsx │ ├── button.tsx │ ├── dialog.tsx │ └── sheet.tsx ├── sidebar-item.tsx ├── promo.tsx ├── user-progress.tsx ├── quests.tsx ├── sidebar.tsx └── modals │ ├── practice-modal.tsx │ ├── exit-modal.tsx │ └── hearts-modal.tsx ├── db ├── drizzle.ts ├── schema.ts └── queries.ts ├── drizzle.config.ts ├── store ├── use-exit-modal.ts ├── use-hearts-modal.ts └── use-practice-modal.ts ├── components.json ├── constants.ts ├── middleware.ts ├── .gitignore ├── tsconfig.json ├── next.config.mjs ├── scripts ├── reset.ts └── seed.ts ├── actions ├── user-subscription.ts ├── challenge-progress.ts └── user-progress.ts ├── package.json ├── README.md └── tailwind.config.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pleopardi/next14-duolingo-clone/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /public/correct.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pleopardi/next14-duolingo-clone/HEAD/public/correct.wav -------------------------------------------------------------------------------- /public/es_boy.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pleopardi/next14-duolingo-clone/HEAD/public/es_boy.mp3 -------------------------------------------------------------------------------- /public/es_girl.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pleopardi/next14-duolingo-clone/HEAD/public/es_girl.mp3 -------------------------------------------------------------------------------- /public/es_man.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pleopardi/next14-duolingo-clone/HEAD/public/es_man.mp3 -------------------------------------------------------------------------------- /public/finish.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pleopardi/next14-duolingo-clone/HEAD/public/finish.mp3 -------------------------------------------------------------------------------- /public/es_robot.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pleopardi/next14-duolingo-clone/HEAD/public/es_robot.mp3 -------------------------------------------------------------------------------- /public/es_woman.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pleopardi/next14-duolingo-clone/HEAD/public/es_woman.mp3 -------------------------------------------------------------------------------- /public/es_zombie.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pleopardi/next14-duolingo-clone/HEAD/public/es_zombie.mp3 -------------------------------------------------------------------------------- /public/incorrect.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pleopardi/next14-duolingo-clone/HEAD/public/incorrect.wav -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /lib/stripe.ts: -------------------------------------------------------------------------------- 1 | import Stripe from "stripe"; 2 | 3 | export const stripe = new Stripe(process.env.STRIPE_API_KEY!, { 4 | apiVersion: "2023-10-16", 5 | typescript: true, 6 | }); 7 | -------------------------------------------------------------------------------- /components/feed-wrapper.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | children: React.ReactNode; 3 | }; 4 | 5 | export const FeedWrapper = ({ 6 | children 7 | }: Props) => { 8 | return ( 9 |
10 | {children} 11 |
12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /db/drizzle.ts: -------------------------------------------------------------------------------- 1 | import { neon } from "@neondatabase/serverless"; 2 | import { drizzle } from "drizzle-orm/neon-http"; 3 | 4 | import * as schema from "./schema"; 5 | 6 | const sql = neon(process.env.DATABASE_URL!); 7 | // @ts-ignore 8 | const db = drizzle(sql, { schema }); 9 | 10 | export default db; 11 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import type { Config } from "drizzle-kit"; 3 | 4 | export default { 5 | schema: "./db/schema.ts", 6 | out: "./drizzle", 7 | driver: "pg", 8 | dbCredentials: { 9 | connectionString: process.env.DATABASE_URL!, 10 | }, 11 | } satisfies Config; 12 | -------------------------------------------------------------------------------- /components/mobile-header.tsx: -------------------------------------------------------------------------------- 1 | import { MobileSidebar } from "./mobile-sidebar"; 2 | 3 | export const MobileHeader = () => { 4 | return ( 5 | 8 | ); 9 | }; 10 | -------------------------------------------------------------------------------- /lib/admin.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "@clerk/nextjs" 2 | 3 | const adminIds = [ 4 | "user_2dGb6YEarBAQHrNYoB5dMtISRWK", 5 | ]; 6 | 7 | export const isAdmin = () => { 8 | const { userId } = auth(); 9 | 10 | if (!userId) { 11 | return false; 12 | } 13 | 14 | return adminIds.indexOf(userId) !== -1; 15 | }; 16 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | 8 | export function absoluteUrl(path: string) { 9 | return `${process.env.NEXT_PUBLIC_APP_URL}${path}`; 10 | }; 11 | -------------------------------------------------------------------------------- /app/(main)/courses/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Loader } from "lucide-react"; 2 | 3 | const Loading = () => { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | }; 10 | 11 | export default Loading; 12 | -------------------------------------------------------------------------------- /app/(main)/learn/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Loader } from "lucide-react"; 2 | 3 | const Loading = () => { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | }; 10 | 11 | export default Loading; 12 | -------------------------------------------------------------------------------- /app/(main)/quests/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Loader } from "lucide-react"; 2 | 3 | const Loading = () => { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | }; 10 | 11 | export default Loading; 12 | -------------------------------------------------------------------------------- /app/(main)/shop/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Loader } from "lucide-react"; 2 | 3 | const Loading = () => { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | }; 10 | 11 | export default Loading; 12 | -------------------------------------------------------------------------------- /app/(main)/leaderboard/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Loader } from "lucide-react"; 2 | 3 | const Loading = () => { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | }; 10 | 11 | export default Loading; 12 | -------------------------------------------------------------------------------- /store/use-exit-modal.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | type ExitModalState = { 4 | isOpen: boolean; 5 | open: () => void; 6 | close: () => void; 7 | }; 8 | 9 | export const useExitModal = create((set) => ({ 10 | isOpen: false, 11 | open: () => set({ isOpen: true }), 12 | close: () => set({ isOpen: false }), 13 | })); 14 | -------------------------------------------------------------------------------- /app/lesson/layout.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | children: React.ReactNode; 3 | }; 4 | 5 | const LessonLayout = ({ children }: Props) => { 6 | return ( 7 |
8 |
9 | {children} 10 |
11 |
12 | ); 13 | }; 14 | 15 | export default LessonLayout; 16 | -------------------------------------------------------------------------------- /store/use-hearts-modal.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | type HeartsModalState = { 4 | isOpen: boolean; 5 | open: () => void; 6 | close: () => void; 7 | }; 8 | 9 | export const useHeartsModal = create((set) => ({ 10 | isOpen: false, 11 | open: () => set({ isOpen: true }), 12 | close: () => set({ isOpen: false }), 13 | })); 14 | -------------------------------------------------------------------------------- /app/admin/course/list.tsx: -------------------------------------------------------------------------------- 1 | import { Datagrid, List, TextField } from "react-admin"; 2 | 3 | export const CourseList = () => { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /store/use-practice-modal.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | type PracticeModalState = { 4 | isOpen: boolean; 5 | open: () => void; 6 | close: () => void; 7 | }; 8 | 9 | export const usePracticeModal = create((set) => ({ 10 | isOpen: false, 11 | open: () => set({ isOpen: true }), 12 | close: () => set({ isOpen: false }), 13 | })); 14 | -------------------------------------------------------------------------------- /components/sticky-wrapper.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | children: React.ReactNode; 3 | }; 4 | 5 | export const StickyWrapper = ({ children }: Props) => { 6 | return ( 7 |
8 |
9 | {children} 10 |
11 |
12 | ); 13 | }; -------------------------------------------------------------------------------- /app/admin/page.tsx: -------------------------------------------------------------------------------- 1 | import dynamic from "next/dynamic"; 2 | import { redirect } from "next/navigation"; 3 | 4 | import { isAdmin } from "@/lib/admin"; 5 | 6 | const App = dynamic(() => import("./app"), { ssr: false }); 7 | 8 | const AdminPage = () => { 9 | if (!isAdmin()) { 10 | redirect("/"); 11 | } 12 | 13 | return ( 14 | 15 | ); 16 | }; 17 | 18 | export default AdminPage; 19 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /constants.ts: -------------------------------------------------------------------------------- 1 | export const POINTS_TO_REFILL = 10; 2 | 3 | export const quests = [ 4 | { 5 | title: "Earn 20 XP", 6 | value: 20, 7 | }, 8 | { 9 | title: "Earn 50 XP", 10 | value: 50, 11 | }, 12 | { 13 | title: "Earn 100 XP", 14 | value: 100, 15 | }, 16 | { 17 | title: "Earn 500 XP", 18 | value: 500, 19 | }, 20 | { 21 | title: "Earn 1000 XP", 22 | value: 1000, 23 | }, 24 | ]; 25 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { authMiddleware } from "@clerk/nextjs"; 2 | 3 | export default authMiddleware({ 4 | publicRoutes: ["/", "/api/webhooks/stripe"], 5 | }); 6 | 7 | export const config = { 8 | // Protects all routes, including api/trpc. 9 | // See https://clerk.com/docs/references/nextjs/auth-middleware 10 | // for more information about configuring your Middleware 11 | matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"], 12 | }; -------------------------------------------------------------------------------- /app/admin/lesson/list.tsx: -------------------------------------------------------------------------------- 1 | import { Datagrid, List, TextField, ReferenceField, NumberField } from "react-admin"; 2 | 3 | export const LessonList = () => { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /app/admin/unit/list.tsx: -------------------------------------------------------------------------------- 1 | import { Datagrid, List, TextField, ReferenceField } from "react-admin"; 2 | 3 | export const UnitList = () => { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /app/(marketing)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Footer } from "./footer"; 2 | import { Header } from "./header"; 3 | 4 | type Props = { 5 | children: React.ReactNode; 6 | }; 7 | 8 | const MarketingLayout = ({ children }: Props) => { 9 | return ( 10 |
11 |
12 |
13 | {children} 14 |
15 |
16 |
17 | ); 18 | }; 19 | 20 | export default MarketingLayout; 21 | -------------------------------------------------------------------------------- /app/admin/course/create.tsx: -------------------------------------------------------------------------------- 1 | import { SimpleForm, Create, TextInput, required } from "react-admin"; 2 | 3 | export const CourseCreate = () => { 4 | return ( 5 | 6 | 7 | 12 | 17 | 18 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /components/mobile-sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { Menu } from "lucide-react"; 2 | 3 | import { 4 | Sheet, 5 | SheetContent, 6 | SheetTrigger 7 | } from "@/components/ui/sheet"; 8 | import { Sidebar } from "@/components/sidebar"; 9 | 10 | export const MobileSidebar = () => { 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /app/admin/challengeOption/list.tsx: -------------------------------------------------------------------------------- 1 | import { Datagrid, List, TextField, ReferenceField, NumberField, BooleanField } from "react-admin"; 2 | 3 | export const ChallengeOptionList = () => { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | ); 16 | }; 17 | 18 | -------------------------------------------------------------------------------- /public/it.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /public/fr.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/(main)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Sidebar } from "@/components/sidebar"; 2 | import { MobileHeader } from "@/components/mobile-header"; 3 | 4 | type Props = { 5 | children: React.ReactNode; 6 | }; 7 | 8 | const MainLayout = ({ 9 | children, 10 | }: Props) => { 11 | return ( 12 | <> 13 | 14 | 15 |
16 |
17 | {children} 18 |
19 |
20 | 21 | ); 22 | }; 23 | 24 | export default MainLayout; 25 | -------------------------------------------------------------------------------- /app/admin/course/edit.tsx: -------------------------------------------------------------------------------- 1 | import { SimpleForm, Edit, TextInput, required } from "react-admin"; 2 | 3 | export const CourseEdit = () => { 4 | return ( 5 | 6 | 7 | 12 | 17 | 22 | 23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /app/admin/lesson/edit.tsx: -------------------------------------------------------------------------------- 1 | import { SimpleForm, Edit, TextInput, ReferenceInput, NumberInput, required } from "react-admin"; 2 | 3 | export const LessonEdit = () => { 4 | return ( 5 | 6 | 7 | 12 | 16 | 21 | 22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /app/admin/lesson/create.tsx: -------------------------------------------------------------------------------- 1 | import { SimpleForm, Create, TextInput, ReferenceInput, NumberInput, required } from "react-admin"; 2 | 3 | export const LessonCreate = () => { 4 | return ( 5 | 6 | 7 | 12 | 16 | 21 | 22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /app/(main)/learn/header.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { ArrowLeft } from "lucide-react"; 3 | 4 | import { Button } from "@/components/ui/button"; 5 | 6 | type Props = { 7 | title: string; 8 | }; 9 | 10 | export const Header = ({ title }: Props) => { 11 | return ( 12 |
13 | 14 | 17 | 18 |

19 | {title} 20 |

21 |
22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /app/(main)/courses/page.tsx: -------------------------------------------------------------------------------- 1 | import { getCourses, getUserProgress } from "@/db/queries"; 2 | 3 | import { List } from "./list"; 4 | 5 | const CoursesPage = async () => { 6 | const coursesData = getCourses(); 7 | const userProgressData = getUserProgress(); 8 | 9 | const [ 10 | courses, 11 | userProgress, 12 | ] = await Promise.all([ 13 | coursesData, 14 | userProgressData, 15 | ]); 16 | 17 | return ( 18 |
19 |

20 | Language Courses 21 |

22 | 26 |
27 | ); 28 | }; 29 | 30 | export default CoursesPage; 31 | -------------------------------------------------------------------------------- /app/api/units/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | import db from "@/db/drizzle"; 4 | import { isAdmin } from "@/lib/admin"; 5 | import { units } from "@/db/schema"; 6 | 7 | export const GET = async () => { 8 | if (!isAdmin()) { 9 | return new NextResponse("Unauthorized", { status: 401 }); 10 | } 11 | 12 | const data = await db.query.units.findMany(); 13 | 14 | return NextResponse.json(data); 15 | }; 16 | 17 | export const POST = async (req: Request) => { 18 | if (!isAdmin()) { 19 | return new NextResponse("Unauthorized", { status: 401 }); 20 | } 21 | 22 | const body = await req.json(); 23 | 24 | const data = await db.insert(units).values({ 25 | ...body, 26 | }).returning(); 27 | 28 | return NextResponse.json(data[0]); 29 | }; 30 | -------------------------------------------------------------------------------- /app/api/courses/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | import db from "@/db/drizzle"; 4 | import { isAdmin } from "@/lib/admin"; 5 | import { courses } from "@/db/schema"; 6 | 7 | export const GET = async () => { 8 | if (!isAdmin()) { 9 | return new NextResponse("Unauthorized", { status: 401 }); 10 | } 11 | 12 | const data = await db.query.courses.findMany(); 13 | 14 | return NextResponse.json(data); 15 | }; 16 | 17 | export const POST = async (req: Request) => { 18 | if (!isAdmin()) { 19 | return new NextResponse("Unauthorized", { status: 401 }); 20 | } 21 | 22 | const body = await req.json(); 23 | 24 | const data = await db.insert(courses).values({ 25 | ...body, 26 | }).returning(); 27 | 28 | return NextResponse.json(data[0]); 29 | }; 30 | -------------------------------------------------------------------------------- /app/api/lessons/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | import db from "@/db/drizzle"; 4 | import { isAdmin } from "@/lib/admin"; 5 | import { lessons } from "@/db/schema"; 6 | 7 | export const GET = async () => { 8 | if (!isAdmin()) { 9 | return new NextResponse("Unauthorized", { status: 401 }); 10 | } 11 | 12 | const data = await db.query.lessons.findMany(); 13 | 14 | return NextResponse.json(data); 15 | }; 16 | 17 | export const POST = async (req: Request) => { 18 | if (!isAdmin()) { 19 | return new NextResponse("Unauthorized", { status: 401 }); 20 | } 21 | 22 | const body = await req.json(); 23 | 24 | const data = await db.insert(lessons).values({ 25 | ...body, 26 | }).returning(); 27 | 28 | return NextResponse.json(data[0]); 29 | }; 30 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | async headers() { 4 | return [ 5 | { 6 | source: "/api/(.*)", 7 | headers: [ 8 | { 9 | key: "Access-Control-Allow-Origin", 10 | value: "*", 11 | }, 12 | { 13 | key: "Access-Control-Allow-Methods", 14 | value: "GET, POST, PUT, DELETE, OPTIONS", 15 | }, 16 | { 17 | key: "Access-Control-Allow-Headers", 18 | value: "Content-Type, Authorization", 19 | }, 20 | { 21 | key: "Content-Range", 22 | value: "bytes : 0-9/*", 23 | }, 24 | ], 25 | }, 26 | ]; 27 | }, 28 | }; 29 | 30 | export default nextConfig; 31 | -------------------------------------------------------------------------------- /app/admin/challenge/list.tsx: -------------------------------------------------------------------------------- 1 | import { Datagrid, List, TextField, ReferenceField, NumberField, SelectField } from "react-admin"; 2 | 3 | export const ChallengeList = () => { 4 | return ( 5 | 6 | 7 | 8 | 9 | 22 | 23 | 24 | 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /app/api/challenges/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | import db from "@/db/drizzle"; 4 | import { isAdmin } from "@/lib/admin"; 5 | import { challenges } from "@/db/schema"; 6 | 7 | export const GET = async () => { 8 | if (!isAdmin()) { 9 | return new NextResponse("Unauthorized", { status: 401 }); 10 | } 11 | 12 | const data = await db.query.challenges.findMany(); 13 | 14 | return NextResponse.json(data); 15 | }; 16 | 17 | export const POST = async (req: Request) => { 18 | if (!isAdmin()) { 19 | return new NextResponse("Unauthorized", { status: 401 }); 20 | } 21 | 22 | const body = await req.json(); 23 | 24 | const data = await db.insert(challenges).values({ 25 | ...body, 26 | }).returning(); 27 | 28 | return NextResponse.json(data[0]); 29 | }; 30 | -------------------------------------------------------------------------------- /app/admin/unit/create.tsx: -------------------------------------------------------------------------------- 1 | import { SimpleForm, Create, TextInput, ReferenceInput, NumberInput, required } from "react-admin"; 2 | 3 | export const UnitCreate = () => { 4 | return ( 5 | 6 | 7 | 12 | 17 | 21 | 26 | 27 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /app/api/challengeOptions/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | import db from "@/db/drizzle"; 4 | import { isAdmin } from "@/lib/admin"; 5 | import { challengeOptions } from "@/db/schema"; 6 | 7 | export const GET = async () => { 8 | if (!isAdmin()) { 9 | return new NextResponse("Unauthorized", { status: 401 }); 10 | } 11 | 12 | const data = await db.query.challengeOptions.findMany(); 13 | 14 | return NextResponse.json(data); 15 | }; 16 | 17 | export const POST = async (req: Request) => { 18 | if (!isAdmin()) { 19 | return new NextResponse("Unauthorized", { status: 401 }); 20 | } 21 | 22 | const body = await req.json(); 23 | 24 | const data = await db.insert(challengeOptions).values({ 25 | ...body, 26 | }).returning(); 27 | 28 | return NextResponse.json(data[0]); 29 | }; 30 | -------------------------------------------------------------------------------- /app/admin/challengeOption/edit.tsx: -------------------------------------------------------------------------------- 1 | import { SimpleForm, Edit, TextInput, ReferenceInput, required, BooleanInput } from "react-admin"; 2 | 3 | export const ChallengeOptionEdit = () => { 4 | return ( 5 | 6 | 7 | 12 | 16 | 20 | 24 | 28 | 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /app/admin/challengeOption/create.tsx: -------------------------------------------------------------------------------- 1 | import { SimpleForm, Create, TextInput, ReferenceInput, required, BooleanInput } from "react-admin"; 2 | 3 | export const ChallengeOptionCreate = () => { 4 | return ( 5 | 6 | 7 | 12 | 16 | 20 | 24 | 28 | 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /public/jp.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >( 12 | ( 13 | { className, orientation = "horizontal", decorative = true, ...props }, 14 | ref 15 | ) => ( 16 | 27 | ) 28 | ) 29 | Separator.displayName = SeparatorPrimitive.Root.displayName 30 | 31 | export { Separator } 32 | -------------------------------------------------------------------------------- /components/ui/progress.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as ProgressPrimitive from "@radix-ui/react-progress" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Progress = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, value, ...props }, ref) => ( 12 | 20 | 24 | 25 | )) 26 | Progress.displayName = ProgressPrimitive.Root.displayName 27 | 28 | export { Progress } 29 | -------------------------------------------------------------------------------- /app/lesson/question-bubble.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | type Props = { 4 | question: string; 5 | }; 6 | 7 | export const QuestionBubble = ({ question }: Props) => { 8 | return ( 9 |
10 | Mascot 17 | Mascot 24 |
25 | {question} 26 |
29 |
30 |
31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /app/admin/unit/edit.tsx: -------------------------------------------------------------------------------- 1 | import { SimpleForm, Edit, TextInput, ReferenceInput, NumberInput, required } from "react-admin"; 2 | 3 | export const UnitEdit = () => { 4 | return ( 5 | 6 | 7 | 12 | 17 | 22 | 26 | 31 | 32 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /components/sidebar-item.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import Image from "next/image"; 5 | import { usePathname } from "next/navigation"; 6 | 7 | import { Button } from "@/components/ui/button"; 8 | 9 | type Props = { 10 | label: string; 11 | iconSrc: string; 12 | href: string; 13 | }; 14 | 15 | export const SidebarItem = ({ 16 | label, 17 | iconSrc, 18 | href, 19 | }: Props) => { 20 | const pathname = usePathname(); 21 | const active = pathname === href; 22 | 23 | return ( 24 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /scripts/reset.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import { drizzle } from "drizzle-orm/neon-http"; 3 | import { neon } from "@neondatabase/serverless"; 4 | 5 | import * as schema from "../db/schema"; 6 | 7 | const sql = neon(process.env.DATABASE_URL!); 8 | // @ts-ignore 9 | const db = drizzle(sql, { schema }); 10 | 11 | const main = async () => { 12 | try { 13 | console.log("Resetting the database"); 14 | 15 | await db.delete(schema.courses); 16 | await db.delete(schema.userProgress); 17 | await db.delete(schema.units); 18 | await db.delete(schema.lessons); 19 | await db.delete(schema.challenges); 20 | await db.delete(schema.challengeOptions); 21 | await db.delete(schema.challengeProgress); 22 | await db.delete(schema.userSubscription); 23 | 24 | console.log("Resetting finished"); 25 | } catch (error) { 26 | console.error(error); 27 | throw new Error("Failed to reset the database"); 28 | } 29 | }; 30 | 31 | main(); 32 | 33 | -------------------------------------------------------------------------------- /components/promo.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import Image from "next/image"; 3 | 4 | import { Button } from "@/components/ui/button"; 5 | 6 | export const Promo = () => { 7 | return ( 8 |
9 |
10 |
11 | Pro 17 |

18 | Upgrade to Pro 19 |

20 |
21 |

22 | Get unlimited hearts and more! 23 |

24 |
25 | 35 |
36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /app/(main)/learn/unit-banner.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { NotebookText } from "lucide-react"; 3 | 4 | import { Button } from "@/components/ui/button"; 5 | 6 | type Props = { 7 | title: string; 8 | description: string; 9 | }; 10 | 11 | export const UnitBanner = ({ 12 | title, 13 | description, 14 | }: Props) => { 15 | return ( 16 |
17 |
18 |

19 | {title} 20 |

21 |

22 | {description} 23 |

24 |
25 | 26 | 34 | 35 |
36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /app/admin/challenge/edit.tsx: -------------------------------------------------------------------------------- 1 | import { SimpleForm, Edit, TextInput, ReferenceInput, NumberInput, required, SelectInput } from "react-admin"; 2 | 3 | export const ChallengeEdit = () => { 4 | return ( 5 | 6 | 7 | 12 | 26 | 30 | 35 | 36 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useTheme } from "next-themes" 4 | import { Toaster as Sonner } from "sonner" 5 | 6 | type ToasterProps = React.ComponentProps 7 | 8 | const Toaster = ({ ...props }: ToasterProps) => { 9 | const { theme = "system" } = useTheme() 10 | 11 | return ( 12 | 28 | ) 29 | } 30 | 31 | export { Toaster } 32 | -------------------------------------------------------------------------------- /app/admin/challenge/create.tsx: -------------------------------------------------------------------------------- 1 | import { SimpleForm, Create, TextInput, ReferenceInput, NumberInput, required, SelectInput } from "react-admin"; 2 | 3 | export const ChallengeCreate = () => { 4 | return ( 5 | 6 | 7 | 12 | 26 | 30 | 35 | 36 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /public/heart.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Nunito } from "next/font/google"; 3 | import { ClerkProvider } from '@clerk/nextjs' 4 | import { Toaster } from "@/components/ui/sonner"; 5 | import { ExitModal } from "@/components/modals/exit-modal"; 6 | import { HeartsModal } from "@/components/modals/hearts-modal"; 7 | import { PracticeModal } from "@/components/modals/practice-modal"; 8 | import "./globals.css"; 9 | 10 | const font = Nunito({ subsets: ["latin"] }); 11 | 12 | export const metadata: Metadata = { 13 | title: "Create Next App", 14 | description: "Generated by create next app", 15 | }; 16 | 17 | export default function RootLayout({ 18 | children, 19 | }: Readonly<{ 20 | children: React.ReactNode; 21 | }>) { 22 | return ( 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {children} 31 | 32 | 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /app/lesson/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | import { getLesson, getUserProgress, getUserSubscription } from "@/db/queries"; 4 | 5 | import { Quiz } from "./quiz"; 6 | 7 | const LessonPage = async () => { 8 | const lessonData = getLesson(); 9 | const userProgressData = getUserProgress(); 10 | const userSubscriptionData = getUserSubscription(); 11 | 12 | const [ 13 | lesson, 14 | userProgress, 15 | userSubscription, 16 | ] = await Promise.all([ 17 | lessonData, 18 | userProgressData, 19 | userSubscriptionData, 20 | ]); 21 | 22 | if (!lesson || !userProgress) { 23 | redirect("/learn"); 24 | } 25 | 26 | const initialPercentage = lesson.challenges 27 | .filter((challenge) => challenge.completed) 28 | .length / lesson.challenges.length * 100; 29 | 30 | return ( 31 | 38 | ); 39 | }; 40 | 41 | export default LessonPage; 42 | -------------------------------------------------------------------------------- /app/buttons/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | 3 | const ButtonsPage = () => { 4 | return ( 5 |
6 | 9 | 12 | 15 | 18 | 21 | 24 | 27 | 30 | 33 | 36 | 39 | 42 |
43 | ); 44 | }; 45 | 46 | export default ButtonsPage; 47 | -------------------------------------------------------------------------------- /app/lesson/header.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { InfinityIcon, X } from "lucide-react"; 3 | 4 | import { Progress } from "@/components/ui/progress"; 5 | import { useExitModal } from "@/store/use-exit-modal"; 6 | 7 | type Props = { 8 | hearts: number; 9 | percentage: number; 10 | hasActiveSubscription: boolean; 11 | }; 12 | 13 | export const Header = ({ 14 | hearts, 15 | percentage, 16 | hasActiveSubscription, 17 | }: Props) => { 18 | const { open } = useExitModal(); 19 | 20 | return ( 21 |
22 | 26 | 27 |
28 | Heart 35 | {hasActiveSubscription 36 | ? 37 | : hearts 38 | } 39 |
40 |
41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /app/lesson/[lessonId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | import { getLesson, getUserProgress, getUserSubscription } from "@/db/queries"; 4 | 5 | import { Quiz } from "../quiz"; 6 | 7 | type Props = { 8 | params: { 9 | lessonId: number; 10 | }; 11 | }; 12 | 13 | const LessonIdPage = async ({ 14 | params, 15 | }: Props) => { 16 | const lessonData = getLesson(params.lessonId); 17 | const userProgressData = getUserProgress(); 18 | const userSubscriptionData = getUserSubscription(); 19 | 20 | const [ 21 | lesson, 22 | userProgress, 23 | userSubscription, 24 | ] = await Promise.all([ 25 | lessonData, 26 | userProgressData, 27 | userSubscriptionData, 28 | ]); 29 | 30 | if (!lesson || !userProgress) { 31 | redirect("/learn"); 32 | } 33 | 34 | const initialPercentage = lesson.challenges 35 | .filter((challenge) => challenge.completed) 36 | .length / lesson.challenges.length * 100; 37 | 38 | return ( 39 | 46 | ); 47 | }; 48 | 49 | export default LessonIdPage; 50 | -------------------------------------------------------------------------------- /app/lesson/challenge.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { challengeOptions, challenges } from "@/db/schema"; 3 | 4 | import { Card } from "./card"; 5 | 6 | type Props = { 7 | options: typeof challengeOptions.$inferSelect[]; 8 | onSelect: (id: number) => void; 9 | status: "correct" | "wrong" | "none"; 10 | selectedOption?: number; 11 | disabled?: boolean; 12 | type: typeof challenges.$inferSelect["type"]; 13 | }; 14 | 15 | export const Challenge = ({ 16 | options, 17 | onSelect, 18 | status, 19 | selectedOption, 20 | disabled, 21 | type, 22 | }: Props) => { 23 | return ( 24 |
29 | {options.map((option, i) => ( 30 | onSelect(option.id)} 38 | status={status} 39 | audioSrc={option.audioSrc} 40 | disabled={disabled} 41 | type={type} 42 | /> 43 | ))} 44 |
45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/lesson/result-card.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | type Props = { 6 | value: number; 7 | variant: "points" | "hearts"; 8 | }; 9 | 10 | export const ResultCard = ({ value, variant }: Props) => { 11 | const imageSrc = variant === "hearts" ? "/heart.svg" : "/points.svg"; 12 | 13 | return ( 14 |
19 |
24 | {variant === "hearts" ? "Hearts Left" : "Total XP"} 25 |
26 |
31 | Icon 38 | {value} 39 |
40 |
41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /app/api/units/[unitId]/route.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import { NextResponse } from "next/server"; 3 | 4 | import db from "@/db/drizzle"; 5 | import { units } from "@/db/schema"; 6 | import { isAdmin } from "@/lib/admin"; 7 | 8 | export const GET = async ( 9 | req: Request, 10 | { params }: { params: { unitId: number } }, 11 | ) => { 12 | if (!isAdmin()) { 13 | return new NextResponse("Unauthorized", { status: 403 }); 14 | } 15 | 16 | const data = await db.query.units.findFirst({ 17 | where: eq(units.id, params.unitId), 18 | }); 19 | 20 | return NextResponse.json(data); 21 | }; 22 | 23 | export const PUT = async ( 24 | req: Request, 25 | { params }: { params: { unitId: number } }, 26 | ) => { 27 | if (!isAdmin()) { 28 | return new NextResponse("Unauthorized", { status: 403 }); 29 | } 30 | 31 | const body = await req.json(); 32 | const data = await db.update(units).set({ 33 | ...body, 34 | }).where(eq(units.id, params.unitId)).returning(); 35 | 36 | return NextResponse.json(data[0]); 37 | }; 38 | 39 | export const DELETE = async ( 40 | req: Request, 41 | { params }: { params: { unitId: number } }, 42 | ) => { 43 | if (!isAdmin()) { 44 | return new NextResponse("Unauthorized", { status: 403 }); 45 | } 46 | 47 | const data = await db.delete(units) 48 | .where(eq(units.id, params.unitId)).returning(); 49 | 50 | return NextResponse.json(data[0]); 51 | }; 52 | -------------------------------------------------------------------------------- /app/api/courses/[courseId]/route.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import { NextResponse } from "next/server"; 3 | 4 | import db from "@/db/drizzle"; 5 | import { courses } from "@/db/schema"; 6 | import { isAdmin } from "@/lib/admin"; 7 | 8 | export const GET = async ( 9 | req: Request, 10 | { params }: { params: { courseId: number } }, 11 | ) => { 12 | if (!isAdmin()) { 13 | return new NextResponse("Unauthorized", { status: 403 }); 14 | } 15 | 16 | const data = await db.query.courses.findFirst({ 17 | where: eq(courses.id, params.courseId), 18 | }); 19 | 20 | return NextResponse.json(data); 21 | }; 22 | 23 | export const PUT = async ( 24 | req: Request, 25 | { params }: { params: { courseId: number } }, 26 | ) => { 27 | if (!isAdmin()) { 28 | return new NextResponse("Unauthorized", { status: 403 }); 29 | } 30 | 31 | const body = await req.json(); 32 | const data = await db.update(courses).set({ 33 | ...body, 34 | }).where(eq(courses.id, params.courseId)).returning(); 35 | 36 | return NextResponse.json(data[0]); 37 | }; 38 | 39 | export const DELETE = async ( 40 | req: Request, 41 | { params }: { params: { courseId: number } }, 42 | ) => { 43 | if (!isAdmin()) { 44 | return new NextResponse("Unauthorized", { status: 403 }); 45 | } 46 | 47 | const data = await db.delete(courses) 48 | .where(eq(courses.id, params.courseId)).returning(); 49 | 50 | return NextResponse.json(data[0]); 51 | }; 52 | -------------------------------------------------------------------------------- /app/api/lessons/[lessonId]/route.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import { NextResponse } from "next/server"; 3 | 4 | import db from "@/db/drizzle"; 5 | import { lessons } from "@/db/schema"; 6 | import { isAdmin } from "@/lib/admin"; 7 | 8 | export const GET = async ( 9 | req: Request, 10 | { params }: { params: { lessonId: number } }, 11 | ) => { 12 | if (!isAdmin()) { 13 | return new NextResponse("Unauthorized", { status: 403 }); 14 | } 15 | 16 | const data = await db.query.lessons.findFirst({ 17 | where: eq(lessons.id, params.lessonId), 18 | }); 19 | 20 | return NextResponse.json(data); 21 | }; 22 | 23 | export const PUT = async ( 24 | req: Request, 25 | { params }: { params: { lessonId: number } }, 26 | ) => { 27 | if (!isAdmin()) { 28 | return new NextResponse("Unauthorized", { status: 403 }); 29 | } 30 | 31 | const body = await req.json(); 32 | const data = await db.update(lessons).set({ 33 | ...body, 34 | }).where(eq(lessons.id, params.lessonId)).returning(); 35 | 36 | return NextResponse.json(data[0]); 37 | }; 38 | 39 | export const DELETE = async ( 40 | req: Request, 41 | { params }: { params: { lessonId: number } }, 42 | ) => { 43 | if (!isAdmin()) { 44 | return new NextResponse("Unauthorized", { status: 403 }); 45 | } 46 | 47 | const data = await db.delete(lessons) 48 | .where(eq(lessons.id, params.lessonId)).returning(); 49 | 50 | return NextResponse.json(data[0]); 51 | }; 52 | -------------------------------------------------------------------------------- /app/(main)/courses/card.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { Check } from "lucide-react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | type Props = { 7 | title: string; 8 | id: number; 9 | imageSrc: string; 10 | onClick: (id: number) => void; 11 | disabled?: boolean; 12 | active?: boolean; 13 | }; 14 | 15 | export const Card = ({ 16 | title, 17 | id, 18 | imageSrc, 19 | disabled, 20 | onClick, 21 | active, 22 | }: Props) => { 23 | return ( 24 |
onClick(id)} 26 | className={cn( 27 | "h-full border-2 rounded-xl border-b-4 hover:bg-black/5 cursor-pointer active:border-b-2 flex flex-col items-center justify-between p-3 pb-6 min-h-[217px] min-w-[200px]", 28 | disabled && "pointer-events-none opacity-50" 29 | )} 30 | > 31 |
32 | {active && ( 33 |
34 | 35 |
36 | )} 37 |
38 | {title} 45 |

46 | {title} 47 |

48 |
49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /app/(main)/courses/list.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { toast } from "sonner"; 4 | import { useTransition } from "react"; 5 | import { useRouter } from "next/navigation"; 6 | 7 | import { courses, userProgress } from "@/db/schema"; 8 | import { upsertUserProgress } from "@/actions/user-progress"; 9 | 10 | import { Card } from "./card"; 11 | 12 | type Props = { 13 | courses: typeof courses.$inferSelect[]; 14 | activeCourseId?: typeof userProgress.$inferSelect.activeCourseId; 15 | }; 16 | 17 | export const List = ({ courses, activeCourseId }: Props) => { 18 | const router = useRouter(); 19 | const [pending, startTransition] = useTransition(); 20 | 21 | const onClick = (id: number) => { 22 | if (pending) return; 23 | 24 | if (id === activeCourseId) { 25 | return router.push("/learn"); 26 | } 27 | 28 | startTransition(() => { 29 | upsertUserProgress(id) 30 | .catch(() => toast.error("Something went wrong.")); 31 | }); 32 | }; 33 | 34 | return ( 35 |
36 | {courses.map((course) => ( 37 | 46 | ))} 47 |
48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /app/(main)/learn/unit.tsx: -------------------------------------------------------------------------------- 1 | import { lessons, units } from "@/db/schema" 2 | 3 | import { UnitBanner } from "./unit-banner"; 4 | import { LessonButton } from "./lesson-button"; 5 | 6 | type Props = { 7 | id: number; 8 | order: number; 9 | title: string; 10 | description: string; 11 | lessons: (typeof lessons.$inferSelect & { 12 | completed: boolean; 13 | })[]; 14 | activeLesson: typeof lessons.$inferSelect & { 15 | unit: typeof units.$inferSelect; 16 | } | undefined; 17 | activeLessonPercentage: number; 18 | }; 19 | 20 | export const Unit = ({ 21 | id, 22 | order, 23 | title, 24 | description, 25 | lessons, 26 | activeLesson, 27 | activeLessonPercentage, 28 | }: Props) => { 29 | return ( 30 | <> 31 | 32 |
33 | {lessons.map((lesson, index) => { 34 | const isCurrent = lesson.id === activeLesson?.id; 35 | const isLocked = !lesson.completed && !isCurrent; 36 | 37 | return ( 38 | 47 | ); 48 | })} 49 |
50 | 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /app/api/challenges/[challengeId]/route.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import { NextResponse } from "next/server"; 3 | 4 | import db from "@/db/drizzle"; 5 | import { challenges } from "@/db/schema"; 6 | import { isAdmin } from "@/lib/admin"; 7 | 8 | export const GET = async ( 9 | req: Request, 10 | { params }: { params: { challengeId: number } }, 11 | ) => { 12 | if (!isAdmin()) { 13 | return new NextResponse("Unauthorized", { status: 403 }); 14 | } 15 | 16 | const data = await db.query.challenges.findFirst({ 17 | where: eq(challenges.id, params.challengeId), 18 | }); 19 | 20 | return NextResponse.json(data); 21 | }; 22 | 23 | export const PUT = async ( 24 | req: Request, 25 | { params }: { params: { challengeId: number } }, 26 | ) => { 27 | if (!isAdmin()) { 28 | return new NextResponse("Unauthorized", { status: 403 }); 29 | } 30 | 31 | const body = await req.json(); 32 | const data = await db.update(challenges).set({ 33 | ...body, 34 | }).where(eq(challenges.id, params.challengeId)).returning(); 35 | 36 | return NextResponse.json(data[0]); 37 | }; 38 | 39 | export const DELETE = async ( 40 | req: Request, 41 | { params }: { params: { challengeId: number } }, 42 | ) => { 43 | if (!isAdmin()) { 44 | return new NextResponse("Unauthorized", { status: 403 }); 45 | } 46 | 47 | const data = await db.delete(challenges) 48 | .where(eq(challenges.id, params.challengeId)).returning(); 49 | 50 | return NextResponse.json(data[0]); 51 | }; 52 | -------------------------------------------------------------------------------- /public/quests.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/(marketing)/header.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { Loader } from "lucide-react"; 3 | import { 4 | ClerkLoaded, 5 | ClerkLoading, 6 | SignedIn, 7 | SignedOut, 8 | SignInButton, 9 | UserButton, 10 | } from "@clerk/nextjs"; 11 | import { Button } from "@/components/ui/button"; 12 | 13 | export const Header = () => { 14 | return ( 15 |
16 |
17 |
18 | Mascot 19 |

20 | Lingo 21 |

22 |
23 | 24 | 25 | 26 | 27 | 28 | 31 | 32 | 33 | 38 | 41 | 42 | 43 | 44 |
45 |
46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /app/api/challengeOptions/[challengeOptionId]/route.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import { NextResponse } from "next/server"; 3 | 4 | import db from "@/db/drizzle"; 5 | import { challengeOptions } from "@/db/schema"; 6 | import { isAdmin } from "@/lib/admin"; 7 | 8 | export const GET = async ( 9 | req: Request, 10 | { params }: { params: { challengeOptionId: number } }, 11 | ) => { 12 | if (!isAdmin()) { 13 | return new NextResponse("Unauthorized", { status: 403 }); 14 | } 15 | 16 | const data = await db.query.challengeOptions.findFirst({ 17 | where: eq(challengeOptions.id, params.challengeOptionId), 18 | }); 19 | 20 | return NextResponse.json(data); 21 | }; 22 | 23 | export const PUT = async ( 24 | req: Request, 25 | { params }: { params: { challengeOptionId: number } }, 26 | ) => { 27 | if (!isAdmin()) { 28 | return new NextResponse("Unauthorized", { status: 403 }); 29 | } 30 | 31 | const body = await req.json(); 32 | const data = await db.update(challengeOptions).set({ 33 | ...body, 34 | }).where(eq(challengeOptions.id, params.challengeOptionId)).returning(); 35 | 36 | return NextResponse.json(data[0]); 37 | }; 38 | 39 | export const DELETE = async ( 40 | req: Request, 41 | { params }: { params: { challengeOptionId: number } }, 42 | ) => { 43 | if (!isAdmin()) { 44 | return new NextResponse("Unauthorized", { status: 403 }); 45 | } 46 | 47 | const data = await db.delete(challengeOptions) 48 | .where(eq(challengeOptions.id, params.challengeOptionId)).returning(); 49 | 50 | return NextResponse.json(data[0]); 51 | }; 52 | -------------------------------------------------------------------------------- /components/user-progress.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import Image from "next/image"; 3 | import { InfinityIcon } from "lucide-react"; 4 | 5 | import { courses } from "@/db/schema"; 6 | import { Button } from "@/components/ui/button"; 7 | 8 | type Props = { 9 | activeCourse: typeof courses.$inferSelect; 10 | hearts: number; 11 | points: number; 12 | hasActiveSubscription: boolean; 13 | }; 14 | 15 | export const UserProgress = ({ 16 | activeCourse, 17 | points, 18 | hearts, 19 | hasActiveSubscription 20 | }: Props) => { 21 | return ( 22 |
23 | 24 | 33 | 34 | 35 | 39 | 40 | 41 | 48 | 49 |
50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /actions/user-subscription.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { auth, currentUser } from "@clerk/nextjs"; 4 | 5 | import { stripe } from "@/lib/stripe"; 6 | import { absoluteUrl } from "@/lib/utils"; 7 | import { getUserSubscription } from "@/db/queries"; 8 | 9 | const returnUrl = absoluteUrl("/shop"); 10 | 11 | export const createStripeUrl = async () => { 12 | const { userId } = await auth(); 13 | const user = await currentUser(); 14 | 15 | if (!userId || !user) { 16 | throw new Error("Unauthorized"); 17 | } 18 | 19 | const userSubscription = await getUserSubscription(); 20 | 21 | if (userSubscription && userSubscription.stripeCustomerId) { 22 | const stripeSession = await stripe.billingPortal.sessions.create({ 23 | customer: userSubscription.stripeCustomerId, 24 | return_url: returnUrl, 25 | }); 26 | 27 | return { data: stripeSession.url }; 28 | } 29 | 30 | const stripeSession = await stripe.checkout.sessions.create({ 31 | mode: "subscription", 32 | payment_method_types: ["card"], 33 | customer_email: user.emailAddresses[0].emailAddress, 34 | line_items: [ 35 | { 36 | quantity: 1, 37 | price_data: { 38 | currency: "USD", 39 | product_data: { 40 | name: "Lingo Pro", 41 | description: "Unlimited Hearts", 42 | }, 43 | unit_amount: 2000, // $20.00 USD 44 | recurring: { 45 | interval: "month", 46 | }, 47 | }, 48 | }, 49 | ], 50 | metadata: { 51 | userId, 52 | }, 53 | success_url: returnUrl, 54 | cancel_url: returnUrl, 55 | }); 56 | 57 | return { data: stripeSession.url }; 58 | }; 59 | -------------------------------------------------------------------------------- /components/quests.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import Image from "next/image"; 3 | 4 | import { quests } from "@/constants"; 5 | import { Button } from "@/components/ui/button"; 6 | import { Progress } from "@/components/ui/progress"; 7 | 8 | type Props = { 9 | points: number; 10 | }; 11 | 12 | export const Quests = ({ points }: Props) => { 13 | return ( 14 |
15 |
16 |

17 | Quests 18 |

19 | 20 | 26 | 27 |
28 |
    29 | {quests.map((quest) => { 30 | const progress = (points / quest.value) * 100; 31 | 32 | return ( 33 |
    37 | Points 43 |
    44 |

    45 | {quest.title} 46 |

    47 | 48 |
    49 |
    50 | ) 51 | })} 52 |
53 |
54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lingo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "db:studio": "npx drizzle-kit studio", 11 | "db:push": "npx drizzle-kit push:pg", 12 | "db:seed": "tsx ./scripts/seed.ts", 13 | "db:prod": "tsx ./scripts/prod.ts", 14 | "db:reset": "tsx ./scripts/reset.ts" 15 | }, 16 | "dependencies": { 17 | "@clerk/nextjs": "^4.29.9", 18 | "@neondatabase/serverless": "^0.9.0", 19 | "@radix-ui/react-avatar": "^1.0.4", 20 | "@radix-ui/react-dialog": "^1.0.5", 21 | "@radix-ui/react-progress": "^1.0.3", 22 | "@radix-ui/react-separator": "^1.0.3", 23 | "@radix-ui/react-slot": "^1.0.2", 24 | "class-variance-authority": "^0.7.0", 25 | "clsx": "^2.1.0", 26 | "dotenv": "^16.4.5", 27 | "drizzle-orm": "^0.30.1", 28 | "lucide-react": "^0.344.0", 29 | "next": "14.1.1", 30 | "next-themes": "^0.2.1", 31 | "ra-data-simple-rest": "^4.16.12", 32 | "react": "^18", 33 | "react-admin": "^4.16.12", 34 | "react-circular-progressbar": "^2.1.0", 35 | "react-confetti": "^6.1.0", 36 | "react-dom": "^18", 37 | "react-use": "^17.5.0", 38 | "sonner": "^1.4.3", 39 | "stripe": "^14.20.0", 40 | "tailwind-merge": "^2.2.1", 41 | "tailwindcss-animate": "^1.0.7", 42 | "zustand": "^4.5.2" 43 | }, 44 | "devDependencies": { 45 | "@types/node": "^20", 46 | "@types/react": "^18", 47 | "@types/react-dom": "^18", 48 | "autoprefixer": "^10.0.1", 49 | "drizzle-kit": "^0.20.14", 50 | "eslint": "^8", 51 | "eslint-config-next": "14.1.1", 52 | "pg": "^8.11.3", 53 | "postcss": "^8", 54 | "tailwindcss": "^3.3.0", 55 | "tsx": "^4.7.1", 56 | "typescript": "^5" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /components/sidebar.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import Image from "next/image"; 3 | import { 4 | ClerkLoading, 5 | ClerkLoaded, 6 | UserButton, 7 | } from "@clerk/nextjs"; 8 | import { Loader } from "lucide-react"; 9 | 10 | import { cn } from "@/lib/utils"; 11 | 12 | import { SidebarItem } from "./sidebar-item"; 13 | 14 | type Props = { 15 | className?: string; 16 | }; 17 | 18 | export const Sidebar = ({ className }: Props) => { 19 | return ( 20 |
24 | 25 |
26 | Mascot 27 |

28 | Lingo 29 |

30 |
31 | 32 |
33 | 38 | 43 | 48 | 53 |
54 |
55 | 56 | 57 | 58 | 59 | 60 | 61 |
62 |
63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /components/modals/practice-modal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import { useEffect, useState } from "react"; 5 | 6 | import { 7 | Dialog, 8 | DialogContent, 9 | DialogDescription, 10 | DialogFooter, 11 | DialogHeader, 12 | DialogTitle, 13 | } from "@/components/ui/dialog"; 14 | import { Button } from "@/components/ui/button"; 15 | import { usePracticeModal } from "@/store/use-practice-modal"; 16 | 17 | export const PracticeModal = () => { 18 | const [isClient, setIsClient] = useState(false); 19 | const { isOpen, close } = usePracticeModal(); 20 | 21 | useEffect(() => setIsClient(true), []); 22 | 23 | if (!isClient) { 24 | return null; 25 | } 26 | 27 | return ( 28 | 29 | 30 | 31 |
32 | Heart 38 |
39 | 40 | Practice lesson 41 | 42 | 43 | Use practice lessons to regain hearts and points. You cannot loose hearts or points in practice lessons. 44 | 45 |
46 | 47 |
48 | 56 |
57 |
58 |
59 |
60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /app/(marketing)/footer.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import Image from "next/image"; 3 | 4 | export const Footer = () => { 5 | return ( 6 |
7 |
8 | 18 | 28 | 38 | 48 | 58 |
59 |
60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Build a Duolingo Clone With Nextjs, React, Drizzle, Stripe (2024) 2 | 3 | ![Duolingo thumb (1)](https://github.com/AntonioErdeljac/next14-duolingo-clone/assets/23248726/d58e4b55-bb09-456f-978e-f5f31e81b870) 4 | 5 | This is a repository for a "Build a Duolingo Clone With Nextjs, React, Drizzle, Stripe (2024)" youtube video. 6 | 7 | [VIDEO TUTORIAL](https://www.youtube.com/watch?v=dP75Khfy4s4) 8 | 9 | Key Features: 10 | - 🌐 Next.js 14 & server actions 11 | - 🗣 AI Voices using Elevenlabs AI 12 | - 🎨 Beautiful component system using Shadcn UI 13 | - 🎭 Amazing characters thanks to KenneyNL 14 | - 🔐 Auth using Clerk 15 | - 🔊 Sound effects 16 | - ❤️ Hearts system 17 | - 🌟 Points / XP system 18 | - 💔 No hearts left popup 19 | - 🚪 Exit confirmation popup 20 | - 🔄 Practice old lessons to regain hearts 21 | - 🏆 Leaderboard 22 | - 🗺 Quests milestones 23 | - 🛍 Shop system to exchange points with hearts 24 | - 💳 Pro tier for unlimited hearts using Stripe 25 | - 🏠 Landing page 26 | - 📊 Admin dashboard React Admin 27 | - 🌧 ORM using DrizzleORM 28 | - 💾 PostgresDB using NeonDB 29 | - 🚀 Deployment on Vercel 30 | - 📱 Mobile responsiveness 31 | 32 | ### Prerequisites 33 | 34 | **Node version 14.x** 35 | 36 | ### Cloning the repository 37 | 38 | ```shell 39 | git clone https://github.com/AntonioErdeljac/next14-duolingo-clone.git 40 | ``` 41 | 42 | ### Install packages 43 | 44 | ```shell 45 | npm i 46 | ``` 47 | 48 | ### Setup .env file 49 | 50 | 51 | ```js 52 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="" 53 | CLERK_SECRET_KEY="" 54 | DATABASE_URL="postgresql://..." 55 | STRIPE_API_KEY="" 56 | NEXT_PUBLIC_APP_URL="http://localhost:3000" 57 | STRIPE_WEBHOOK_SECRET="" 58 | ``` 59 | 60 | ### Setup Drizzle ORM 61 | 62 | ```shell 63 | npm run db:push 64 | 65 | ``` 66 | 67 | ### Seed the app 68 | 69 | ```shell 70 | npm run db:seed 71 | 72 | ``` 73 | 74 | or 75 | 76 | ```shell 77 | npm run db:prod 78 | 79 | ``` 80 | 81 | ### Start the app 82 | 83 | ```shell 84 | npm run dev 85 | ``` 86 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body, 7 | :root { 8 | @apply h-full; 9 | } 10 | 11 | @layer base { 12 | :root { 13 | --background: 0 0% 100%; 14 | --foreground: 222.2 84% 4.9%; 15 | 16 | --card: 0 0% 100%; 17 | --card-foreground: 222.2 84% 4.9%; 18 | 19 | --popover: 0 0% 100%; 20 | --popover-foreground: 222.2 84% 4.9%; 21 | 22 | --primary: 222.2 47.4% 11.2%; 23 | --primary-foreground: 210 40% 98%; 24 | 25 | --secondary: 210 40% 96.1%; 26 | --secondary-foreground: 222.2 47.4% 11.2%; 27 | 28 | --muted: 210 40% 96.1%; 29 | --muted-foreground: 215.4 16.3% 46.9%; 30 | 31 | --accent: 210 40% 96.1%; 32 | --accent-foreground: 222.2 47.4% 11.2%; 33 | 34 | --destructive: 0 84.2% 60.2%; 35 | --destructive-foreground: 210 40% 98%; 36 | 37 | --border: 214.3 31.8% 91.4%; 38 | --input: 214.3 31.8% 91.4%; 39 | --ring: 222.2 84% 4.9%; 40 | 41 | --radius: 0.5rem; 42 | } 43 | 44 | .dark { 45 | --background: 222.2 84% 4.9%; 46 | --foreground: 210 40% 98%; 47 | 48 | --card: 222.2 84% 4.9%; 49 | --card-foreground: 210 40% 98%; 50 | 51 | --popover: 222.2 84% 4.9%; 52 | --popover-foreground: 210 40% 98%; 53 | 54 | --primary: 210 40% 98%; 55 | --primary-foreground: 222.2 47.4% 11.2%; 56 | 57 | --secondary: 217.2 32.6% 17.5%; 58 | --secondary-foreground: 210 40% 98%; 59 | 60 | --muted: 217.2 32.6% 17.5%; 61 | --muted-foreground: 215 20.2% 65.1%; 62 | 63 | --accent: 217.2 32.6% 17.5%; 64 | --accent-foreground: 210 40% 98%; 65 | 66 | --destructive: 0 62.8% 30.6%; 67 | --destructive-foreground: 210 40% 98%; 68 | 69 | --border: 217.2 32.6% 17.5%; 70 | --input: 217.2 32.6% 17.5%; 71 | --ring: 212.7 26.8% 83.9%; 72 | } 73 | } 74 | 75 | @layer base { 76 | * { 77 | @apply border-border; 78 | } 79 | body { 80 | @apply bg-background text-foreground; 81 | } 82 | } -------------------------------------------------------------------------------- /app/api/webhooks/stripe/route.ts: -------------------------------------------------------------------------------- 1 | import Stripe from "stripe"; 2 | import { eq } from "drizzle-orm"; 3 | import { headers } from "next/headers"; 4 | import { NextResponse } from "next/server"; 5 | 6 | import db from "@/db/drizzle"; 7 | import { stripe } from "@/lib/stripe"; 8 | import { userSubscription } from "@/db/schema"; 9 | 10 | export async function POST(req: Request) { 11 | const body = await req.text(); 12 | const signature = headers().get("Stripe-Signature") as string; 13 | 14 | let event: Stripe.Event; 15 | 16 | try { 17 | event = stripe.webhooks.constructEvent( 18 | body, 19 | signature, 20 | process.env.STRIPE_WEBHOOK_SECRET!, 21 | ); 22 | } catch(error: any) { 23 | return new NextResponse(`Webhook error: ${error.message}`, { 24 | status: 400, 25 | }); 26 | } 27 | 28 | const session = event.data.object as Stripe.Checkout.Session; 29 | 30 | if (event.type === "checkout.session.completed") { 31 | const subscription = await stripe.subscriptions.retrieve( 32 | session.subscription as string 33 | ); 34 | 35 | if (!session?.metadata?.userId) { 36 | return new NextResponse("User ID is required", { status: 400 }); 37 | } 38 | 39 | await db.insert(userSubscription).values({ 40 | userId: session.metadata.userId, 41 | stripeSubscriptionId: subscription.id, 42 | stripeCustomerId: subscription.customer as string, 43 | stripePriceId: subscription.items.data[0].price.id, 44 | stripeCurrentPeriodEnd: new Date( 45 | subscription.current_period_end * 1000, 46 | ), 47 | }); 48 | } 49 | 50 | if (event.type === "invoice.payment_succeeded") { 51 | const subscription = await stripe.subscriptions.retrieve( 52 | session.subscription as string 53 | ); 54 | 55 | await db.update(userSubscription).set({ 56 | stripePriceId: subscription.items.data[0].price.id, 57 | stripeCurrentPeriodEnd: new Date( 58 | subscription.current_period_end * 1000, 59 | ), 60 | }).where(eq(userSubscription.stripeSubscriptionId, subscription.id)) 61 | } 62 | 63 | return new NextResponse(null, { status: 200 }); 64 | }; 65 | -------------------------------------------------------------------------------- /app/(main)/shop/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { redirect } from "next/navigation"; 3 | 4 | import { Promo } from "@/components/promo"; 5 | import { FeedWrapper } from "@/components/feed-wrapper"; 6 | import { UserProgress } from "@/components/user-progress"; 7 | import { StickyWrapper } from "@/components/sticky-wrapper"; 8 | import { getUserProgress, getUserSubscription } from "@/db/queries"; 9 | 10 | import { Items } from "./items"; 11 | import { Quests } from "@/components/quests"; 12 | 13 | const ShopPage = async () => { 14 | const userProgressData = getUserProgress(); 15 | const userSubscriptionData = getUserSubscription(); 16 | 17 | const [ 18 | userProgress, 19 | userSubscription, 20 | ] = await Promise.all([ 21 | userProgressData, 22 | userSubscriptionData 23 | ]); 24 | 25 | if (!userProgress || !userProgress.activeCourse) { 26 | redirect("/courses"); 27 | } 28 | 29 | const isPro = !!userSubscription?.isActive; 30 | 31 | return ( 32 |
33 | 34 | 40 | {!isPro && ( 41 | 42 | )} 43 | 44 | 45 | 46 |
47 | Shop 53 |

54 | Shop 55 |

56 |

57 | Spend your points on cool stuff. 58 |

59 | 64 |
65 |
66 |
67 | ); 68 | }; 69 | 70 | export default ShopPage; 71 | -------------------------------------------------------------------------------- /app/admin/app.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Admin, Resource } from "react-admin"; 4 | import simpleRestProvider from "ra-data-simple-rest"; 5 | 6 | import { CourseList } from "./course/list"; 7 | import { CourseEdit } from "./course/edit"; 8 | import { CourseCreate } from "./course/create"; 9 | 10 | import { UnitList } from "./unit/list"; 11 | import { UnitEdit } from "./unit/edit"; 12 | import { UnitCreate } from "./unit/create"; 13 | 14 | import { LessonList } from "./lesson/list"; 15 | import { LessonEdit } from "./lesson/edit"; 16 | import { LessonCreate } from "./lesson/create"; 17 | 18 | import { ChallengeList } from "./challenge/list"; 19 | import { ChallengeEdit } from "./challenge/edit"; 20 | import { ChallengeCreate } from "./challenge/create"; 21 | 22 | import { ChallengeOptionList } from "./challengeOption/list"; 23 | import { ChallengeOptionEdit } from "./challengeOption/edit"; 24 | import { ChallengeOptionCreate } from "./challengeOption/create"; 25 | 26 | const dataProvider = simpleRestProvider("/api"); 27 | 28 | const App = () => { 29 | return ( 30 | 31 | 38 | 45 | 52 | 59 | 67 | 68 | ); 69 | }; 70 | 71 | export default App; 72 | -------------------------------------------------------------------------------- /app/lesson/footer.tsx: -------------------------------------------------------------------------------- 1 | import { useKey, useMedia } from "react-use"; 2 | import { CheckCircle, XCircle } from "lucide-react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | import { Button } from "@/components/ui/button"; 6 | 7 | type Props = { 8 | onCheck: () => void; 9 | status: "correct" | "wrong" | "none" | "completed"; 10 | disabled?: boolean; 11 | lessonId?: number; 12 | }; 13 | 14 | export const Footer = ({ 15 | onCheck, 16 | status, 17 | disabled, 18 | lessonId, 19 | }: Props) => { 20 | useKey("Enter", onCheck, {}, [onCheck]); 21 | const isMobile = useMedia("(max-width: 1024px)"); 22 | 23 | return ( 24 |
29 |
30 | {status === "correct" && ( 31 |
32 | 33 | Nicely done! 34 |
35 | )} 36 | {status === "wrong" && ( 37 |
38 | 39 | Try again. 40 |
41 | )} 42 | {status === "completed" && ( 43 | 50 | )} 51 | 63 |
64 |
65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /components/modals/exit-modal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import { useRouter } from "next/navigation"; 5 | import { useEffect, useState } from "react"; 6 | 7 | import { 8 | Dialog, 9 | DialogContent, 10 | DialogDescription, 11 | DialogFooter, 12 | DialogHeader, 13 | DialogTitle, 14 | } from "@/components/ui/dialog"; 15 | import { Button } from "@/components/ui/button"; 16 | import { useExitModal } from "@/store/use-exit-modal"; 17 | 18 | export const ExitModal = () => { 19 | const router = useRouter(); 20 | const [isClient, setIsClient] = useState(false); 21 | const { isOpen, close } = useExitModal(); 22 | 23 | useEffect(() => setIsClient(true), []); 24 | 25 | if (!isClient) { 26 | return null; 27 | } 28 | 29 | return ( 30 | 31 | 32 | 33 |
34 | Mascot 40 |
41 | 42 | Wait, don't go! 43 | 44 | 45 | You're about to leave the lesson. Are you sure? 46 | 47 |
48 | 49 |
50 | 58 | 69 |
70 |
71 |
72 |
73 | ); 74 | }; 75 | -------------------------------------------------------------------------------- /components/modals/hearts-modal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import { useRouter } from "next/navigation"; 5 | import { useEffect, useState } from "react"; 6 | 7 | import { 8 | Dialog, 9 | DialogContent, 10 | DialogDescription, 11 | DialogFooter, 12 | DialogHeader, 13 | DialogTitle, 14 | } from "@/components/ui/dialog"; 15 | import { Button } from "@/components/ui/button"; 16 | import { useHeartsModal } from "@/store/use-hearts-modal"; 17 | 18 | export const HeartsModal = () => { 19 | const router = useRouter(); 20 | const [isClient, setIsClient] = useState(false); 21 | const { isOpen, close } = useHeartsModal(); 22 | 23 | useEffect(() => setIsClient(true), []); 24 | 25 | const onClick = () => { 26 | close(); 27 | router.push("/store"); 28 | }; 29 | 30 | if (!isClient) { 31 | return null; 32 | } 33 | 34 | return ( 35 | 36 | 37 | 38 |
39 | Mascot 45 |
46 | 47 | You ran out of hearts! 48 | 49 | 50 | Get Pro for unlimited hearts, or purchase them in the store. 51 | 52 |
53 | 54 |
55 | 63 | 71 |
72 |
73 |
74 |
75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /app/(marketing)/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { Loader } from "lucide-react"; 3 | import { 4 | ClerkLoaded, 5 | ClerkLoading, 6 | SignInButton, 7 | SignUpButton, 8 | SignedIn, 9 | SignedOut 10 | } from "@clerk/nextjs"; 11 | import { Button } from "@/components/ui/button"; 12 | import Link from "next/link"; 13 | 14 | export default function Home() { 15 | return ( 16 |
17 |
18 | Hero 19 |
20 |
21 |

22 | Learn, practice, and master new languages with Lingo. 23 |

24 |
25 | 26 | 27 | 28 | 29 | 30 | 35 | 38 | 39 | 44 | 47 | 48 | 49 | 50 | 55 | 56 | 57 |
58 |
59 |
60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss" 2 | 3 | const config = { 4 | darkMode: ["class"], 5 | content: [ 6 | './pages/**/*.{ts,tsx}', 7 | './components/**/*.{ts,tsx}', 8 | './app/**/*.{ts,tsx}', 9 | './src/**/*.{ts,tsx}', 10 | ], 11 | prefix: "", 12 | theme: { 13 | container: { 14 | center: true, 15 | padding: "2rem", 16 | screens: { 17 | "2xl": "1400px", 18 | }, 19 | }, 20 | extend: { 21 | colors: { 22 | border: "hsl(var(--border))", 23 | input: "hsl(var(--input))", 24 | ring: "hsl(var(--ring))", 25 | background: "hsl(var(--background))", 26 | foreground: "hsl(var(--foreground))", 27 | primary: { 28 | DEFAULT: "hsl(var(--primary))", 29 | foreground: "hsl(var(--primary-foreground))", 30 | }, 31 | secondary: { 32 | DEFAULT: "hsl(var(--secondary))", 33 | foreground: "hsl(var(--secondary-foreground))", 34 | }, 35 | destructive: { 36 | DEFAULT: "hsl(var(--destructive))", 37 | foreground: "hsl(var(--destructive-foreground))", 38 | }, 39 | muted: { 40 | DEFAULT: "hsl(var(--muted))", 41 | foreground: "hsl(var(--muted-foreground))", 42 | }, 43 | accent: { 44 | DEFAULT: "hsl(var(--accent))", 45 | foreground: "hsl(var(--accent-foreground))", 46 | }, 47 | popover: { 48 | DEFAULT: "hsl(var(--popover))", 49 | foreground: "hsl(var(--popover-foreground))", 50 | }, 51 | card: { 52 | DEFAULT: "hsl(var(--card))", 53 | foreground: "hsl(var(--card-foreground))", 54 | }, 55 | }, 56 | borderRadius: { 57 | lg: "var(--radius)", 58 | md: "calc(var(--radius) - 2px)", 59 | sm: "calc(var(--radius) - 4px)", 60 | }, 61 | keyframes: { 62 | "accordion-down": { 63 | from: { height: "0" }, 64 | to: { height: "var(--radix-accordion-content-height)" }, 65 | }, 66 | "accordion-up": { 67 | from: { height: "var(--radix-accordion-content-height)" }, 68 | to: { height: "0" }, 69 | }, 70 | }, 71 | animation: { 72 | "accordion-down": "accordion-down 0.2s ease-out", 73 | "accordion-up": "accordion-up 0.2s ease-out", 74 | }, 75 | }, 76 | }, 77 | plugins: [require("tailwindcss-animate")], 78 | } satisfies Config 79 | 80 | export default config -------------------------------------------------------------------------------- /actions/challenge-progress.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { auth } from "@clerk/nextjs"; 4 | import { and, eq } from "drizzle-orm"; 5 | import { revalidatePath } from "next/cache"; 6 | 7 | import db from "@/db/drizzle"; 8 | import { getUserProgress, getUserSubscription } from "@/db/queries"; 9 | import { challengeProgress, challenges, userProgress } from "@/db/schema"; 10 | 11 | export const upsertChallengeProgress = async (challengeId: number) => { 12 | const { userId } = await auth(); 13 | 14 | if (!userId) { 15 | throw new Error("Unauthorized"); 16 | } 17 | 18 | const currentUserProgress = await getUserProgress(); 19 | const userSubscription = await getUserSubscription(); 20 | 21 | if (!currentUserProgress) { 22 | throw new Error("User progress not found"); 23 | } 24 | 25 | const challenge = await db.query.challenges.findFirst({ 26 | where: eq(challenges.id, challengeId) 27 | }); 28 | 29 | if (!challenge) { 30 | throw new Error("Challenge not found"); 31 | } 32 | 33 | const lessonId = challenge.lessonId; 34 | 35 | const existingChallengeProgress = await db.query.challengeProgress.findFirst({ 36 | where: and( 37 | eq(challengeProgress.userId, userId), 38 | eq(challengeProgress.challengeId, challengeId), 39 | ), 40 | }); 41 | 42 | const isPractice = !!existingChallengeProgress; 43 | 44 | if ( 45 | currentUserProgress.hearts === 0 && 46 | !isPractice && 47 | !userSubscription?.isActive 48 | ) { 49 | return { error: "hearts" }; 50 | } 51 | 52 | if (isPractice) { 53 | await db.update(challengeProgress).set({ 54 | completed: true, 55 | }) 56 | .where( 57 | eq(challengeProgress.id, existingChallengeProgress.id) 58 | ); 59 | 60 | await db.update(userProgress).set({ 61 | hearts: Math.min(currentUserProgress.hearts + 1, 5), 62 | points: currentUserProgress.points + 10, 63 | }).where(eq(userProgress.userId, userId)); 64 | 65 | revalidatePath("/learn"); 66 | revalidatePath("/lesson"); 67 | revalidatePath("/quests"); 68 | revalidatePath("/leaderboard"); 69 | revalidatePath(`/lesson/${lessonId}`); 70 | return; 71 | } 72 | 73 | await db.insert(challengeProgress).values({ 74 | challengeId, 75 | userId, 76 | completed: true, 77 | }); 78 | 79 | await db.update(userProgress).set({ 80 | points: currentUserProgress.points + 10, 81 | }).where(eq(userProgress.userId, userId)); 82 | 83 | revalidatePath("/learn"); 84 | revalidatePath("/lesson"); 85 | revalidatePath("/quests"); 86 | revalidatePath("/leaderboard"); 87 | revalidatePath(`/lesson/${lessonId}`); 88 | }; 89 | -------------------------------------------------------------------------------- /app/(main)/learn/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | import { Promo } from "@/components/promo"; 4 | import { Quests } from "@/components/quests"; 5 | import { FeedWrapper } from "@/components/feed-wrapper"; 6 | import { UserProgress } from "@/components/user-progress"; 7 | import { StickyWrapper } from "@/components/sticky-wrapper"; 8 | import { lessons, units as unitsSchema } from "@/db/schema"; 9 | import { 10 | getCourseProgress, 11 | getLessonPercentage, 12 | getUnits, 13 | getUserProgress, 14 | getUserSubscription 15 | } from "@/db/queries"; 16 | 17 | import { Unit } from "./unit"; 18 | import { Header } from "./header"; 19 | 20 | const LearnPage = async () => { 21 | const userProgressData = getUserProgress(); 22 | const courseProgressData = getCourseProgress(); 23 | const lessonPercentageData = getLessonPercentage(); 24 | const unitsData = getUnits(); 25 | const userSubscriptionData = getUserSubscription(); 26 | 27 | const [ 28 | userProgress, 29 | units, 30 | courseProgress, 31 | lessonPercentage, 32 | userSubscription, 33 | ] = await Promise.all([ 34 | userProgressData, 35 | unitsData, 36 | courseProgressData, 37 | lessonPercentageData, 38 | userSubscriptionData, 39 | ]); 40 | 41 | if (!userProgress || !userProgress.activeCourse) { 42 | redirect("/courses"); 43 | } 44 | 45 | if (!courseProgress) { 46 | redirect("/courses"); 47 | } 48 | 49 | const isPro = !!userSubscription?.isActive; 50 | 51 | return ( 52 |
53 | 54 | 60 | {!isPro && ( 61 | 62 | )} 63 | 64 | 65 | 66 |
67 | {units.map((unit) => ( 68 |
69 | 80 |
81 | ))} 82 | 83 |
84 | ); 85 | }; 86 | 87 | export default LearnPage; 88 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-xl text-sm font-bold ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 uppercase tracking-wide", 9 | { 10 | variants: { 11 | variant: { 12 | locked: "bg-neutral-200 text-primary-foreground hover:bg-neutral-200/90 border-neutral-400 border-b-4 active:border-b-0", 13 | default: "bg-white text-black border-slate-200 border-2 border-b-4 active:border-b-2 hover:bg-slate-100 text-slate-500", 14 | primary: "bg-sky-400 text-primary-foreground hover:bg-sky-400/90 border-sky-500 border-b-4 active:border-b-0", 15 | primaryOutline: "bg-white text-sky-500 hover:bg-slate-100", 16 | secondary: "bg-green-500 text-primary-foreground hover:bg-green-500/90 border-green-600 border-b-4 active:border-b-0", 17 | secondaryOutline: "bg-white text-green-500 hover:bg-slate-100", 18 | danger: "bg-rose-500 text-primary-foreground hover:bg-rose-500/90 border-rose-600 border-b-4 active:border-b-0", 19 | dangerOutline: "bg-white text-rose-500 hover:bg-slate-100", 20 | super: "bg-indigo-500 text-primary-foreground hover:bg-indigo-500/90 border-indigo-600 border-b-4 active:border-b-0", 21 | superOutline: "bg-white text-indigo-500 hover:bg-slate-100", 22 | ghost: "bg-transparent text-slate-500 border-transparent border-0 hover:bg-slate-100", 23 | sidebar: "bg-transparent text-slate-500 border-2 border-transparent hover:bg-slate-100 transition-none", 24 | sidebarOutline: "bg-sky-500/15 text-sky-500 border-sky-300 border-2 hover:bg-sky-500/20 transition-none" 25 | }, 26 | size: { 27 | default: "h-11 px-4 py-2", 28 | sm: "h-9 px-3", 29 | lg: "h-12 px-8", 30 | icon: "h-10 w-10", 31 | rounded: "rounded-full", 32 | }, 33 | }, 34 | defaultVariants: { 35 | variant: "default", 36 | size: "default", 37 | }, 38 | } 39 | ) 40 | 41 | export interface ButtonProps 42 | extends React.ButtonHTMLAttributes, 43 | VariantProps { 44 | asChild?: boolean 45 | } 46 | 47 | const Button = React.forwardRef( 48 | ({ className, variant, size, asChild = false, ...props }, ref) => { 49 | const Comp = asChild ? Slot : "button" 50 | return ( 51 | 56 | ) 57 | } 58 | ) 59 | Button.displayName = "Button" 60 | 61 | export { Button, buttonVariants } 62 | -------------------------------------------------------------------------------- /app/(main)/quests/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { redirect } from "next/navigation"; 3 | 4 | import { FeedWrapper } from "@/components/feed-wrapper"; 5 | import { UserProgress } from "@/components/user-progress"; 6 | import { StickyWrapper } from "@/components/sticky-wrapper"; 7 | import { getUserProgress, getUserSubscription } from "@/db/queries"; 8 | import { Progress } from "@/components/ui/progress"; 9 | import { Promo } from "@/components/promo"; 10 | import { quests } from "@/constants"; 11 | 12 | const QuestsPage = async () => { 13 | const userProgressData = getUserProgress(); 14 | const userSubscriptionData = getUserSubscription(); 15 | 16 | const [ 17 | userProgress, 18 | userSubscription, 19 | ] = await Promise.all([ 20 | userProgressData, 21 | userSubscriptionData, 22 | ]); 23 | 24 | if (!userProgress || !userProgress.activeCourse) { 25 | redirect("/courses"); 26 | } 27 | 28 | const isPro = !!userSubscription?.isActive; 29 | 30 | return ( 31 |
32 | 33 | 39 | {!isPro && ( 40 | 41 | )} 42 | 43 | 44 |
45 | Quests 51 |

52 | Quests 53 |

54 |

55 | Complete quests by earning points. 56 |

57 |
    58 | {quests.map((quest) => { 59 | const progress = (userProgress.points / quest.value) * 100; 60 | 61 | return ( 62 |
    66 | Points 72 |
    73 |

    74 | {quest.title} 75 |

    76 | 77 |
    78 |
    79 | ) 80 | })} 81 |
82 |
83 |
84 |
85 | ); 86 | }; 87 | 88 | export default QuestsPage; 89 | -------------------------------------------------------------------------------- /app/lesson/card.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { useCallback } from "react"; 3 | import { useAudio, useKey } from "react-use"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | import { challenges } from "@/db/schema"; 7 | 8 | type Props = { 9 | id: number; 10 | imageSrc: string | null; 11 | audioSrc: string | null; 12 | text: string; 13 | shortcut: string; 14 | selected?: boolean; 15 | onClick: () => void; 16 | disabled?: boolean; 17 | status?: "correct" | "wrong" | "none", 18 | type: typeof challenges.$inferSelect["type"]; 19 | }; 20 | 21 | export const Card = ({ 22 | id, 23 | imageSrc, 24 | audioSrc, 25 | text, 26 | shortcut, 27 | selected, 28 | onClick, 29 | status, 30 | disabled, 31 | type, 32 | }: Props) => { 33 | const [audio, _, controls] = useAudio({ src: audioSrc || "" }); 34 | 35 | const handleClick = useCallback(() => { 36 | if (disabled) return; 37 | 38 | controls.play(); 39 | onClick(); 40 | }, [disabled, onClick, controls]); 41 | 42 | useKey(shortcut, handleClick, {}, [handleClick]); 43 | 44 | return ( 45 |
58 | {audio} 59 | {imageSrc && ( 60 |
63 | {text} 64 |
65 | )} 66 |
70 | {type === "ASSIST" &&
} 71 |

79 | {text} 80 |

81 |
89 | {shortcut} 90 |
91 |
92 |
93 | ); 94 | }; 95 | -------------------------------------------------------------------------------- /app/(main)/shop/items.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { toast } from "sonner"; 4 | import Image from "next/image"; 5 | import { useTransition } from "react"; 6 | 7 | import { Button } from "@/components/ui/button"; 8 | import { POINTS_TO_REFILL } from "@/constants"; 9 | import { refillHearts } from "@/actions/user-progress"; 10 | import { createStripeUrl } from "@/actions/user-subscription"; 11 | 12 | type Props = { 13 | hearts: number; 14 | points: number; 15 | hasActiveSubscription: boolean; 16 | }; 17 | 18 | export const Items = ({ 19 | hearts, 20 | points, 21 | hasActiveSubscription, 22 | }: Props) => { 23 | const [pending, startTransition] = useTransition(); 24 | 25 | const onRefillHearts = () => { 26 | if (pending || hearts === 5 || points < POINTS_TO_REFILL) { 27 | return; 28 | } 29 | 30 | startTransition(() => { 31 | refillHearts() 32 | .catch(() => toast.error("Something went wrong")); 33 | }); 34 | }; 35 | 36 | const onUpgrade = () => { 37 | startTransition(() => { 38 | createStripeUrl() 39 | .then((response) => { 40 | if (response.data) { 41 | window.location.href = response.data; 42 | } 43 | }) 44 | .catch(() => toast.error("Something went wrong")); 45 | }); 46 | }; 47 | 48 | return ( 49 |
    50 |
    51 | Heart 57 |
    58 |

    59 | Refill hearts 60 |

    61 |
    62 | 87 |
    88 |
    89 | Unlimited 95 |
    96 |

    97 | Unlimited hearts 98 |

    99 |
    100 | 106 |
    107 |
108 | ); 109 | }; 110 | -------------------------------------------------------------------------------- /app/(main)/leaderboard/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { redirect } from "next/navigation"; 3 | 4 | import { FeedWrapper } from "@/components/feed-wrapper"; 5 | import { UserProgress } from "@/components/user-progress"; 6 | import { StickyWrapper } from "@/components/sticky-wrapper"; 7 | import { getTopTenUsers, getUserProgress, getUserSubscription } from "@/db/queries"; 8 | import { Separator } from "@/components/ui/separator"; 9 | import { Avatar, AvatarImage } from "@/components/ui/avatar"; 10 | import { Promo } from "@/components/promo"; 11 | import { Quests } from "@/components/quests"; 12 | 13 | const LearderboardPage = async () => { 14 | const userProgressData = getUserProgress(); 15 | const userSubscriptionData = getUserSubscription(); 16 | const leaderboardData = getTopTenUsers(); 17 | 18 | const [ 19 | userProgress, 20 | userSubscription, 21 | leaderboard, 22 | ] = await Promise.all([ 23 | userProgressData, 24 | userSubscriptionData, 25 | leaderboardData, 26 | ]); 27 | 28 | if (!userProgress || !userProgress.activeCourse) { 29 | redirect("/courses"); 30 | } 31 | 32 | const isPro = !!userSubscription?.isActive; 33 | 34 | return ( 35 |
36 | 37 | 43 | {!isPro && ( 44 | 45 | )} 46 | 47 | 48 | 49 |
50 | Leaderboard 56 |

57 | Leaderboard 58 |

59 |

60 | See where you stand among other learners in the community. 61 |

62 | 63 | {leaderboard.map((userProgress, index) => ( 64 |
68 |

{index + 1}

69 | 72 | 76 | 77 |

78 | {userProgress.userName} 79 |

80 |

81 | {userProgress.points} XP 82 |

83 |
84 | ))} 85 |
86 |
87 |
88 | ); 89 | }; 90 | 91 | export default LearderboardPage; 92 | -------------------------------------------------------------------------------- /public/mascot.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /public/points.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/(main)/learn/lesson-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { Check, Crown, Star } from "lucide-react"; 5 | import { CircularProgressbarWithChildren } from "react-circular-progressbar"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | import { Button } from "@/components/ui/button"; 9 | 10 | import "react-circular-progressbar/dist/styles.css"; 11 | 12 | type Props = { 13 | id: number; 14 | index: number; 15 | totalCount: number; 16 | locked?: boolean; 17 | current?: boolean; 18 | percentage: number; 19 | }; 20 | 21 | export const LessonButton = ({ 22 | id, 23 | index, 24 | totalCount, 25 | locked, 26 | current, 27 | percentage 28 | }: Props) => { 29 | const cycleLength = 8; 30 | const cycleIndex = index % cycleLength; 31 | 32 | let indentationLevel; 33 | 34 | if (cycleIndex <= 2) { 35 | indentationLevel = cycleIndex; 36 | } else if (cycleIndex <= 4) { 37 | indentationLevel = 4 - cycleIndex; 38 | } else if (cycleIndex <= 6) { 39 | indentationLevel = 4 - cycleIndex; 40 | } else { 41 | indentationLevel = cycleIndex - 8; 42 | } 43 | 44 | const rightPosition = indentationLevel * 40; 45 | 46 | const isFirst = index === 0; 47 | const isLast = index === totalCount; 48 | const isCompleted = !current && !locked; 49 | 50 | const Icon = isCompleted ? Check : isLast ? Crown : Star; 51 | 52 | const href = isCompleted ? `/lesson/${id}` : "/lesson"; 53 | 54 | return ( 55 | 60 |
67 | {current ? ( 68 |
69 |
70 | Start 71 |
74 |
75 | 86 | 101 | 102 |
103 | ) : ( 104 | 119 | )} 120 |
121 | 122 | ); 123 | }; 124 | -------------------------------------------------------------------------------- /actions/user-progress.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { and, eq } from "drizzle-orm"; 4 | import { redirect } from "next/navigation"; 5 | import { revalidatePath } from "next/cache"; 6 | import { auth, currentUser } from "@clerk/nextjs"; 7 | 8 | import db from "@/db/drizzle"; 9 | import { POINTS_TO_REFILL } from "@/constants"; 10 | import { getCourseById, getUserProgress, getUserSubscription } from "@/db/queries"; 11 | import { challengeProgress, challenges, userProgress } from "@/db/schema"; 12 | 13 | export const upsertUserProgress = async (courseId: number) => { 14 | const { userId } = await auth(); 15 | const user = await currentUser(); 16 | 17 | if (!userId || !user) { 18 | throw new Error("Unauthorized"); 19 | } 20 | 21 | const course = await getCourseById(courseId); 22 | 23 | if (!course) { 24 | throw new Error("Course not found"); 25 | } 26 | 27 | if (!course.units.length || !course.units[0].lessons.length) { 28 | throw new Error("Course is empty"); 29 | } 30 | 31 | const existingUserProgress = await getUserProgress(); 32 | 33 | if (existingUserProgress) { 34 | await db.update(userProgress).set({ 35 | activeCourseId: courseId, 36 | userName: user.firstName || "User", 37 | userImageSrc: user.imageUrl || "/mascot.svg", 38 | }); 39 | 40 | revalidatePath("/courses"); 41 | revalidatePath("/learn"); 42 | redirect("/learn"); 43 | } 44 | 45 | await db.insert(userProgress).values({ 46 | userId, 47 | activeCourseId: courseId, 48 | userName: user.firstName || "User", 49 | userImageSrc: user.imageUrl || "/mascot.svg", 50 | }); 51 | 52 | revalidatePath("/courses"); 53 | revalidatePath("/learn"); 54 | redirect("/learn"); 55 | }; 56 | 57 | export const reduceHearts = async (challengeId: number) => { 58 | const { userId } = await auth(); 59 | 60 | if (!userId) { 61 | throw new Error("Unauthorized"); 62 | } 63 | 64 | const currentUserProgress = await getUserProgress(); 65 | const userSubscription = await getUserSubscription(); 66 | 67 | const challenge = await db.query.challenges.findFirst({ 68 | where: eq(challenges.id, challengeId), 69 | }); 70 | 71 | if (!challenge) { 72 | throw new Error("Challenge not found"); 73 | } 74 | 75 | const lessonId = challenge.lessonId; 76 | 77 | const existingChallengeProgress = await db.query.challengeProgress.findFirst({ 78 | where: and( 79 | eq(challengeProgress.userId, userId), 80 | eq(challengeProgress.challengeId, challengeId), 81 | ), 82 | }); 83 | 84 | const isPractice = !!existingChallengeProgress; 85 | 86 | if (isPractice) { 87 | return { error: "practice" }; 88 | } 89 | 90 | if (!currentUserProgress) { 91 | throw new Error("User progress not found"); 92 | } 93 | 94 | if (userSubscription?.isActive) { 95 | return { error: "subscription" }; 96 | } 97 | 98 | if (currentUserProgress.hearts === 0) { 99 | return { error: "hearts" }; 100 | } 101 | 102 | await db.update(userProgress).set({ 103 | hearts: Math.max(currentUserProgress.hearts - 1, 0), 104 | }).where(eq(userProgress.userId, userId)); 105 | 106 | revalidatePath("/shop"); 107 | revalidatePath("/learn"); 108 | revalidatePath("/quests"); 109 | revalidatePath("/leaderboard"); 110 | revalidatePath(`/lesson/${lessonId}`); 111 | }; 112 | 113 | export const refillHearts = async () => { 114 | const currentUserProgress = await getUserProgress(); 115 | 116 | if (!currentUserProgress) { 117 | throw new Error("User progress not found"); 118 | } 119 | 120 | if (currentUserProgress.hearts === 5) { 121 | throw new Error("Hearts are already full"); 122 | } 123 | 124 | if (currentUserProgress.points < POINTS_TO_REFILL) { 125 | throw new Error("Not enough points"); 126 | } 127 | 128 | await db.update(userProgress).set({ 129 | hearts: 5, 130 | points: currentUserProgress.points - POINTS_TO_REFILL, 131 | }).where(eq(userProgress.userId, currentUserProgress.userId)); 132 | 133 | revalidatePath("/shop"); 134 | revalidatePath("/learn"); 135 | revalidatePath("/quests"); 136 | revalidatePath("/leaderboard"); 137 | }; 138 | -------------------------------------------------------------------------------- /public/mascot_sad.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { X } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogClose, 116 | DialogTrigger, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | -------------------------------------------------------------------------------- /public/mascot_bad.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /db/schema.ts: -------------------------------------------------------------------------------- 1 | import { relations } from "drizzle-orm"; 2 | import { boolean, integer, pgEnum, pgTable, serial, text, timestamp } from "drizzle-orm/pg-core"; 3 | 4 | export const courses = pgTable("courses", { 5 | id: serial("id").primaryKey(), 6 | title: text("title").notNull(), 7 | imageSrc: text("image_src").notNull(), 8 | }); 9 | 10 | export const coursesRelations = relations(courses, ({ many }) => ({ 11 | userProgress: many(userProgress), 12 | units: many(units), 13 | })); 14 | 15 | export const units = pgTable("units", { 16 | id: serial("id").primaryKey(), 17 | title: text("title").notNull(), // Unit 1 18 | description: text("description").notNull(), // Learn the basics of spanish 19 | courseId: integer("course_id").references(() => courses.id, { onDelete: "cascade" }).notNull(), 20 | order: integer("order").notNull(), 21 | }); 22 | 23 | export const unitsRelations = relations(units, ({ many, one }) => ({ 24 | course: one(courses, { 25 | fields: [units.courseId], 26 | references: [courses.id], 27 | }), 28 | lessons: many(lessons), 29 | })); 30 | 31 | export const lessons = pgTable("lessons", { 32 | id: serial("id").primaryKey(), 33 | title: text("title").notNull(), 34 | unitId: integer("unit_id").references(() => units.id, { onDelete: "cascade" }).notNull(), 35 | order: integer("order").notNull(), 36 | }); 37 | 38 | export const lessonsRelations = relations(lessons, ({ one, many }) => ({ 39 | unit: one(units, { 40 | fields: [lessons.unitId], 41 | references: [units.id], 42 | }), 43 | challenges: many(challenges), 44 | })); 45 | 46 | export const challengesEnum = pgEnum("type", ["SELECT", "ASSIST"]); 47 | 48 | export const challenges = pgTable("challenges", { 49 | id: serial("id").primaryKey(), 50 | lessonId: integer("lesson_id").references(() => lessons.id, { onDelete: "cascade" }).notNull(), 51 | type: challengesEnum("type").notNull(), 52 | question: text("question").notNull(), 53 | order: integer("order").notNull(), 54 | }); 55 | 56 | export const challengesRelations = relations(challenges, ({ one, many }) => ({ 57 | lesson: one(lessons, { 58 | fields: [challenges.lessonId], 59 | references: [lessons.id], 60 | }), 61 | challengeOptions: many(challengeOptions), 62 | challengeProgress: many(challengeProgress), 63 | })); 64 | 65 | export const challengeOptions = pgTable("challenge_options", { 66 | id: serial("id").primaryKey(), 67 | challengeId: integer("challenge_id").references(() => challenges.id, { onDelete: "cascade" }).notNull(), 68 | text: text("text").notNull(), 69 | correct: boolean("correct").notNull(), 70 | imageSrc: text("image_src"), 71 | audioSrc: text("audio_src"), 72 | }); 73 | 74 | export const challengeOptionsRelations = relations(challengeOptions, ({ one }) => ({ 75 | challenge: one(challenges, { 76 | fields: [challengeOptions.challengeId], 77 | references: [challenges.id], 78 | }), 79 | })); 80 | 81 | export const challengeProgress = pgTable("challenge_progress", { 82 | id: serial("id").primaryKey(), 83 | userId: text("user_id").notNull(), 84 | challengeId: integer("challenge_id").references(() => challenges.id, { onDelete: "cascade" }).notNull(), 85 | completed: boolean("completed").notNull().default(false), 86 | }); 87 | 88 | export const challengeProgressRelations = relations(challengeProgress, ({ one }) => ({ 89 | challenge: one(challenges, { 90 | fields: [challengeProgress.challengeId], 91 | references: [challenges.id], 92 | }), 93 | })); 94 | 95 | export const userProgress = pgTable("user_progress", { 96 | userId: text("user_id").primaryKey(), 97 | userName: text("user_name").notNull().default("User"), 98 | userImageSrc: text("user_image_src").notNull().default("/mascot.svg"), 99 | activeCourseId: integer("active_course_id").references(() => courses.id, { onDelete: "cascade" }), 100 | hearts: integer("hearts").notNull().default(5), 101 | points: integer("points").notNull().default(0), 102 | }); 103 | 104 | export const userProgressRelations = relations(userProgress, ({ one }) => ({ 105 | activeCourse: one(courses, { 106 | fields: [userProgress.activeCourseId], 107 | references: [courses.id], 108 | }), 109 | })); 110 | 111 | export const userSubscription = pgTable("user_subscription", { 112 | id: serial("id").primaryKey(), 113 | userId: text("user_id").notNull().unique(), 114 | stripeCustomerId: text("stripe_customer_id").notNull().unique(), 115 | stripeSubscriptionId: text("stripe_subscription_id").notNull().unique(), 116 | stripePriceId: text("stripe_price_id").notNull(), 117 | stripeCurrentPeriodEnd: timestamp("stripe_current_period_end").notNull(), 118 | }); 119 | -------------------------------------------------------------------------------- /components/ui/sheet.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SheetPrimitive from "@radix-ui/react-dialog" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | import { X } from "lucide-react" 7 | 8 | import { cn } from "@/lib/utils" 9 | 10 | const Sheet = SheetPrimitive.Root 11 | 12 | const SheetTrigger = SheetPrimitive.Trigger 13 | 14 | const SheetClose = SheetPrimitive.Close 15 | 16 | const SheetPortal = SheetPrimitive.Portal 17 | 18 | const SheetOverlay = React.forwardRef< 19 | React.ElementRef, 20 | React.ComponentPropsWithoutRef 21 | >(({ className, ...props }, ref) => ( 22 | 30 | )) 31 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName 32 | 33 | const sheetVariants = cva( 34 | "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", 35 | { 36 | variants: { 37 | side: { 38 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", 39 | bottom: 40 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", 41 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", 42 | right: 43 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", 44 | }, 45 | }, 46 | defaultVariants: { 47 | side: "right", 48 | }, 49 | } 50 | ) 51 | 52 | interface SheetContentProps 53 | extends React.ComponentPropsWithoutRef, 54 | VariantProps {} 55 | 56 | const SheetContent = React.forwardRef< 57 | React.ElementRef, 58 | SheetContentProps 59 | >(({ side = "right", className, children, ...props }, ref) => ( 60 | 61 | 62 | 67 | {children} 68 | 69 | 70 | Close 71 | 72 | 73 | 74 | )) 75 | SheetContent.displayName = SheetPrimitive.Content.displayName 76 | 77 | const SheetHeader = ({ 78 | className, 79 | ...props 80 | }: React.HTMLAttributes) => ( 81 |
88 | ) 89 | SheetHeader.displayName = "SheetHeader" 90 | 91 | const SheetFooter = ({ 92 | className, 93 | ...props 94 | }: React.HTMLAttributes) => ( 95 |
102 | ) 103 | SheetFooter.displayName = "SheetFooter" 104 | 105 | const SheetTitle = React.forwardRef< 106 | React.ElementRef, 107 | React.ComponentPropsWithoutRef 108 | >(({ className, ...props }, ref) => ( 109 | 114 | )) 115 | SheetTitle.displayName = SheetPrimitive.Title.displayName 116 | 117 | const SheetDescription = React.forwardRef< 118 | React.ElementRef, 119 | React.ComponentPropsWithoutRef 120 | >(({ className, ...props }, ref) => ( 121 | 126 | )) 127 | SheetDescription.displayName = SheetPrimitive.Description.displayName 128 | 129 | export { 130 | Sheet, 131 | SheetPortal, 132 | SheetOverlay, 133 | SheetTrigger, 134 | SheetClose, 135 | SheetContent, 136 | SheetHeader, 137 | SheetFooter, 138 | SheetTitle, 139 | SheetDescription, 140 | } 141 | -------------------------------------------------------------------------------- /scripts/seed.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import { drizzle } from "drizzle-orm/neon-http"; 3 | import { neon } from "@neondatabase/serverless"; 4 | 5 | import * as schema from "../db/schema"; 6 | 7 | const sql = neon(process.env.DATABASE_URL!); 8 | // @ts-ignore 9 | const db = drizzle(sql, { schema }); 10 | 11 | const main = async () => { 12 | try { 13 | console.log("Seeding database"); 14 | 15 | await db.delete(schema.courses); 16 | await db.delete(schema.userProgress); 17 | await db.delete(schema.units); 18 | await db.delete(schema.lessons); 19 | await db.delete(schema.challenges); 20 | await db.delete(schema.challengeOptions); 21 | await db.delete(schema.challengeProgress); 22 | await db.delete(schema.userSubscription); 23 | 24 | await db.insert(schema.courses).values([ 25 | { 26 | id: 1, 27 | title: "Spanish", 28 | imageSrc: "/es.svg", 29 | }, 30 | { 31 | id: 2, 32 | title: "Italian", 33 | imageSrc: "/it.svg", 34 | }, 35 | { 36 | id: 3, 37 | title: "French", 38 | imageSrc: "/fr.svg", 39 | }, 40 | { 41 | id: 4, 42 | title: "Croatian", 43 | imageSrc: "/hr.svg", 44 | }, 45 | ]); 46 | 47 | await db.insert(schema.units).values([ 48 | { 49 | id: 1, 50 | courseId: 1, // Spanish 51 | title: "Unit 1", 52 | description: "Learn the basics of Spanish", 53 | order: 1, 54 | } 55 | ]); 56 | 57 | await db.insert(schema.lessons).values([ 58 | { 59 | id: 1, 60 | unitId: 1, // Unit 1 (Learn the basics...) 61 | order: 1, 62 | title: "Nouns", 63 | }, 64 | { 65 | id: 2, 66 | unitId: 1, // Unit 1 (Learn the basics...) 67 | order: 2, 68 | title: "Verbs", 69 | }, 70 | { 71 | id: 3, 72 | unitId: 1, // Unit 1 (Learn the basics...) 73 | order: 3, 74 | title: "Verbs", 75 | }, 76 | { 77 | id: 4, 78 | unitId: 1, // Unit 1 (Learn the basics...) 79 | order: 4, 80 | title: "Verbs", 81 | }, 82 | { 83 | id: 5, 84 | unitId: 1, // Unit 1 (Learn the basics...) 85 | order: 5, 86 | title: "Verbs", 87 | }, 88 | ]); 89 | 90 | await db.insert(schema.challenges).values([ 91 | { 92 | id: 1, 93 | lessonId: 1, // Nouns 94 | type: "SELECT", 95 | order: 1, 96 | question: 'Which one of these is the "the man"?', 97 | }, 98 | { 99 | id: 2, 100 | lessonId: 1, // Nouns 101 | type: "ASSIST", 102 | order: 2, 103 | question: '"the man"', 104 | }, 105 | { 106 | id: 3, 107 | lessonId: 1, // Nouns 108 | type: "SELECT", 109 | order: 3, 110 | question: 'Which one of these is the "the robot"?', 111 | }, 112 | ]); 113 | 114 | await db.insert(schema.challengeOptions).values([ 115 | { 116 | challengeId: 1, // Which one of these is "the man"? 117 | imageSrc: "/man.svg", 118 | correct: true, 119 | text: "el hombre", 120 | audioSrc: "/es_man.mp3", 121 | }, 122 | { 123 | challengeId: 1, 124 | imageSrc: "/woman.svg", 125 | correct: false, 126 | text: "la mujer", 127 | audioSrc: "/es_woman.mp3", 128 | }, 129 | { 130 | challengeId: 1, 131 | imageSrc: "/robot.svg", 132 | correct: false, 133 | text: "el robot", 134 | audioSrc: "/es_robot.mp3", 135 | }, 136 | ]); 137 | 138 | await db.insert(schema.challengeOptions).values([ 139 | { 140 | challengeId: 2, // "the man"? 141 | correct: true, 142 | text: "el hombre", 143 | audioSrc: "/es_man.mp3", 144 | }, 145 | { 146 | challengeId: 2, 147 | correct: false, 148 | text: "la mujer", 149 | audioSrc: "/es_woman.mp3", 150 | }, 151 | { 152 | challengeId: 2, 153 | correct: false, 154 | text: "el robot", 155 | audioSrc: "/es_robot.mp3", 156 | }, 157 | ]); 158 | 159 | await db.insert(schema.challengeOptions).values([ 160 | { 161 | challengeId: 3, // Which one of these is the "the robot"? 162 | imageSrc: "/man.svg", 163 | correct: false, 164 | text: "el hombre", 165 | audioSrc: "/es_man.mp3", 166 | }, 167 | { 168 | challengeId: 3, 169 | imageSrc: "/woman.svg", 170 | correct: false, 171 | text: "la mujer", 172 | audioSrc: "/es_woman.mp3", 173 | }, 174 | { 175 | challengeId: 3, 176 | imageSrc: "/robot.svg", 177 | correct: true, 178 | text: "el robot", 179 | audioSrc: "/es_robot.mp3", 180 | }, 181 | ]); 182 | 183 | await db.insert(schema.challenges).values([ 184 | { 185 | id: 4, 186 | lessonId: 2, // Verbs 187 | type: "SELECT", 188 | order: 1, 189 | question: 'Which one of these is the "the man"?', 190 | }, 191 | { 192 | id: 5, 193 | lessonId: 2, // Verbs 194 | type: "ASSIST", 195 | order: 2, 196 | question: '"the man"', 197 | }, 198 | { 199 | id: 6, 200 | lessonId: 2, // Verbs 201 | type: "SELECT", 202 | order: 3, 203 | question: 'Which one of these is the "the robot"?', 204 | }, 205 | ]); 206 | console.log("Seeding finished"); 207 | } catch (error) { 208 | console.error(error); 209 | throw new Error("Failed to seed the database"); 210 | } 211 | }; 212 | 213 | main(); 214 | 215 | -------------------------------------------------------------------------------- /db/queries.ts: -------------------------------------------------------------------------------- 1 | import { cache } from "react"; 2 | import { eq } from "drizzle-orm"; 3 | import { auth } from "@clerk/nextjs"; 4 | 5 | import db from "@/db/drizzle"; 6 | import { 7 | challengeProgress, 8 | courses, 9 | lessons, 10 | units, 11 | userProgress, 12 | userSubscription 13 | } from "@/db/schema"; 14 | 15 | export const getUserProgress = cache(async () => { 16 | const { userId } = await auth(); 17 | 18 | if (!userId) { 19 | return null; 20 | } 21 | 22 | const data = await db.query.userProgress.findFirst({ 23 | where: eq(userProgress.userId, userId), 24 | with: { 25 | activeCourse: true, 26 | }, 27 | }); 28 | 29 | return data; 30 | }); 31 | 32 | export const getUnits = cache(async () => { 33 | const { userId } = await auth(); 34 | const userProgress = await getUserProgress(); 35 | 36 | if (!userId || !userProgress?.activeCourseId) { 37 | return []; 38 | } 39 | 40 | const data = await db.query.units.findMany({ 41 | orderBy: (units, { asc }) => [asc(units.order)], 42 | where: eq(units.courseId, userProgress.activeCourseId), 43 | with: { 44 | lessons: { 45 | orderBy: (lessons, { asc }) => [asc(lessons.order)], 46 | with: { 47 | challenges: { 48 | orderBy: (challenges, { asc }) => [asc(challenges.order)], 49 | with: { 50 | challengeProgress: { 51 | where: eq( 52 | challengeProgress.userId, 53 | userId, 54 | ), 55 | }, 56 | }, 57 | }, 58 | }, 59 | }, 60 | }, 61 | }); 62 | 63 | const normalizedData = data.map((unit) => { 64 | const lessonsWithCompletedStatus = unit.lessons.map((lesson) => { 65 | if ( 66 | lesson.challenges.length === 0 67 | ) { 68 | return { ...lesson, completed: false }; 69 | } 70 | 71 | const allCompletedChallenges = lesson.challenges.every((challenge) => { 72 | return challenge.challengeProgress 73 | && challenge.challengeProgress.length > 0 74 | && challenge.challengeProgress.every((progress) => progress.completed); 75 | }); 76 | 77 | return { ...lesson, completed: allCompletedChallenges }; 78 | }); 79 | 80 | return { ...unit, lessons: lessonsWithCompletedStatus }; 81 | }); 82 | 83 | return normalizedData; 84 | }); 85 | 86 | export const getCourses = cache(async () => { 87 | const data = await db.query.courses.findMany(); 88 | 89 | return data; 90 | }); 91 | 92 | export const getCourseById = cache(async (courseId: number) => { 93 | const data = await db.query.courses.findFirst({ 94 | where: eq(courses.id, courseId), 95 | with: { 96 | units: { 97 | orderBy: (units, { asc }) => [asc(units.order)], 98 | with: { 99 | lessons: { 100 | orderBy: (lessons, { asc }) => [asc(lessons.order)], 101 | }, 102 | }, 103 | }, 104 | }, 105 | }); 106 | 107 | return data; 108 | }); 109 | 110 | export const getCourseProgress = cache(async () => { 111 | const { userId } = await auth(); 112 | const userProgress = await getUserProgress(); 113 | 114 | if (!userId || !userProgress?.activeCourseId) { 115 | return null; 116 | } 117 | 118 | const unitsInActiveCourse = await db.query.units.findMany({ 119 | orderBy: (units, { asc }) => [asc(units.order)], 120 | where: eq(units.courseId, userProgress.activeCourseId), 121 | with: { 122 | lessons: { 123 | orderBy: (lessons, { asc }) => [asc(lessons.order)], 124 | with: { 125 | unit: true, 126 | challenges: { 127 | with: { 128 | challengeProgress: { 129 | where: eq(challengeProgress.userId, userId), 130 | }, 131 | }, 132 | }, 133 | }, 134 | }, 135 | }, 136 | }); 137 | 138 | const firstUncompletedLesson = unitsInActiveCourse 139 | .flatMap((unit) => unit.lessons) 140 | .find((lesson) => { 141 | return lesson.challenges.some((challenge) => { 142 | return !challenge.challengeProgress 143 | || challenge.challengeProgress.length === 0 144 | || challenge.challengeProgress.some((progress) => progress.completed === false) 145 | }); 146 | }); 147 | 148 | return { 149 | activeLesson: firstUncompletedLesson, 150 | activeLessonId: firstUncompletedLesson?.id, 151 | }; 152 | }); 153 | 154 | export const getLesson = cache(async (id?: number) => { 155 | const { userId } = await auth(); 156 | 157 | if (!userId) { 158 | return null; 159 | } 160 | 161 | const courseProgress = await getCourseProgress(); 162 | 163 | const lessonId = id || courseProgress?.activeLessonId; 164 | 165 | if (!lessonId) { 166 | return null; 167 | } 168 | 169 | const data = await db.query.lessons.findFirst({ 170 | where: eq(lessons.id, lessonId), 171 | with: { 172 | challenges: { 173 | orderBy: (challenges, { asc }) => [asc(challenges.order)], 174 | with: { 175 | challengeOptions: true, 176 | challengeProgress: { 177 | where: eq(challengeProgress.userId, userId), 178 | }, 179 | }, 180 | }, 181 | }, 182 | }); 183 | 184 | if (!data || !data.challenges) { 185 | return null; 186 | } 187 | 188 | const normalizedChallenges = data.challenges.map((challenge) => { 189 | const completed = challenge.challengeProgress 190 | && challenge.challengeProgress.length > 0 191 | && challenge.challengeProgress.every((progress) => progress.completed) 192 | 193 | return { ...challenge, completed }; 194 | }); 195 | 196 | return { ...data, challenges: normalizedChallenges } 197 | }); 198 | 199 | export const getLessonPercentage = cache(async () => { 200 | const courseProgress = await getCourseProgress(); 201 | 202 | if (!courseProgress?.activeLessonId) { 203 | return 0; 204 | } 205 | 206 | const lesson = await getLesson(courseProgress.activeLessonId); 207 | 208 | if (!lesson) { 209 | return 0; 210 | } 211 | 212 | const completedChallenges = lesson.challenges 213 | .filter((challenge) => challenge.completed); 214 | const percentage = Math.round( 215 | (completedChallenges.length / lesson.challenges.length) * 100, 216 | ); 217 | 218 | return percentage; 219 | }); 220 | 221 | const DAY_IN_MS = 86_400_000; 222 | export const getUserSubscription = cache(async () => { 223 | const { userId } = await auth(); 224 | 225 | if (!userId) return null; 226 | 227 | const data = await db.query.userSubscription.findFirst({ 228 | where: eq(userSubscription.userId, userId), 229 | }); 230 | 231 | if (!data) return null; 232 | 233 | const isActive = 234 | data.stripePriceId && 235 | data.stripeCurrentPeriodEnd?.getTime()! + DAY_IN_MS > Date.now(); 236 | 237 | return { 238 | ...data, 239 | isActive: !!isActive, 240 | }; 241 | }); 242 | 243 | export const getTopTenUsers = cache(async () => { 244 | const { userId } = await auth(); 245 | 246 | if (!userId) { 247 | return []; 248 | } 249 | 250 | const data = await db.query.userProgress.findMany({ 251 | orderBy: (userProgress, { desc }) => [desc(userProgress.points)], 252 | limit: 10, 253 | columns: { 254 | userId: true, 255 | userName: true, 256 | userImageSrc: true, 257 | points: true, 258 | }, 259 | }); 260 | 261 | return data; 262 | }); 263 | -------------------------------------------------------------------------------- /app/lesson/quiz.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { toast } from "sonner"; 4 | import Image from "next/image"; 5 | import Confetti from "react-confetti"; 6 | import { useRouter } from "next/navigation"; 7 | import { useState, useTransition } from "react"; 8 | import { useAudio, useWindowSize, useMount } from "react-use"; 9 | 10 | import { reduceHearts } from "@/actions/user-progress"; 11 | import { useHeartsModal } from "@/store/use-hearts-modal"; 12 | import { challengeOptions, challenges, userSubscription } from "@/db/schema"; 13 | import { usePracticeModal } from "@/store/use-practice-modal"; 14 | import { upsertChallengeProgress } from "@/actions/challenge-progress"; 15 | 16 | import { Header } from "./header"; 17 | import { Footer } from "./footer"; 18 | import { Challenge } from "./challenge"; 19 | import { ResultCard } from "./result-card"; 20 | import { QuestionBubble } from "./question-bubble"; 21 | 22 | type Props ={ 23 | initialPercentage: number; 24 | initialHearts: number; 25 | initialLessonId: number; 26 | initialLessonChallenges: (typeof challenges.$inferSelect & { 27 | completed: boolean; 28 | challengeOptions: typeof challengeOptions.$inferSelect[]; 29 | })[]; 30 | userSubscription: typeof userSubscription.$inferSelect & { 31 | isActive: boolean; 32 | } | null; 33 | }; 34 | 35 | export const Quiz = ({ 36 | initialPercentage, 37 | initialHearts, 38 | initialLessonId, 39 | initialLessonChallenges, 40 | userSubscription, 41 | }: Props) => { 42 | const { open: openHeartsModal } = useHeartsModal(); 43 | const { open: openPracticeModal } = usePracticeModal(); 44 | 45 | useMount(() => { 46 | if (initialPercentage === 100) { 47 | openPracticeModal(); 48 | } 49 | }); 50 | 51 | const { width, height } = useWindowSize(); 52 | 53 | const router = useRouter(); 54 | 55 | const [finishAudio] = useAudio({ src: "/finish.mp3", autoPlay: true }); 56 | const [ 57 | correctAudio, 58 | _c, 59 | correctControls, 60 | ] = useAudio({ src: "/correct.wav" }); 61 | const [ 62 | incorrectAudio, 63 | _i, 64 | incorrectControls, 65 | ] = useAudio({ src: "/incorrect.wav" }); 66 | const [pending, startTransition] = useTransition(); 67 | 68 | const [lessonId] = useState(initialLessonId); 69 | const [hearts, setHearts] = useState(initialHearts); 70 | const [percentage, setPercentage] = useState(() => { 71 | return initialPercentage === 100 ? 0 : initialPercentage; 72 | }); 73 | const [challenges] = useState(initialLessonChallenges); 74 | const [activeIndex, setActiveIndex] = useState(() => { 75 | const uncompletedIndex = challenges.findIndex((challenge) => !challenge.completed); 76 | return uncompletedIndex === -1 ? 0 : uncompletedIndex; 77 | }); 78 | 79 | const [selectedOption, setSelectedOption] = useState(); 80 | const [status, setStatus] = useState<"correct" | "wrong" | "none">("none"); 81 | 82 | const challenge = challenges[activeIndex]; 83 | const options = challenge?.challengeOptions ?? []; 84 | 85 | const onNext = () => { 86 | setActiveIndex((current) => current + 1); 87 | }; 88 | 89 | const onSelect = (id: number) => { 90 | if (status !== "none") return; 91 | 92 | setSelectedOption(id); 93 | }; 94 | 95 | const onContinue = () => { 96 | if (!selectedOption) return; 97 | 98 | if (status === "wrong") { 99 | setStatus("none"); 100 | setSelectedOption(undefined); 101 | return; 102 | } 103 | 104 | if (status === "correct") { 105 | onNext(); 106 | setStatus("none"); 107 | setSelectedOption(undefined); 108 | return; 109 | } 110 | 111 | const correctOption = options.find((option) => option.correct); 112 | 113 | if (!correctOption) { 114 | return; 115 | } 116 | 117 | if (correctOption.id === selectedOption) { 118 | startTransition(() => { 119 | upsertChallengeProgress(challenge.id) 120 | .then((response) => { 121 | if (response?.error === "hearts") { 122 | openHeartsModal(); 123 | return; 124 | } 125 | 126 | correctControls.play(); 127 | setStatus("correct"); 128 | setPercentage((prev) => prev + 100 / challenges.length); 129 | 130 | // This is a practice 131 | if (initialPercentage === 100) { 132 | setHearts((prev) => Math.min(prev + 1, 5)); 133 | } 134 | }) 135 | .catch(() => toast.error("Something went wrong. Please try again.")) 136 | }); 137 | } else { 138 | startTransition(() => { 139 | reduceHearts(challenge.id) 140 | .then((response) => { 141 | if (response?.error === "hearts") { 142 | openHeartsModal(); 143 | return; 144 | } 145 | 146 | incorrectControls.play(); 147 | setStatus("wrong"); 148 | 149 | if (!response?.error) { 150 | setHearts((prev) => Math.max(prev - 1, 0)); 151 | } 152 | }) 153 | .catch(() => toast.error("Something went wrong. Please try again.")) 154 | }); 155 | } 156 | }; 157 | 158 | if (!challenge) { 159 | return ( 160 | <> 161 | {finishAudio} 162 | 169 |
170 | Finish 177 | Finish 184 |

185 | Great job!
You've completed the lesson. 186 |

187 |
188 | 192 | 196 |
197 |
198 |