├── .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 |
14 |
--------------------------------------------------------------------------------
/public/heart.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/incorrect.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nabarvn/lingo/1e9d35474ace866fae0f2b59778243de67941669/public/incorrect.wav
--------------------------------------------------------------------------------
/public/it.svg:
--------------------------------------------------------------------------------
1 |
14 |
--------------------------------------------------------------------------------
/public/jp.svg:
--------------------------------------------------------------------------------
1 |
18 |
--------------------------------------------------------------------------------
/public/mascot.svg:
--------------------------------------------------------------------------------
1 |
22 |
--------------------------------------------------------------------------------
/public/mascot_bad.svg:
--------------------------------------------------------------------------------
1 |
19 |
--------------------------------------------------------------------------------
/public/mascot_sad.svg:
--------------------------------------------------------------------------------
1 |
22 |
--------------------------------------------------------------------------------
/public/points.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/quests.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/woman.svg:
--------------------------------------------------------------------------------
1 |
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 |
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 |
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 |
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 |
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 |
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 |
47 |
48 |
49 |
50 | Refill hearts
51 |
52 |
53 |
54 |
72 |
73 |
74 |
75 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
17 |
18 |
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 |
188 |
189 |
196 |
197 |
198 |
205 |
206 |
213 |
214 |
215 | Great job!
You've completed the lesson.
216 |
217 |
218 |
219 |
223 |
224 |
225 |
226 |
227 |
228 |