├── .env.example ├── .eslintrc.json ├── .gitignore ├── README.md ├── drizzle.config.ts ├── next.config.mjs ├── package.json ├── pnpm-lock.yaml ├── postcss.config.mjs ├── public ├── boy.svg ├── correct.wav ├── es.svg ├── es_boy.mp3 ├── es_girl.mp3 ├── es_man.mp3 ├── es_robot.mp3 ├── es_woman.mp3 ├── es_zombie.mp3 ├── finish.mp3 ├── finish.svg ├── fr.svg ├── girl.svg ├── heart.svg ├── hero.svg ├── hr.svg ├── incorrect.wav ├── it.svg ├── jp.svg ├── leaderboard.svg ├── learn.svg ├── man.svg ├── mascot.svg ├── mascot_bad.svg ├── mascot_sad.svg ├── points.svg ├── quests.svg ├── robot.svg ├── shop.svg ├── unlimited.svg ├── woman.svg └── zombie.svg ├── screenshot.png ├── src ├── app │ ├── (auth) │ │ ├── sign-in │ │ │ └── [[...sign-in]] │ │ │ │ └── page.tsx │ │ └── sign-up │ │ │ └── [[...sign-up]] │ │ │ └── page.tsx │ ├── (main) │ │ ├── courses │ │ │ ├── card.tsx │ │ │ ├── list.tsx │ │ │ ├── loading.tsx │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── leaderboard │ │ │ └── page.tsx │ │ ├── learn │ │ │ ├── header.tsx │ │ │ ├── lesson-button.tsx │ │ │ ├── page.tsx │ │ │ ├── unit-banner.tsx │ │ │ └── unit.tsx │ │ ├── quests │ │ │ └── page.tsx │ │ └── shop │ │ │ ├── items.tsx │ │ │ └── page.tsx │ ├── (marketing) │ │ ├── footer.tsx │ │ ├── header.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ ├── admin │ │ ├── app.tsx │ │ └── page.tsx │ ├── api │ │ ├── challengeOptions │ │ │ ├── [challengeOptionId] │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── challenges │ │ │ ├── [challengeId] │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── courses │ │ │ ├── [courseId] │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── lessons │ │ │ ├── [lessonId] │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── units │ │ │ ├── [unitId] │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ └── webhooks │ │ │ └── stripe │ │ │ └── route.ts │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ └── lesson │ │ ├── [lessonId] │ │ └── page.tsx │ │ ├── card.tsx │ │ ├── challenge.tsx │ │ ├── footer.tsx │ │ ├── header.tsx │ │ ├── layout.tsx │ │ ├── page.tsx │ │ ├── question-bubble.tsx │ │ ├── quiz.tsx │ │ └── result-card.tsx ├── components │ ├── FeedWrapper.tsx │ ├── MobileHeader.tsx │ ├── MobileSidebar.tsx │ ├── Promo.tsx │ ├── Quests.tsx │ ├── RepoStar.tsx │ ├── Sidebar.tsx │ ├── SidebarItem.tsx │ ├── StickyWrapper.tsx │ ├── UpgradeButton.tsx │ ├── UserProgress.tsx │ ├── admin │ │ ├── challenge-option │ │ │ ├── ChallengeOptionCreate.tsx │ │ │ ├── ChallengeOptionEdit.tsx │ │ │ ├── ChallengeOptionList.tsx │ │ │ └── index.ts │ │ ├── challenge │ │ │ ├── ChallengeCreate.tsx │ │ │ ├── ChallengeEdit.tsx │ │ │ ├── ChallengeList.tsx │ │ │ └── index.ts │ │ ├── course │ │ │ ├── CourseCreate.tsx │ │ │ ├── CourseEdit.tsx │ │ │ ├── CourseList.tsx │ │ │ └── index.ts │ │ ├── lesson │ │ │ ├── LessonCreate.tsx │ │ │ ├── LessonEdit.tsx │ │ │ ├── LessonList.tsx │ │ │ └── index.ts │ │ └── unit │ │ │ ├── UnitCreate.tsx │ │ │ ├── UnitEdit.tsx │ │ │ ├── UnitList.tsx │ │ │ └── index.ts │ ├── index.ts │ ├── modals │ │ ├── ExitModal.tsx │ │ ├── HeartsModal.tsx │ │ ├── PracticeModal.tsx │ │ └── index.ts │ └── ui │ │ ├── Avatar.tsx │ │ ├── Button.tsx │ │ ├── Dialog.tsx │ │ ├── Progress.tsx │ │ ├── Separator.tsx │ │ ├── Sheet.tsx │ │ ├── Sonner.tsx │ │ └── index.ts ├── constants.ts ├── lib │ ├── admin.ts │ ├── shadcn-theming │ │ ├── plugin.ts │ │ └── preset.ts │ ├── stripe.ts │ └── utils.ts ├── middleware.ts ├── server │ ├── actions │ │ ├── challenge-progress.ts │ │ ├── user-progress.ts │ │ └── user-subscription.ts │ ├── db │ │ ├── drizzle.ts │ │ ├── queries.ts │ │ └── schema.ts │ └── scripts │ │ ├── prod.ts │ │ ├── reset.ts │ │ └── seed.ts └── store │ ├── use-exit-modal.ts │ ├── use-hearts-modal.ts │ └── use-practice-modal.ts ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # ------------------------ 2 | # Preferences 3 | # ------------------------ 4 | 5 | ADMIN_USER_ID= 6 | NEXT_PUBLIC_APP_URL= 7 | NEXT_PUBLIC_ALLOWED_ORIGIN= 8 | 9 | # ------------------------ 10 | # Clerk Authentication 11 | # ------------------------ 12 | 13 | NEXT_PUBLIC_CLERK_SIGN_IN_URL= 14 | NEXT_PUBLIC_CLERK_SIGN_UP_URL= 15 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= 16 | CLERK_SECRET_KEY= 17 | 18 | # ------------------------ 19 | # Neon 20 | # ------------------------ 21 | 22 | DATABASE_URL= 23 | 24 | # ------------------------ 25 | # Stripe 26 | # ------------------------ 27 | 28 | STRIPE_API_KEY= 29 | STRIPE_WEBHOOK_SECRET= -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.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 | .history 23 | 24 | # debug 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | 29 | # local env files 30 | .env*.local 31 | .env 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | next-env.d.ts 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lingo 2 | 3 | Lingo aims to provide a super interactive and user-friendly platform for learning languages, regardless of your proficiency. Whether you’re just starting out or aiming to perfect your skills, this web app is loaded with features to make your language learning journey both enjoyable and effective. Dive in and discover a whole new way to learn! 4 | 5 | ## Screenshot 6 | 7 | 8 | 9 |

10 | View Project » 11 |

12 | 13 | ## Running Locally 14 | 15 | This application requires Node.js v20.12.1+. 16 | 17 | ### Cloning the repository to the local machine: 18 | 19 | ```bash 20 | git clone https://github.com/nabarvn/lingo.git 21 | cd lingo 22 | ``` 23 | 24 | ### Installing the dependencies: 25 | 26 | ```bash 27 | pnpm install 28 | ``` 29 | 30 | ### Setting up the `.env` file: 31 | 32 | ```bash 33 | cp .env.example .env 34 | ``` 35 | 36 | > [!IMPORTANT] 37 | > Ensure you populate the variables with your respective API keys and configuration values before proceeding. 38 | 39 | ### Configuring Drizzle: 40 | 41 | ```bash 42 | pnpm db:push 43 | ``` 44 | 45 | ### Seeding the application: 46 | 47 | ```bash 48 | pnpm db:seed 49 | ``` 50 | 51 | ### Running the application: 52 | 53 | ```bash 54 | pnpm dev 55 | ``` 56 | 57 | ## Tech Stack 58 | 59 | - **Language**: [TypeScript](https://www.typescriptlang.org) 60 | - **Framework**: [Next.js](https://nextjs.org) 61 | - **Styling**: [Tailwind CSS](https://tailwindcss.com) 62 | - **Analytics**: [Vercel Analytics](https://vercel.com/analytics) 63 | - **State Management**: [Zustand](https://docs.pmnd.rs/zustand/getting-started/introduction) 64 | - **ORM Toolkit**: [Drizzle](https://orm.drizzle.team/docs/overview) 65 | - **Postgres Database**: [Neon](https://neon.tech/docs/introduction/about) 66 | - **Authentication**: [Clerk](https://clerk.com/docs/quickstarts/nextjs) 67 | - **Payments**: [Stripe](https://stripe.com/docs/payments) 68 | - **Deployment**: [Vercel](https://vercel.com) 69 | 70 | ## Acknowledgements 71 | 72 | - **Speech Generator**: [ElevenLabs](https://elevenlabs.io) 73 | - **Character Assets**: [Kenney](https://kenney.nl/assets/toon-characters-1) 74 | 75 | ## Credits 76 | 77 | Huge props to Antonio for coming up with such an incredible tutorial. Knowledge packed content, as always! 78 | 79 |
80 | 81 |
Don't forget to leave a STAR 🌟
82 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "drizzle-kit"; 2 | 3 | export default defineConfig({ 4 | dialect: "postgresql", 5 | schema: "./src/server/db/schema.ts", 6 | dbCredentials: { 7 | url: process.env.DATABASE_URL as string, 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /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: process.env.NEXT_PUBLIC_ALLOWED_ORIGIN, 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 | -------------------------------------------------------------------------------- /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 | "db:push": "pnpm drizzle-kit push", 10 | "db:studio": "pnpm drizzle-kit studio", 11 | "db:seed": "pnpm tsx ./src/server/scripts/seed.ts", 12 | "db:reset": "pnpm tsx ./src/server/scripts/reset.ts", 13 | "db:prod": "pnpm tsx ./src/server/scripts/prod.ts", 14 | "lint": "next lint" 15 | }, 16 | "dependencies": { 17 | "@clerk/nextjs": "^5.0.5", 18 | "@neondatabase/serverless": "^0.9.3", 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 | "@tailwindcss/typography": "^0.5.12", 25 | "class-variance-authority": "^0.7.0", 26 | "clsx": "^2.1.1", 27 | "drizzle-orm": "^0.31.1", 28 | "lucide-react": "^0.383.0", 29 | "next": "14.2.2", 30 | "next-themes": "^0.3.0", 31 | "ra-data-simple-rest": "^4.16.17", 32 | "react": "^18", 33 | "react-admin": "^4.16.18", 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.41", 39 | "stripe": "^15.8.0", 40 | "tailwind-merge": "^2.3.0", 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 | "dotenv": "^16.4.5", 49 | "drizzle-kit": "^0.22.4", 50 | "eslint": "^8", 51 | "eslint-config-next": "14.2.2", 52 | "pg": "^8.12.0", 53 | "postcss": "^8", 54 | "tailwindcss": "^3.4.1", 55 | "tsx": "^4.9.1", 56 | "typescript": "^5" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /public/correct.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabarvn/lingo/1e9d35474ace866fae0f2b59778243de67941669/public/correct.wav -------------------------------------------------------------------------------- /public/es_boy.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabarvn/lingo/1e9d35474ace866fae0f2b59778243de67941669/public/es_boy.mp3 -------------------------------------------------------------------------------- /public/es_girl.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabarvn/lingo/1e9d35474ace866fae0f2b59778243de67941669/public/es_girl.mp3 -------------------------------------------------------------------------------- /public/es_man.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabarvn/lingo/1e9d35474ace866fae0f2b59778243de67941669/public/es_man.mp3 -------------------------------------------------------------------------------- /public/es_robot.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabarvn/lingo/1e9d35474ace866fae0f2b59778243de67941669/public/es_robot.mp3 -------------------------------------------------------------------------------- /public/es_woman.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabarvn/lingo/1e9d35474ace866fae0f2b59778243de67941669/public/es_woman.mp3 -------------------------------------------------------------------------------- /public/es_zombie.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabarvn/lingo/1e9d35474ace866fae0f2b59778243de67941669/public/es_zombie.mp3 -------------------------------------------------------------------------------- /public/finish.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabarvn/lingo/1e9d35474ace866fae0f2b59778243de67941669/public/finish.mp3 -------------------------------------------------------------------------------- /public/fr.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /public/heart.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/incorrect.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabarvn/lingo/1e9d35474ace866fae0f2b59778243de67941669/public/incorrect.wav -------------------------------------------------------------------------------- /public/it.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /public/jp.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /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/mascot_bad.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/points.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/quests.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/woman.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabarvn/lingo/1e9d35474ace866fae0f2b59778243de67941669/screenshot.png -------------------------------------------------------------------------------- /src/app/(auth)/sign-in/[[...sign-in]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignIn } from "@clerk/nextjs"; 2 | 3 | export default function SignInPage() { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-up/[[...sign-up]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignUp } from "@clerk/nextjs"; 2 | 3 | export default function SignUpPage() { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/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 CardProps = { 7 | title: string; 8 | id: number; 9 | imageSrc: string; 10 | onClick: (id: number) => void; 11 | disabled?: boolean; 12 | active?: boolean; 13 | }; 14 | 15 | export function Card({ 16 | title, 17 | id, 18 | imageSrc, 19 | onClick, 20 | disabled, 21 | active, 22 | }: CardProps) { 23 | return ( 24 |
onClick(id)} 26 | className={cn( 27 | "flex flex-col items-center justify-between h-full min-h-[217px] min-w-[200px] cursor-pointer rounded-xl border-2 border-b-4 p-3 pb-6 hover:bg-black/5 active:border-b-2", 28 | { 29 | "pointer-events-none opacity-50": disabled, 30 | } 31 | )} 32 | > 33 |
34 | {active && ( 35 |
36 | 37 |
38 | )} 39 |
40 | 41 | {title} 48 | 49 |

{title}

50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/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 { Card } from "./card"; 8 | import { courses, userProgress } from "@/server/db/schema"; 9 | import { upsertUserProgress } from "@/server/actions/user-progress"; 10 | 11 | type ListProps = { 12 | courses: (typeof courses.$inferSelect)[]; 13 | activeCourseId?: typeof userProgress.$inferSelect.activeCourseId; 14 | }; 15 | 16 | export const List = ({ courses, activeCourseId }: ListProps) => { 17 | const router = useRouter(); 18 | const [pending, startTransition] = useTransition(); 19 | 20 | const handleClick = (id: number) => { 21 | if (pending) return; 22 | 23 | if (id === activeCourseId) { 24 | startTransition(() => router.push("/learn")); 25 | } 26 | 27 | startTransition(() => { 28 | upsertUserProgress(id).catch(() => toast.error("Something went wrong.")); 29 | }); 30 | }; 31 | 32 | return ( 33 |
34 | {courses.map((course) => ( 35 | 44 | ))} 45 |
46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /src/app/(main)/courses/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Loader2 } from "lucide-react"; 2 | 3 | const Loading = () => { 4 | return ( 5 |
6 |
7 | 8 | 9 |
10 |

Processing...

11 |

This won't take long.

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

Language Courses

16 | 17 |
18 | ); 19 | }; 20 | 21 | export default CoursesPage; 22 | -------------------------------------------------------------------------------- /src/app/(main)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, PropsWithChildren } from "react"; 2 | import { MobileHeader, Sidebar } from "@/components"; 3 | 4 | const MainLayout = ({ children }: PropsWithChildren) => ( 5 | 6 | 7 | 8 | 9 |
10 |
{children}
11 |
12 |
13 | ); 14 | 15 | export default MainLayout; 16 | -------------------------------------------------------------------------------- /src/app/(main)/leaderboard/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { redirect } from "next/navigation"; 3 | import { currentUser } from "@clerk/nextjs/server"; 4 | 5 | import { 6 | getTopTenUsers, 7 | getUserProgress, 8 | getUserSubscription, 9 | } from "@/server/db/queries"; 10 | 11 | import { Separator } from "@/components/ui"; 12 | import { Avatar, AvatarImage } from "@/components/ui/Avatar"; 13 | 14 | import { 15 | FeedWrapper, 16 | UserProgress, 17 | StickyWrapper, 18 | Promo, 19 | Quests, 20 | } from "@/components"; 21 | 22 | const LeaderboardPage = async () => { 23 | const user = await currentUser(); 24 | 25 | const leaderboardData = getTopTenUsers(); 26 | const userProgressData = getUserProgress(); 27 | const userSubscriptionData = getUserSubscription(); 28 | 29 | const [leaderboard, userProgress, userSubscription] = await Promise.all([ 30 | leaderboardData, 31 | userProgressData, 32 | userSubscriptionData, 33 | ]); 34 | 35 | if (!userProgress || !userProgress.activeCourse) { 36 | redirect("/courses"); 37 | } 38 | 39 | const isPro = !!userSubscription?.isActive; 40 | 41 | return ( 42 |
43 |
44 | 50 |
51 | 52 | 53 |
54 | Leaderboard 60 | 61 |

62 | Leaderboard 63 |

64 | 65 |

66 | See where you stand among other learners in the community. 67 |

68 | 69 | 70 | 71 | {leaderboard.map((userProgress, index) => ( 72 |
76 |

{index + 1}

77 | 78 |
79 | 80 | 84 | 85 | 86 |

87 | {user?.id === userProgress.userId && 88 | user.firstName !== userProgress.userName 89 | ? user.firstName || "Anon" 90 | : userProgress.userName} 91 |

92 | 93 |

94 | {userProgress.points} XP 95 |

96 |
97 |
98 | ))} 99 |
100 |
101 | 102 | 103 | 109 | 110 | {!isPro && } 111 | 112 | 113 |
114 | ); 115 | }; 116 | 117 | export default LeaderboardPage; 118 | -------------------------------------------------------------------------------- /src/app/(main)/learn/header.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Button } from "@/components/ui"; 3 | import { ArrowLeft } from "lucide-react"; 4 | 5 | interface HeaderProps { 6 | title: string; 7 | } 8 | 9 | const Header = ({ title }: HeaderProps) => ( 10 |
11 | 12 | 15 | 16 | 17 |

{title}

18 |
19 |
20 | ); 21 | 22 | export default Header; 23 | -------------------------------------------------------------------------------- /src/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"; 9 | 10 | import "react-circular-progressbar/dist/styles.css"; 11 | 12 | type LessonButtonProps = { 13 | id: number; 14 | index: number; 15 | totalCount: number; 16 | current?: boolean; 17 | locked?: boolean; 18 | percentage: number; 19 | }; 20 | 21 | const LessonButton = ({ 22 | id, 23 | index, 24 | totalCount, 25 | current, 26 | locked, 27 | percentage, 28 | }: LessonButtonProps) => { 29 | // determining indentation level based on the index of the lesson 30 | const cycleLength = 8; 31 | const cycleIndex = index % cycleLength; 32 | 33 | let indentationLevel; 34 | 35 | if (cycleIndex <= 2) { 36 | indentationLevel = cycleIndex; 37 | } else if (cycleIndex <= 4) { 38 | indentationLevel = 4 - cycleIndex; 39 | } else if (cycleIndex <= 6) { 40 | indentationLevel = 4 - cycleIndex; 41 | } else { 42 | indentationLevel = cycleIndex - 8; 43 | } 44 | 45 | const rightPosition = indentationLevel * 40; 46 | 47 | // checking if it's the first or last lesson, or if it's completed 48 | const isFirst = index === 0; 49 | const isLast = index === totalCount; 50 | const isCompleted = !current && !locked; 51 | 52 | const Icon = isCompleted ? Check : isLast ? Crown : Star; 53 | 54 | const href = isCompleted ? `/lesson/${id}` : "/lesson"; 55 | 56 | return ( 57 | 62 |
69 | {current ? ( 70 |
71 |
72 | Start 73 |
74 |
75 | 76 | 87 | 102 | 103 |
104 | ) : ( 105 | 120 | )} 121 |
122 | 123 | ); 124 | }; 125 | 126 | export default LessonButton; 127 | -------------------------------------------------------------------------------- /src/app/(main)/learn/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | import { 4 | FeedWrapper, 5 | StickyWrapper, 6 | UserProgress, 7 | Promo, 8 | Quests, 9 | } from "@/components"; 10 | 11 | import { 12 | getCourseProgress, 13 | getLessonPercentage, 14 | getUnits, 15 | getUserProgress, 16 | getUserSubscription, 17 | } from "@/server/db/queries"; 18 | 19 | import Unit from "./unit"; 20 | import Header from "./header"; 21 | 22 | const LearnPage = async () => { 23 | const unitsData = getUnits(); 24 | const userProgressData = getUserProgress(); 25 | const courseProgressData = getCourseProgress(); 26 | const lessonPercentageData = getLessonPercentage(); 27 | const userSubscriptionData = getUserSubscription(); 28 | 29 | const [ 30 | units, 31 | userProgress, 32 | courseProgress, 33 | lessonPercentage, 34 | userSubscription, 35 | ] = await Promise.all([ 36 | unitsData, 37 | userProgressData, 38 | courseProgressData, 39 | lessonPercentageData, 40 | userSubscriptionData, 41 | ]); 42 | 43 | if (!userProgress || !userProgress.activeCourse) { 44 | redirect("/courses"); 45 | } 46 | 47 | if (!courseProgress) { 48 | redirect("/courses"); 49 | } 50 | 51 | const isPro = !!userSubscription?.isActive; 52 | 53 | return ( 54 |
55 |
56 | 62 |
63 | 64 | 65 |
66 | 67 | {units.map((unit, i) => ( 68 |
69 | 77 |
78 | ))} 79 | 80 | 81 | 82 | 88 | 89 | {!isPro && } 90 | 91 | 92 |
93 | ); 94 | }; 95 | 96 | export default LearnPage; 97 | -------------------------------------------------------------------------------- /src/app/(main)/learn/unit-banner.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { cn } from "@/lib/utils"; 3 | import { NotebookText } from "lucide-react"; 4 | 5 | import { Button } from "@/components/ui"; 6 | 7 | type UnitBannerProps = { 8 | title: string; 9 | description: string; 10 | access: boolean; 11 | }; 12 | 13 | const UnitBanner = ({ title, description, access }: UnitBannerProps) => ( 14 |
22 |
23 |

{title}

24 |

{description}

25 |
26 | 27 | 34 | 45 | 46 |
47 | ); 48 | 49 | export default UnitBanner; 50 | -------------------------------------------------------------------------------- /src/app/(main)/learn/unit.tsx: -------------------------------------------------------------------------------- 1 | import { lessons, units } from "@/server/db/schema"; 2 | 3 | import UnitBanner from "./unit-banner"; 4 | import LessonButton from "./lesson-button"; 5 | 6 | type UnitProps = { 7 | id: number; 8 | title: string; 9 | description: string; 10 | lessons: (typeof lessons.$inferSelect & { 11 | completed: boolean; 12 | })[]; 13 | activeLesson: 14 | | (typeof lessons.$inferSelect & { 15 | unit: typeof units.$inferSelect; 16 | }) 17 | | undefined; 18 | activeLessonPercentage: number; 19 | }; 20 | 21 | const Unit = ({ 22 | id, 23 | title, 24 | description, 25 | lessons, 26 | activeLesson, 27 | activeLessonPercentage, 28 | }: UnitProps) => { 29 | let unitAccess = false; 30 | const allCompletedLessons = lessons.every((lesson) => lesson.completed); 31 | 32 | if (activeLesson?.unitId === id || allCompletedLessons) unitAccess = true; 33 | 34 | return ( 35 | <> 36 | 37 | 38 |
39 | {lessons.map((lesson, index) => { 40 | const isCurrent = lesson.id === activeLesson?.id; 41 | const isLocked = !lesson.completed && !isCurrent; 42 | 43 | return ( 44 | 53 | ); 54 | })} 55 |
56 | 57 | ); 58 | }; 59 | 60 | export default Unit; 61 | -------------------------------------------------------------------------------- /src/app/(main)/quests/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { redirect } from "next/navigation"; 3 | 4 | import { quests } from "@/constants"; 5 | import { Progress } from "@/components/ui"; 6 | import { getUserProgress, getUserSubscription } from "@/server/db/queries"; 7 | import { FeedWrapper, UserProgress, StickyWrapper, Promo } from "@/components"; 8 | 9 | const QuestsPage = async () => { 10 | const userProgressData = getUserProgress(); 11 | const userSubscriptionData = getUserSubscription(); 12 | 13 | const [userProgress, userSubscription] = await Promise.all([ 14 | userProgressData, 15 | userSubscriptionData, 16 | ]); 17 | 18 | if (!userProgress || !userProgress.activeCourse) { 19 | redirect("/courses"); 20 | } 21 | 22 | const isPro = !!userSubscription?.isActive; 23 | 24 | return ( 25 |
26 |
27 | 33 |
34 | 35 | 36 |
37 | Quests 38 | 39 |

40 | Quests 41 |

42 | 43 |

44 | Complete quests by earning points. 45 |

46 | 47 |
    48 | {quests.map((quest) => { 49 | const progress = (userProgress.points / quest.value) * 100; 50 | 51 | return ( 52 |
    56 | Points 62 | 63 |
    64 |

    65 | {quest.title} 66 |

    67 | 68 | 69 |
    70 |
    71 | ); 72 | })} 73 |
74 |
75 |
76 | 77 | 78 | 84 | 85 | {!isPro && } 86 | 87 |
88 | ); 89 | }; 90 | 91 | export default QuestsPage; 92 | -------------------------------------------------------------------------------- /src/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"; 8 | import { refillHearts } from "@/server/actions/user-progress"; 9 | import { DEFAULT_HEARTS_MAX, POINTS_TO_REFILL } from "@/constants"; 10 | import { createStripeUrl } from "@/server/actions/user-subscription"; 11 | 12 | type ItemsProps = { 13 | hearts: number; 14 | points: number; 15 | hasActiveSubscription: boolean; 16 | }; 17 | 18 | const Items = ({ hearts, points, hasActiveSubscription }: ItemsProps) => { 19 | const [pending, startTransition] = useTransition(); 20 | 21 | const onRefillHearts = () => { 22 | if (pending || hearts === DEFAULT_HEARTS_MAX || points < POINTS_TO_REFILL) { 23 | return; 24 | } 25 | 26 | startTransition(() => { 27 | refillHearts().catch(() => toast.error("Something went wrong.")); 28 | }); 29 | }; 30 | 31 | const onUpgrade = () => { 32 | startTransition(() => { 33 | createStripeUrl() 34 | .then((response) => { 35 | if (response.data) { 36 | window.location.href = response.data; 37 | } 38 | }) 39 | .catch(() => toast.error("Something went wrong.")); 40 | }); 41 | }; 42 | 43 | return ( 44 |
    45 |
    46 | Heart 47 | 48 |
    49 |

    50 | Refill hearts 51 |

    52 |
    53 | 54 | 72 |
    73 | 74 |
    75 | Unlimited 76 | 77 |
    78 |

    79 | Unlimited hearts 80 |

    81 |
    82 | 83 | 86 |
    87 |
88 | ); 89 | }; 90 | 91 | export default Items; 92 | -------------------------------------------------------------------------------- /src/app/(main)/shop/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { redirect } from "next/navigation"; 3 | 4 | import Items from "./items"; 5 | import { getUserProgress, getUserSubscription } from "@/server/db/queries"; 6 | import { FeedWrapper, UserProgress, StickyWrapper, Quests } from "@/components"; 7 | 8 | const ShopPage = async () => { 9 | const userProgressData = getUserProgress(); 10 | const userSubscriptionData = getUserSubscription(); 11 | 12 | const [userProgress, userSubscription] = await Promise.all([ 13 | userProgressData, 14 | userSubscriptionData, 15 | ]); 16 | 17 | if (!userProgress || !userProgress.activeCourse) { 18 | redirect("/courses"); 19 | } 20 | 21 | const isPro = !!userSubscription?.isActive; 22 | 23 | return ( 24 |
25 |
26 | 32 |
33 | 34 | 35 |
36 | Shop 37 | 38 |

39 | Shop 40 |

41 | 42 |

43 | Spend your points on cool stuff. 44 |

45 | 46 | 51 |
52 |
53 | 54 | 55 | 61 | 62 | 63 | 64 |
65 | ); 66 | }; 67 | 68 | export default ShopPage; 69 | -------------------------------------------------------------------------------- /src/app/(marketing)/footer.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { Button } from "@/components/ui"; 3 | 4 | const Footer = () => { 5 | return ( 6 |
7 |
8 | 23 | 24 | 39 | 40 | 55 | 56 | 71 | 72 | 87 |
88 |
89 | ); 90 | }; 91 | 92 | export default Footer; 93 | -------------------------------------------------------------------------------- /src/app/(marketing)/header.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { Button } from "@/components/ui"; 3 | 4 | import { 5 | ClerkLoaded, 6 | ClerkLoading, 7 | SignInButton, 8 | SignedIn, 9 | SignedOut, 10 | UserButton, 11 | } from "@clerk/nextjs"; 12 | 13 | const Header = () => ( 14 |
15 |
16 |
17 | Mascot 18 | 19 |

20 | Lingo 21 |

22 |
23 | 24 | 25 | 26 | 68 | 69 | 70 | 71 |
72 |
73 | ); 74 | 75 | export default Header; 76 | -------------------------------------------------------------------------------- /src/app/(marketing)/layout.tsx: -------------------------------------------------------------------------------- 1 | import Header from "./header"; 2 | import Footer from "./footer"; 3 | import { PropsWithChildren } from "react"; 4 | 5 | const MarketingLayout = ({ children }: PropsWithChildren) => { 6 | return ( 7 |
8 |
9 | 10 |
11 | {children} 12 |
13 | 14 |
15 |
16 | ); 17 | }; 18 | 19 | export default MarketingLayout; 20 | -------------------------------------------------------------------------------- /src/app/(marketing)/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import Image from "next/image"; 3 | import { Button } from "@/components/ui"; 4 | 5 | import { 6 | ClerkLoaded, 7 | ClerkLoading, 8 | SignInButton, 9 | SignUpButton, 10 | SignedIn, 11 | SignedOut, 12 | } from "@clerk/nextjs"; 13 | 14 | export default function HomePage() { 15 | return ( 16 |
17 |
18 | Hero 19 |
20 | 21 |
22 |
23 | 29 | Star on GitHub 🌟 30 | 31 |
32 | 33 |

34 | Learn, refine, and master your language skills with Lingo. 35 |

36 | 37 |
38 | 39 | 40 |
41 |
42 | 43 |
44 |
45 |
46 |
47 | 48 | 49 | 50 |
51 | 52 | 53 | 54 | 55 | 56 | 61 | 64 | 65 | 66 | 71 | 74 | 75 | 76 | 77 | 78 | 81 | 82 | 83 |
84 |
85 |
86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /src/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 { 7 | CourseList, 8 | CourseCreate, 9 | CourseEdit, 10 | } from "../../components/admin/course"; 11 | 12 | import { UnitList, UnitCreate, UnitEdit } from "../../components/admin/unit"; 13 | 14 | import { 15 | LessonList, 16 | LessonCreate, 17 | LessonEdit, 18 | } from "../../components/admin/lesson"; 19 | 20 | import { 21 | ChallengeList, 22 | ChallengeCreate, 23 | ChallengeEdit, 24 | } from "../../components/admin/challenge"; 25 | 26 | import { 27 | ChallengeOptionList, 28 | ChallengeOptionCreate, 29 | ChallengeOptionEdit, 30 | } from "../../components/admin/challenge-option"; 31 | 32 | const dataProvider = simpleRestProvider("/api"); 33 | 34 | const App = () => { 35 | return ( 36 | 37 | 44 | 45 | 52 | 53 | 60 | 61 | 68 | 69 | 77 | 78 | ); 79 | }; 80 | 81 | export default App; 82 | -------------------------------------------------------------------------------- /src/app/admin/page.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from "next"; 2 | import dynamic from "next/dynamic"; 3 | import { redirect } from "next/navigation"; 4 | 5 | import { isAdmin } from "@/lib/admin"; 6 | 7 | const App = dynamic(() => import("./app"), { ssr: false }); 8 | 9 | const AdminPage: NextPage = () => { 10 | if (!isAdmin()) { 11 | redirect("/"); 12 | } 13 | 14 | return ; 15 | }; 16 | 17 | export default AdminPage; 18 | -------------------------------------------------------------------------------- /src/app/api/challengeOptions/[challengeOptionId]/route.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import { NextResponse } from "next/server"; 3 | 4 | import db from "@/server/db/drizzle"; 5 | import { isAdmin } from "@/lib/admin"; 6 | import { challengeOptions } from "@/server/db/schema"; 7 | 8 | export const GET = async (req: Request) => { 9 | const url = new URL(req.url); 10 | const searchParams = new URLSearchParams(url.searchParams); 11 | 12 | const challengeOptionId = parseInt( 13 | searchParams.get("challengeOptionId") || "0", 14 | 10 15 | ); 16 | 17 | if (!isAdmin()) return new NextResponse("Unauthorized", { status: 401 }); 18 | 19 | const data = await db.query.challengeOptions.findFirst({ 20 | where: eq(challengeOptions.id, challengeOptionId), 21 | }); 22 | 23 | return NextResponse.json(data); 24 | }; 25 | 26 | export const PUT = async (req: Request) => { 27 | const url = new URL(req.url); 28 | const searchParams = new URLSearchParams(url.searchParams); 29 | 30 | const challengeOptionId = parseInt( 31 | searchParams.get("challengeOptionId") || "0", 32 | 10 33 | ); 34 | 35 | if (!isAdmin()) return new NextResponse("Unauthorized", { status: 401 }); 36 | 37 | const body = await req.json(); 38 | 39 | const data = await db 40 | .update(challengeOptions) 41 | .set({ 42 | ...body, 43 | }) 44 | .where(eq(challengeOptions.id, challengeOptionId)) 45 | .returning(); 46 | 47 | return NextResponse.json(data[0]); 48 | }; 49 | 50 | export const DELETE = async (req: Request) => { 51 | const url = new URL(req.url); 52 | const searchParams = new URLSearchParams(url.searchParams); 53 | 54 | const challengeOptionId = parseInt( 55 | searchParams.get("challengeOptionId") || "0", 56 | 10 57 | ); 58 | 59 | if (!isAdmin()) return new NextResponse("Unauthorized", { status: 401 }); 60 | 61 | const data = await db 62 | .delete(challengeOptions) 63 | .where(eq(challengeOptions.id, challengeOptionId)) 64 | .returning(); 65 | 66 | return NextResponse.json(data[0]); 67 | }; 68 | -------------------------------------------------------------------------------- /src/app/api/challengeOptions/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | import db from "@/server/db/drizzle"; 4 | import { isAdmin } from "@/lib/admin"; 5 | import { challengeOptions } from "@/server/db/schema"; 6 | 7 | export const GET = async () => { 8 | if (!isAdmin()) return new NextResponse("Unauthorized", { status: 401 }); 9 | 10 | const data = await db.query.challengeOptions.findMany(); 11 | 12 | return NextResponse.json(data); 13 | }; 14 | 15 | export const POST = async (req: Request) => { 16 | if (!isAdmin()) return new NextResponse("Unauthorized", { status: 401 }); 17 | 18 | const body = await req.json(); 19 | 20 | const data = await db 21 | .insert(challengeOptions) 22 | .values({ 23 | ...body, 24 | }) 25 | .returning(); 26 | 27 | return NextResponse.json(data[0]); 28 | }; 29 | -------------------------------------------------------------------------------- /src/app/api/challenges/[challengeId]/route.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import { NextResponse } from "next/server"; 3 | 4 | import db from "@/server/db/drizzle"; 5 | import { isAdmin } from "@/lib/admin"; 6 | import { challenges } from "@/server/db/schema"; 7 | 8 | export const GET = async (req: Request) => { 9 | const url = new URL(req.url); 10 | const searchParams = new URLSearchParams(url.searchParams); 11 | 12 | const challengeId = parseInt(searchParams.get("challengeId") || "0", 10); 13 | 14 | if (!isAdmin()) return new NextResponse("Unauthorized", { status: 401 }); 15 | 16 | const data = await db.query.challenges.findFirst({ 17 | where: eq(challenges.id, challengeId), 18 | }); 19 | 20 | return NextResponse.json(data); 21 | }; 22 | 23 | export const PUT = async (req: Request) => { 24 | const url = new URL(req.url); 25 | const searchParams = new URLSearchParams(url.searchParams); 26 | 27 | const challengeId = parseInt(searchParams.get("challengeId") || "0", 10); 28 | 29 | if (!isAdmin()) return new NextResponse("Unauthorized", { status: 403 }); 30 | 31 | const body = await req.json(); 32 | 33 | const data = await db 34 | .update(challenges) 35 | .set({ 36 | ...body, 37 | }) 38 | .where(eq(challenges.id, challengeId)) 39 | .returning(); 40 | 41 | return NextResponse.json(data[0]); 42 | }; 43 | 44 | export const DELETE = async (req: Request) => { 45 | const url = new URL(req.url); 46 | const searchParams = new URLSearchParams(url.searchParams); 47 | 48 | const challengeId = parseInt(searchParams.get("challengeId") || "0", 10); 49 | 50 | if (!isAdmin()) return new NextResponse("Unauthorized", { status: 401 }); 51 | 52 | const data = await db 53 | .delete(challenges) 54 | .where(eq(challenges.id, challengeId)) 55 | .returning(); 56 | 57 | return NextResponse.json(data[0]); 58 | }; 59 | -------------------------------------------------------------------------------- /src/app/api/challenges/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | import db from "@/server/db/drizzle"; 4 | import { isAdmin } from "@/lib/admin"; 5 | import { challenges } from "@/server/db/schema"; 6 | 7 | export const GET = async () => { 8 | if (!isAdmin()) return new NextResponse("Unauthorized", { status: 401 }); 9 | 10 | const data = await db.query.challenges.findMany(); 11 | 12 | return NextResponse.json(data); 13 | }; 14 | 15 | export const POST = async (req: Request) => { 16 | if (!isAdmin()) return new NextResponse("Unauthorized", { status: 401 }); 17 | 18 | const body = await req.json(); 19 | 20 | const data = await db 21 | .insert(challenges) 22 | .values({ 23 | ...body, 24 | }) 25 | .returning(); 26 | 27 | return NextResponse.json(data[0]); 28 | }; 29 | -------------------------------------------------------------------------------- /src/app/api/courses/[courseId]/route.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import { NextResponse } from "next/server"; 3 | 4 | import db from "@/server/db/drizzle"; 5 | import { isAdmin } from "@/lib/admin"; 6 | import { courses } from "@/server/db/schema"; 7 | 8 | export const GET = async (req: Request) => { 9 | const url = new URL(req.url); 10 | const searchParams = new URLSearchParams(url.searchParams); 11 | 12 | const courseId = parseInt(searchParams.get("courseId") || "0", 10); 13 | 14 | if (!isAdmin()) return new NextResponse("Unauthorized", { status: 401 }); 15 | 16 | const data = await db.query.courses.findFirst({ 17 | where: eq(courses.id, courseId), 18 | }); 19 | 20 | return NextResponse.json(data); 21 | }; 22 | 23 | export const PUT = async (req: Request) => { 24 | const url = new URL(req.url); 25 | const searchParams = new URLSearchParams(url.searchParams); 26 | 27 | const courseId = parseInt(searchParams.get("courseId") || "0", 10); 28 | 29 | if (!isAdmin()) return new NextResponse("Unauthorized", { status: 401 }); 30 | 31 | const body = await req.json(); 32 | 33 | const data = await db 34 | .update(courses) 35 | .set({ 36 | ...body, 37 | }) 38 | .where(eq(courses.id, courseId)) 39 | .returning(); 40 | 41 | return NextResponse.json(data[0]); 42 | }; 43 | 44 | export const DELETE = async (req: Request) => { 45 | const url = new URL(req.url); 46 | const searchParams = new URLSearchParams(url.searchParams); 47 | 48 | const courseId = parseInt(searchParams.get("courseId") || "0", 10); 49 | 50 | if (!isAdmin()) return new NextResponse("Unauthorized", { status: 401 }); 51 | 52 | const data = await db 53 | .delete(courses) 54 | .where(eq(courses.id, courseId)) 55 | .returning(); 56 | 57 | return NextResponse.json(data[0]); 58 | }; 59 | -------------------------------------------------------------------------------- /src/app/api/courses/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | import db from "@/server/db/drizzle"; 4 | import { isAdmin } from "@/lib/admin"; 5 | import { courses } from "@/server/db/schema"; 6 | 7 | export const GET = async () => { 8 | if (!isAdmin()) return new NextResponse("Unauthorized", { status: 401 }); 9 | 10 | const data = await db.query.courses.findMany(); 11 | 12 | return NextResponse.json(data); 13 | }; 14 | 15 | export const POST = async (req: Request) => { 16 | if (!isAdmin()) return new NextResponse("Unauthorized", { status: 401 }); 17 | 18 | const body = await req.json(); 19 | 20 | const data = await db 21 | .insert(courses) 22 | .values({ 23 | ...body, 24 | }) 25 | .returning(); 26 | 27 | return NextResponse.json(data[0]); 28 | }; 29 | -------------------------------------------------------------------------------- /src/app/api/lessons/[lessonId]/route.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import { NextResponse } from "next/server"; 3 | 4 | import db from "@/server/db/drizzle"; 5 | import { isAdmin } from "@/lib/admin"; 6 | import { lessons } from "@/server/db/schema"; 7 | 8 | export const GET = async (req: Request) => { 9 | const url = new URL(req.url); 10 | const searchParams = new URLSearchParams(url.searchParams); 11 | 12 | const lessonId = parseInt(searchParams.get("lessonId") || "0", 10); 13 | 14 | if (!isAdmin()) return new NextResponse("Unauthorized", { status: 401 }); 15 | 16 | const data = await db.query.lessons.findFirst({ 17 | where: eq(lessons.id, lessonId), 18 | }); 19 | 20 | return NextResponse.json(data); 21 | }; 22 | 23 | export const PUT = async (req: Request) => { 24 | const url = new URL(req.url); 25 | const searchParams = new URLSearchParams(url.searchParams); 26 | 27 | const lessonId = parseInt(searchParams.get("lessonId") || "0", 10); 28 | 29 | if (!isAdmin()) return new NextResponse("Unauthorized", { status: 401 }); 30 | 31 | const body = await req.json(); 32 | 33 | const data = await db 34 | .update(lessons) 35 | .set({ 36 | ...body, 37 | }) 38 | .where(eq(lessons.id, lessonId)) 39 | .returning(); 40 | 41 | return NextResponse.json(data[0]); 42 | }; 43 | 44 | export const DELETE = async (req: Request) => { 45 | const url = new URL(req.url); 46 | const searchParams = new URLSearchParams(url.searchParams); 47 | 48 | const lessonId = parseInt(searchParams.get("lessonId") || "0", 10); 49 | 50 | if (!isAdmin()) return new NextResponse("Unauthorized", { status: 401 }); 51 | 52 | const data = await db 53 | .delete(lessons) 54 | .where(eq(lessons.id, lessonId)) 55 | .returning(); 56 | 57 | return NextResponse.json(data[0]); 58 | }; 59 | -------------------------------------------------------------------------------- /src/app/api/lessons/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | import db from "@/server/db/drizzle"; 4 | import { isAdmin } from "@/lib/admin"; 5 | import { lessons } from "@/server/db/schema"; 6 | 7 | export const GET = async () => { 8 | if (!isAdmin()) return new NextResponse("Unauthorized", { status: 401 }); 9 | 10 | const data = await db.query.lessons.findMany(); 11 | 12 | return NextResponse.json(data); 13 | }; 14 | 15 | export const POST = async (req: Request) => { 16 | if (!isAdmin()) return new NextResponse("Unauthorized", { status: 401 }); 17 | 18 | const body = await req.json(); 19 | 20 | const data = await db 21 | .insert(lessons) 22 | .values({ 23 | ...body, 24 | }) 25 | .returning(); 26 | 27 | return NextResponse.json(data[0]); 28 | }; 29 | -------------------------------------------------------------------------------- /src/app/api/units/[unitId]/route.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import { NextResponse } from "next/server"; 3 | 4 | import db from "@/server/db/drizzle"; 5 | import { isAdmin } from "@/lib/admin"; 6 | import { units } from "@/server/db/schema"; 7 | 8 | export const GET = async (req: Request) => { 9 | const url = new URL(req.url); 10 | const searchParams = new URLSearchParams(url.searchParams); 11 | 12 | const unitId = parseInt(searchParams.get("unitId") || "0", 10); 13 | 14 | if (!isAdmin()) return new NextResponse("Unauthorized", { status: 401 }); 15 | 16 | const data = await db.query.units.findFirst({ 17 | where: eq(units.id, unitId), 18 | }); 19 | 20 | return NextResponse.json(data); 21 | }; 22 | 23 | export const PUT = async (req: Request) => { 24 | const url = new URL(req.url); 25 | const searchParams = new URLSearchParams(url.searchParams); 26 | 27 | const unitId = parseInt(searchParams.get("unitId") || "0", 10); 28 | 29 | if (!isAdmin()) return new NextResponse("Unauthorized", { status: 401 }); 30 | 31 | const body = await req.json(); 32 | 33 | const data = await db 34 | .update(units) 35 | .set({ 36 | ...body, 37 | }) 38 | .where(eq(units.id, unitId)) 39 | .returning(); 40 | 41 | return NextResponse.json(data[0]); 42 | }; 43 | 44 | export const DELETE = async (req: Request) => { 45 | const url = new URL(req.url); 46 | const searchParams = new URLSearchParams(url.searchParams); 47 | 48 | const unitId = parseInt(searchParams.get("unitId") || "0", 10); 49 | 50 | if (!isAdmin()) return new NextResponse("Unauthorized", { status: 401 }); 51 | 52 | const data = await db.delete(units).where(eq(units.id, unitId)).returning(); 53 | 54 | return NextResponse.json(data[0]); 55 | }; 56 | -------------------------------------------------------------------------------- /src/app/api/units/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | import db from "@/server/db/drizzle"; 4 | import { isAdmin } from "@/lib/admin"; 5 | import { units } from "@/server/db/schema"; 6 | 7 | export const GET = async () => { 8 | if (!isAdmin()) return new NextResponse("Unauthorized", { status: 401 }); 9 | 10 | const data = await db.query.units.findMany(); 11 | 12 | return NextResponse.json(data); 13 | }; 14 | 15 | export const POST = async (req: Request) => { 16 | if (!isAdmin()) return new NextResponse("Unauthorized", { status: 401 }); 17 | 18 | const body = await req.json(); 19 | 20 | const data = await db 21 | .insert(units) 22 | .values({ 23 | ...body, 24 | }) 25 | .returning(); 26 | 27 | return NextResponse.json(data[0]); 28 | }; 29 | -------------------------------------------------------------------------------- /src/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 "@/server/db/drizzle"; 7 | import { stripe } from "@/lib/stripe"; 8 | import { userSubscription } from "@/server/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 as string 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 | // after successful completion of the subscription creation process 31 | if (event.type === "checkout.session.completed") { 32 | const subscription = await stripe.subscriptions.retrieve( 33 | session.subscription as string 34 | ); 35 | 36 | if (!session?.metadata?.userId) { 37 | return new NextResponse("User ID is required", { status: 400 }); 38 | } 39 | 40 | await db.insert(userSubscription).values({ 41 | userId: session.metadata.userId, 42 | stripeSubscriptionId: subscription.id, 43 | stripeCustomerId: subscription.customer as string, 44 | 45 | // first item [0] in the array because only 1 item is defined in `line_items` 46 | stripePriceId: subscription.items.data[0].price.id, 47 | 48 | // convert Unix timestamp to JavaScript `Date` in ms 49 | stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000), 50 | }); 51 | } 52 | 53 | // after successful completion of the subscription renewal process 54 | if (event.type === "invoice.payment_succeeded") { 55 | const subscription = await stripe.subscriptions.retrieve( 56 | session.subscription as string 57 | ); 58 | 59 | await db 60 | .update(userSubscription) 61 | .set({ 62 | // first item [0] in the array because only 1 item is defined in `line_items` 63 | stripePriceId: subscription.items.data[0].price.id, 64 | 65 | // convert Unix timestamp to JavaScript `Date` in ms 66 | stripeCurrentPeriodEnd: new Date( 67 | subscription.current_period_end * 1000 68 | ), 69 | }) 70 | .where(eq(userSubscription.stripeSubscriptionId, subscription.id)); 71 | } 72 | 73 | return new NextResponse(null, { status: 200 }); 74 | } 75 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabarvn/lingo/1e9d35474ace866fae0f2b59778243de67941669/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; 4 | 5 | .cl-modalContent { 6 | margin: auto; 7 | } 8 | 9 | .cl-rootBox { 10 | display: flex; 11 | justify-content: center; 12 | align-items: center; 13 | } 14 | 15 | @media screen and (max-width: 624px) { 16 | .cl-userProfile-root .cl-cardBox { 17 | height: calc(100svh - 4rem); 18 | } 19 | } 20 | 21 | @media screen and (min-width: 1024px) and (max-height: 1024px) { 22 | .cl-userProfile-root .cl-cardBox { 23 | height: calc(100svh - 9rem); 24 | } 25 | } 26 | 27 | @media screen and (min-width: 750px) { 28 | .scrollbar-w-4::-webkit-scrollbar { 29 | width: 0.5rem; 30 | height: 0.5rem; 31 | } 32 | 33 | .scrollbar-track-gray-lighter::-webkit-scrollbar-track { 34 | --bg-opacity: 0.5; 35 | background-color: #00000015; 36 | } 37 | 38 | .scrollbar-thumb-gray::-webkit-scrollbar-thumb { 39 | --bg-opacity: 0.5; 40 | background-color: #13131374; 41 | } 42 | 43 | .scrollbar-thumb-rounded::-webkit-scrollbar-thumb { 44 | border-radius: 2px; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css"; 2 | import { cn } from "@/lib/utils"; 3 | import type { Metadata } from "next"; 4 | import { Nunito } from "next/font/google"; 5 | import { ClerkProvider } from "@clerk/nextjs"; 6 | import { Toaster } from "@/components/ui/Sonner"; 7 | 8 | import { ExitModal, HeartsModal, PracticeModal } from "@/components/modals"; 9 | 10 | const font = Nunito({ subsets: ["latin"] }); 11 | 12 | export const metadata: Metadata = { 13 | title: "Lingo", 14 | description: "Learn new languages at your own pace.", 15 | }; 16 | 17 | export default function RootLayout({ 18 | children, 19 | }: Readonly<{ 20 | children: React.ReactNode; 21 | }>) { 22 | return ( 23 | 24 | 25 | 31 | {children} 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/app/lesson/[lessonId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | import { 4 | getLesson, 5 | getUserProgress, 6 | getUserSubscription, 7 | } from "@/server/db/queries"; 8 | 9 | import Quiz from "../quiz"; 10 | 11 | type LessonIdPageProps = { 12 | params: { 13 | lessonId: number; 14 | }; 15 | }; 16 | 17 | const LessonIdPage = async ({ params: { lessonId } }: LessonIdPageProps) => { 18 | const lessonData = getLesson(lessonId); 19 | const userProgressData = getUserProgress(); 20 | const userSubscriptionData = getUserSubscription(); 21 | 22 | const [lesson, userProgress, userSubscription] = await Promise.all([ 23 | lessonData, 24 | userProgressData, 25 | userSubscriptionData, 26 | ]); 27 | 28 | if (!lesson || !userProgress) { 29 | redirect("/learn"); 30 | } 31 | 32 | const initialPercentage = 33 | (lesson.challenges.filter((challenge) => challenge.completed).length / 34 | lesson.challenges.length) * 35 | 100; 36 | 37 | return ( 38 | 45 | ); 46 | }; 47 | 48 | export default LessonIdPage; 49 | -------------------------------------------------------------------------------- /src/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 "@/server/db/schema"; 7 | 8 | type CardProps = { 9 | text: string; 10 | imageSrc: string | null; 11 | shortcut: string; 12 | selected?: boolean; 13 | onClick: () => void; 14 | status?: "correct" | "wrong" | "none"; 15 | audioSrc: string | null; 16 | disabled?: boolean; 17 | type: (typeof challenges.$inferSelect)["type"]; 18 | }; 19 | 20 | const Card = ({ 21 | text, 22 | imageSrc, 23 | shortcut, 24 | selected, 25 | onClick, 26 | status, 27 | audioSrc, 28 | disabled, 29 | type, 30 | }: CardProps) => { 31 | const [audio, _, controls] = useAudio({ src: audioSrc ?? "" }); 32 | 33 | // useCallback() hook returns a memoized version of `handleClick` that only changes if one of the dependencies has changed 34 | // memoization is essential here because `handleClick` is being used as a dependency in another hook 35 | const handleClick = useCallback(() => { 36 | if (disabled) return; 37 | 38 | controls.play(); 39 | onClick(); 40 | }, [disabled, onClick, controls]); 41 | 42 | // it is important for `useKey` to provide a stable reference to the callback function 43 | // useCallback() hook ensures that the `handleClick` reference remains stable across renders unless its dependencies change 44 | useKey(shortcut, handleClick, {}, [handleClick]); 45 | 46 | return ( 47 |
62 | {audio} 63 | 64 | {imageSrc && ( 65 |
66 | {text} 67 |
68 | )} 69 | 70 |
75 | {type === "ASSIST" &&
} 76 | 77 |

84 | {text} 85 |

86 | 87 |
98 | {shortcut} 99 |
100 |
101 |
102 | ); 103 | }; 104 | 105 | export default Card; 106 | -------------------------------------------------------------------------------- /src/app/lesson/challenge.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { challengeOptions, challenges } from "@/server/db/schema"; 3 | 4 | import Card from "./card"; 5 | 6 | type ChallengeProps = { 7 | options: (typeof challengeOptions.$inferSelect)[]; 8 | onSelect: (id: number) => void; 9 | status: "correct" | "wrong" | "none"; 10 | disabled?: boolean; 11 | selectedOption?: number; 12 | type: (typeof challenges.$inferSelect)["type"]; 13 | }; 14 | 15 | const Challenge = ({ 16 | options, 17 | onSelect, 18 | status, 19 | disabled, 20 | selectedOption, 21 | type, 22 | }: ChallengeProps) => { 23 | return ( 24 |
31 | {options.map((option, i) => ( 32 | onSelect(option.id)} 39 | status={status} 40 | audioSrc={option.audioSrc} 41 | disabled={disabled} 42 | type={type} 43 | /> 44 | ))} 45 |
46 | ); 47 | }; 48 | 49 | export default Challenge; 50 | -------------------------------------------------------------------------------- /src/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"; 6 | 7 | type Status = "correct" | "wrong" | "none" | "completed"; 8 | 9 | type FooterProps = { 10 | onCheck: () => void; 11 | status: Status; 12 | disabled?: boolean; 13 | lessonId?: number; 14 | }; 15 | 16 | const Footer = ({ onCheck, status, disabled, lessonId }: FooterProps) => { 17 | useKey("Enter", onCheck, {}, [onCheck]); 18 | const isMobile = useMedia("(max-width: 1024px)"); 19 | 20 | return ( 21 |
27 |
28 | {status === "correct" && ( 29 |
30 | 31 | Nicely done! 32 |
33 | )} 34 | 35 | {status === "wrong" && ( 36 |
37 | 38 | Try again. 39 |
40 | )} 41 | 42 | {status === "completed" && ( 43 | 49 | )} 50 | 51 | 63 |
64 |
65 | ); 66 | }; 67 | 68 | export default Footer; 69 | -------------------------------------------------------------------------------- /src/app/lesson/header.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { InfinityIcon, X } from "lucide-react"; 3 | 4 | import { Progress } from "@/components/ui"; 5 | import { useExitModal } from "@/store/use-exit-modal"; 6 | 7 | type HeaderProps = { 8 | hearts: number; 9 | percentage: number; 10 | hasActiveSubscription: boolean; 11 | }; 12 | 13 | const Header = ({ hearts, percentage, hasActiveSubscription }: HeaderProps) => { 14 | const { open } = useExitModal(); 15 | 16 | return ( 17 |
18 | 22 | 23 | 24 | 25 |
26 | Heart 33 | 34 | {hasActiveSubscription ? ( 35 | 36 | ) : ( 37 | hearts 38 | )} 39 |
40 |
41 | ); 42 | }; 43 | 44 | export default Header; 45 | -------------------------------------------------------------------------------- /src/app/lesson/layout.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from "react"; 2 | 3 | const LessonLayout = ({ children }: PropsWithChildren) => { 4 | return ( 5 |
6 |
{children}
7 |
8 | ); 9 | }; 10 | 11 | export default LessonLayout; 12 | -------------------------------------------------------------------------------- /src/app/lesson/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | import { 4 | getLesson, 5 | getUserProgress, 6 | getUserSubscription, 7 | } from "@/server/db/queries"; 8 | 9 | import Quiz from "./quiz"; 10 | 11 | const LessonPage = async () => { 12 | const lessonData = getLesson(); 13 | const userProgressData = getUserProgress(); 14 | const userSubscriptionData = getUserSubscription(); 15 | 16 | const [lesson, userProgress, userSubscription] = await Promise.all([ 17 | lessonData, 18 | userProgressData, 19 | userSubscriptionData, 20 | ]); 21 | 22 | if (!lesson || !userProgress) { 23 | redirect("/learn"); 24 | } 25 | 26 | const initialPercentage = 27 | (lesson.challenges.filter((challenge) => challenge.completed).length / 28 | lesson.challenges.length) * 29 | 100; 30 | 31 | return ( 32 | 39 | ); 40 | }; 41 | 42 | export default LessonPage; 43 | -------------------------------------------------------------------------------- /src/app/lesson/question-bubble.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | type QuestionBubbleProps = { 4 | question: string; 5 | }; 6 | 7 | const QuestionBubble = ({ question }: QuestionBubbleProps) => { 8 | return ( 9 |
10 | Mascot 17 | 18 | Mascot 25 | 26 |
27 | {question} 28 |
29 |
30 |
31 | ); 32 | }; 33 | 34 | export default QuestionBubble; 35 | -------------------------------------------------------------------------------- /src/app/lesson/quiz.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import { toast } from "sonner"; 5 | import ReactConfetti from "react-confetti"; 6 | import { useRouter } from "next/navigation"; 7 | import { useMount, useWindowSize } from "react-use"; 8 | import { useRef, useState, useTransition } from "react"; 9 | 10 | import Header from "./header"; 11 | import Footer from "./footer"; 12 | import Challenge from "./challenge"; 13 | import ResultCard from "./result-card"; 14 | import QuestionBubble from "./question-bubble"; 15 | 16 | import { 17 | challengeOptions, 18 | challenges, 19 | userSubscription, 20 | } from "@/server/db/schema"; 21 | 22 | import { useHeartsModal } from "@/store/use-hearts-modal"; 23 | import { usePracticeModal } from "@/store/use-practice-modal"; 24 | 25 | import { reduceHearts } from "@/server/actions/user-progress"; 26 | import { upsertChallengeProgress } from "@/server/actions/challenge-progress"; 27 | 28 | import { 29 | DEFAULT_HEARTS_MAX, 30 | DEFAULT_POINTS_START, 31 | POINTS_PER_CHALLENGE, 32 | } from "@/constants"; 33 | 34 | type QuizProps = { 35 | initialLessonId: number; 36 | initialLessonChallenges: (typeof challenges.$inferSelect & { 37 | completed: boolean; 38 | challengeOptions: (typeof challengeOptions.$inferSelect)[]; 39 | })[]; 40 | initialHearts: number; 41 | initialPercentage: number; 42 | userSubscription: 43 | | (typeof userSubscription.$inferSelect & { 44 | isActive: boolean; 45 | }) 46 | | null; 47 | }; 48 | 49 | const Quiz = ({ 50 | initialLessonId, 51 | initialLessonChallenges, 52 | initialHearts, 53 | initialPercentage, 54 | userSubscription, 55 | }: QuizProps) => { 56 | const router = useRouter(); 57 | const { width, height } = useWindowSize(); 58 | const [pending, startTransition] = useTransition(); 59 | 60 | const { open: openHeartsModal } = useHeartsModal(); 61 | const { open: openPracticeModal } = usePracticeModal(); 62 | 63 | useMount(() => { 64 | if (initialPercentage === 100) { 65 | openPracticeModal(); 66 | } 67 | }); 68 | 69 | const correctAudioRef = useRef(null); 70 | const incorrectAudioRef = useRef(null); 71 | const finishAudioRef = useRef(null); 72 | 73 | const [lessonId] = useState(initialLessonId); 74 | const [hearts, setHearts] = useState(initialHearts); 75 | 76 | const [percentage, setPercentage] = useState(() => 77 | initialPercentage === 100 ? DEFAULT_POINTS_START : initialPercentage 78 | ); 79 | 80 | const [challenges] = useState(initialLessonChallenges); 81 | 82 | const [activeIndex, setActiveIndex] = useState(() => { 83 | const uncompletedIndex = challenges.findIndex( 84 | (challenge) => !challenge.completed 85 | ); 86 | 87 | return uncompletedIndex === -1 ? 0 : uncompletedIndex; 88 | }); 89 | 90 | const [selectedOption, setSelectedOption] = useState(); 91 | const [status, setStatus] = useState<"correct" | "wrong" | "none">("none"); 92 | 93 | const currentChallenge = challenges[activeIndex]; 94 | const options = currentChallenge?.challengeOptions ?? []; 95 | 96 | const isPro = !!userSubscription?.isActive; 97 | 98 | const title = 99 | currentChallenge?.type === "ASSIST" 100 | ? "Select the correct meaning" 101 | : currentChallenge?.question; 102 | 103 | const onNext = () => { 104 | setActiveIndex((current) => current + 1); 105 | }; 106 | 107 | const onSelect = (id: number) => { 108 | if (status !== "none") return; 109 | setSelectedOption(id); 110 | }; 111 | 112 | const onContinue = () => { 113 | if (pending || !selectedOption) return; 114 | 115 | if (status === "wrong") { 116 | setStatus("none"); 117 | setSelectedOption(undefined); 118 | return; 119 | } 120 | 121 | if (status === "correct") { 122 | startTransition(() => { 123 | onNext(); 124 | setStatus("none"); 125 | setSelectedOption(undefined); 126 | }); 127 | 128 | return; 129 | } 130 | 131 | const correctOption = options.find((option) => option.correct); 132 | 133 | if (!correctOption) { 134 | return; 135 | } 136 | 137 | if (correctOption.id === selectedOption) { 138 | startTransition(() => { 139 | upsertChallengeProgress(currentChallenge.id) 140 | .then((response) => { 141 | if (response?.error === "hearts") { 142 | openHeartsModal(); 143 | return; 144 | } 145 | 146 | if (correctAudioRef.current) { 147 | correctAudioRef.current.play(); 148 | } 149 | 150 | setStatus("correct"); 151 | setPercentage((prev) => prev + 100 / challenges.length); 152 | 153 | // this is a practice challenge 154 | if (initialPercentage === 100) { 155 | setHearts((prev) => Math.min(prev + 1, DEFAULT_HEARTS_MAX)); 156 | } 157 | }) 158 | .catch(() => toast.error("Something went wrong. Please try again.")); 159 | }); 160 | } else { 161 | startTransition(() => { 162 | reduceHearts(currentChallenge.id) 163 | .then((response) => { 164 | if (response?.error === "hearts") { 165 | openHeartsModal(); 166 | return; 167 | } 168 | 169 | if (incorrectAudioRef.current) { 170 | incorrectAudioRef.current.play(); 171 | } 172 | 173 | setStatus("wrong"); 174 | 175 | if (!response?.error) { 176 | setHearts((prev) => Math.max(prev - 1, 0)); 177 | } 178 | }) 179 | .catch(() => toast.error("Something went wrong. Please try again.")); 180 | }); 181 | } 182 | }; 183 | 184 | if (!currentChallenge) { 185 | return ( 186 | <> 187 |