-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
=
committed
Jun 28, 2024
0 parents
commit 3f57237
Showing
145 changed files
with
14,957 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
{ | ||
"extends": "next/core-web-vitals" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,85 @@ | ||
# Build a Duolingo Clone With Nextjs, React, Drizzle, Stripe (2024) | ||
|
||
 | ||
|
||
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 | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}`); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"); | ||
}; |
Oops, something went wrong.