Skip to content

Commit

Permalink
initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
= committed Jun 28, 2024
0 parents commit 3f57237
Show file tree
Hide file tree
Showing 145 changed files with 14,957 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}
37 changes: 37 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# local env files
.env*.local
.env

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts
85 changes: 85 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Build a Duolingo Clone With Nextjs, React, Drizzle, Stripe (2024)

![Duolingo thumb (1)](https://github.com/AntonioErdeljac/next14-duolingo-clone/assets/23248726/d58e4b55-bb09-456f-978e-f5f31e81b870)

This is a repository for a "Build a Duolingo Clone With Nextjs, React, Drizzle, Stripe (2024)" youtube video.

[VIDEO TUTORIAL](https://www.youtube.com/watch?v=dP75Khfy4s4)

Key Features:
- 🌐 Next.js 14 & server actions
- 🗣 AI Voices using Elevenlabs AI
- 🎨 Beautiful component system using Shadcn UI
- 🎭 Amazing characters thanks to KenneyNL
- 🔐 Auth using Clerk
- 🔊 Sound effects
- ❤️ Hearts system
- 🌟 Points / XP system
- 💔 No hearts left popup
- 🚪 Exit confirmation popup
- 🔄 Practice old lessons to regain hearts
- 🏆 Leaderboard
- 🗺 Quests milestones
- 🛍 Shop system to exchange points with hearts
- 💳 Pro tier for unlimited hearts using Stripe
- 🏠 Landing page
- 📊 Admin dashboard React Admin
- 🌧 ORM using DrizzleORM
- 💾 PostgresDB using NeonDB
- 🚀 Deployment on Vercel
- 📱 Mobile responsiveness

### Prerequisites

**Node version 14.x**

### Cloning the repository

```shell
git clone https://github.com/AntonioErdeljac/next14-duolingo-clone.git
```

### Install packages

```shell
npm i
```

### Setup .env file


```js
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=""
CLERK_SECRET_KEY=""
DATABASE_URL="postgresql://..."
STRIPE_API_KEY=""
NEXT_PUBLIC_APP_URL="http://localhost:3000"
STRIPE_WEBHOOK_SECRET=""
```

### Setup Drizzle ORM

```shell
npm run db:push

```

### Seed the app

```shell
npm run db:seed

```

or

```shell
npm run db:prod

```

### Start the app

```shell
npm run dev
```
88 changes: 88 additions & 0 deletions actions/challenge-progress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"use server";

import { auth } from "@clerk/nextjs";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";

import db from "@/db/drizzle";
import { getUserProgress, getUserSubscription } from "@/db/queries";
import { challengeProgress, challenges, userProgress } from "@/db/schema";

export const upsertChallengeProgress = async (challengeId: number) => {
const { userId } = await auth();

if (!userId) {
throw new Error("Unauthorized");
}

const currentUserProgress = await getUserProgress();
const userSubscription = await getUserSubscription();

if (!currentUserProgress) {
throw new Error("User progress not found");
}

const challenge = await db.query.challenges.findFirst({
where: eq(challenges.id, challengeId)
});

if (!challenge) {
throw new Error("Challenge not found");
}

const lessonId = challenge.lessonId;

const existingChallengeProgress = await db.query.challengeProgress.findFirst({
where: and(
eq(challengeProgress.userId, userId),
eq(challengeProgress.challengeId, challengeId),
),
});

const isPractice = !!existingChallengeProgress;

if (
currentUserProgress.hearts === 0 &&
!isPractice &&
!userSubscription?.isActive
) {
return { error: "hearts" };
}

if (isPractice) {
await db.update(challengeProgress).set({
completed: true,
})
.where(
eq(challengeProgress.id, existingChallengeProgress.id)
);

await db.update(userProgress).set({
hearts: Math.min(currentUserProgress.hearts + 1, 5),
points: currentUserProgress.points + 10,
}).where(eq(userProgress.userId, userId));

revalidatePath("/learn");
revalidatePath("/lesson");
revalidatePath("/quests");
revalidatePath("/leaderboard");
revalidatePath(`/lesson/${lessonId}`);
return;
}

await db.insert(challengeProgress).values({
challengeId,
userId,
completed: true,
});

await db.update(userProgress).set({
points: currentUserProgress.points + 10,
}).where(eq(userProgress.userId, userId));

revalidatePath("/learn");
revalidatePath("/lesson");
revalidatePath("/quests");
revalidatePath("/leaderboard");
revalidatePath(`/lesson/${lessonId}`);
};
137 changes: 137 additions & 0 deletions actions/user-progress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"use server";

import { and, eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { auth, currentUser } from "@clerk/nextjs";

import db from "@/db/drizzle";
import { POINTS_TO_REFILL } from "@/constants";
import { getCourseById, getUserProgress, getUserSubscription } from "@/db/queries";
import { challengeProgress, challenges, userProgress } from "@/db/schema";

export const upsertUserProgress = async (courseId: number) => {
const { userId } = await auth();
const user = await currentUser();

if (!userId || !user) {
throw new Error("Unauthorized");
}

const course = await getCourseById(courseId);

if (!course) {
throw new Error("Course not found");
}

if (!course.units.length || !course.units[0].lessons.length) {
throw new Error("Course is empty");
}

const existingUserProgress = await getUserProgress();

if (existingUserProgress) {
await db.update(userProgress).set({
activeCourseId: courseId,
userName: user.firstName || "User",
userImageSrc: user.imageUrl || "/mascot.svg",
});

revalidatePath("/courses");
revalidatePath("/learn");
redirect("/learn");
}

await db.insert(userProgress).values({
userId,
activeCourseId: courseId,
userName: user.firstName || "User",
userImageSrc: user.imageUrl || "/mascot.svg",
});

revalidatePath("/courses");
revalidatePath("/learn");
redirect("/learn");
};

export const reduceHearts = async (challengeId: number) => {
const { userId } = await auth();

if (!userId) {
throw new Error("Unauthorized");
}

const currentUserProgress = await getUserProgress();
const userSubscription = await getUserSubscription();

const challenge = await db.query.challenges.findFirst({
where: eq(challenges.id, challengeId),
});

if (!challenge) {
throw new Error("Challenge not found");
}

const lessonId = challenge.lessonId;

const existingChallengeProgress = await db.query.challengeProgress.findFirst({
where: and(
eq(challengeProgress.userId, userId),
eq(challengeProgress.challengeId, challengeId),
),
});

const isPractice = !!existingChallengeProgress;

if (isPractice) {
return { error: "practice" };
}

if (!currentUserProgress) {
throw new Error("User progress not found");
}

if (userSubscription?.isActive) {
return { error: "subscription" };
}

if (currentUserProgress.hearts === 0) {
return { error: "hearts" };
}

await db.update(userProgress).set({
hearts: Math.max(currentUserProgress.hearts - 1, 0),
}).where(eq(userProgress.userId, userId));

revalidatePath("/shop");
revalidatePath("/learn");
revalidatePath("/quests");
revalidatePath("/leaderboard");
revalidatePath(`/lesson/${lessonId}`);
};

export const refillHearts = async () => {
const currentUserProgress = await getUserProgress();

if (!currentUserProgress) {
throw new Error("User progress not found");
}

if (currentUserProgress.hearts === 5) {
throw new Error("Hearts are already full");
}

if (currentUserProgress.points < POINTS_TO_REFILL) {
throw new Error("Not enough points");
}

await db.update(userProgress).set({
hearts: 5,
points: currentUserProgress.points - POINTS_TO_REFILL,
}).where(eq(userProgress.userId, currentUserProgress.userId));

revalidatePath("/shop");
revalidatePath("/learn");
revalidatePath("/quests");
revalidatePath("/leaderboard");
};
Loading

0 comments on commit 3f57237

Please sign in to comment.