7 |
8 | const Toaster = ({ ...props }: ToasterProps) => {
9 | const { theme = "system" } = useTheme()
10 |
11 | return (
12 |
28 | )
29 | }
30 |
31 | export { Toaster }
32 |
--------------------------------------------------------------------------------
/app/admin/challenge/create.tsx:
--------------------------------------------------------------------------------
1 | import { SimpleForm, Create, TextInput, ReferenceInput, NumberInput, required, SelectInput } from "react-admin";
2 |
3 | export const ChallengeCreate = () => {
4 | return (
5 |
6 |
7 |
12 |
26 |
30 |
35 |
36 |
37 | );
38 | };
39 |
--------------------------------------------------------------------------------
/public/heart.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Nunito } from "next/font/google";
3 | import { ClerkProvider } from '@clerk/nextjs'
4 | import { Toaster } from "@/components/ui/sonner";
5 | import { ExitModal } from "@/components/modals/exit-modal";
6 | import { HeartsModal } from "@/components/modals/hearts-modal";
7 | import { PracticeModal } from "@/components/modals/practice-modal";
8 | import "./globals.css";
9 |
10 | const font = Nunito({ subsets: ["latin"] });
11 |
12 | export const metadata: Metadata = {
13 | title: "Create Next App",
14 | description: "Generated by create next app",
15 | };
16 |
17 | export default function RootLayout({
18 | children,
19 | }: Readonly<{
20 | children: React.ReactNode;
21 | }>) {
22 | return (
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | {children}
31 |
32 |
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/app/lesson/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "next/navigation";
2 |
3 | import { getLesson, getUserProgress, getUserSubscription } from "@/db/queries";
4 |
5 | import { Quiz } from "./quiz";
6 |
7 | const LessonPage = async () => {
8 | const lessonData = getLesson();
9 | const userProgressData = getUserProgress();
10 | const userSubscriptionData = getUserSubscription();
11 |
12 | const [
13 | lesson,
14 | userProgress,
15 | userSubscription,
16 | ] = await Promise.all([
17 | lessonData,
18 | userProgressData,
19 | userSubscriptionData,
20 | ]);
21 |
22 | if (!lesson || !userProgress) {
23 | redirect("/learn");
24 | }
25 |
26 | const initialPercentage = lesson.challenges
27 | .filter((challenge) => challenge.completed)
28 | .length / lesson.challenges.length * 100;
29 |
30 | return (
31 |
38 | );
39 | };
40 |
41 | export default LessonPage;
42 |
--------------------------------------------------------------------------------
/app/buttons/page.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 |
3 | const ButtonsPage = () => {
4 | return (
5 |
6 |
9 |
12 |
15 |
18 |
21 |
24 |
27 |
30 |
33 |
36 |
39 |
42 |
43 | );
44 | };
45 |
46 | export default ButtonsPage;
47 |
--------------------------------------------------------------------------------
/app/lesson/header.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import { InfinityIcon, X } from "lucide-react";
3 |
4 | import { Progress } from "@/components/ui/progress";
5 | import { useExitModal } from "@/store/use-exit-modal";
6 |
7 | type Props = {
8 | hearts: number;
9 | percentage: number;
10 | hasActiveSubscription: boolean;
11 | };
12 |
13 | export const Header = ({
14 | hearts,
15 | percentage,
16 | hasActiveSubscription,
17 | }: Props) => {
18 | const { open } = useExitModal();
19 |
20 | return (
21 |
41 | );
42 | };
43 |
--------------------------------------------------------------------------------
/app/lesson/[lessonId]/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "next/navigation";
2 |
3 | import { getLesson, getUserProgress, getUserSubscription } from "@/db/queries";
4 |
5 | import { Quiz } from "../quiz";
6 |
7 | type Props = {
8 | params: {
9 | lessonId: number;
10 | };
11 | };
12 |
13 | const LessonIdPage = async ({
14 | params,
15 | }: Props) => {
16 | const lessonData = getLesson(params.lessonId);
17 | const userProgressData = getUserProgress();
18 | const userSubscriptionData = getUserSubscription();
19 |
20 | const [
21 | lesson,
22 | userProgress,
23 | userSubscription,
24 | ] = await Promise.all([
25 | lessonData,
26 | userProgressData,
27 | userSubscriptionData,
28 | ]);
29 |
30 | if (!lesson || !userProgress) {
31 | redirect("/learn");
32 | }
33 |
34 | const initialPercentage = lesson.challenges
35 | .filter((challenge) => challenge.completed)
36 | .length / lesson.challenges.length * 100;
37 |
38 | return (
39 |
46 | );
47 | };
48 |
49 | export default LessonIdPage;
50 |
--------------------------------------------------------------------------------
/app/lesson/challenge.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 | import { challengeOptions, challenges } from "@/db/schema";
3 |
4 | import { Card } from "./card";
5 |
6 | type Props = {
7 | options: typeof challengeOptions.$inferSelect[];
8 | onSelect: (id: number) => void;
9 | status: "correct" | "wrong" | "none";
10 | selectedOption?: number;
11 | disabled?: boolean;
12 | type: typeof challenges.$inferSelect["type"];
13 | };
14 |
15 | export const Challenge = ({
16 | options,
17 | onSelect,
18 | status,
19 | selectedOption,
20 | disabled,
21 | type,
22 | }: Props) => {
23 | return (
24 |
29 | {options.map((option, i) => (
30 | onSelect(option.id)}
38 | status={status}
39 | audioSrc={option.audioSrc}
40 | disabled={disabled}
41 | type={type}
42 | />
43 | ))}
44 |
45 | );
46 | };
47 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/lesson/result-card.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | type Props = {
6 | value: number;
7 | variant: "points" | "hearts";
8 | };
9 |
10 | export const ResultCard = ({ value, variant }: Props) => {
11 | const imageSrc = variant === "hearts" ? "/heart.svg" : "/points.svg";
12 |
13 | return (
14 |
19 |
24 | {variant === "hearts" ? "Hearts Left" : "Total XP"}
25 |
26 |
31 |
38 | {value}
39 |
40 |
41 | );
42 | };
43 |
--------------------------------------------------------------------------------
/app/api/units/[unitId]/route.ts:
--------------------------------------------------------------------------------
1 | import { eq } from "drizzle-orm";
2 | import { NextResponse } from "next/server";
3 |
4 | import db from "@/db/drizzle";
5 | import { units } from "@/db/schema";
6 | import { isAdmin } from "@/lib/admin";
7 |
8 | export const GET = async (
9 | req: Request,
10 | { params }: { params: { unitId: number } },
11 | ) => {
12 | if (!isAdmin()) {
13 | return new NextResponse("Unauthorized", { status: 403 });
14 | }
15 |
16 | const data = await db.query.units.findFirst({
17 | where: eq(units.id, params.unitId),
18 | });
19 |
20 | return NextResponse.json(data);
21 | };
22 |
23 | export const PUT = async (
24 | req: Request,
25 | { params }: { params: { unitId: number } },
26 | ) => {
27 | if (!isAdmin()) {
28 | return new NextResponse("Unauthorized", { status: 403 });
29 | }
30 |
31 | const body = await req.json();
32 | const data = await db.update(units).set({
33 | ...body,
34 | }).where(eq(units.id, params.unitId)).returning();
35 |
36 | return NextResponse.json(data[0]);
37 | };
38 |
39 | export const DELETE = async (
40 | req: Request,
41 | { params }: { params: { unitId: number } },
42 | ) => {
43 | if (!isAdmin()) {
44 | return new NextResponse("Unauthorized", { status: 403 });
45 | }
46 |
47 | const data = await db.delete(units)
48 | .where(eq(units.id, params.unitId)).returning();
49 |
50 | return NextResponse.json(data[0]);
51 | };
52 |
--------------------------------------------------------------------------------
/app/api/courses/[courseId]/route.ts:
--------------------------------------------------------------------------------
1 | import { eq } from "drizzle-orm";
2 | import { NextResponse } from "next/server";
3 |
4 | import db from "@/db/drizzle";
5 | import { courses } from "@/db/schema";
6 | import { isAdmin } from "@/lib/admin";
7 |
8 | export const GET = async (
9 | req: Request,
10 | { params }: { params: { courseId: number } },
11 | ) => {
12 | if (!isAdmin()) {
13 | return new NextResponse("Unauthorized", { status: 403 });
14 | }
15 |
16 | const data = await db.query.courses.findFirst({
17 | where: eq(courses.id, params.courseId),
18 | });
19 |
20 | return NextResponse.json(data);
21 | };
22 |
23 | export const PUT = async (
24 | req: Request,
25 | { params }: { params: { courseId: number } },
26 | ) => {
27 | if (!isAdmin()) {
28 | return new NextResponse("Unauthorized", { status: 403 });
29 | }
30 |
31 | const body = await req.json();
32 | const data = await db.update(courses).set({
33 | ...body,
34 | }).where(eq(courses.id, params.courseId)).returning();
35 |
36 | return NextResponse.json(data[0]);
37 | };
38 |
39 | export const DELETE = async (
40 | req: Request,
41 | { params }: { params: { courseId: number } },
42 | ) => {
43 | if (!isAdmin()) {
44 | return new NextResponse("Unauthorized", { status: 403 });
45 | }
46 |
47 | const data = await db.delete(courses)
48 | .where(eq(courses.id, params.courseId)).returning();
49 |
50 | return NextResponse.json(data[0]);
51 | };
52 |
--------------------------------------------------------------------------------
/app/api/lessons/[lessonId]/route.ts:
--------------------------------------------------------------------------------
1 | import { eq } from "drizzle-orm";
2 | import { NextResponse } from "next/server";
3 |
4 | import db from "@/db/drizzle";
5 | import { lessons } from "@/db/schema";
6 | import { isAdmin } from "@/lib/admin";
7 |
8 | export const GET = async (
9 | req: Request,
10 | { params }: { params: { lessonId: number } },
11 | ) => {
12 | if (!isAdmin()) {
13 | return new NextResponse("Unauthorized", { status: 403 });
14 | }
15 |
16 | const data = await db.query.lessons.findFirst({
17 | where: eq(lessons.id, params.lessonId),
18 | });
19 |
20 | return NextResponse.json(data);
21 | };
22 |
23 | export const PUT = async (
24 | req: Request,
25 | { params }: { params: { lessonId: number } },
26 | ) => {
27 | if (!isAdmin()) {
28 | return new NextResponse("Unauthorized", { status: 403 });
29 | }
30 |
31 | const body = await req.json();
32 | const data = await db.update(lessons).set({
33 | ...body,
34 | }).where(eq(lessons.id, params.lessonId)).returning();
35 |
36 | return NextResponse.json(data[0]);
37 | };
38 |
39 | export const DELETE = async (
40 | req: Request,
41 | { params }: { params: { lessonId: number } },
42 | ) => {
43 | if (!isAdmin()) {
44 | return new NextResponse("Unauthorized", { status: 403 });
45 | }
46 |
47 | const data = await db.delete(lessons)
48 | .where(eq(lessons.id, params.lessonId)).returning();
49 |
50 | return NextResponse.json(data[0]);
51 | };
52 |
--------------------------------------------------------------------------------
/app/(main)/courses/card.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import { Check } from "lucide-react";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | type Props = {
7 | title: string;
8 | id: number;
9 | imageSrc: string;
10 | onClick: (id: number) => void;
11 | disabled?: boolean;
12 | active?: boolean;
13 | };
14 |
15 | export const Card = ({
16 | title,
17 | id,
18 | imageSrc,
19 | disabled,
20 | onClick,
21 | active,
22 | }: Props) => {
23 | return (
24 | onClick(id)}
26 | className={cn(
27 | "h-full border-2 rounded-xl border-b-4 hover:bg-black/5 cursor-pointer active:border-b-2 flex flex-col items-center justify-between p-3 pb-6 min-h-[217px] min-w-[200px]",
28 | disabled && "pointer-events-none opacity-50"
29 | )}
30 | >
31 |
32 | {active && (
33 |
34 |
35 |
36 | )}
37 |
38 |
45 |
46 | {title}
47 |
48 |
49 | );
50 | };
51 |
--------------------------------------------------------------------------------
/app/(main)/courses/list.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { toast } from "sonner";
4 | import { useTransition } from "react";
5 | import { useRouter } from "next/navigation";
6 |
7 | import { courses, userProgress } from "@/db/schema";
8 | import { upsertUserProgress } from "@/actions/user-progress";
9 |
10 | import { Card } from "./card";
11 |
12 | type Props = {
13 | courses: typeof courses.$inferSelect[];
14 | activeCourseId?: typeof userProgress.$inferSelect.activeCourseId;
15 | };
16 |
17 | export const List = ({ courses, activeCourseId }: Props) => {
18 | const router = useRouter();
19 | const [pending, startTransition] = useTransition();
20 |
21 | const onClick = (id: number) => {
22 | if (pending) return;
23 |
24 | if (id === activeCourseId) {
25 | return router.push("/learn");
26 | }
27 |
28 | startTransition(() => {
29 | upsertUserProgress(id)
30 | .catch(() => toast.error("Something went wrong."));
31 | });
32 | };
33 |
34 | return (
35 |
36 | {courses.map((course) => (
37 |
46 | ))}
47 |
48 | );
49 | };
50 |
--------------------------------------------------------------------------------
/app/(main)/learn/unit.tsx:
--------------------------------------------------------------------------------
1 | import { lessons, units } from "@/db/schema"
2 |
3 | import { UnitBanner } from "./unit-banner";
4 | import { LessonButton } from "./lesson-button";
5 |
6 | type Props = {
7 | id: number;
8 | order: number;
9 | title: string;
10 | description: string;
11 | lessons: (typeof lessons.$inferSelect & {
12 | completed: boolean;
13 | })[];
14 | activeLesson: typeof lessons.$inferSelect & {
15 | unit: typeof units.$inferSelect;
16 | } | undefined;
17 | activeLessonPercentage: number;
18 | };
19 |
20 | export const Unit = ({
21 | id,
22 | order,
23 | title,
24 | description,
25 | lessons,
26 | activeLesson,
27 | activeLessonPercentage,
28 | }: Props) => {
29 | return (
30 | <>
31 |
32 |
33 | {lessons.map((lesson, index) => {
34 | const isCurrent = lesson.id === activeLesson?.id;
35 | const isLocked = !lesson.completed && !isCurrent;
36 |
37 | return (
38 |
47 | );
48 | })}
49 |
50 | >
51 | );
52 | };
53 |
--------------------------------------------------------------------------------
/app/api/challenges/[challengeId]/route.ts:
--------------------------------------------------------------------------------
1 | import { eq } from "drizzle-orm";
2 | import { NextResponse } from "next/server";
3 |
4 | import db from "@/db/drizzle";
5 | import { challenges } from "@/db/schema";
6 | import { isAdmin } from "@/lib/admin";
7 |
8 | export const GET = async (
9 | req: Request,
10 | { params }: { params: { challengeId: number } },
11 | ) => {
12 | if (!isAdmin()) {
13 | return new NextResponse("Unauthorized", { status: 403 });
14 | }
15 |
16 | const data = await db.query.challenges.findFirst({
17 | where: eq(challenges.id, params.challengeId),
18 | });
19 |
20 | return NextResponse.json(data);
21 | };
22 |
23 | export const PUT = async (
24 | req: Request,
25 | { params }: { params: { challengeId: number } },
26 | ) => {
27 | if (!isAdmin()) {
28 | return new NextResponse("Unauthorized", { status: 403 });
29 | }
30 |
31 | const body = await req.json();
32 | const data = await db.update(challenges).set({
33 | ...body,
34 | }).where(eq(challenges.id, params.challengeId)).returning();
35 |
36 | return NextResponse.json(data[0]);
37 | };
38 |
39 | export const DELETE = async (
40 | req: Request,
41 | { params }: { params: { challengeId: number } },
42 | ) => {
43 | if (!isAdmin()) {
44 | return new NextResponse("Unauthorized", { status: 403 });
45 | }
46 |
47 | const data = await db.delete(challenges)
48 | .where(eq(challenges.id, params.challengeId)).returning();
49 |
50 | return NextResponse.json(data[0]);
51 | };
52 |
--------------------------------------------------------------------------------
/public/quests.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/(marketing)/header.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import { Loader } from "lucide-react";
3 | import {
4 | ClerkLoaded,
5 | ClerkLoading,
6 | SignedIn,
7 | SignedOut,
8 | SignInButton,
9 | UserButton,
10 | } from "@clerk/nextjs";
11 | import { Button } from "@/components/ui/button";
12 |
13 | export const Header = () => {
14 | return (
15 |
46 | );
47 | };
48 |
--------------------------------------------------------------------------------
/app/api/challengeOptions/[challengeOptionId]/route.ts:
--------------------------------------------------------------------------------
1 | import { eq } from "drizzle-orm";
2 | import { NextResponse } from "next/server";
3 |
4 | import db from "@/db/drizzle";
5 | import { challengeOptions } from "@/db/schema";
6 | import { isAdmin } from "@/lib/admin";
7 |
8 | export const GET = async (
9 | req: Request,
10 | { params }: { params: { challengeOptionId: number } },
11 | ) => {
12 | if (!isAdmin()) {
13 | return new NextResponse("Unauthorized", { status: 403 });
14 | }
15 |
16 | const data = await db.query.challengeOptions.findFirst({
17 | where: eq(challengeOptions.id, params.challengeOptionId),
18 | });
19 |
20 | return NextResponse.json(data);
21 | };
22 |
23 | export const PUT = async (
24 | req: Request,
25 | { params }: { params: { challengeOptionId: number } },
26 | ) => {
27 | if (!isAdmin()) {
28 | return new NextResponse("Unauthorized", { status: 403 });
29 | }
30 |
31 | const body = await req.json();
32 | const data = await db.update(challengeOptions).set({
33 | ...body,
34 | }).where(eq(challengeOptions.id, params.challengeOptionId)).returning();
35 |
36 | return NextResponse.json(data[0]);
37 | };
38 |
39 | export const DELETE = async (
40 | req: Request,
41 | { params }: { params: { challengeOptionId: number } },
42 | ) => {
43 | if (!isAdmin()) {
44 | return new NextResponse("Unauthorized", { status: 403 });
45 | }
46 |
47 | const data = await db.delete(challengeOptions)
48 | .where(eq(challengeOptions.id, params.challengeOptionId)).returning();
49 |
50 | return NextResponse.json(data[0]);
51 | };
52 |
--------------------------------------------------------------------------------
/components/user-progress.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import Image from "next/image";
3 | import { InfinityIcon } from "lucide-react";
4 |
5 | import { courses } from "@/db/schema";
6 | import { Button } from "@/components/ui/button";
7 |
8 | type Props = {
9 | activeCourse: typeof courses.$inferSelect;
10 | hearts: number;
11 | points: number;
12 | hasActiveSubscription: boolean;
13 | };
14 |
15 | export const UserProgress = ({
16 | activeCourse,
17 | points,
18 | hearts,
19 | hasActiveSubscription
20 | }: Props) => {
21 | return (
22 |
23 |
24 |
33 |
34 |
35 |
39 |
40 |
41 |
48 |
49 |
50 | );
51 | };
52 |
--------------------------------------------------------------------------------
/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Avatar = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ))
21 | Avatar.displayName = AvatarPrimitive.Root.displayName
22 |
23 | const AvatarImage = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
32 | ))
33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName
34 |
35 | const AvatarFallback = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 | ))
48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
49 |
50 | export { Avatar, AvatarImage, AvatarFallback }
51 |
--------------------------------------------------------------------------------
/actions/user-subscription.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { auth, currentUser } from "@clerk/nextjs";
4 |
5 | import { stripe } from "@/lib/stripe";
6 | import { absoluteUrl } from "@/lib/utils";
7 | import { getUserSubscription } from "@/db/queries";
8 |
9 | const returnUrl = absoluteUrl("/shop");
10 |
11 | export const createStripeUrl = async () => {
12 | const { userId } = await auth();
13 | const user = await currentUser();
14 |
15 | if (!userId || !user) {
16 | throw new Error("Unauthorized");
17 | }
18 |
19 | const userSubscription = await getUserSubscription();
20 |
21 | if (userSubscription && userSubscription.stripeCustomerId) {
22 | const stripeSession = await stripe.billingPortal.sessions.create({
23 | customer: userSubscription.stripeCustomerId,
24 | return_url: returnUrl,
25 | });
26 |
27 | return { data: stripeSession.url };
28 | }
29 |
30 | const stripeSession = await stripe.checkout.sessions.create({
31 | mode: "subscription",
32 | payment_method_types: ["card"],
33 | customer_email: user.emailAddresses[0].emailAddress,
34 | line_items: [
35 | {
36 | quantity: 1,
37 | price_data: {
38 | currency: "USD",
39 | product_data: {
40 | name: "Lingo Pro",
41 | description: "Unlimited Hearts",
42 | },
43 | unit_amount: 2000, // $20.00 USD
44 | recurring: {
45 | interval: "month",
46 | },
47 | },
48 | },
49 | ],
50 | metadata: {
51 | userId,
52 | },
53 | success_url: returnUrl,
54 | cancel_url: returnUrl,
55 | });
56 |
57 | return { data: stripeSession.url };
58 | };
59 |
--------------------------------------------------------------------------------
/components/quests.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import Image from "next/image";
3 |
4 | import { quests } from "@/constants";
5 | import { Button } from "@/components/ui/button";
6 | import { Progress } from "@/components/ui/progress";
7 |
8 | type Props = {
9 | points: number;
10 | };
11 |
12 | export const Quests = ({ points }: Props) => {
13 | return (
14 |
15 |
16 |
17 | Quests
18 |
19 |
20 |
26 |
27 |
28 |
29 | {quests.map((quest) => {
30 | const progress = (points / quest.value) * 100;
31 |
32 | return (
33 |
37 |
43 |
44 |
45 | {quest.title}
46 |
47 |
48 |
49 |
50 | )
51 | })}
52 |
53 |
54 | );
55 | };
56 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "lingo",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "db:studio": "npx drizzle-kit studio",
11 | "db:push": "npx drizzle-kit push:pg",
12 | "db:seed": "tsx ./scripts/seed.ts",
13 | "db:prod": "tsx ./scripts/prod.ts",
14 | "db:reset": "tsx ./scripts/reset.ts"
15 | },
16 | "dependencies": {
17 | "@clerk/nextjs": "^4.29.9",
18 | "@neondatabase/serverless": "^0.9.0",
19 | "@radix-ui/react-avatar": "^1.0.4",
20 | "@radix-ui/react-dialog": "^1.0.5",
21 | "@radix-ui/react-progress": "^1.0.3",
22 | "@radix-ui/react-separator": "^1.0.3",
23 | "@radix-ui/react-slot": "^1.0.2",
24 | "class-variance-authority": "^0.7.0",
25 | "clsx": "^2.1.0",
26 | "dotenv": "^16.4.5",
27 | "drizzle-orm": "^0.30.1",
28 | "lucide-react": "^0.344.0",
29 | "next": "14.1.1",
30 | "next-themes": "^0.2.1",
31 | "ra-data-simple-rest": "^4.16.12",
32 | "react": "^18",
33 | "react-admin": "^4.16.12",
34 | "react-circular-progressbar": "^2.1.0",
35 | "react-confetti": "^6.1.0",
36 | "react-dom": "^18",
37 | "react-use": "^17.5.0",
38 | "sonner": "^1.4.3",
39 | "stripe": "^14.20.0",
40 | "tailwind-merge": "^2.2.1",
41 | "tailwindcss-animate": "^1.0.7",
42 | "zustand": "^4.5.2"
43 | },
44 | "devDependencies": {
45 | "@types/node": "^20",
46 | "@types/react": "^18",
47 | "@types/react-dom": "^18",
48 | "autoprefixer": "^10.0.1",
49 | "drizzle-kit": "^0.20.14",
50 | "eslint": "^8",
51 | "eslint-config-next": "14.1.1",
52 | "pg": "^8.11.3",
53 | "postcss": "^8",
54 | "tailwindcss": "^3.3.0",
55 | "tsx": "^4.7.1",
56 | "typescript": "^5"
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/components/sidebar.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import Image from "next/image";
3 | import {
4 | ClerkLoading,
5 | ClerkLoaded,
6 | UserButton,
7 | } from "@clerk/nextjs";
8 | import { Loader } from "lucide-react";
9 |
10 | import { cn } from "@/lib/utils";
11 |
12 | import { SidebarItem } from "./sidebar-item";
13 |
14 | type Props = {
15 | className?: string;
16 | };
17 |
18 | export const Sidebar = ({ className }: Props) => {
19 | return (
20 |
24 |
25 |
26 |
27 |
28 | Lingo
29 |
30 |
31 |
32 |
33 |
38 |
43 |
48 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 | );
64 | };
65 |
--------------------------------------------------------------------------------
/components/modals/practice-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Image from "next/image";
4 | import { useEffect, useState } from "react";
5 |
6 | import {
7 | Dialog,
8 | DialogContent,
9 | DialogDescription,
10 | DialogFooter,
11 | DialogHeader,
12 | DialogTitle,
13 | } from "@/components/ui/dialog";
14 | import { Button } from "@/components/ui/button";
15 | import { usePracticeModal } from "@/store/use-practice-modal";
16 |
17 | export const PracticeModal = () => {
18 | const [isClient, setIsClient] = useState(false);
19 | const { isOpen, close } = usePracticeModal();
20 |
21 | useEffect(() => setIsClient(true), []);
22 |
23 | if (!isClient) {
24 | return null;
25 | }
26 |
27 | return (
28 |
60 | );
61 | };
62 |
--------------------------------------------------------------------------------
/app/(marketing)/footer.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import Image from "next/image";
3 |
4 | export const Footer = () => {
5 | return (
6 |
60 | );
61 | };
62 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Build a Duolingo Clone With Nextjs, React, Drizzle, Stripe (2024)
2 |
3 | 
4 |
5 | This is a repository for a "Build a Duolingo Clone With Nextjs, React, Drizzle, Stripe (2024)" youtube video.
6 |
7 | [VIDEO TUTORIAL](https://www.youtube.com/watch?v=dP75Khfy4s4)
8 |
9 | Key Features:
10 | - 🌐 Next.js 14 & server actions
11 | - 🗣 AI Voices using Elevenlabs AI
12 | - 🎨 Beautiful component system using Shadcn UI
13 | - 🎭 Amazing characters thanks to KenneyNL
14 | - 🔐 Auth using Clerk
15 | - 🔊 Sound effects
16 | - ❤️ Hearts system
17 | - 🌟 Points / XP system
18 | - 💔 No hearts left popup
19 | - 🚪 Exit confirmation popup
20 | - 🔄 Practice old lessons to regain hearts
21 | - 🏆 Leaderboard
22 | - 🗺 Quests milestones
23 | - 🛍 Shop system to exchange points with hearts
24 | - 💳 Pro tier for unlimited hearts using Stripe
25 | - 🏠 Landing page
26 | - 📊 Admin dashboard React Admin
27 | - 🌧 ORM using DrizzleORM
28 | - 💾 PostgresDB using NeonDB
29 | - 🚀 Deployment on Vercel
30 | - 📱 Mobile responsiveness
31 |
32 | ### Prerequisites
33 |
34 | **Node version 14.x**
35 |
36 | ### Cloning the repository
37 |
38 | ```shell
39 | git clone https://github.com/AntonioErdeljac/next14-duolingo-clone.git
40 | ```
41 |
42 | ### Install packages
43 |
44 | ```shell
45 | npm i
46 | ```
47 |
48 | ### Setup .env file
49 |
50 |
51 | ```js
52 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=""
53 | CLERK_SECRET_KEY=""
54 | DATABASE_URL="postgresql://..."
55 | STRIPE_API_KEY=""
56 | NEXT_PUBLIC_APP_URL="http://localhost:3000"
57 | STRIPE_WEBHOOK_SECRET=""
58 | ```
59 |
60 | ### Setup Drizzle ORM
61 |
62 | ```shell
63 | npm run db:push
64 |
65 | ```
66 |
67 | ### Seed the app
68 |
69 | ```shell
70 | npm run db:seed
71 |
72 | ```
73 |
74 | or
75 |
76 | ```shell
77 | npm run db:prod
78 |
79 | ```
80 |
81 | ### Start the app
82 |
83 | ```shell
84 | npm run dev
85 | ```
86 |
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | html,
6 | body,
7 | :root {
8 | @apply h-full;
9 | }
10 |
11 | @layer base {
12 | :root {
13 | --background: 0 0% 100%;
14 | --foreground: 222.2 84% 4.9%;
15 |
16 | --card: 0 0% 100%;
17 | --card-foreground: 222.2 84% 4.9%;
18 |
19 | --popover: 0 0% 100%;
20 | --popover-foreground: 222.2 84% 4.9%;
21 |
22 | --primary: 222.2 47.4% 11.2%;
23 | --primary-foreground: 210 40% 98%;
24 |
25 | --secondary: 210 40% 96.1%;
26 | --secondary-foreground: 222.2 47.4% 11.2%;
27 |
28 | --muted: 210 40% 96.1%;
29 | --muted-foreground: 215.4 16.3% 46.9%;
30 |
31 | --accent: 210 40% 96.1%;
32 | --accent-foreground: 222.2 47.4% 11.2%;
33 |
34 | --destructive: 0 84.2% 60.2%;
35 | --destructive-foreground: 210 40% 98%;
36 |
37 | --border: 214.3 31.8% 91.4%;
38 | --input: 214.3 31.8% 91.4%;
39 | --ring: 222.2 84% 4.9%;
40 |
41 | --radius: 0.5rem;
42 | }
43 |
44 | .dark {
45 | --background: 222.2 84% 4.9%;
46 | --foreground: 210 40% 98%;
47 |
48 | --card: 222.2 84% 4.9%;
49 | --card-foreground: 210 40% 98%;
50 |
51 | --popover: 222.2 84% 4.9%;
52 | --popover-foreground: 210 40% 98%;
53 |
54 | --primary: 210 40% 98%;
55 | --primary-foreground: 222.2 47.4% 11.2%;
56 |
57 | --secondary: 217.2 32.6% 17.5%;
58 | --secondary-foreground: 210 40% 98%;
59 |
60 | --muted: 217.2 32.6% 17.5%;
61 | --muted-foreground: 215 20.2% 65.1%;
62 |
63 | --accent: 217.2 32.6% 17.5%;
64 | --accent-foreground: 210 40% 98%;
65 |
66 | --destructive: 0 62.8% 30.6%;
67 | --destructive-foreground: 210 40% 98%;
68 |
69 | --border: 217.2 32.6% 17.5%;
70 | --input: 217.2 32.6% 17.5%;
71 | --ring: 212.7 26.8% 83.9%;
72 | }
73 | }
74 |
75 | @layer base {
76 | * {
77 | @apply border-border;
78 | }
79 | body {
80 | @apply bg-background text-foreground;
81 | }
82 | }
--------------------------------------------------------------------------------
/app/api/webhooks/stripe/route.ts:
--------------------------------------------------------------------------------
1 | import Stripe from "stripe";
2 | import { eq } from "drizzle-orm";
3 | import { headers } from "next/headers";
4 | import { NextResponse } from "next/server";
5 |
6 | import db from "@/db/drizzle";
7 | import { stripe } from "@/lib/stripe";
8 | import { userSubscription } from "@/db/schema";
9 |
10 | export async function POST(req: Request) {
11 | const body = await req.text();
12 | const signature = headers().get("Stripe-Signature") as string;
13 |
14 | let event: Stripe.Event;
15 |
16 | try {
17 | event = stripe.webhooks.constructEvent(
18 | body,
19 | signature,
20 | process.env.STRIPE_WEBHOOK_SECRET!,
21 | );
22 | } catch(error: any) {
23 | return new NextResponse(`Webhook error: ${error.message}`, {
24 | status: 400,
25 | });
26 | }
27 |
28 | const session = event.data.object as Stripe.Checkout.Session;
29 |
30 | if (event.type === "checkout.session.completed") {
31 | const subscription = await stripe.subscriptions.retrieve(
32 | session.subscription as string
33 | );
34 |
35 | if (!session?.metadata?.userId) {
36 | return new NextResponse("User ID is required", { status: 400 });
37 | }
38 |
39 | await db.insert(userSubscription).values({
40 | userId: session.metadata.userId,
41 | stripeSubscriptionId: subscription.id,
42 | stripeCustomerId: subscription.customer as string,
43 | stripePriceId: subscription.items.data[0].price.id,
44 | stripeCurrentPeriodEnd: new Date(
45 | subscription.current_period_end * 1000,
46 | ),
47 | });
48 | }
49 |
50 | if (event.type === "invoice.payment_succeeded") {
51 | const subscription = await stripe.subscriptions.retrieve(
52 | session.subscription as string
53 | );
54 |
55 | await db.update(userSubscription).set({
56 | stripePriceId: subscription.items.data[0].price.id,
57 | stripeCurrentPeriodEnd: new Date(
58 | subscription.current_period_end * 1000,
59 | ),
60 | }).where(eq(userSubscription.stripeSubscriptionId, subscription.id))
61 | }
62 |
63 | return new NextResponse(null, { status: 200 });
64 | };
65 |
--------------------------------------------------------------------------------
/app/(main)/shop/page.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import { redirect } from "next/navigation";
3 |
4 | import { Promo } from "@/components/promo";
5 | import { FeedWrapper } from "@/components/feed-wrapper";
6 | import { UserProgress } from "@/components/user-progress";
7 | import { StickyWrapper } from "@/components/sticky-wrapper";
8 | import { getUserProgress, getUserSubscription } from "@/db/queries";
9 |
10 | import { Items } from "./items";
11 | import { Quests } from "@/components/quests";
12 |
13 | const ShopPage = async () => {
14 | const userProgressData = getUserProgress();
15 | const userSubscriptionData = getUserSubscription();
16 |
17 | const [
18 | userProgress,
19 | userSubscription,
20 | ] = await Promise.all([
21 | userProgressData,
22 | userSubscriptionData
23 | ]);
24 |
25 | if (!userProgress || !userProgress.activeCourse) {
26 | redirect("/courses");
27 | }
28 |
29 | const isPro = !!userSubscription?.isActive;
30 |
31 | return (
32 |
33 |
34 |
40 | {!isPro && (
41 |
42 | )}
43 |
44 |
45 |
46 |
47 |
53 |
54 | Shop
55 |
56 |
57 | Spend your points on cool stuff.
58 |
59 |
64 |
65 |
66 |
67 | );
68 | };
69 |
70 | export default ShopPage;
71 |
--------------------------------------------------------------------------------
/app/admin/app.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Admin, Resource } from "react-admin";
4 | import simpleRestProvider from "ra-data-simple-rest";
5 |
6 | import { CourseList } from "./course/list";
7 | import { CourseEdit } from "./course/edit";
8 | import { CourseCreate } from "./course/create";
9 |
10 | import { UnitList } from "./unit/list";
11 | import { UnitEdit } from "./unit/edit";
12 | import { UnitCreate } from "./unit/create";
13 |
14 | import { LessonList } from "./lesson/list";
15 | import { LessonEdit } from "./lesson/edit";
16 | import { LessonCreate } from "./lesson/create";
17 |
18 | import { ChallengeList } from "./challenge/list";
19 | import { ChallengeEdit } from "./challenge/edit";
20 | import { ChallengeCreate } from "./challenge/create";
21 |
22 | import { ChallengeOptionList } from "./challengeOption/list";
23 | import { ChallengeOptionEdit } from "./challengeOption/edit";
24 | import { ChallengeOptionCreate } from "./challengeOption/create";
25 |
26 | const dataProvider = simpleRestProvider("/api");
27 |
28 | const App = () => {
29 | return (
30 |
31 |
38 |
45 |
52 |
59 |
67 |
68 | );
69 | };
70 |
71 | export default App;
72 |
--------------------------------------------------------------------------------
/app/lesson/footer.tsx:
--------------------------------------------------------------------------------
1 | import { useKey, useMedia } from "react-use";
2 | import { CheckCircle, XCircle } from "lucide-react";
3 |
4 | import { cn } from "@/lib/utils";
5 | import { Button } from "@/components/ui/button";
6 |
7 | type Props = {
8 | onCheck: () => void;
9 | status: "correct" | "wrong" | "none" | "completed";
10 | disabled?: boolean;
11 | lessonId?: number;
12 | };
13 |
14 | export const Footer = ({
15 | onCheck,
16 | status,
17 | disabled,
18 | lessonId,
19 | }: Props) => {
20 | useKey("Enter", onCheck, {}, [onCheck]);
21 | const isMobile = useMedia("(max-width: 1024px)");
22 |
23 | return (
24 |
65 | );
66 | };
67 |
--------------------------------------------------------------------------------
/components/modals/exit-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Image from "next/image";
4 | import { useRouter } from "next/navigation";
5 | import { useEffect, useState } from "react";
6 |
7 | import {
8 | Dialog,
9 | DialogContent,
10 | DialogDescription,
11 | DialogFooter,
12 | DialogHeader,
13 | DialogTitle,
14 | } from "@/components/ui/dialog";
15 | import { Button } from "@/components/ui/button";
16 | import { useExitModal } from "@/store/use-exit-modal";
17 |
18 | export const ExitModal = () => {
19 | const router = useRouter();
20 | const [isClient, setIsClient] = useState(false);
21 | const { isOpen, close } = useExitModal();
22 |
23 | useEffect(() => setIsClient(true), []);
24 |
25 | if (!isClient) {
26 | return null;
27 | }
28 |
29 | return (
30 |
73 | );
74 | };
75 |
--------------------------------------------------------------------------------
/components/modals/hearts-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Image from "next/image";
4 | import { useRouter } from "next/navigation";
5 | import { useEffect, useState } from "react";
6 |
7 | import {
8 | Dialog,
9 | DialogContent,
10 | DialogDescription,
11 | DialogFooter,
12 | DialogHeader,
13 | DialogTitle,
14 | } from "@/components/ui/dialog";
15 | import { Button } from "@/components/ui/button";
16 | import { useHeartsModal } from "@/store/use-hearts-modal";
17 |
18 | export const HeartsModal = () => {
19 | const router = useRouter();
20 | const [isClient, setIsClient] = useState(false);
21 | const { isOpen, close } = useHeartsModal();
22 |
23 | useEffect(() => setIsClient(true), []);
24 |
25 | const onClick = () => {
26 | close();
27 | router.push("/store");
28 | };
29 |
30 | if (!isClient) {
31 | return null;
32 | }
33 |
34 | return (
35 |
75 | );
76 | };
77 |
--------------------------------------------------------------------------------
/app/(marketing)/page.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import { Loader } from "lucide-react";
3 | import {
4 | ClerkLoaded,
5 | ClerkLoading,
6 | SignInButton,
7 | SignUpButton,
8 | SignedIn,
9 | SignedOut
10 | } from "@clerk/nextjs";
11 | import { Button } from "@/components/ui/button";
12 | import Link from "next/link";
13 |
14 | export default function Home() {
15 | return (
16 |
17 |
18 |
19 |
20 |
21 |
22 | Learn, practice, and master new languages with Lingo.
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
35 |
38 |
39 |
44 |
47 |
48 |
49 |
50 |
55 |
56 |
57 |
58 |
59 |
60 | )
61 | }
62 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss"
2 |
3 | const config = {
4 | darkMode: ["class"],
5 | content: [
6 | './pages/**/*.{ts,tsx}',
7 | './components/**/*.{ts,tsx}',
8 | './app/**/*.{ts,tsx}',
9 | './src/**/*.{ts,tsx}',
10 | ],
11 | prefix: "",
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: "2rem",
16 | screens: {
17 | "2xl": "1400px",
18 | },
19 | },
20 | extend: {
21 | colors: {
22 | border: "hsl(var(--border))",
23 | input: "hsl(var(--input))",
24 | ring: "hsl(var(--ring))",
25 | background: "hsl(var(--background))",
26 | foreground: "hsl(var(--foreground))",
27 | primary: {
28 | DEFAULT: "hsl(var(--primary))",
29 | foreground: "hsl(var(--primary-foreground))",
30 | },
31 | secondary: {
32 | DEFAULT: "hsl(var(--secondary))",
33 | foreground: "hsl(var(--secondary-foreground))",
34 | },
35 | destructive: {
36 | DEFAULT: "hsl(var(--destructive))",
37 | foreground: "hsl(var(--destructive-foreground))",
38 | },
39 | muted: {
40 | DEFAULT: "hsl(var(--muted))",
41 | foreground: "hsl(var(--muted-foreground))",
42 | },
43 | accent: {
44 | DEFAULT: "hsl(var(--accent))",
45 | foreground: "hsl(var(--accent-foreground))",
46 | },
47 | popover: {
48 | DEFAULT: "hsl(var(--popover))",
49 | foreground: "hsl(var(--popover-foreground))",
50 | },
51 | card: {
52 | DEFAULT: "hsl(var(--card))",
53 | foreground: "hsl(var(--card-foreground))",
54 | },
55 | },
56 | borderRadius: {
57 | lg: "var(--radius)",
58 | md: "calc(var(--radius) - 2px)",
59 | sm: "calc(var(--radius) - 4px)",
60 | },
61 | keyframes: {
62 | "accordion-down": {
63 | from: { height: "0" },
64 | to: { height: "var(--radix-accordion-content-height)" },
65 | },
66 | "accordion-up": {
67 | from: { height: "var(--radix-accordion-content-height)" },
68 | to: { height: "0" },
69 | },
70 | },
71 | animation: {
72 | "accordion-down": "accordion-down 0.2s ease-out",
73 | "accordion-up": "accordion-up 0.2s ease-out",
74 | },
75 | },
76 | },
77 | plugins: [require("tailwindcss-animate")],
78 | } satisfies Config
79 |
80 | export default config
--------------------------------------------------------------------------------
/actions/challenge-progress.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { auth } from "@clerk/nextjs";
4 | import { and, eq } from "drizzle-orm";
5 | import { revalidatePath } from "next/cache";
6 |
7 | import db from "@/db/drizzle";
8 | import { getUserProgress, getUserSubscription } from "@/db/queries";
9 | import { challengeProgress, challenges, userProgress } from "@/db/schema";
10 |
11 | export const upsertChallengeProgress = async (challengeId: number) => {
12 | const { userId } = await auth();
13 |
14 | if (!userId) {
15 | throw new Error("Unauthorized");
16 | }
17 |
18 | const currentUserProgress = await getUserProgress();
19 | const userSubscription = await getUserSubscription();
20 |
21 | if (!currentUserProgress) {
22 | throw new Error("User progress not found");
23 | }
24 |
25 | const challenge = await db.query.challenges.findFirst({
26 | where: eq(challenges.id, challengeId)
27 | });
28 |
29 | if (!challenge) {
30 | throw new Error("Challenge not found");
31 | }
32 |
33 | const lessonId = challenge.lessonId;
34 |
35 | const existingChallengeProgress = await db.query.challengeProgress.findFirst({
36 | where: and(
37 | eq(challengeProgress.userId, userId),
38 | eq(challengeProgress.challengeId, challengeId),
39 | ),
40 | });
41 |
42 | const isPractice = !!existingChallengeProgress;
43 |
44 | if (
45 | currentUserProgress.hearts === 0 &&
46 | !isPractice &&
47 | !userSubscription?.isActive
48 | ) {
49 | return { error: "hearts" };
50 | }
51 |
52 | if (isPractice) {
53 | await db.update(challengeProgress).set({
54 | completed: true,
55 | })
56 | .where(
57 | eq(challengeProgress.id, existingChallengeProgress.id)
58 | );
59 |
60 | await db.update(userProgress).set({
61 | hearts: Math.min(currentUserProgress.hearts + 1, 5),
62 | points: currentUserProgress.points + 10,
63 | }).where(eq(userProgress.userId, userId));
64 |
65 | revalidatePath("/learn");
66 | revalidatePath("/lesson");
67 | revalidatePath("/quests");
68 | revalidatePath("/leaderboard");
69 | revalidatePath(`/lesson/${lessonId}`);
70 | return;
71 | }
72 |
73 | await db.insert(challengeProgress).values({
74 | challengeId,
75 | userId,
76 | completed: true,
77 | });
78 |
79 | await db.update(userProgress).set({
80 | points: currentUserProgress.points + 10,
81 | }).where(eq(userProgress.userId, userId));
82 |
83 | revalidatePath("/learn");
84 | revalidatePath("/lesson");
85 | revalidatePath("/quests");
86 | revalidatePath("/leaderboard");
87 | revalidatePath(`/lesson/${lessonId}`);
88 | };
89 |
--------------------------------------------------------------------------------
/app/(main)/learn/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "next/navigation";
2 |
3 | import { Promo } from "@/components/promo";
4 | import { Quests } from "@/components/quests";
5 | import { FeedWrapper } from "@/components/feed-wrapper";
6 | import { UserProgress } from "@/components/user-progress";
7 | import { StickyWrapper } from "@/components/sticky-wrapper";
8 | import { lessons, units as unitsSchema } from "@/db/schema";
9 | import {
10 | getCourseProgress,
11 | getLessonPercentage,
12 | getUnits,
13 | getUserProgress,
14 | getUserSubscription
15 | } from "@/db/queries";
16 |
17 | import { Unit } from "./unit";
18 | import { Header } from "./header";
19 |
20 | const LearnPage = async () => {
21 | const userProgressData = getUserProgress();
22 | const courseProgressData = getCourseProgress();
23 | const lessonPercentageData = getLessonPercentage();
24 | const unitsData = getUnits();
25 | const userSubscriptionData = getUserSubscription();
26 |
27 | const [
28 | userProgress,
29 | units,
30 | courseProgress,
31 | lessonPercentage,
32 | userSubscription,
33 | ] = await Promise.all([
34 | userProgressData,
35 | unitsData,
36 | courseProgressData,
37 | lessonPercentageData,
38 | userSubscriptionData,
39 | ]);
40 |
41 | if (!userProgress || !userProgress.activeCourse) {
42 | redirect("/courses");
43 | }
44 |
45 | if (!courseProgress) {
46 | redirect("/courses");
47 | }
48 |
49 | const isPro = !!userSubscription?.isActive;
50 |
51 | return (
52 |
53 |
54 |
60 | {!isPro && (
61 |
62 | )}
63 |
64 |
65 |
66 |
67 | {units.map((unit) => (
68 |
69 |
80 |
81 | ))}
82 |
83 |
84 | );
85 | };
86 |
87 | export default LearnPage;
88 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-xl text-sm font-bold ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 uppercase tracking-wide",
9 | {
10 | variants: {
11 | variant: {
12 | locked: "bg-neutral-200 text-primary-foreground hover:bg-neutral-200/90 border-neutral-400 border-b-4 active:border-b-0",
13 | default: "bg-white text-black border-slate-200 border-2 border-b-4 active:border-b-2 hover:bg-slate-100 text-slate-500",
14 | primary: "bg-sky-400 text-primary-foreground hover:bg-sky-400/90 border-sky-500 border-b-4 active:border-b-0",
15 | primaryOutline: "bg-white text-sky-500 hover:bg-slate-100",
16 | secondary: "bg-green-500 text-primary-foreground hover:bg-green-500/90 border-green-600 border-b-4 active:border-b-0",
17 | secondaryOutline: "bg-white text-green-500 hover:bg-slate-100",
18 | danger: "bg-rose-500 text-primary-foreground hover:bg-rose-500/90 border-rose-600 border-b-4 active:border-b-0",
19 | dangerOutline: "bg-white text-rose-500 hover:bg-slate-100",
20 | super: "bg-indigo-500 text-primary-foreground hover:bg-indigo-500/90 border-indigo-600 border-b-4 active:border-b-0",
21 | superOutline: "bg-white text-indigo-500 hover:bg-slate-100",
22 | ghost: "bg-transparent text-slate-500 border-transparent border-0 hover:bg-slate-100",
23 | sidebar: "bg-transparent text-slate-500 border-2 border-transparent hover:bg-slate-100 transition-none",
24 | sidebarOutline: "bg-sky-500/15 text-sky-500 border-sky-300 border-2 hover:bg-sky-500/20 transition-none"
25 | },
26 | size: {
27 | default: "h-11 px-4 py-2",
28 | sm: "h-9 px-3",
29 | lg: "h-12 px-8",
30 | icon: "h-10 w-10",
31 | rounded: "rounded-full",
32 | },
33 | },
34 | defaultVariants: {
35 | variant: "default",
36 | size: "default",
37 | },
38 | }
39 | )
40 |
41 | export interface ButtonProps
42 | extends React.ButtonHTMLAttributes,
43 | VariantProps {
44 | asChild?: boolean
45 | }
46 |
47 | const Button = React.forwardRef(
48 | ({ className, variant, size, asChild = false, ...props }, ref) => {
49 | const Comp = asChild ? Slot : "button"
50 | return (
51 |
56 | )
57 | }
58 | )
59 | Button.displayName = "Button"
60 |
61 | export { Button, buttonVariants }
62 |
--------------------------------------------------------------------------------
/app/(main)/quests/page.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import { redirect } from "next/navigation";
3 |
4 | import { FeedWrapper } from "@/components/feed-wrapper";
5 | import { UserProgress } from "@/components/user-progress";
6 | import { StickyWrapper } from "@/components/sticky-wrapper";
7 | import { getUserProgress, getUserSubscription } from "@/db/queries";
8 | import { Progress } from "@/components/ui/progress";
9 | import { Promo } from "@/components/promo";
10 | import { quests } from "@/constants";
11 |
12 | const QuestsPage = async () => {
13 | const userProgressData = getUserProgress();
14 | const userSubscriptionData = getUserSubscription();
15 |
16 | const [
17 | userProgress,
18 | userSubscription,
19 | ] = await Promise.all([
20 | userProgressData,
21 | userSubscriptionData,
22 | ]);
23 |
24 | if (!userProgress || !userProgress.activeCourse) {
25 | redirect("/courses");
26 | }
27 |
28 | const isPro = !!userSubscription?.isActive;
29 |
30 | return (
31 |
32 |
33 |
39 | {!isPro && (
40 |
41 | )}
42 |
43 |
44 |
45 |
51 |
52 | Quests
53 |
54 |
55 | Complete quests by earning points.
56 |
57 |
58 | {quests.map((quest) => {
59 | const progress = (userProgress.points / quest.value) * 100;
60 |
61 | return (
62 |
66 |
72 |
73 |
74 | {quest.title}
75 |
76 |
77 |
78 |
79 | )
80 | })}
81 |
82 |
83 |
84 |
85 | );
86 | };
87 |
88 | export default QuestsPage;
89 |
--------------------------------------------------------------------------------
/app/lesson/card.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import { useCallback } from "react";
3 | import { useAudio, useKey } from "react-use";
4 |
5 | import { cn } from "@/lib/utils";
6 | import { challenges } from "@/db/schema";
7 |
8 | type Props = {
9 | id: number;
10 | imageSrc: string | null;
11 | audioSrc: string | null;
12 | text: string;
13 | shortcut: string;
14 | selected?: boolean;
15 | onClick: () => void;
16 | disabled?: boolean;
17 | status?: "correct" | "wrong" | "none",
18 | type: typeof challenges.$inferSelect["type"];
19 | };
20 |
21 | export const Card = ({
22 | id,
23 | imageSrc,
24 | audioSrc,
25 | text,
26 | shortcut,
27 | selected,
28 | onClick,
29 | status,
30 | disabled,
31 | type,
32 | }: Props) => {
33 | const [audio, _, controls] = useAudio({ src: audioSrc || "" });
34 |
35 | const handleClick = useCallback(() => {
36 | if (disabled) return;
37 |
38 | controls.play();
39 | onClick();
40 | }, [disabled, onClick, controls]);
41 |
42 | useKey(shortcut, handleClick, {}, [handleClick]);
43 |
44 | return (
45 |
58 | {audio}
59 | {imageSrc && (
60 |
63 |
64 |
65 | )}
66 |
70 | {type === "ASSIST" &&
}
71 |
79 | {text}
80 |
81 |
89 | {shortcut}
90 |
91 |
92 |
93 | );
94 | };
95 |
--------------------------------------------------------------------------------
/app/(main)/shop/items.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { toast } from "sonner";
4 | import Image from "next/image";
5 | import { useTransition } from "react";
6 |
7 | import { Button } from "@/components/ui/button";
8 | import { POINTS_TO_REFILL } from "@/constants";
9 | import { refillHearts } from "@/actions/user-progress";
10 | import { createStripeUrl } from "@/actions/user-subscription";
11 |
12 | type Props = {
13 | hearts: number;
14 | points: number;
15 | hasActiveSubscription: boolean;
16 | };
17 |
18 | export const Items = ({
19 | hearts,
20 | points,
21 | hasActiveSubscription,
22 | }: Props) => {
23 | const [pending, startTransition] = useTransition();
24 |
25 | const onRefillHearts = () => {
26 | if (pending || hearts === 5 || points < POINTS_TO_REFILL) {
27 | return;
28 | }
29 |
30 | startTransition(() => {
31 | refillHearts()
32 | .catch(() => toast.error("Something went wrong"));
33 | });
34 | };
35 |
36 | const onUpgrade = () => {
37 | startTransition(() => {
38 | createStripeUrl()
39 | .then((response) => {
40 | if (response.data) {
41 | window.location.href = response.data;
42 | }
43 | })
44 | .catch(() => toast.error("Something went wrong"));
45 | });
46 | };
47 |
48 | return (
49 |
50 |
51 |
57 |
58 |
59 | Refill hearts
60 |
61 |
62 |
87 |
88 |
89 |
95 |
96 |
97 | Unlimited hearts
98 |
99 |
100 |
106 |
107 |
108 | );
109 | };
110 |
--------------------------------------------------------------------------------
/app/(main)/leaderboard/page.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import { redirect } from "next/navigation";
3 |
4 | import { FeedWrapper } from "@/components/feed-wrapper";
5 | import { UserProgress } from "@/components/user-progress";
6 | import { StickyWrapper } from "@/components/sticky-wrapper";
7 | import { getTopTenUsers, getUserProgress, getUserSubscription } from "@/db/queries";
8 | import { Separator } from "@/components/ui/separator";
9 | import { Avatar, AvatarImage } from "@/components/ui/avatar";
10 | import { Promo } from "@/components/promo";
11 | import { Quests } from "@/components/quests";
12 |
13 | const LearderboardPage = async () => {
14 | const userProgressData = getUserProgress();
15 | const userSubscriptionData = getUserSubscription();
16 | const leaderboardData = getTopTenUsers();
17 |
18 | const [
19 | userProgress,
20 | userSubscription,
21 | leaderboard,
22 | ] = await Promise.all([
23 | userProgressData,
24 | userSubscriptionData,
25 | leaderboardData,
26 | ]);
27 |
28 | if (!userProgress || !userProgress.activeCourse) {
29 | redirect("/courses");
30 | }
31 |
32 | const isPro = !!userSubscription?.isActive;
33 |
34 | return (
35 |
36 |
37 |
43 | {!isPro && (
44 |
45 | )}
46 |
47 |
48 |
49 |
50 |
56 |
57 | Leaderboard
58 |
59 |
60 | See where you stand among other learners in the community.
61 |
62 |
63 | {leaderboard.map((userProgress, index) => (
64 |
68 |
{index + 1}
69 |
72 |
76 |
77 |
78 | {userProgress.userName}
79 |
80 |
81 | {userProgress.points} XP
82 |
83 |
84 | ))}
85 |
86 |
87 |
88 | );
89 | };
90 |
91 | export default LearderboardPage;
92 |
--------------------------------------------------------------------------------
/public/mascot.svg:
--------------------------------------------------------------------------------
1 |
22 |
--------------------------------------------------------------------------------
/public/points.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/(main)/learn/lesson-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Link from "next/link";
4 | import { Check, Crown, Star } from "lucide-react";
5 | import { CircularProgressbarWithChildren } from "react-circular-progressbar";
6 |
7 | import { cn } from "@/lib/utils";
8 | import { Button } from "@/components/ui/button";
9 |
10 | import "react-circular-progressbar/dist/styles.css";
11 |
12 | type Props = {
13 | id: number;
14 | index: number;
15 | totalCount: number;
16 | locked?: boolean;
17 | current?: boolean;
18 | percentage: number;
19 | };
20 |
21 | export const LessonButton = ({
22 | id,
23 | index,
24 | totalCount,
25 | locked,
26 | current,
27 | percentage
28 | }: Props) => {
29 | const cycleLength = 8;
30 | const cycleIndex = index % cycleLength;
31 |
32 | let indentationLevel;
33 |
34 | if (cycleIndex <= 2) {
35 | indentationLevel = cycleIndex;
36 | } else if (cycleIndex <= 4) {
37 | indentationLevel = 4 - cycleIndex;
38 | } else if (cycleIndex <= 6) {
39 | indentationLevel = 4 - cycleIndex;
40 | } else {
41 | indentationLevel = cycleIndex - 8;
42 | }
43 |
44 | const rightPosition = indentationLevel * 40;
45 |
46 | const isFirst = index === 0;
47 | const isLast = index === totalCount;
48 | const isCompleted = !current && !locked;
49 |
50 | const Icon = isCompleted ? Check : isLast ? Crown : Star;
51 |
52 | const href = isCompleted ? `/lesson/${id}` : "/lesson";
53 |
54 | return (
55 |
60 |
67 | {current ? (
68 |
69 |
75 |
86 |
101 |
102 |
103 | ) : (
104 |
119 | )}
120 |
121 |
122 | );
123 | };
124 |
--------------------------------------------------------------------------------
/actions/user-progress.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { and, eq } from "drizzle-orm";
4 | import { redirect } from "next/navigation";
5 | import { revalidatePath } from "next/cache";
6 | import { auth, currentUser } from "@clerk/nextjs";
7 |
8 | import db from "@/db/drizzle";
9 | import { POINTS_TO_REFILL } from "@/constants";
10 | import { getCourseById, getUserProgress, getUserSubscription } from "@/db/queries";
11 | import { challengeProgress, challenges, userProgress } from "@/db/schema";
12 |
13 | export const upsertUserProgress = async (courseId: number) => {
14 | const { userId } = await auth();
15 | const user = await currentUser();
16 |
17 | if (!userId || !user) {
18 | throw new Error("Unauthorized");
19 | }
20 |
21 | const course = await getCourseById(courseId);
22 |
23 | if (!course) {
24 | throw new Error("Course not found");
25 | }
26 |
27 | if (!course.units.length || !course.units[0].lessons.length) {
28 | throw new Error("Course is empty");
29 | }
30 |
31 | const existingUserProgress = await getUserProgress();
32 |
33 | if (existingUserProgress) {
34 | await db.update(userProgress).set({
35 | activeCourseId: courseId,
36 | userName: user.firstName || "User",
37 | userImageSrc: user.imageUrl || "/mascot.svg",
38 | });
39 |
40 | revalidatePath("/courses");
41 | revalidatePath("/learn");
42 | redirect("/learn");
43 | }
44 |
45 | await db.insert(userProgress).values({
46 | userId,
47 | activeCourseId: courseId,
48 | userName: user.firstName || "User",
49 | userImageSrc: user.imageUrl || "/mascot.svg",
50 | });
51 |
52 | revalidatePath("/courses");
53 | revalidatePath("/learn");
54 | redirect("/learn");
55 | };
56 |
57 | export const reduceHearts = async (challengeId: number) => {
58 | const { userId } = await auth();
59 |
60 | if (!userId) {
61 | throw new Error("Unauthorized");
62 | }
63 |
64 | const currentUserProgress = await getUserProgress();
65 | const userSubscription = await getUserSubscription();
66 |
67 | const challenge = await db.query.challenges.findFirst({
68 | where: eq(challenges.id, challengeId),
69 | });
70 |
71 | if (!challenge) {
72 | throw new Error("Challenge not found");
73 | }
74 |
75 | const lessonId = challenge.lessonId;
76 |
77 | const existingChallengeProgress = await db.query.challengeProgress.findFirst({
78 | where: and(
79 | eq(challengeProgress.userId, userId),
80 | eq(challengeProgress.challengeId, challengeId),
81 | ),
82 | });
83 |
84 | const isPractice = !!existingChallengeProgress;
85 |
86 | if (isPractice) {
87 | return { error: "practice" };
88 | }
89 |
90 | if (!currentUserProgress) {
91 | throw new Error("User progress not found");
92 | }
93 |
94 | if (userSubscription?.isActive) {
95 | return { error: "subscription" };
96 | }
97 |
98 | if (currentUserProgress.hearts === 0) {
99 | return { error: "hearts" };
100 | }
101 |
102 | await db.update(userProgress).set({
103 | hearts: Math.max(currentUserProgress.hearts - 1, 0),
104 | }).where(eq(userProgress.userId, userId));
105 |
106 | revalidatePath("/shop");
107 | revalidatePath("/learn");
108 | revalidatePath("/quests");
109 | revalidatePath("/leaderboard");
110 | revalidatePath(`/lesson/${lessonId}`);
111 | };
112 |
113 | export const refillHearts = async () => {
114 | const currentUserProgress = await getUserProgress();
115 |
116 | if (!currentUserProgress) {
117 | throw new Error("User progress not found");
118 | }
119 |
120 | if (currentUserProgress.hearts === 5) {
121 | throw new Error("Hearts are already full");
122 | }
123 |
124 | if (currentUserProgress.points < POINTS_TO_REFILL) {
125 | throw new Error("Not enough points");
126 | }
127 |
128 | await db.update(userProgress).set({
129 | hearts: 5,
130 | points: currentUserProgress.points - POINTS_TO_REFILL,
131 | }).where(eq(userProgress.userId, currentUserProgress.userId));
132 |
133 | revalidatePath("/shop");
134 | revalidatePath("/learn");
135 | revalidatePath("/quests");
136 | revalidatePath("/leaderboard");
137 | };
138 |
--------------------------------------------------------------------------------
/public/mascot_sad.svg:
--------------------------------------------------------------------------------
1 |
22 |
--------------------------------------------------------------------------------
/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { X } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Dialog = DialogPrimitive.Root
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger
12 |
13 | const DialogPortal = DialogPrimitive.Portal
14 |
15 | const DialogClose = DialogPrimitive.Close
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ))
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 |
48 |
49 | Close
50 |
51 |
52 |
53 | ))
54 | DialogContent.displayName = DialogPrimitive.Content.displayName
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | )
68 | DialogHeader.displayName = "DialogHeader"
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | )
82 | DialogFooter.displayName = "DialogFooter"
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ))
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogClose,
116 | DialogTrigger,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | }
123 |
--------------------------------------------------------------------------------
/public/mascot_bad.svg:
--------------------------------------------------------------------------------
1 |
19 |
--------------------------------------------------------------------------------
/db/schema.ts:
--------------------------------------------------------------------------------
1 | import { relations } from "drizzle-orm";
2 | import { boolean, integer, pgEnum, pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";
3 |
4 | export const courses = pgTable("courses", {
5 | id: serial("id").primaryKey(),
6 | title: text("title").notNull(),
7 | imageSrc: text("image_src").notNull(),
8 | });
9 |
10 | export const coursesRelations = relations(courses, ({ many }) => ({
11 | userProgress: many(userProgress),
12 | units: many(units),
13 | }));
14 |
15 | export const units = pgTable("units", {
16 | id: serial("id").primaryKey(),
17 | title: text("title").notNull(), // Unit 1
18 | description: text("description").notNull(), // Learn the basics of spanish
19 | courseId: integer("course_id").references(() => courses.id, { onDelete: "cascade" }).notNull(),
20 | order: integer("order").notNull(),
21 | });
22 |
23 | export const unitsRelations = relations(units, ({ many, one }) => ({
24 | course: one(courses, {
25 | fields: [units.courseId],
26 | references: [courses.id],
27 | }),
28 | lessons: many(lessons),
29 | }));
30 |
31 | export const lessons = pgTable("lessons", {
32 | id: serial("id").primaryKey(),
33 | title: text("title").notNull(),
34 | unitId: integer("unit_id").references(() => units.id, { onDelete: "cascade" }).notNull(),
35 | order: integer("order").notNull(),
36 | });
37 |
38 | export const lessonsRelations = relations(lessons, ({ one, many }) => ({
39 | unit: one(units, {
40 | fields: [lessons.unitId],
41 | references: [units.id],
42 | }),
43 | challenges: many(challenges),
44 | }));
45 |
46 | export const challengesEnum = pgEnum("type", ["SELECT", "ASSIST"]);
47 |
48 | export const challenges = pgTable("challenges", {
49 | id: serial("id").primaryKey(),
50 | lessonId: integer("lesson_id").references(() => lessons.id, { onDelete: "cascade" }).notNull(),
51 | type: challengesEnum("type").notNull(),
52 | question: text("question").notNull(),
53 | order: integer("order").notNull(),
54 | });
55 |
56 | export const challengesRelations = relations(challenges, ({ one, many }) => ({
57 | lesson: one(lessons, {
58 | fields: [challenges.lessonId],
59 | references: [lessons.id],
60 | }),
61 | challengeOptions: many(challengeOptions),
62 | challengeProgress: many(challengeProgress),
63 | }));
64 |
65 | export const challengeOptions = pgTable("challenge_options", {
66 | id: serial("id").primaryKey(),
67 | challengeId: integer("challenge_id").references(() => challenges.id, { onDelete: "cascade" }).notNull(),
68 | text: text("text").notNull(),
69 | correct: boolean("correct").notNull(),
70 | imageSrc: text("image_src"),
71 | audioSrc: text("audio_src"),
72 | });
73 |
74 | export const challengeOptionsRelations = relations(challengeOptions, ({ one }) => ({
75 | challenge: one(challenges, {
76 | fields: [challengeOptions.challengeId],
77 | references: [challenges.id],
78 | }),
79 | }));
80 |
81 | export const challengeProgress = pgTable("challenge_progress", {
82 | id: serial("id").primaryKey(),
83 | userId: text("user_id").notNull(),
84 | challengeId: integer("challenge_id").references(() => challenges.id, { onDelete: "cascade" }).notNull(),
85 | completed: boolean("completed").notNull().default(false),
86 | });
87 |
88 | export const challengeProgressRelations = relations(challengeProgress, ({ one }) => ({
89 | challenge: one(challenges, {
90 | fields: [challengeProgress.challengeId],
91 | references: [challenges.id],
92 | }),
93 | }));
94 |
95 | export const userProgress = pgTable("user_progress", {
96 | userId: text("user_id").primaryKey(),
97 | userName: text("user_name").notNull().default("User"),
98 | userImageSrc: text("user_image_src").notNull().default("/mascot.svg"),
99 | activeCourseId: integer("active_course_id").references(() => courses.id, { onDelete: "cascade" }),
100 | hearts: integer("hearts").notNull().default(5),
101 | points: integer("points").notNull().default(0),
102 | });
103 |
104 | export const userProgressRelations = relations(userProgress, ({ one }) => ({
105 | activeCourse: one(courses, {
106 | fields: [userProgress.activeCourseId],
107 | references: [courses.id],
108 | }),
109 | }));
110 |
111 | export const userSubscription = pgTable("user_subscription", {
112 | id: serial("id").primaryKey(),
113 | userId: text("user_id").notNull().unique(),
114 | stripeCustomerId: text("stripe_customer_id").notNull().unique(),
115 | stripeSubscriptionId: text("stripe_subscription_id").notNull().unique(),
116 | stripePriceId: text("stripe_price_id").notNull(),
117 | stripeCurrentPeriodEnd: timestamp("stripe_current_period_end").notNull(),
118 | });
119 |
--------------------------------------------------------------------------------
/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SheetPrimitive from "@radix-ui/react-dialog"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 | import { X } from "lucide-react"
7 |
8 | import { cn } from "@/lib/utils"
9 |
10 | const Sheet = SheetPrimitive.Root
11 |
12 | const SheetTrigger = SheetPrimitive.Trigger
13 |
14 | const SheetClose = SheetPrimitive.Close
15 |
16 | const SheetPortal = SheetPrimitive.Portal
17 |
18 | const SheetOverlay = React.forwardRef<
19 | React.ElementRef,
20 | React.ComponentPropsWithoutRef
21 | >(({ className, ...props }, ref) => (
22 |
30 | ))
31 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
32 |
33 | const sheetVariants = cva(
34 | "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
35 | {
36 | variants: {
37 | side: {
38 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
39 | bottom:
40 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
41 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
42 | right:
43 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
44 | },
45 | },
46 | defaultVariants: {
47 | side: "right",
48 | },
49 | }
50 | )
51 |
52 | interface SheetContentProps
53 | extends React.ComponentPropsWithoutRef,
54 | VariantProps {}
55 |
56 | const SheetContent = React.forwardRef<
57 | React.ElementRef,
58 | SheetContentProps
59 | >(({ side = "right", className, children, ...props }, ref) => (
60 |
61 |
62 |
67 | {children}
68 |
69 |
70 | Close
71 |
72 |
73 |
74 | ))
75 | SheetContent.displayName = SheetPrimitive.Content.displayName
76 |
77 | const SheetHeader = ({
78 | className,
79 | ...props
80 | }: React.HTMLAttributes) => (
81 |
88 | )
89 | SheetHeader.displayName = "SheetHeader"
90 |
91 | const SheetFooter = ({
92 | className,
93 | ...props
94 | }: React.HTMLAttributes) => (
95 |
102 | )
103 | SheetFooter.displayName = "SheetFooter"
104 |
105 | const SheetTitle = React.forwardRef<
106 | React.ElementRef,
107 | React.ComponentPropsWithoutRef
108 | >(({ className, ...props }, ref) => (
109 |
114 | ))
115 | SheetTitle.displayName = SheetPrimitive.Title.displayName
116 |
117 | const SheetDescription = React.forwardRef<
118 | React.ElementRef,
119 | React.ComponentPropsWithoutRef
120 | >(({ className, ...props }, ref) => (
121 |
126 | ))
127 | SheetDescription.displayName = SheetPrimitive.Description.displayName
128 |
129 | export {
130 | Sheet,
131 | SheetPortal,
132 | SheetOverlay,
133 | SheetTrigger,
134 | SheetClose,
135 | SheetContent,
136 | SheetHeader,
137 | SheetFooter,
138 | SheetTitle,
139 | SheetDescription,
140 | }
141 |
--------------------------------------------------------------------------------
/scripts/seed.ts:
--------------------------------------------------------------------------------
1 | import "dotenv/config";
2 | import { drizzle } from "drizzle-orm/neon-http";
3 | import { neon } from "@neondatabase/serverless";
4 |
5 | import * as schema from "../db/schema";
6 |
7 | const sql = neon(process.env.DATABASE_URL!);
8 | // @ts-ignore
9 | const db = drizzle(sql, { schema });
10 |
11 | const main = async () => {
12 | try {
13 | console.log("Seeding database");
14 |
15 | await db.delete(schema.courses);
16 | await db.delete(schema.userProgress);
17 | await db.delete(schema.units);
18 | await db.delete(schema.lessons);
19 | await db.delete(schema.challenges);
20 | await db.delete(schema.challengeOptions);
21 | await db.delete(schema.challengeProgress);
22 | await db.delete(schema.userSubscription);
23 |
24 | await db.insert(schema.courses).values([
25 | {
26 | id: 1,
27 | title: "Spanish",
28 | imageSrc: "/es.svg",
29 | },
30 | {
31 | id: 2,
32 | title: "Italian",
33 | imageSrc: "/it.svg",
34 | },
35 | {
36 | id: 3,
37 | title: "French",
38 | imageSrc: "/fr.svg",
39 | },
40 | {
41 | id: 4,
42 | title: "Croatian",
43 | imageSrc: "/hr.svg",
44 | },
45 | ]);
46 |
47 | await db.insert(schema.units).values([
48 | {
49 | id: 1,
50 | courseId: 1, // Spanish
51 | title: "Unit 1",
52 | description: "Learn the basics of Spanish",
53 | order: 1,
54 | }
55 | ]);
56 |
57 | await db.insert(schema.lessons).values([
58 | {
59 | id: 1,
60 | unitId: 1, // Unit 1 (Learn the basics...)
61 | order: 1,
62 | title: "Nouns",
63 | },
64 | {
65 | id: 2,
66 | unitId: 1, // Unit 1 (Learn the basics...)
67 | order: 2,
68 | title: "Verbs",
69 | },
70 | {
71 | id: 3,
72 | unitId: 1, // Unit 1 (Learn the basics...)
73 | order: 3,
74 | title: "Verbs",
75 | },
76 | {
77 | id: 4,
78 | unitId: 1, // Unit 1 (Learn the basics...)
79 | order: 4,
80 | title: "Verbs",
81 | },
82 | {
83 | id: 5,
84 | unitId: 1, // Unit 1 (Learn the basics...)
85 | order: 5,
86 | title: "Verbs",
87 | },
88 | ]);
89 |
90 | await db.insert(schema.challenges).values([
91 | {
92 | id: 1,
93 | lessonId: 1, // Nouns
94 | type: "SELECT",
95 | order: 1,
96 | question: 'Which one of these is the "the man"?',
97 | },
98 | {
99 | id: 2,
100 | lessonId: 1, // Nouns
101 | type: "ASSIST",
102 | order: 2,
103 | question: '"the man"',
104 | },
105 | {
106 | id: 3,
107 | lessonId: 1, // Nouns
108 | type: "SELECT",
109 | order: 3,
110 | question: 'Which one of these is the "the robot"?',
111 | },
112 | ]);
113 |
114 | await db.insert(schema.challengeOptions).values([
115 | {
116 | challengeId: 1, // Which one of these is "the man"?
117 | imageSrc: "/man.svg",
118 | correct: true,
119 | text: "el hombre",
120 | audioSrc: "/es_man.mp3",
121 | },
122 | {
123 | challengeId: 1,
124 | imageSrc: "/woman.svg",
125 | correct: false,
126 | text: "la mujer",
127 | audioSrc: "/es_woman.mp3",
128 | },
129 | {
130 | challengeId: 1,
131 | imageSrc: "/robot.svg",
132 | correct: false,
133 | text: "el robot",
134 | audioSrc: "/es_robot.mp3",
135 | },
136 | ]);
137 |
138 | await db.insert(schema.challengeOptions).values([
139 | {
140 | challengeId: 2, // "the man"?
141 | correct: true,
142 | text: "el hombre",
143 | audioSrc: "/es_man.mp3",
144 | },
145 | {
146 | challengeId: 2,
147 | correct: false,
148 | text: "la mujer",
149 | audioSrc: "/es_woman.mp3",
150 | },
151 | {
152 | challengeId: 2,
153 | correct: false,
154 | text: "el robot",
155 | audioSrc: "/es_robot.mp3",
156 | },
157 | ]);
158 |
159 | await db.insert(schema.challengeOptions).values([
160 | {
161 | challengeId: 3, // Which one of these is the "the robot"?
162 | imageSrc: "/man.svg",
163 | correct: false,
164 | text: "el hombre",
165 | audioSrc: "/es_man.mp3",
166 | },
167 | {
168 | challengeId: 3,
169 | imageSrc: "/woman.svg",
170 | correct: false,
171 | text: "la mujer",
172 | audioSrc: "/es_woman.mp3",
173 | },
174 | {
175 | challengeId: 3,
176 | imageSrc: "/robot.svg",
177 | correct: true,
178 | text: "el robot",
179 | audioSrc: "/es_robot.mp3",
180 | },
181 | ]);
182 |
183 | await db.insert(schema.challenges).values([
184 | {
185 | id: 4,
186 | lessonId: 2, // Verbs
187 | type: "SELECT",
188 | order: 1,
189 | question: 'Which one of these is the "the man"?',
190 | },
191 | {
192 | id: 5,
193 | lessonId: 2, // Verbs
194 | type: "ASSIST",
195 | order: 2,
196 | question: '"the man"',
197 | },
198 | {
199 | id: 6,
200 | lessonId: 2, // Verbs
201 | type: "SELECT",
202 | order: 3,
203 | question: 'Which one of these is the "the robot"?',
204 | },
205 | ]);
206 | console.log("Seeding finished");
207 | } catch (error) {
208 | console.error(error);
209 | throw new Error("Failed to seed the database");
210 | }
211 | };
212 |
213 | main();
214 |
215 |
--------------------------------------------------------------------------------
/db/queries.ts:
--------------------------------------------------------------------------------
1 | import { cache } from "react";
2 | import { eq } from "drizzle-orm";
3 | import { auth } from "@clerk/nextjs";
4 |
5 | import db from "@/db/drizzle";
6 | import {
7 | challengeProgress,
8 | courses,
9 | lessons,
10 | units,
11 | userProgress,
12 | userSubscription
13 | } from "@/db/schema";
14 |
15 | export const getUserProgress = cache(async () => {
16 | const { userId } = await auth();
17 |
18 | if (!userId) {
19 | return null;
20 | }
21 |
22 | const data = await db.query.userProgress.findFirst({
23 | where: eq(userProgress.userId, userId),
24 | with: {
25 | activeCourse: true,
26 | },
27 | });
28 |
29 | return data;
30 | });
31 |
32 | export const getUnits = cache(async () => {
33 | const { userId } = await auth();
34 | const userProgress = await getUserProgress();
35 |
36 | if (!userId || !userProgress?.activeCourseId) {
37 | return [];
38 | }
39 |
40 | const data = await db.query.units.findMany({
41 | orderBy: (units, { asc }) => [asc(units.order)],
42 | where: eq(units.courseId, userProgress.activeCourseId),
43 | with: {
44 | lessons: {
45 | orderBy: (lessons, { asc }) => [asc(lessons.order)],
46 | with: {
47 | challenges: {
48 | orderBy: (challenges, { asc }) => [asc(challenges.order)],
49 | with: {
50 | challengeProgress: {
51 | where: eq(
52 | challengeProgress.userId,
53 | userId,
54 | ),
55 | },
56 | },
57 | },
58 | },
59 | },
60 | },
61 | });
62 |
63 | const normalizedData = data.map((unit) => {
64 | const lessonsWithCompletedStatus = unit.lessons.map((lesson) => {
65 | if (
66 | lesson.challenges.length === 0
67 | ) {
68 | return { ...lesson, completed: false };
69 | }
70 |
71 | const allCompletedChallenges = lesson.challenges.every((challenge) => {
72 | return challenge.challengeProgress
73 | && challenge.challengeProgress.length > 0
74 | && challenge.challengeProgress.every((progress) => progress.completed);
75 | });
76 |
77 | return { ...lesson, completed: allCompletedChallenges };
78 | });
79 |
80 | return { ...unit, lessons: lessonsWithCompletedStatus };
81 | });
82 |
83 | return normalizedData;
84 | });
85 |
86 | export const getCourses = cache(async () => {
87 | const data = await db.query.courses.findMany();
88 |
89 | return data;
90 | });
91 |
92 | export const getCourseById = cache(async (courseId: number) => {
93 | const data = await db.query.courses.findFirst({
94 | where: eq(courses.id, courseId),
95 | with: {
96 | units: {
97 | orderBy: (units, { asc }) => [asc(units.order)],
98 | with: {
99 | lessons: {
100 | orderBy: (lessons, { asc }) => [asc(lessons.order)],
101 | },
102 | },
103 | },
104 | },
105 | });
106 |
107 | return data;
108 | });
109 |
110 | export const getCourseProgress = cache(async () => {
111 | const { userId } = await auth();
112 | const userProgress = await getUserProgress();
113 |
114 | if (!userId || !userProgress?.activeCourseId) {
115 | return null;
116 | }
117 |
118 | const unitsInActiveCourse = await db.query.units.findMany({
119 | orderBy: (units, { asc }) => [asc(units.order)],
120 | where: eq(units.courseId, userProgress.activeCourseId),
121 | with: {
122 | lessons: {
123 | orderBy: (lessons, { asc }) => [asc(lessons.order)],
124 | with: {
125 | unit: true,
126 | challenges: {
127 | with: {
128 | challengeProgress: {
129 | where: eq(challengeProgress.userId, userId),
130 | },
131 | },
132 | },
133 | },
134 | },
135 | },
136 | });
137 |
138 | const firstUncompletedLesson = unitsInActiveCourse
139 | .flatMap((unit) => unit.lessons)
140 | .find((lesson) => {
141 | return lesson.challenges.some((challenge) => {
142 | return !challenge.challengeProgress
143 | || challenge.challengeProgress.length === 0
144 | || challenge.challengeProgress.some((progress) => progress.completed === false)
145 | });
146 | });
147 |
148 | return {
149 | activeLesson: firstUncompletedLesson,
150 | activeLessonId: firstUncompletedLesson?.id,
151 | };
152 | });
153 |
154 | export const getLesson = cache(async (id?: number) => {
155 | const { userId } = await auth();
156 |
157 | if (!userId) {
158 | return null;
159 | }
160 |
161 | const courseProgress = await getCourseProgress();
162 |
163 | const lessonId = id || courseProgress?.activeLessonId;
164 |
165 | if (!lessonId) {
166 | return null;
167 | }
168 |
169 | const data = await db.query.lessons.findFirst({
170 | where: eq(lessons.id, lessonId),
171 | with: {
172 | challenges: {
173 | orderBy: (challenges, { asc }) => [asc(challenges.order)],
174 | with: {
175 | challengeOptions: true,
176 | challengeProgress: {
177 | where: eq(challengeProgress.userId, userId),
178 | },
179 | },
180 | },
181 | },
182 | });
183 |
184 | if (!data || !data.challenges) {
185 | return null;
186 | }
187 |
188 | const normalizedChallenges = data.challenges.map((challenge) => {
189 | const completed = challenge.challengeProgress
190 | && challenge.challengeProgress.length > 0
191 | && challenge.challengeProgress.every((progress) => progress.completed)
192 |
193 | return { ...challenge, completed };
194 | });
195 |
196 | return { ...data, challenges: normalizedChallenges }
197 | });
198 |
199 | export const getLessonPercentage = cache(async () => {
200 | const courseProgress = await getCourseProgress();
201 |
202 | if (!courseProgress?.activeLessonId) {
203 | return 0;
204 | }
205 |
206 | const lesson = await getLesson(courseProgress.activeLessonId);
207 |
208 | if (!lesson) {
209 | return 0;
210 | }
211 |
212 | const completedChallenges = lesson.challenges
213 | .filter((challenge) => challenge.completed);
214 | const percentage = Math.round(
215 | (completedChallenges.length / lesson.challenges.length) * 100,
216 | );
217 |
218 | return percentage;
219 | });
220 |
221 | const DAY_IN_MS = 86_400_000;
222 | export const getUserSubscription = cache(async () => {
223 | const { userId } = await auth();
224 |
225 | if (!userId) return null;
226 |
227 | const data = await db.query.userSubscription.findFirst({
228 | where: eq(userSubscription.userId, userId),
229 | });
230 |
231 | if (!data) return null;
232 |
233 | const isActive =
234 | data.stripePriceId &&
235 | data.stripeCurrentPeriodEnd?.getTime()! + DAY_IN_MS > Date.now();
236 |
237 | return {
238 | ...data,
239 | isActive: !!isActive,
240 | };
241 | });
242 |
243 | export const getTopTenUsers = cache(async () => {
244 | const { userId } = await auth();
245 |
246 | if (!userId) {
247 | return [];
248 | }
249 |
250 | const data = await db.query.userProgress.findMany({
251 | orderBy: (userProgress, { desc }) => [desc(userProgress.points)],
252 | limit: 10,
253 | columns: {
254 | userId: true,
255 | userName: true,
256 | userImageSrc: true,
257 | points: true,
258 | },
259 | });
260 |
261 | return data;
262 | });
263 |
--------------------------------------------------------------------------------
/app/lesson/quiz.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { toast } from "sonner";
4 | import Image from "next/image";
5 | import Confetti from "react-confetti";
6 | import { useRouter } from "next/navigation";
7 | import { useState, useTransition } from "react";
8 | import { useAudio, useWindowSize, useMount } from "react-use";
9 |
10 | import { reduceHearts } from "@/actions/user-progress";
11 | import { useHeartsModal } from "@/store/use-hearts-modal";
12 | import { challengeOptions, challenges, userSubscription } from "@/db/schema";
13 | import { usePracticeModal } from "@/store/use-practice-modal";
14 | import { upsertChallengeProgress } from "@/actions/challenge-progress";
15 |
16 | import { Header } from "./header";
17 | import { Footer } from "./footer";
18 | import { Challenge } from "./challenge";
19 | import { ResultCard } from "./result-card";
20 | import { QuestionBubble } from "./question-bubble";
21 |
22 | type Props ={
23 | initialPercentage: number;
24 | initialHearts: number;
25 | initialLessonId: number;
26 | initialLessonChallenges: (typeof challenges.$inferSelect & {
27 | completed: boolean;
28 | challengeOptions: typeof challengeOptions.$inferSelect[];
29 | })[];
30 | userSubscription: typeof userSubscription.$inferSelect & {
31 | isActive: boolean;
32 | } | null;
33 | };
34 |
35 | export const Quiz = ({
36 | initialPercentage,
37 | initialHearts,
38 | initialLessonId,
39 | initialLessonChallenges,
40 | userSubscription,
41 | }: Props) => {
42 | const { open: openHeartsModal } = useHeartsModal();
43 | const { open: openPracticeModal } = usePracticeModal();
44 |
45 | useMount(() => {
46 | if (initialPercentage === 100) {
47 | openPracticeModal();
48 | }
49 | });
50 |
51 | const { width, height } = useWindowSize();
52 |
53 | const router = useRouter();
54 |
55 | const [finishAudio] = useAudio({ src: "/finish.mp3", autoPlay: true });
56 | const [
57 | correctAudio,
58 | _c,
59 | correctControls,
60 | ] = useAudio({ src: "/correct.wav" });
61 | const [
62 | incorrectAudio,
63 | _i,
64 | incorrectControls,
65 | ] = useAudio({ src: "/incorrect.wav" });
66 | const [pending, startTransition] = useTransition();
67 |
68 | const [lessonId] = useState(initialLessonId);
69 | const [hearts, setHearts] = useState(initialHearts);
70 | const [percentage, setPercentage] = useState(() => {
71 | return initialPercentage === 100 ? 0 : initialPercentage;
72 | });
73 | const [challenges] = useState(initialLessonChallenges);
74 | const [activeIndex, setActiveIndex] = useState(() => {
75 | const uncompletedIndex = challenges.findIndex((challenge) => !challenge.completed);
76 | return uncompletedIndex === -1 ? 0 : uncompletedIndex;
77 | });
78 |
79 | const [selectedOption, setSelectedOption] = useState();
80 | const [status, setStatus] = useState<"correct" | "wrong" | "none">("none");
81 |
82 | const challenge = challenges[activeIndex];
83 | const options = challenge?.challengeOptions ?? [];
84 |
85 | const onNext = () => {
86 | setActiveIndex((current) => current + 1);
87 | };
88 |
89 | const onSelect = (id: number) => {
90 | if (status !== "none") return;
91 |
92 | setSelectedOption(id);
93 | };
94 |
95 | const onContinue = () => {
96 | if (!selectedOption) return;
97 |
98 | if (status === "wrong") {
99 | setStatus("none");
100 | setSelectedOption(undefined);
101 | return;
102 | }
103 |
104 | if (status === "correct") {
105 | onNext();
106 | setStatus("none");
107 | setSelectedOption(undefined);
108 | return;
109 | }
110 |
111 | const correctOption = options.find((option) => option.correct);
112 |
113 | if (!correctOption) {
114 | return;
115 | }
116 |
117 | if (correctOption.id === selectedOption) {
118 | startTransition(() => {
119 | upsertChallengeProgress(challenge.id)
120 | .then((response) => {
121 | if (response?.error === "hearts") {
122 | openHeartsModal();
123 | return;
124 | }
125 |
126 | correctControls.play();
127 | setStatus("correct");
128 | setPercentage((prev) => prev + 100 / challenges.length);
129 |
130 | // This is a practice
131 | if (initialPercentage === 100) {
132 | setHearts((prev) => Math.min(prev + 1, 5));
133 | }
134 | })
135 | .catch(() => toast.error("Something went wrong. Please try again."))
136 | });
137 | } else {
138 | startTransition(() => {
139 | reduceHearts(challenge.id)
140 | .then((response) => {
141 | if (response?.error === "hearts") {
142 | openHeartsModal();
143 | return;
144 | }
145 |
146 | incorrectControls.play();
147 | setStatus("wrong");
148 |
149 | if (!response?.error) {
150 | setHearts((prev) => Math.max(prev - 1, 0));
151 | }
152 | })
153 | .catch(() => toast.error("Something went wrong. Please try again."))
154 | });
155 | }
156 | };
157 |
158 | if (!challenge) {
159 | return (
160 | <>
161 | {finishAudio}
162 |
169 |
170 |
177 |
184 |
185 | Great job!
You've completed the lesson.
186 |
187 |
188 |
192 |
196 |
197 |
198 |