├── .env.example ├── .eslintrc.cjs ├── .gitignore ├── LICENSE ├── README.md ├── next.config.mjs ├── package.json ├── pnpm-lock.yaml ├── postcss.config.cjs ├── prettier.config.cjs ├── public ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── app.webmanifest ├── apple-touch-icon.png ├── bg-snow.svg ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── flags.svg └── woman.png ├── screenshots ├── screenshot-desktop.png └── screenshot-mobile.png ├── src ├── components │ ├── BottomBar.tsx │ ├── Calendar.tsx │ ├── Flag.tsx │ ├── LanguageCarousel.tsx │ ├── LanguageDropDown.tsx │ ├── LanguageHeader.tsx │ ├── LeftBar.tsx │ ├── LoginScreen.tsx │ ├── RightBar.tsx │ ├── SettingsRightNav.tsx │ ├── Svgs.tsx │ └── TopBar.tsx ├── env.mjs ├── hooks │ ├── useBoundStore.ts │ └── useLeaderboard.ts ├── pages │ ├── _app.tsx │ ├── forgot-password.tsx │ ├── index.tsx │ ├── leaderboard.tsx │ ├── learn.tsx │ ├── lesson.tsx │ ├── profile.tsx │ ├── register.tsx │ ├── settings │ │ ├── account.tsx │ │ ├── coach.tsx │ │ └── sound.tsx │ └── shop.tsx ├── stores │ ├── createGoalXpStore.ts │ ├── createLanguageStore.ts │ ├── createLessonStore.ts │ ├── createLingotStore.ts │ ├── createSoundSettingsStore.ts │ ├── createStreakStore.ts │ ├── createUserStore.ts │ └── createXpStore.ts ├── styles │ └── globals.css └── utils │ ├── array-utils.ts │ ├── dateString.ts │ ├── fakeUsers.ts │ ├── languages.ts │ └── units.ts ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # Since the ".env" file is gitignored, you can use the ".env.example" file to 2 | # build a new ".env" file when you clone the repo. Keep this file up-to-date 3 | # when you add new variables to `.env`. 4 | 5 | # This file will be committed to version control, so make sure not to have any 6 | # secrets in it. If you are cloning this repo, create a copy of this file named 7 | # ".env" and populate it with your secrets. 8 | 9 | # When adding additional environment variables, the schema in "/src/env.mjs" 10 | # should be updated accordingly. 11 | 12 | # Example: 13 | # SERVERVAR="foo" 14 | # NEXT_PUBLIC_CLIENTVAR="bar" 15 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const path = require("path"); 3 | 4 | /** @type {import("eslint").Linter.Config} */ 5 | const config = { 6 | overrides: [ 7 | { 8 | extends: [ 9 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 10 | ], 11 | files: ["*.ts", "*.tsx"], 12 | parserOptions: { 13 | project: path.join(__dirname, "tsconfig.json"), 14 | }, 15 | }, 16 | ], 17 | parser: "@typescript-eslint/parser", 18 | parserOptions: { 19 | project: path.join(__dirname, "tsconfig.json"), 20 | }, 21 | plugins: ["@typescript-eslint"], 22 | extends: ["next/core-web-vitals", "plugin:@typescript-eslint/recommended"], 23 | rules: { 24 | "@typescript-eslint/consistent-type-imports": [ 25 | "warn", 26 | { 27 | prefer: "type-imports", 28 | fixStyle: "inline-type-imports", 29 | }, 30 | ], 31 | "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], 32 | }, 33 | }; 34 | 35 | module.exports = config; 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # database 12 | /prisma/db.sqlite 13 | /prisma/db.sqlite-journal 14 | 15 | # next.js 16 | /.next/ 17 | /out/ 18 | next-env.d.ts 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # local env files 34 | # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables 35 | .env 36 | .env*.local 37 | 38 | # vercel 39 | .vercel 40 | 41 | # typescript 42 | *.tsbuildinfo 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Bryan Jennings 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Duolingo 2 | 3 | https://react-duolingo-clone.vercel.app/ 4 | 5 | A simple [Duolingo](https://www.duolingo.com) web app clone written with [React](https://react.dev/), [TypeScript](https://www.typescriptlang.org/), [Next.js](https://nextjs.org/), [Tailwind](https://tailwindcss.com/), and [Zustand](https://github.com/pmndrs/zustand). I used [create-t3-app](https://github.com/t3-oss/create-t3-app) to initialize the project. 6 | 7 | Mobile screenshot 8 | Desktop screenshot 9 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful 3 | * for Docker builds. 4 | */ 5 | await import("./src/env.mjs"); 6 | 7 | /** @type {import("next").NextConfig} */ 8 | const config = { 9 | reactStrictMode: true, 10 | 11 | /** 12 | * If you have `experimental: { appDir: true }` set, then you must comment the below `i18n` config 13 | * out. 14 | * 15 | * @see https://github.com/vercel/next.js/issues/41980 16 | */ 17 | i18n: { 18 | locales: ["en"], 19 | defaultLocale: "en", 20 | }, 21 | images: { 22 | remotePatterns: [ 23 | { 24 | protocol: "https", 25 | hostname: "placekitten.com", 26 | port: "", 27 | pathname: "/100/100", 28 | }, 29 | ], 30 | }, 31 | }; 32 | export default config; 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-duolingo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "next build", 7 | "dev": "next dev", 8 | "lint": "next lint", 9 | "start": "next start" 10 | }, 11 | "dependencies": { 12 | "@t3-oss/env-nextjs": "^0.7.3", 13 | "dayjs": "^1.11.13", 14 | "next": "^14.2.11", 15 | "react": "18.3.1", 16 | "react-dom": "18.3.1", 17 | "zod": "^3.23.8", 18 | "zustand": "^4.4.7" 19 | }, 20 | "devDependencies": { 21 | "@types/eslint": "^8.56.2", 22 | "@types/node": "^22.5.5", 23 | "@types/react": "^18.3.5", 24 | "@types/react-dom": "^18.3.0", 25 | "@typescript-eslint/eslint-plugin": "^6.19.0", 26 | "@typescript-eslint/parser": "^6.19.0", 27 | "autoprefixer": "^10.4.16", 28 | "eslint": "^8.56.0", 29 | "eslint-config-next": "^14.0.4", 30 | "postcss": "^8.4.47", 31 | "prettier": "^3.3.3", 32 | "prettier-plugin-tailwindcss": "^0.6.6", 33 | "tailwindcss": "^3.4.11", 34 | "typescript": "^5.3.0" 35 | }, 36 | "ct3aMetadata": { 37 | "initVersion": "7.13.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | 8 | module.exports = config; 9 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Config} */ 2 | const config = { 3 | plugins: [require.resolve("prettier-plugin-tailwindcss")], 4 | }; 5 | 6 | module.exports = config; 7 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bryanjenningz/react-duolingo/2d0fdbca433f36a0e54f56f3cde58ce1080790ba/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bryanjenningz/react-duolingo/2d0fdbca433f36a0e54f56f3cde58ce1080790ba/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/app.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "React Duolingo Clone", 3 | "short_name": "Duoclone", 4 | "start_url": "/", 5 | "display": "standalone", 6 | "background_color": "#0A0", 7 | "theme_color": "#0A0", 8 | "description": "A React Duolingo clone web app written with React, TypeScript, Next.js, TailwindCSS, and Zustand.", 9 | "icons": [ 10 | { 11 | "src": "/favicon-16x16.png", 12 | "sizes": "16x16", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "/favicon-32x32.png", 17 | "sizes": "32x32", 18 | "type": "image/png" 19 | }, 20 | { 21 | "src": "/favicon.ico", 22 | "sizes": "48x48", 23 | "type": "image/png" 24 | }, 25 | { 26 | "src": "/apple-touch-icon.png", 27 | "sizes": "180x180", 28 | "type": "image/png" 29 | }, 30 | { 31 | "src": "/android-chrome-192x192.png", 32 | "sizes": "192x192", 33 | "type": "image/png" 34 | }, 35 | { 36 | "src": "/android-chrome-512x512.png", 37 | "sizes": "512x512", 38 | "type": "image/png", 39 | "purpose": "maskable" 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bryanjenningz/react-duolingo/2d0fdbca433f36a0e54f56f3cde58ce1080790ba/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/bg-snow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bryanjenningz/react-duolingo/2d0fdbca433f36a0e54f56f3cde58ce1080790ba/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bryanjenningz/react-duolingo/2d0fdbca433f36a0e54f56f3cde58ce1080790ba/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bryanjenningz/react-duolingo/2d0fdbca433f36a0e54f56f3cde58ce1080790ba/public/favicon.ico -------------------------------------------------------------------------------- /public/woman.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bryanjenningz/react-duolingo/2d0fdbca433f36a0e54f56f3cde58ce1080790ba/public/woman.png -------------------------------------------------------------------------------- /screenshots/screenshot-desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bryanjenningz/react-duolingo/2d0fdbca433f36a0e54f56f3cde58ce1080790ba/screenshots/screenshot-desktop.png -------------------------------------------------------------------------------- /screenshots/screenshot-mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bryanjenningz/react-duolingo/2d0fdbca433f36a0e54f56f3cde58ce1080790ba/screenshots/screenshot-mobile.png -------------------------------------------------------------------------------- /src/components/BottomBar.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { useBoundStore } from "~/hooks/useBoundStore"; 3 | 4 | type BottomBarItem = { 5 | name: Tab; 6 | href: string; 7 | icon: JSX.Element; 8 | }; 9 | 10 | export type Tab = "Learn" | "Shop" | "Profile" | "Leaderboards"; 11 | 12 | export const useBottomBarItems = () => { 13 | const loggedIn = useBoundStore((x) => x.loggedIn); 14 | 15 | const bottomBarItems: BottomBarItem[] = [ 16 | { 17 | name: "Learn", 18 | href: "/learn", 19 | icon: ( 20 | 27 | 31 | 38 | 42 | 46 | 47 | ), 48 | }, 49 | { 50 | name: "Shop", 51 | href: "/shop", 52 | icon: ( 53 | 60 | 64 | 65 | 69 | 73 | 77 | 78 | 82 | 86 | 90 | 91 | ), 92 | }, 93 | { 94 | name: "Profile", 95 | href: loggedIn ? "/profile" : "/learn?sign-up", 96 | icon: ( 97 | 104 | 110 | 114 | 118 | 122 | 126 | 130 | 134 | 135 | ), 136 | }, 137 | ]; 138 | 139 | if (loggedIn) { 140 | bottomBarItems.splice(1, 0, { 141 | name: "Leaderboards", 142 | href: "/leaderboard", 143 | icon: ( 144 | 145 | 149 | 154 | 155 | ), 156 | }); 157 | } 158 | 159 | return bottomBarItems; 160 | }; 161 | 162 | export const BottomBar = ({ selectedTab }: { selectedTab: Tab | null }) => { 163 | const bottomBarItems = useBottomBarItems(); 164 | return ( 165 | 189 | ); 190 | }; 191 | -------------------------------------------------------------------------------- /src/components/Calendar.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { useBoundStore } from "~/hooks/useBoundStore"; 3 | import { ChevronLeftSvg, ChevronRightSvg } from "./Svgs"; 4 | import { range } from "~/utils/array-utils"; 5 | 6 | const getCalendarDays = (now: dayjs.Dayjs): (number | null)[][] => { 7 | const startOfMonth = now.startOf("month"); 8 | const calendarDays: (number | null)[][] = []; 9 | const firstWeekEndDate = 8 - startOfMonth.day(); 10 | const firstWeek = [ 11 | ...range(0, startOfMonth.day()).map(() => null), 12 | ...range(1, firstWeekEndDate), 13 | ]; 14 | calendarDays.push(firstWeek); 15 | for ( 16 | let weekStartDate = firstWeekEndDate; 17 | weekStartDate <= now.daysInMonth(); 18 | weekStartDate += 7 19 | ) { 20 | calendarDays.push( 21 | range(weekStartDate, weekStartDate + 7).map((date) => 22 | date <= now.daysInMonth() ? date : null, 23 | ), 24 | ); 25 | } 26 | return calendarDays; 27 | }; 28 | 29 | export const Calendar = ({ 30 | now, 31 | setNow, 32 | }: { 33 | now: dayjs.Dayjs; 34 | setNow: React.Dispatch>; 35 | }) => { 36 | const isActiveDay = useBoundStore((x) => x.isActiveDay); 37 | const formattedNowMonth = now.format("MMMM YYYY"); 38 | const staticNow = dayjs(); 39 | const calendarDays = getCalendarDays(now); 40 | return ( 41 |
42 |
43 | 50 |

51 | {formattedNowMonth} 52 |

53 | 60 |
61 |
62 | {"SMTWTFS".split("").map((day, i) => { 63 | return ( 64 |
65 | {day} 66 |
67 | ); 68 | })} 69 |
70 |
71 | {calendarDays.map((week, i) => { 72 | return ( 73 |
74 | {week.map((date, i) => { 75 | const isActiveDate = 76 | date !== null && isActiveDay(now.date(date)); 77 | const isCurrentDate = 78 | date === staticNow.date() && 79 | now.month() === staticNow.month() && 80 | now.year() === staticNow.year(); 81 | return ( 82 |
93 | {date} 94 |
95 | ); 96 | })} 97 |
98 | ); 99 | })} 100 |
101 |
102 | ); 103 | }; 104 | -------------------------------------------------------------------------------- /src/components/Flag.tsx: -------------------------------------------------------------------------------- 1 | import type { StaticImageData } from "next/image"; 2 | import _flagsSvg from "../../public/flags.svg"; 3 | import type { Language } from "~/utils/languages"; 4 | 5 | const flagsSvg = _flagsSvg as StaticImageData; 6 | 7 | export const Flag = ({ 8 | language, 9 | width = 84, 10 | }: { 11 | language: Language; 12 | width?: number; 13 | }) => { 14 | const height = width * (19.3171 / 24); 15 | return ( 16 | 17 | 22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/components/LanguageCarousel.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { ChevronLeftSvg, ChevronRightSvg } from "./Svgs"; 3 | import React, { useRef } from "react"; 4 | import languages from "~/utils/languages"; 5 | import { useBoundStore } from "~/hooks/useBoundStore"; 6 | import { Flag } from "./Flag"; 7 | 8 | declare global { 9 | interface Element { 10 | offsetLeft: number; 11 | } 12 | } 13 | 14 | const scrollCarousel = ({ 15 | container, 16 | startIndexRef, 17 | endIndex, 18 | }: { 19 | container: Element; 20 | startIndexRef: React.MutableRefObject; 21 | endIndex: number; 22 | }) => { 23 | const startIndex = startIndexRef.current; 24 | const startChild = container.children[startIndex]; 25 | const endChild = container.children[endIndex]; 26 | if (!startChild || !endChild) return; 27 | const startX = startChild.offsetLeft - container.offsetLeft; 28 | const endX = endChild.offsetLeft - container.offsetLeft; 29 | const startTime = Date.now(); 30 | const intervalTime = 500; 31 | const endTime = Date.now() + intervalTime; 32 | const tick = () => { 33 | const nowTime = Date.now(); 34 | if (nowTime >= endTime) { 35 | container.scrollTo(endX, 0); 36 | return; 37 | } 38 | const dx = ((nowTime - startTime) / intervalTime) * (endX - startX); 39 | container.scrollTo(startX + dx, 0); 40 | requestAnimationFrame(tick); 41 | }; 42 | tick(); 43 | startIndexRef.current = endIndex; 44 | }; 45 | 46 | const scrollCarouselLeft = ({ 47 | languagesContainer, 48 | startIndexRef, 49 | lastLanguageIndex, 50 | }: { 51 | languagesContainer: React.MutableRefObject; 52 | startIndexRef: React.MutableRefObject; 53 | lastLanguageIndex: number; 54 | }) => { 55 | const container = languagesContainer.current; 56 | if (!container) return; 57 | const startIndex = startIndexRef.current; 58 | const endIndex = 59 | startIndex === 0 ? lastLanguageIndex : Math.max(0, startIndex - 2); 60 | scrollCarousel({ container, startIndexRef, endIndex }); 61 | }; 62 | 63 | const scrollCarouselRight = ({ 64 | languagesContainer, 65 | startIndexRef, 66 | lastLanguageIndex, 67 | }: { 68 | languagesContainer: React.MutableRefObject; 69 | startIndexRef: React.MutableRefObject; 70 | lastLanguageIndex: number; 71 | }) => { 72 | const container = languagesContainer.current; 73 | if (!container) return; 74 | const startIndex = startIndexRef.current; 75 | const endIndex = 76 | startIndex >= lastLanguageIndex 77 | ? 0 78 | : (startIndex + 2) % container.children.length; 79 | scrollCarousel({ container, startIndexRef, endIndex }); 80 | }; 81 | 82 | export const LanguageCarousel = () => { 83 | const setLanguage = useBoundStore((x) => x.setLanguage); 84 | 85 | const startIndexRef = useRef(0); 86 | const languagesContainer = useRef(null); 87 | const lastLanguageIndex = 19; 88 | return ( 89 |
90 |
91 | 104 |
108 | {languages.map((language) => { 109 | return ( 110 | setLanguage(language)} 115 | > 116 | 117 | 118 | {language.name} 119 | 120 | 121 | ); 122 | })} 123 |
124 | 137 |
138 |
139 | ); 140 | }; 141 | -------------------------------------------------------------------------------- /src/components/LanguageDropDown.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronDownSvg } from "./Svgs"; 2 | import { useState } from "react"; 3 | import languages from "~/utils/languages"; 4 | import Link from "next/link"; 5 | import { Flag } from "./Flag"; 6 | 7 | export const LanguageDropDown = () => { 8 | const [languagesShown, setLanguagesShown] = useState(false); 9 | return ( 10 |
setLanguagesShown(true)} 13 | onMouseLeave={() => setLanguagesShown(false)} 14 | aria-haspopup={true} 15 | aria-expanded={languagesShown} 16 | role="button" 17 | tabIndex={0} 18 | onKeyDown={(e) => { 19 | if (e.key === "Enter" || e.key === " ") { 20 | setLanguagesShown((isShown) => !isShown); 21 | } 22 | }} 23 | > 24 | Site language: English{" "} 25 | 26 | {languagesShown && ( 27 |
    28 | {languages.map((language) => { 29 | return ( 30 |
  • 31 | 36 | 37 | {language.nativeName} 38 | 39 |
  • 40 | ); 41 | })} 42 |
43 | )} 44 |
45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /src/components/LanguageHeader.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { LanguageDropDown } from "./LanguageDropDown"; 3 | 4 | export const LanguageHeader = () => { 5 | return ( 6 |
7 | 8 | duolingo 9 | 10 | 11 |
12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /src/components/LeftBar.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import type { ComponentProps } from "react"; 3 | import React, { useState } from "react"; 4 | import type { Tab } from "./BottomBar"; 5 | import { useBottomBarItems } from "./BottomBar"; 6 | import type { LoginScreenState } from "./LoginScreen"; 7 | import { LoginScreen } from "./LoginScreen"; 8 | import { GlobeIconSvg, PodcastIconSvg } from "./Svgs"; 9 | import { useBoundStore } from "~/hooks/useBoundStore"; 10 | 11 | const LeftBarMoreMenuSvg = (props: ComponentProps<"svg">) => { 12 | return ( 13 | 14 | 22 | 23 | 24 | 25 | 26 | ); 27 | }; 28 | 29 | export const LeftBar = ({ selectedTab }: { selectedTab: Tab | null }) => { 30 | const loggedIn = useBoundStore((x) => x.loggedIn); 31 | const logOut = useBoundStore((x) => x.logOut); 32 | 33 | const [moreMenuShown, setMoreMenuShown] = useState(false); 34 | const [loginScreenState, setLoginScreenState] = 35 | useState("HIDDEN"); 36 | 37 | const bottomBarItems = useBottomBarItems(); 38 | 39 | return ( 40 | <> 41 | 150 | 154 | 155 | ); 156 | }; 157 | -------------------------------------------------------------------------------- /src/components/LoginScreen.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { CloseSvg } from "./Svgs"; 3 | import type { ComponentProps } from "react"; 4 | import React, { useEffect, useRef, useState } from "react"; 5 | import { useBoundStore } from "~/hooks/useBoundStore"; 6 | import { useRouter } from "next/router"; 7 | 8 | export const FacebookLogoSvg = (props: ComponentProps<"svg">) => { 9 | return ( 10 | 11 | Fill 4 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | }; 24 | 25 | export const GoogleLogoSvg = (props: ComponentProps<"svg">) => { 26 | return ( 27 | 28 | 29 | 33 | 37 | 41 | 45 | 46 | 47 | 48 | ); 49 | }; 50 | 51 | export type LoginScreenState = "HIDDEN" | "LOGIN" | "SIGNUP"; 52 | 53 | export const useLoginScreen = () => { 54 | const router = useRouter(); 55 | const loggedIn = useBoundStore((x) => x.loggedIn); 56 | const queryState: LoginScreenState = (() => { 57 | if (loggedIn) return "HIDDEN"; 58 | if ("login" in router.query) return "LOGIN"; 59 | if ("sign-up" in router.query) return "SIGNUP"; 60 | return "HIDDEN"; 61 | })(); 62 | const [loginScreenState, setLoginScreenState] = useState(queryState); 63 | useEffect(() => setLoginScreenState(queryState), [queryState]); 64 | return { loginScreenState, setLoginScreenState }; 65 | }; 66 | 67 | export const LoginScreen = ({ 68 | loginScreenState, 69 | setLoginScreenState, 70 | }: { 71 | loginScreenState: LoginScreenState; 72 | setLoginScreenState: React.Dispatch>; 73 | }) => { 74 | const router = useRouter(); 75 | const loggedIn = useBoundStore((x) => x.loggedIn); 76 | const logIn = useBoundStore((x) => x.logIn); 77 | const setUsername = useBoundStore((x) => x.setUsername); 78 | const setName = useBoundStore((x) => x.setName); 79 | 80 | const [ageTooltipShown, setAgeTooltipShown] = useState(false); 81 | 82 | const nameInputRef = useRef(null); 83 | 84 | useEffect(() => { 85 | if (loginScreenState !== "HIDDEN" && loggedIn) { 86 | setLoginScreenState("HIDDEN"); 87 | } 88 | }, [loginScreenState, loggedIn, setLoginScreenState]); 89 | 90 | const logInAndSetUserProperties = () => { 91 | const name = 92 | nameInputRef.current?.value.trim() || Math.random().toString().slice(2); 93 | const username = name.replace(/ +/g, "-"); 94 | setUsername(username); 95 | setName(name); 96 | logIn(); 97 | void router.push("/learn"); 98 | }; 99 | 100 | return ( 101 |
110 |
111 | 118 | 126 |
127 |
128 |
129 |

130 | {loginScreenState === "LOGIN" ? "Log in" : "Create your profile"} 131 |

132 |
133 | {loginScreenState === "SIGNUP" && ( 134 | <> 135 |
136 | 140 |
141 |
setAgeTooltipShown(true)} 144 | onMouseLeave={() => setAgeTooltipShown(false)} 145 | onClick={() => setAgeTooltipShown((x) => !x)} 146 | role="button" 147 | tabIndex={0} 148 | aria-label="Why do you need an age?" 149 | > 150 | ? 151 | {ageTooltipShown && ( 152 |
153 | Providing your age ensures you get the right Duolingo 154 | experience. For more details, please visit our{" "} 155 | 159 | Privacy Policy 160 | 161 |
162 | )} 163 |
164 |
165 |
166 | 171 | 172 | )} 173 | 181 |
182 | 187 | {loginScreenState === "LOGIN" && ( 188 |
189 | 193 | Forgot? 194 | 195 |
196 | )} 197 |
198 |
199 | 205 |
206 |
207 | or 208 |
209 |
210 |
211 | 217 | 223 |
224 |

225 | By signing in to Duolingo, you agree to our{" "} 226 | 230 | Terms 231 | {" "} 232 | and{" "} 233 | 237 | Privacy Policy 238 | 239 | . 240 |

241 |

242 | This site is protected by reCAPTCHA Enterprise and the Google{" "} 243 | 247 | Privacy Policy 248 | {" "} 249 | and{" "} 250 | 254 | Terms of Service 255 | {" "} 256 | apply. 257 |

258 |

259 | 260 | {loginScreenState === "LOGIN" 261 | ? "Don't have an account?" 262 | : "Have an account?"} 263 | {" "} 264 | 272 |

273 |
274 |
275 |
276 | ); 277 | }; 278 | -------------------------------------------------------------------------------- /src/components/RightBar.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import type { ComponentProps } from "react"; 3 | import React, { useState } from "react"; 4 | import dayjs from "dayjs"; 5 | import { 6 | BronzeLeagueSvg, 7 | EmptyFireSvg, 8 | EmptyGemSvg, 9 | FireSvg, 10 | GemSvg, 11 | LightningProgressSvg, 12 | LingotsTreasureChestSvg, 13 | TreasureProgressSvg, 14 | } from "./Svgs"; 15 | import { Calendar } from "./Calendar"; 16 | import { useBoundStore } from "~/hooks/useBoundStore"; 17 | import { Flag } from "./Flag"; 18 | import type { LoginScreenState } from "./LoginScreen"; 19 | import { LoginScreen } from "./LoginScreen"; 20 | import { useLeaderboardRank } from "~/hooks/useLeaderboard"; 21 | 22 | export const RightBar = () => { 23 | const loggedIn = useBoundStore((x) => x.loggedIn); 24 | const lingots = useBoundStore((x) => x.lingots); 25 | const streak = useBoundStore((x) => x.streak); 26 | const language = useBoundStore((x) => x.language); 27 | const lessonsCompleted = useBoundStore((x) => x.lessonsCompleted); 28 | 29 | const [languagesShown, setLanguagesShown] = useState(false); 30 | 31 | const [streakShown, setStreakShown] = useState(false); 32 | const [now, setNow] = useState(dayjs()); 33 | 34 | const [gemsShown, setGemsShown] = useState(false); 35 | 36 | const [loginScreenState, setLoginScreenState] = 37 | useState("HIDDEN"); 38 | 39 | return ( 40 | <> 41 | 161 | 165 | 166 | ); 167 | }; 168 | 169 | const UnlockLeaderboardsSection = () => { 170 | const lessonsCompleted = useBoundStore((x) => x.lessonsCompleted); 171 | 172 | if (lessonsCompleted >= 10) { 173 | return null; 174 | } 175 | 176 | const lessonsNeededToUnlockLeaderboards = 10 - lessonsCompleted; 177 | 178 | return ( 179 |
180 |

Unlock Leaderboards!

181 |
182 | 183 |

184 | Complete {lessonsNeededToUnlockLeaderboards} more lesson 185 | {lessonsNeededToUnlockLeaderboards === 1 ? "" : "s"} to start 186 | competing 187 |

188 |
189 |
190 | ); 191 | }; 192 | 193 | const LeaderboardRankSection = () => { 194 | const xpThisWeek = useBoundStore((x) => x.xpThisWeek()); 195 | const rank = useLeaderboardRank(); 196 | const leaderboardLeague = "Bronze League"; 197 | return ( 198 |
199 |
200 |

{leaderboardLeague}

201 | 202 | View league 203 | 204 |
205 |
206 | 207 |
208 | {rank !== null && ( 209 |

210 | {`You're ranked #${rank}`} 211 |

212 | )} 213 |

214 | You earned {xpThisWeek} XP this week so far 215 |

216 |
217 |
218 |
219 | ); 220 | }; 221 | 222 | const DailyQuestsSection = () => { 223 | const xpToday = useBoundStore((x) => x.xpToday()); 224 | const goalXp = useBoundStore((x) => x.goalXp); 225 | return ( 226 |
227 |

Daily Quests

228 |
229 | 230 |
231 |

Earn {goalXp} XP

232 |
233 |
234 |
241 |
242 |
243 |
244 | {xpToday} / {goalXp} 245 |
246 |
247 | 248 |
249 |
250 |
251 |
252 | ); 253 | }; 254 | 255 | const LockedLeaderboardsSvg = () => { 256 | return ( 257 | 258 | 262 | 266 | 270 | 274 | 278 | 282 | 288 | 289 | ); 290 | }; 291 | 292 | const TreasureClosedSvg = (props: ComponentProps<"svg">) => { 293 | return ( 294 | 295 | 296 | 300 | 304 | 308 | 312 | 316 | 320 | 324 | 328 | 332 | 336 | 340 | 344 | 348 | 352 | 356 | 360 | 364 | 365 | 372 | 376 | 377 | 378 | 379 | 380 | 381 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 483 | 484 | 485 | 486 | 487 | 488 | 489 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 507 | 508 | 509 | 510 | 511 | 512 | 513 | 519 | 520 | 521 | 522 | 523 | 524 | 525 | 531 | 532 | 533 | 534 | 535 | 536 | 537 | 543 | 544 | 545 | 546 | 547 | 548 | 549 | 555 | 556 | 557 | 558 | 559 | 560 | 561 | 567 | 568 | 569 | 570 | 571 | 572 | 573 | 579 | 580 | 581 | 582 | 583 | ); 584 | }; 585 | 586 | const XpProgressSection = () => { 587 | const xpToday = useBoundStore((x) => x.xpToday()); 588 | const goalXp = useBoundStore((x) => x.goalXp); 589 | return ( 590 |
591 |
592 |

XP Progress

593 | 594 | Edit goal 595 | 596 |
597 |
598 | 599 |
600 |

Daily goal

601 |
602 |
603 | {xpToday > 0 && ( 604 |
608 |
609 |
610 | )} 611 |
612 |
613 | {xpToday}/{goalXp} XP 614 |
615 |
616 |
617 |
618 |
619 | ); 620 | }; 621 | 622 | const CreateAProfileSection = ({ 623 | setLoginScreenState, 624 | }: { 625 | setLoginScreenState: React.Dispatch>; 626 | }) => { 627 | return ( 628 |
629 |

Create a profile to save your progress!

630 | 636 | 642 |
643 | ); 644 | }; 645 | -------------------------------------------------------------------------------- /src/components/SettingsRightNav.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import React from "react"; 3 | import { useBoundStore } from "~/hooks/useBoundStore"; 4 | 5 | type SettingsTitle = ReturnType[number]["title"]; 6 | 7 | const useSettingsPages = () => { 8 | const loggedIn = useBoundStore((x) => x.loggedIn); 9 | return loggedIn 10 | ? ([ 11 | { title: "Account", href: "/settings/account" }, 12 | { title: "Sound", href: "/settings/sound" }, 13 | { title: "Edit Daily Goal", href: "/settings/coach" }, 14 | ] as const) 15 | : ([ 16 | { title: "Sound", href: "/settings/sound" }, 17 | { title: "Edit Daily Goal", href: "/settings/coach" }, 18 | ] as const); 19 | }; 20 | 21 | export const SettingsRightNav = ({ 22 | selectedTab, 23 | }: { 24 | selectedTab: SettingsTitle; 25 | }) => { 26 | const settingsPages = useSettingsPages(); 27 | return ( 28 |
29 | {settingsPages.map(({ title, href }) => { 30 | return ( 31 | 39 | {title} 40 | 41 | ); 42 | })} 43 |
44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /src/components/TopBar.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import Link from "next/link"; 3 | import type { ComponentProps } from "react"; 4 | import React, { useState } from "react"; 5 | import { useBoundStore } from "~/hooks/useBoundStore"; 6 | import { Calendar } from "./Calendar"; 7 | import { Flag } from "./Flag"; 8 | import { 9 | FireSvg, 10 | GemSvg, 11 | GlobeIconSvg, 12 | LingotsTreasureChestSvg, 13 | MoreOptionsSvg, 14 | PodcastIconSvg, 15 | } from "./Svgs"; 16 | 17 | const EmptyFireTopBarSvg = (props: ComponentProps<"svg">) => { 18 | return ( 19 | 20 | 21 | 27 | 28 | 29 | ); 30 | }; 31 | 32 | const EmptyGemTopBarSvg = (props: ComponentProps<"svg">) => { 33 | return ( 34 | 35 | 36 | 42 | 43 | 44 | ); 45 | }; 46 | 47 | const AddLanguageSvg = (props: ComponentProps<"svg">) => { 48 | return ( 49 | 50 | 51 | 52 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | ); 63 | }; 64 | 65 | type MenuState = "HIDDEN" | "LANGUAGES" | "STREAK" | "GEMS" | "MORE"; 66 | 67 | export const TopBar = ({ 68 | backgroundColor = "bg-[#58cc02]", 69 | borderColor = "border-[#46a302]", 70 | }: { 71 | backgroundColor?: `bg-${string}`; 72 | borderColor?: `border-${string}`; 73 | }) => { 74 | const [menu, setMenu] = useState("HIDDEN"); 75 | const [now, setNow] = useState(dayjs()); 76 | const streak = useBoundStore((x) => x.streak); 77 | const lingots = useBoundStore((x) => x.lingots); 78 | const language = useBoundStore((x) => x.language); 79 | return ( 80 |
81 |
84 | 92 | 93 | 103 | 115 | setMenu((x) => (x === "MORE" ? "HIDDEN" : "MORE"))} 117 | role="button" 118 | tabIndex={0} 119 | aria-label="Toggle more menu" 120 | /> 121 | 122 |
129 | {((): null | JSX.Element => { 130 | switch (menu) { 131 | case "LANGUAGES": 132 | return ( 133 |
134 |
135 |
136 | 137 |
138 | {language.name} 139 |
140 | 144 |
145 | 146 |
147 | Courses 148 | 149 |
150 | ); 151 | 152 | case "STREAK": 153 | return ( 154 |
155 |

Streak

156 |

157 | {`Practice each day so your streak won't reset!`} 158 |

159 |
160 | 161 |
162 |
163 | ); 164 | 165 | case "GEMS": 166 | return ( 167 |
168 | 169 |
170 |

Lingots

171 |

172 | You have {lingots}{" "} 173 | {lingots === 1 ? "lingot" : "lingots"}. 174 |

175 | 179 | Go to shop 180 | 181 |
182 |
183 | ); 184 | 185 | case "MORE": 186 | return ( 187 |
188 | 194 | 195 | Podcast 196 | 197 | 203 | 204 | Schools 205 | 206 |
207 | ); 208 | 209 | case "HIDDEN": 210 | return null; 211 | } 212 | })()} 213 |
setMenu("HIDDEN")} 219 | aria-label="Hide menu" 220 | role="button" 221 | >
222 |
223 |
224 |
225 | ); 226 | }; 227 | -------------------------------------------------------------------------------- /src/env.mjs: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { createEnv } from "@t3-oss/env-nextjs"; 3 | 4 | export const env = createEnv({ 5 | /** 6 | * Specify your server-side environment variables schema here. This way you can ensure the app 7 | * isn't built with invalid env vars. 8 | */ 9 | server: { 10 | NODE_ENV: z.enum(["development", "test", "production"]), 11 | }, 12 | 13 | /** 14 | * Specify your client-side environment variables schema here. This way you can ensure the app 15 | * isn't built with invalid env vars. To expose them to the client, prefix them with 16 | * `NEXT_PUBLIC_`. 17 | */ 18 | client: { 19 | // NEXT_PUBLIC_CLIENTVAR: z.string().min(1), 20 | }, 21 | 22 | /** 23 | * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g. 24 | * middlewares) or client-side so we need to destruct manually. 25 | */ 26 | runtimeEnv: { 27 | NODE_ENV: process.env.NODE_ENV, 28 | // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR, 29 | }, 30 | }); 31 | -------------------------------------------------------------------------------- /src/hooks/useBoundStore.ts: -------------------------------------------------------------------------------- 1 | import type { StateCreator } from "zustand"; 2 | import { create } from "zustand"; 3 | import type { GoalXpSlice } from "~/stores/createGoalXpStore"; 4 | import { createGoalXpSlice } from "~/stores/createGoalXpStore"; 5 | import type { LanguageSlice } from "~/stores/createLanguageStore"; 6 | import { createLanguageSlice } from "~/stores/createLanguageStore"; 7 | import type { LessonSlice } from "~/stores/createLessonStore"; 8 | import { createLessonSlice } from "~/stores/createLessonStore"; 9 | import type { LingotSlice } from "~/stores/createLingotStore"; 10 | import { createLingotSlice } from "~/stores/createLingotStore"; 11 | import type { SoundSettingsSlice } from "~/stores/createSoundSettingsStore"; 12 | import { createSoundSettingsSlice } from "~/stores/createSoundSettingsStore"; 13 | import type { StreakSlice } from "~/stores/createStreakStore"; 14 | import { createStreakSlice } from "~/stores/createStreakStore"; 15 | import type { UserSlice } from "~/stores/createUserStore"; 16 | import { createUserSlice } from "~/stores/createUserStore"; 17 | import type { XpSlice } from "~/stores/createXpStore"; 18 | import { createXpSlice } from "~/stores/createXpStore"; 19 | 20 | type BoundState = GoalXpSlice & 21 | LanguageSlice & 22 | LessonSlice & 23 | LingotSlice & 24 | SoundSettingsSlice & 25 | StreakSlice & 26 | UserSlice & 27 | XpSlice; 28 | 29 | export type BoundStateCreator = StateCreator< 30 | BoundState, 31 | [], 32 | [], 33 | SliceState 34 | >; 35 | 36 | export const useBoundStore = create((...args) => ({ 37 | ...createGoalXpSlice(...args), 38 | ...createLanguageSlice(...args), 39 | ...createLessonSlice(...args), 40 | ...createLingotSlice(...args), 41 | ...createSoundSettingsSlice(...args), 42 | ...createStreakSlice(...args), 43 | ...createUserSlice(...args), 44 | ...createXpSlice(...args), 45 | })); 46 | -------------------------------------------------------------------------------- /src/hooks/useLeaderboard.ts: -------------------------------------------------------------------------------- 1 | import { fakeUsers } from "~/utils/fakeUsers"; 2 | import { useBoundStore } from "~/hooks/useBoundStore"; 3 | 4 | export const useLeaderboardUsers = () => { 5 | const xpThisWeek = useBoundStore((x) => x.xpThisWeek()); 6 | const name = useBoundStore((x) => x.name); 7 | const userInfo = { 8 | name, 9 | xp: xpThisWeek, 10 | isCurrentUser: true, 11 | } as const; 12 | return [...fakeUsers, userInfo].sort((a, b) => b.xp - a.xp); 13 | }; 14 | 15 | export const useLeaderboardRank = () => { 16 | const leaderboardUsers = useLeaderboardUsers(); 17 | const index = leaderboardUsers.findIndex((user) => user.isCurrentUser); 18 | return index === -1 ? null : index + 1; 19 | }; 20 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { type AppType } from "next/dist/shared/lib/utils"; 2 | import Head from "next/head"; 3 | 4 | import "~/styles/globals.css"; 5 | 6 | const MyApp: AppType = ({ Component, pageProps }) => { 7 | return ( 8 | <> 9 | 10 | React Duolingo Clone 11 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ); 22 | }; 23 | 24 | export default MyApp; 25 | -------------------------------------------------------------------------------- /src/pages/forgot-password.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from "next"; 2 | import Link from "next/link"; 3 | import type { ComponentProps } from "react"; 4 | import React, { useState } from "react"; 5 | import { LanguageDropDown } from "~/components/LanguageDropDown"; 6 | import type { LoginScreenState } from "~/components/LoginScreen"; 7 | import { LoginScreen } from "~/components/LoginScreen"; 8 | 9 | const MenuIconSvg = (props: ComponentProps<"svg">) => { 10 | return ( 11 | 12 | Artboard 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ); 22 | }; 23 | 24 | const ForgotPassword: NextPage = () => { 25 | const [loginScreenState, setLoginScreenState] = 26 | useState("HIDDEN"); 27 | const [mobileMenuShown, setMobileMenuShown] = useState(false); 28 | return ( 29 |
30 |
31 |
32 | 33 | duolingo 34 | 35 |
36 | 37 | 43 | 47 | Get started 48 | 49 |
50 |
setMobileMenuShown((x) => !x)} 53 | role="button" 54 | tabIndex={0} 55 | > 56 |
77 |
78 |
79 |
80 |

81 | Forgot password 82 |

83 |

84 | We will send you instructions on how to reset your password by email. 85 |

86 |
87 | 91 | 94 |
95 |
96 | 100 |
101 | ); 102 | }; 103 | 104 | export default ForgotPassword; 105 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { type NextPage } from "next"; 2 | import Link from "next/link"; 3 | import { GlobeSvg } from "~/components/Svgs"; 4 | import React from "react"; 5 | import { LanguageHeader } from "~/components/LanguageHeader"; 6 | import { useLoginScreen, LoginScreen } from "~/components/LoginScreen"; 7 | import _bgSnow from "../../public/bg-snow.svg"; 8 | import type { StaticImageData } from "next/image"; 9 | import { LanguageCarousel } from "~/components/LanguageCarousel"; 10 | 11 | const bgSnow = _bgSnow as StaticImageData; 12 | 13 | const Home: NextPage = () => { 14 | const { loginScreenState, setLoginScreenState } = useLoginScreen(); 15 | return ( 16 |
20 | 21 |
22 | 23 |
24 |

25 | The free, fun, and effective way to learn a language! 26 |

27 |
28 | 32 | Get started 33 | 34 | 40 |
41 |
42 |
43 | 44 | 48 |
49 | ); 50 | }; 51 | 52 | export default Home; 53 | -------------------------------------------------------------------------------- /src/pages/leaderboard.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from "next"; 2 | import React, { useEffect } from "react"; 3 | import { LeftBar } from "~/components/LeftBar"; 4 | import { BottomBar } from "~/components/BottomBar"; 5 | import { useBoundStore } from "~/hooks/useBoundStore"; 6 | import Link from "next/link"; 7 | import { 8 | BronzeLeagueSvg, 9 | FirstPlaceSvg, 10 | LeaderboardBannerSvg, 11 | LeaderboardExplanationSvg, 12 | LockedLeaderboardSvg, 13 | LockedLeagueSvg, 14 | SecondPlaceSvg, 15 | ThirdPlaceSvg, 16 | } from "~/components/Svgs"; 17 | import dayjs from "dayjs"; 18 | import { useRouter } from "next/router"; 19 | import { useLeaderboardUsers } from "~/hooks/useLeaderboard"; 20 | import Image from "next/image"; 21 | 22 | const LeaderboardExplanationSection = () => { 23 | return ( 24 |
25 |
26 |

27 | What are leaderboards? 28 |

29 |

Do lessons. Earn XP. Compete.

30 |

31 | Earn XP through lessons, then compete with players in a weekly 32 | leaderboard 33 |

34 |
35 | 36 |
37 | 38 | 39 |
40 | ); 41 | }; 42 | 43 | type TimeLeftUnit = "days" | "hours" | "minutes"; 44 | 45 | const timeUntilStartOfWeek = (units: TimeLeftUnit): number => { 46 | const startOfWeekDay = 0; 47 | const startOfWeekHour = 20; 48 | const daysAhead = 49 | dayjs().day() === startOfWeekDay && dayjs().hour() < startOfWeekHour 50 | ? 0 51 | : 7 - dayjs().day(); 52 | const startOfWeek = dayjs() 53 | .startOf("day") 54 | .add(startOfWeekHour, "hours") 55 | .add(daysAhead, "day"); 56 | return startOfWeek.diff(dayjs(), units); 57 | }; 58 | 59 | const timeLeft = (): `${number} ${TimeLeftUnit}` => { 60 | if (timeUntilStartOfWeek("days") > 0) { 61 | return `${timeUntilStartOfWeek("days")} days`; 62 | } 63 | if (timeUntilStartOfWeek("hours") > 0) { 64 | return `${timeUntilStartOfWeek("hours")} hours`; 65 | } 66 | return `${timeUntilStartOfWeek("minutes")} minutes`; 67 | }; 68 | 69 | const defaultPicture = "https://placekitten.com/100/100"; 70 | 71 | const LeaderboardProfile = ({ 72 | place, 73 | name, 74 | xp, 75 | isCurrentUser, 76 | }: { 77 | place: number; 78 | name: string; 79 | xp: number; 80 | isCurrentUser: boolean; 81 | }) => { 82 | return ( 83 |
89 |
90 | {place === 1 ? ( 91 | 92 | ) : place === 2 ? ( 93 | 94 | ) : place === 3 ? ( 95 | 96 | ) : ( 97 |
98 | {place} 99 |
100 | )} 101 | 108 |
109 |
110 | {name} 111 |
112 |
{`${xp} XP`}
113 |
114 | ); 115 | }; 116 | 117 | const Leaderboard: NextPage = () => { 118 | const router = useRouter(); 119 | const loggedIn = useBoundStore((x) => x.loggedIn); 120 | 121 | const lessonsCompleted = useBoundStore((x) => x.lessonsCompleted); 122 | 123 | useEffect(() => { 124 | if (!loggedIn) { 125 | void router.push("/"); 126 | } 127 | }, [loggedIn, router]); 128 | 129 | const lessonsToUnlockLeaderboard = 10; 130 | const lessonsRemainingToUnlockLeaderboard = 131 | lessonsToUnlockLeaderboard - lessonsCompleted; 132 | const leaderboardIsUnlocked = lessonsCompleted >= lessonsToUnlockLeaderboard; 133 | 134 | const leaderboardLeague = "Bronze League"; 135 | 136 | const leaderboardUsers = useLeaderboardUsers(); 137 | 138 | return ( 139 |
140 | 141 |
142 |
143 | {!leaderboardIsUnlocked && ( 144 | <> 145 | 146 |

147 | Unlock Leaderboards! 148 |

149 |

150 | Complete {lessonsRemainingToUnlockLeaderboard} more lesson 151 | {lessonsRemainingToUnlockLeaderboard === 1 ? "" : "s"} to start 152 | competing 153 |

154 | 158 | Start a lesson 159 | 160 |
161 | 162 | 163 | )} 164 | {leaderboardIsUnlocked && ( 165 | <> 166 |
167 |
168 | 169 | 170 | 171 | 172 | 173 |
174 |

{leaderboardLeague}

175 |
176 |

177 | Top 20 advance to the next league 178 |

179 | 182 |
183 |
184 |
185 |
186 | {leaderboardUsers.map((user, i) => { 187 | return ( 188 | 195 | ); 196 | })} 197 |
198 | 199 | )} 200 |
201 | {!leaderboardIsUnlocked && } 202 |
203 | 204 |
205 | ); 206 | }; 207 | 208 | export default Leaderboard; 209 | -------------------------------------------------------------------------------- /src/pages/learn.tsx: -------------------------------------------------------------------------------- 1 | import { type NextPage } from "next"; 2 | import Link from "next/link"; 3 | import { Fragment, useCallback, useEffect, useRef, useState } from "react"; 4 | import { 5 | ActiveBookSvg, 6 | LockedBookSvg, 7 | CheckmarkSvg, 8 | LockedDumbbellSvg, 9 | FastForwardSvg, 10 | GoldenBookSvg, 11 | GoldenDumbbellSvg, 12 | GoldenTreasureSvg, 13 | GoldenTrophySvg, 14 | GuidebookSvg, 15 | LessonCompletionSvg0, 16 | LessonCompletionSvg1, 17 | LessonCompletionSvg2, 18 | LessonCompletionSvg3, 19 | LockSvg, 20 | StarSvg, 21 | LockedTreasureSvg, 22 | LockedTrophySvg, 23 | UpArrowSvg, 24 | ActiveTreasureSvg, 25 | ActiveTrophySvg, 26 | ActiveDumbbellSvg, 27 | PracticeExerciseSvg, 28 | } from "~/components/Svgs"; 29 | import { TopBar } from "~/components/TopBar"; 30 | import { BottomBar } from "~/components/BottomBar"; 31 | import { RightBar } from "~/components/RightBar"; 32 | import { LeftBar } from "~/components/LeftBar"; 33 | import { useRouter } from "next/router"; 34 | import { LoginScreen, useLoginScreen } from "~/components/LoginScreen"; 35 | import { useBoundStore } from "~/hooks/useBoundStore"; 36 | import type { Tile, TileType, Unit } from "~/utils/units"; 37 | import { units } from "~/utils/units"; 38 | 39 | type TileStatus = "LOCKED" | "ACTIVE" | "COMPLETE"; 40 | 41 | const tileStatus = (tile: Tile, lessonsCompleted: number): TileStatus => { 42 | const lessonsPerTile = 4; 43 | const tilesCompleted = Math.floor(lessonsCompleted / lessonsPerTile); 44 | const tiles = units.flatMap((unit) => unit.tiles); 45 | const tileIndex = tiles.findIndex((t) => t === tile); 46 | 47 | if (tileIndex < tilesCompleted) { 48 | return "COMPLETE"; 49 | } 50 | if (tileIndex > tilesCompleted) { 51 | return "LOCKED"; 52 | } 53 | return "ACTIVE"; 54 | }; 55 | 56 | const TileIcon = ({ 57 | tileType, 58 | status, 59 | }: { 60 | tileType: TileType; 61 | status: TileStatus; 62 | }): JSX.Element => { 63 | switch (tileType) { 64 | case "star": 65 | return status === "COMPLETE" ? ( 66 | 67 | ) : status === "ACTIVE" ? ( 68 | 69 | ) : ( 70 | 71 | ); 72 | case "book": 73 | return status === "COMPLETE" ? ( 74 | 75 | ) : status === "ACTIVE" ? ( 76 | 77 | ) : ( 78 | 79 | ); 80 | case "dumbbell": 81 | return status === "COMPLETE" ? ( 82 | 83 | ) : status === "ACTIVE" ? ( 84 | 85 | ) : ( 86 | 87 | ); 88 | case "fast-forward": 89 | return status === "COMPLETE" ? ( 90 | 91 | ) : status === "ACTIVE" ? ( 92 | 93 | ) : ( 94 | 95 | ); 96 | case "treasure": 97 | return status === "COMPLETE" ? ( 98 | 99 | ) : status === "ACTIVE" ? ( 100 | 101 | ) : ( 102 | 103 | ); 104 | case "trophy": 105 | return status === "COMPLETE" ? ( 106 | 107 | ) : status === "ACTIVE" ? ( 108 | 109 | ) : ( 110 | 111 | ); 112 | } 113 | }; 114 | 115 | const tileLeftClassNames = [ 116 | "left-0", 117 | "left-[-45px]", 118 | "left-[-70px]", 119 | "left-[-45px]", 120 | "left-0", 121 | "left-[45px]", 122 | "left-[70px]", 123 | "left-[45px]", 124 | ] as const; 125 | 126 | type TileLeftClassName = (typeof tileLeftClassNames)[number]; 127 | 128 | const getTileLeftClassName = ({ 129 | index, 130 | unitNumber, 131 | tilesLength, 132 | }: { 133 | index: number; 134 | unitNumber: number; 135 | tilesLength: number; 136 | }): TileLeftClassName => { 137 | if (index >= tilesLength - 1) { 138 | return "left-0"; 139 | } 140 | 141 | const classNames = 142 | unitNumber % 2 === 1 143 | ? tileLeftClassNames 144 | : [...tileLeftClassNames.slice(4), ...tileLeftClassNames.slice(0, 4)]; 145 | 146 | return classNames[index % classNames.length] ?? "left-0"; 147 | }; 148 | 149 | const tileTooltipLeftOffsets = [140, 95, 70, 95, 140, 185, 210, 185] as const; 150 | 151 | type TileTooltipLeftOffset = (typeof tileTooltipLeftOffsets)[number]; 152 | 153 | const getTileTooltipLeftOffset = ({ 154 | index, 155 | unitNumber, 156 | tilesLength, 157 | }: { 158 | index: number; 159 | unitNumber: number; 160 | tilesLength: number; 161 | }): TileTooltipLeftOffset => { 162 | if (index >= tilesLength - 1) { 163 | return tileTooltipLeftOffsets[0]; 164 | } 165 | 166 | const offsets = 167 | unitNumber % 2 === 1 168 | ? tileTooltipLeftOffsets 169 | : [ 170 | ...tileTooltipLeftOffsets.slice(4), 171 | ...tileTooltipLeftOffsets.slice(0, 4), 172 | ]; 173 | 174 | return offsets[index % offsets.length] ?? tileTooltipLeftOffsets[0]; 175 | }; 176 | 177 | const getTileColors = ({ 178 | tileType, 179 | status, 180 | defaultColors, 181 | }: { 182 | tileType: TileType; 183 | status: TileStatus; 184 | defaultColors: `border-${string} bg-${string}`; 185 | }): `border-${string} bg-${string}` => { 186 | switch (status) { 187 | case "LOCKED": 188 | if (tileType === "fast-forward") return defaultColors; 189 | return "border-[#b7b7b7] bg-[#e5e5e5]"; 190 | case "COMPLETE": 191 | return "border-yellow-500 bg-yellow-400"; 192 | case "ACTIVE": 193 | return defaultColors; 194 | } 195 | }; 196 | 197 | const TileTooltip = ({ 198 | selectedTile, 199 | index, 200 | unitNumber, 201 | tilesLength, 202 | description, 203 | status, 204 | closeTooltip, 205 | }: { 206 | selectedTile: number | null; 207 | index: number; 208 | unitNumber: number; 209 | tilesLength: number; 210 | description: string; 211 | status: TileStatus; 212 | closeTooltip: () => void; 213 | }) => { 214 | const tileTooltipRef = useRef(null); 215 | 216 | useEffect(() => { 217 | const containsTileTooltip = (event: MouseEvent) => { 218 | if (selectedTile !== index) return; 219 | const clickIsInsideTooltip = tileTooltipRef.current?.contains( 220 | event.target as Node, 221 | ); 222 | if (clickIsInsideTooltip) return; 223 | closeTooltip(); 224 | }; 225 | 226 | window.addEventListener("click", containsTileTooltip, true); 227 | return () => window.removeEventListener("click", containsTileTooltip, true); 228 | }, [selectedTile, tileTooltipRef, closeTooltip, index]); 229 | 230 | const unit = units.find((unit) => unit.unitNumber === unitNumber); 231 | const activeBackgroundColor = unit?.backgroundColor ?? "bg-green-500"; 232 | const activeTextColor = unit?.textColor ?? "text-green-500"; 233 | 234 | return ( 235 |
242 |
254 |
267 |
277 | {description} 278 |
279 | {status === "ACTIVE" ? ( 280 | 287 | Start +10 XP 288 | 289 | ) : status === "LOCKED" ? ( 290 | 296 | ) : ( 297 | 301 | Practice +5 XP 302 | 303 | )} 304 |
305 |
306 | ); 307 | }; 308 | 309 | const UnitSection = ({ unit }: { unit: Unit }): JSX.Element => { 310 | const router = useRouter(); 311 | 312 | const [selectedTile, setSelectedTile] = useState(null); 313 | 314 | useEffect(() => { 315 | const unselectTile = () => setSelectedTile(null); 316 | window.addEventListener("scroll", unselectTile); 317 | return () => window.removeEventListener("scroll", unselectTile); 318 | }, []); 319 | 320 | const closeTooltip = useCallback(() => setSelectedTile(null), []); 321 | 322 | const lessonsCompleted = useBoundStore((x) => x.lessonsCompleted); 323 | const increaseLessonsCompleted = useBoundStore( 324 | (x) => x.increaseLessonsCompleted, 325 | ); 326 | const increaseLingots = useBoundStore((x) => x.increaseLingots); 327 | 328 | return ( 329 | <> 330 | 336 |
337 | {unit.tiles.map((tile, i): JSX.Element => { 338 | const status = tileStatus(tile, lessonsCompleted); 339 | return ( 340 | 341 | {(() => { 342 | switch (tile.type) { 343 | case "star": 344 | case "book": 345 | case "dumbbell": 346 | case "trophy": 347 | case "fast-forward": 348 | if (tile.type === "trophy" && status === "COMPLETE") { 349 | return ( 350 |
351 | 352 |
353 | {unit.unitNumber} 354 |
355 |
356 | ); 357 | } 358 | return ( 359 |
369 | {tile.type === "fast-forward" && status === "LOCKED" ? ( 370 | 374 | ) : selectedTile !== i && status === "ACTIVE" ? ( 375 | 376 | ) : null} 377 | 381 | 406 |
407 | ); 408 | case "treasure": 409 | return ( 410 |
{ 420 | if (status === "ACTIVE") { 421 | increaseLessonsCompleted(4); 422 | increaseLingots(1); 423 | } 424 | }} 425 | role="button" 426 | tabIndex={status === "ACTIVE" ? 0 : undefined} 427 | aria-hidden={status !== "ACTIVE"} 428 | aria-label={status === "ACTIVE" ? "Collect reward" : ""} 429 | > 430 | {status === "ACTIVE" && ( 431 | 432 | )} 433 | 434 |
435 | ); 436 | } 437 | })()} 438 | { 444 | switch (tile.type) { 445 | case "book": 446 | case "dumbbell": 447 | case "star": 448 | return tile.description; 449 | case "fast-forward": 450 | return status === "LOCKED" 451 | ? "Jump here?" 452 | : tile.description; 453 | case "trophy": 454 | return `Unit ${unit.unitNumber} review`; 455 | case "treasure": 456 | return ""; 457 | } 458 | })()} 459 | status={status} 460 | closeTooltip={closeTooltip} 461 | /> 462 |
463 | ); 464 | })} 465 |
466 | 467 | ); 468 | }; 469 | 470 | const getTopBarColors = ( 471 | scrollY: number, 472 | ): { 473 | backgroundColor: `bg-${string}`; 474 | borderColor: `border-${string}`; 475 | } => { 476 | const defaultColors = { 477 | backgroundColor: "bg-[#58cc02]", 478 | borderColor: "border-[#46a302]", 479 | } as const; 480 | 481 | if (scrollY < 680) { 482 | return defaultColors; 483 | } else if (scrollY < 1830) { 484 | return units[1] ?? defaultColors; 485 | } else { 486 | return units[2] ?? defaultColors; 487 | } 488 | }; 489 | 490 | const Learn: NextPage = () => { 491 | const { loginScreenState, setLoginScreenState } = useLoginScreen(); 492 | 493 | const [scrollY, setScrollY] = useState(0); 494 | useEffect(() => { 495 | const updateScrollY = () => setScrollY(globalThis.scrollY ?? scrollY); 496 | updateScrollY(); 497 | document.addEventListener("scroll", updateScrollY); 498 | return () => document.removeEventListener("scroll", updateScrollY); 499 | }, [scrollY]); 500 | 501 | const topBarColors = getTopBarColors(scrollY); 502 | 503 | return ( 504 | <> 505 | 509 | 510 | 511 |
512 |
513 | {units.map((unit) => ( 514 | 515 | ))} 516 |
517 | 521 | Practice exercise 522 | 523 | 524 | {scrollY > 100 && ( 525 | 532 | )} 533 |
534 |
535 | 536 |
537 | 538 |
539 | 540 | 541 | 545 | 546 | ); 547 | }; 548 | 549 | export default Learn; 550 | 551 | const LessonCompletionSvg = ({ 552 | lessonsCompleted, 553 | status, 554 | style = {}, 555 | }: { 556 | lessonsCompleted: number; 557 | status: TileStatus; 558 | style?: React.HTMLAttributes["style"]; 559 | }) => { 560 | if (status !== "ACTIVE") { 561 | return null; 562 | } 563 | switch (lessonsCompleted % 4) { 564 | case 0: 565 | return ; 566 | case 1: 567 | return ; 568 | case 2: 569 | return ; 570 | case 3: 571 | return ; 572 | default: 573 | return null; 574 | } 575 | }; 576 | 577 | const HoverLabel = ({ 578 | text, 579 | textColor, 580 | }: { 581 | text: string; 582 | textColor: `text-${string}`; 583 | }) => { 584 | const hoverElement = useRef(null); 585 | const [width, setWidth] = useState(72); 586 | 587 | useEffect(() => { 588 | setWidth(hoverElement.current?.clientWidth ?? width); 589 | }, [hoverElement.current?.clientWidth, width]); 590 | 591 | return ( 592 |
600 | {text} 601 |
605 |
606 | ); 607 | }; 608 | 609 | const UnitHeader = ({ 610 | unitNumber, 611 | description, 612 | backgroundColor, 613 | borderColor, 614 | }: { 615 | unitNumber: number; 616 | description: string; 617 | backgroundColor: `bg-${string}`; 618 | borderColor: `border-${string}`; 619 | }) => { 620 | const language = useBoundStore((x) => x.language); 621 | return ( 622 |
627 |
628 |
629 |

Unit {unitNumber}

630 |

{description}

631 |
632 | 639 | 640 | 641 | Guidebook 642 | 643 | 644 |
645 |
646 | ); 647 | }; 648 | -------------------------------------------------------------------------------- /src/pages/profile.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from "next"; 2 | import { BottomBar } from "~/components/BottomBar"; 3 | import { LeftBar } from "~/components/LeftBar"; 4 | import { 5 | BronzeLeagueSvg, 6 | EditPencilSvg, 7 | EmptyFireSvg, 8 | FireSvg, 9 | LightningProgressSvg, 10 | EmptyMedalSvg, 11 | ProfileFriendsSvg, 12 | ProfileTimeJoinedSvg, 13 | SettingsGearSvg, 14 | } from "~/components/Svgs"; 15 | import Link from "next/link"; 16 | import { Flag } from "~/components/Flag"; 17 | import { useBoundStore } from "~/hooks/useBoundStore"; 18 | import { useEffect, useState } from "react"; 19 | import { useRouter } from "next/router"; 20 | 21 | const Profile: NextPage = () => { 22 | return ( 23 |
24 | 25 | 26 |
27 |
28 | 29 | 30 | 31 |
32 |
33 |
34 | 35 |
36 | ); 37 | }; 38 | 39 | export default Profile; 40 | 41 | const ProfileTopBar = () => { 42 | return ( 43 |
44 |
45 | 46 |
47 | Profile 48 | 49 | 50 | Settings 51 | 52 |
53 | ); 54 | }; 55 | 56 | const ProfileTopSection = () => { 57 | const router = useRouter(); 58 | const loggedIn = useBoundStore((x) => x.loggedIn); 59 | const name = useBoundStore((x) => x.name); 60 | const username = useBoundStore((x) => x.username); 61 | const joinedAt = useBoundStore((x) => x.joinedAt).format("MMMM YYYY"); 62 | const followingCount = 0; 63 | const followersCount = 0; 64 | const language = useBoundStore((x) => x.language); 65 | 66 | useEffect(() => { 67 | if (!loggedIn) { 68 | void router.push("/"); 69 | } 70 | }, [loggedIn, router]); 71 | 72 | return ( 73 |
74 |
75 | {username.charAt(0).toUpperCase()} 76 |
77 |
78 |
79 |
80 |

{name}

81 |
{username}
82 |
83 |
84 | 85 | {`Joined ${joinedAt}`} 86 |
87 |
88 | 89 | {`${followingCount} Following / ${followersCount} Followers`} 90 |
91 |
92 | 93 | 94 |
95 | 99 | 100 | Edit profile 101 | 102 |
103 | ); 104 | }; 105 | 106 | const ProfileStatsSection = () => { 107 | const streak = useBoundStore((x) => x.streak); 108 | const totalXp = 125; 109 | const league = "Bronze"; 110 | const top3Finishes = 0; 111 | 112 | return ( 113 |
114 |

Statistics

115 |
116 |
117 | {streak === 0 ? : } 118 |
119 | 125 | {streak} 126 | 127 | 128 | Day streak 129 | 130 |
131 |
132 |
133 | 134 |
135 | {totalXp} 136 | Total XP 137 |
138 |
139 |
140 | 141 |
142 | {league} 143 | 144 | Current league 145 | 146 |
147 |
148 |
149 | {top3Finishes === 0 ? : } 150 |
151 | 157 | {top3Finishes} 158 | 159 | 160 | Top 3 finishes 161 | 162 |
163 |
164 |
165 |
166 | ); 167 | }; 168 | 169 | const ProfileFriendsSection = () => { 170 | const [state, setState] = useState<"FOLLOWING" | "FOLLOWERS">("FOLLOWING"); 171 | return ( 172 |
173 |

Friends

174 |
175 |
176 | 187 | 198 |
199 |
200 | {state === "FOLLOWING" 201 | ? "Not following anyone yet" 202 | : "No followers yet"} 203 |
204 |
205 |
206 | ); 207 | }; 208 | -------------------------------------------------------------------------------- /src/pages/register.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from "next"; 2 | import Link from "next/link"; 3 | import languages from "~/utils/languages"; 4 | import { LanguageHeader } from "~/components/LanguageHeader"; 5 | import { useBoundStore } from "~/hooks/useBoundStore"; 6 | import { Flag } from "~/components/Flag"; 7 | import _bgSnow from "../../public/bg-snow.svg"; 8 | import type { StaticImageData } from "next/image"; 9 | 10 | const bgSnow = _bgSnow as StaticImageData; 11 | 12 | const Register: NextPage = () => { 13 | const setLanguage = useBoundStore((x) => x.setLanguage); 14 | return ( 15 |
19 | 20 |
21 |

22 | I want to learn... 23 |

24 |
25 | {languages.map((language) => ( 26 | setLanguage(language)} 33 | > 34 | 35 | {language.name} 36 | 37 | ))} 38 |
39 |
40 |
41 | ); 42 | }; 43 | 44 | export default Register; 45 | -------------------------------------------------------------------------------- /src/pages/settings/account.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from "next"; 2 | import React, { useState } from "react"; 3 | import { BottomBar } from "~/components/BottomBar"; 4 | import { LeftBar } from "~/components/LeftBar"; 5 | import { TopBar } from "~/components/TopBar"; 6 | import { SettingsRightNav } from "~/components/SettingsRightNav"; 7 | import { useBoundStore } from "~/hooks/useBoundStore"; 8 | 9 | const Account: NextPage = () => { 10 | const name = useBoundStore((x) => x.name); 11 | const setName = useBoundStore((x) => x.setName); 12 | const [localName, setLocalName] = useState(name); 13 | 14 | const username = useBoundStore((x) => x.username); 15 | const setUsername = useBoundStore((x) => x.setUsername); 16 | const [localUsername, setLocalUsername] = useState(username); 17 | 18 | const accountOptions = [ 19 | { title: "Name", value: localName, setValue: setLocalName }, 20 | { title: "Username", value: localUsername, setValue: setLocalUsername }, 21 | ]; 22 | 23 | return ( 24 |
25 | 26 | 27 | 28 |
29 |
30 |

31 | Account 32 |

33 | 43 |
44 |
45 |
46 | {accountOptions.map(({ title, value, setValue }) => { 47 | return ( 48 |
52 |
{title}
53 | setValue(e.target.value)} 57 | /> 58 |
59 | ); 60 | })} 61 |
62 | 63 |
64 |
65 |
66 | ); 67 | }; 68 | 69 | export default Account; 70 | -------------------------------------------------------------------------------- /src/pages/settings/coach.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from "next"; 2 | import type { ComponentProps } from "react"; 3 | import React, { useState } from "react"; 4 | import { BottomBar } from "~/components/BottomBar"; 5 | import { LeftBar } from "~/components/LeftBar"; 6 | import { TopBar } from "~/components/TopBar"; 7 | import { useBoundStore } from "~/hooks/useBoundStore"; 8 | import { SettingsRightNav } from "~/components/SettingsRightNav"; 9 | 10 | const CoachSvg = (props: ComponentProps<"svg">) => { 11 | return ( 12 | 13 | owl-coach 14 | 15 | 16 | 21 | 26 | 30 | 35 | 40 | 44 | 48 | 52 | 56 | 60 | 65 | 69 | 73 | 77 | 82 | 86 | 92 | 96 | 101 | 106 | 110 | 114 | 118 | 125 | 126 | 127 | 128 | ); 129 | }; 130 | 131 | const goalXpOptions = [ 132 | { title: "Basic", xp: 1 }, 133 | { title: "Casual", xp: 10 }, 134 | { title: "Regular", xp: 20 }, 135 | { title: "Serious", xp: 30 }, 136 | { title: "Intense", xp: 50 }, 137 | ] as const; 138 | 139 | const Coach: NextPage = () => { 140 | const goalXp = useBoundStore((x) => x.goalXp); 141 | const setGoalXp = useBoundStore((x) => x.setGoalXp); 142 | 143 | const [localGoalXp, setLocalGoalXp] = useState(goalXp); 144 | return ( 145 |
146 | 147 | 148 | 149 |
150 |
151 |

152 | Edit Daily Goal 153 |

154 | 161 |
162 |
163 |
164 |

165 | Coach here! Selecting a daily goal will help you stay motivated 166 | while learning a language. You can change your goal at any time. 167 |

168 |
169 | 170 |
171 | {goalXpOptions.map(({ title, xp }, i) => { 172 | return ( 173 | 189 | ); 190 | })} 191 |
192 |
193 |
194 | 195 |
196 |
197 |
198 | ); 199 | }; 200 | 201 | export default Coach; 202 | -------------------------------------------------------------------------------- /src/pages/settings/sound.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from "next"; 2 | import React, { useState } from "react"; 3 | import { BottomBar } from "~/components/BottomBar"; 4 | import { LeftBar } from "~/components/LeftBar"; 5 | import { TopBar } from "~/components/TopBar"; 6 | import { SettingsRightNav } from "~/components/SettingsRightNav"; 7 | import { useBoundStore } from "~/hooks/useBoundStore"; 8 | 9 | const Sound: NextPage = () => { 10 | const soundEffects = useBoundStore((x) => x.soundEffects); 11 | const setSoundEffects = useBoundStore((x) => x.setSoundEffects); 12 | const [localSoundEffects, setLocalSoundEffects] = useState(soundEffects); 13 | 14 | const speakingExercises = useBoundStore((x) => x.speakingExercises); 15 | const setSpeakingExercises = useBoundStore((x) => x.setSpeakingExercises); 16 | const [localSpeakingExercises, setLocalSpeakingExercises] = 17 | useState(speakingExercises); 18 | 19 | const listeningExercises = useBoundStore((x) => x.listeningExercises); 20 | const setListeningExercises = useBoundStore((x) => x.setListeningExercises); 21 | const [localListeningExercises, setLocalListeningExercises] = 22 | useState(listeningExercises); 23 | 24 | const soundOptions = [ 25 | { 26 | title: "Sound effects", 27 | value: localSoundEffects, 28 | setValue: setLocalSoundEffects, 29 | }, 30 | { 31 | title: "Speaking exercises", 32 | value: localSpeakingExercises, 33 | setValue: setLocalSpeakingExercises, 34 | }, 35 | { 36 | title: "Listening exercises", 37 | value: localListeningExercises, 38 | setValue: setLocalListeningExercises, 39 | }, 40 | ]; 41 | 42 | return ( 43 |
44 | 45 | 46 | 47 |
48 |
49 |

Sound

50 | 65 |
66 |
67 |
68 | {soundOptions.map(({ title, value, setValue }) => { 69 | return ( 70 |
74 |
{title}
75 | 100 |
101 | ); 102 | })} 103 |
104 | 105 |
106 |
107 |
108 | ); 109 | }; 110 | 111 | export default Sound; 112 | -------------------------------------------------------------------------------- /src/pages/shop.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from "next"; 2 | import type { ComponentProps } from "react"; 3 | import React from "react"; 4 | 5 | import { BottomBar } from "~/components/BottomBar"; 6 | import { LeftBar } from "~/components/LeftBar"; 7 | import { RightBar } from "~/components/RightBar"; 8 | import { TopBar } from "~/components/TopBar"; 9 | 10 | const StreakFreezeSvg = (props: ComponentProps<"svg">) => { 11 | return ( 12 | 13 | 14 | 15 | 19 | 23 | 27 | 28 | 29 | 30 | 34 | 39 | 40 | 41 | 42 | 47 | 56 | 61 | 70 | 71 | 72 | 73 | ); 74 | }; 75 | 76 | const EmptyGemSvg = (props: ComponentProps<"svg">) => { 77 | return ( 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | ); 86 | }; 87 | 88 | const DoubleOrNothingSvg = (props: ComponentProps<"svg">) => { 89 | return ( 90 | 91 | double_or_nothing 92 | 93 | 94 | 95 | 96 | 97 | 98 | 102 | 106 | 110 | 114 | 118 | 122 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 145 | 149 | 150 | 151 | 152 | ); 153 | }; 154 | 155 | const DuoPlushieSvg = (props: ComponentProps<"svg">) => { 156 | return ( 157 | 158 | 159 | 163 | 167 | 173 | 179 | 183 | 189 | 193 | 197 | 201 | 207 | 213 | 217 | 221 | 225 | 229 | 235 | 241 | 245 | 249 | 253 | 259 | 263 | 267 | 271 | 275 | 279 | 285 | 291 | 295 | 301 | 305 | 309 | 315 | 319 | 323 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | ); 337 | }; 338 | 339 | const Shop: NextPage = () => { 340 | const streakFreezes = 0; 341 | 342 | return ( 343 |
344 | 345 | 346 |
347 |
348 |
349 |

Power-ups

350 |
351 | 352 |
353 |

Streak Freeze

354 |

355 | Streak Freeze allows your streak to remain in place for one 356 | full day of inactivity. 357 |

358 |
359 | {streakFreezes} / 2 equipped 360 |
361 | 367 |
368 |
369 |
370 | 371 |
372 |

Double or Nothing

373 |

374 | Attempt to double your five lingot wager by maintaining a 375 | seven day streak. 376 |

377 | 383 |
384 |
385 |
386 |
387 |

Merch

388 |
389 | 390 |
391 |

Duo Plushie

392 |

393 | {`Celebrate Duolingo's 10 year anniversary with a new exclusive Duo plushie!`} 394 |

395 | 398 |
399 |
400 |
401 |
402 | 403 |
404 | 405 |
406 | ); 407 | }; 408 | 409 | export default Shop; 410 | -------------------------------------------------------------------------------- /src/stores/createGoalXpStore.ts: -------------------------------------------------------------------------------- 1 | import type { BoundStateCreator } from "~/hooks/useBoundStore"; 2 | 3 | export type GoalXp = 1 | 10 | 20 | 30 | 50; 4 | 5 | export type GoalXpSlice = { 6 | goalXp: GoalXp; 7 | setGoalXp: (newGoalXp: GoalXp) => void; 8 | }; 9 | 10 | export const createGoalXpSlice: BoundStateCreator = (set) => ({ 11 | goalXp: 10, 12 | setGoalXp: (newGoalXp: GoalXp) => set({ goalXp: newGoalXp }), 13 | }); 14 | -------------------------------------------------------------------------------- /src/stores/createLanguageStore.ts: -------------------------------------------------------------------------------- 1 | import languages, { type Language } from "~/utils/languages"; 2 | import type { BoundStateCreator } from "~/hooks/useBoundStore"; 3 | 4 | export type LanguageSlice = { 5 | language: Language; 6 | setLanguage: (newLanguage: Language) => void; 7 | }; 8 | 9 | const spanishLanguageIndex = 6; 10 | 11 | export const createLanguageSlice: BoundStateCreator = (set) => ({ 12 | language: languages[spanishLanguageIndex], 13 | setLanguage: (newLanguage: Language) => set({ language: newLanguage }), 14 | }); 15 | -------------------------------------------------------------------------------- /src/stores/createLessonStore.ts: -------------------------------------------------------------------------------- 1 | import { units } from "~/utils/units"; 2 | import type { BoundStateCreator } from "~/hooks/useBoundStore"; 3 | 4 | export type LessonSlice = { 5 | lessonsCompleted: number; 6 | increaseLessonsCompleted: (by?: number) => void; 7 | jumpToUnit: (unitNumber: number) => void; 8 | }; 9 | 10 | export const createLessonSlice: BoundStateCreator = (set) => ({ 11 | lessonsCompleted: 0, 12 | increaseLessonsCompleted: (by = 1) => 13 | set(({ lessonsCompleted }) => ({ 14 | lessonsCompleted: lessonsCompleted + by, 15 | })), 16 | jumpToUnit: (unitNumber: number) => 17 | set(({ lessonsCompleted }) => { 18 | const lessonsPerTile = 4; 19 | const totalLessonsToJumpToUnit = units 20 | .filter((unit) => unit.unitNumber < unitNumber) 21 | .map((unit) => unit.tiles.length * lessonsPerTile) 22 | .reduce((a, b) => a + b, 0); 23 | return { 24 | lessonsCompleted: Math.max(lessonsCompleted, totalLessonsToJumpToUnit), 25 | }; 26 | }), 27 | }); 28 | -------------------------------------------------------------------------------- /src/stores/createLingotStore.ts: -------------------------------------------------------------------------------- 1 | import type { BoundStateCreator } from "~/hooks/useBoundStore"; 2 | 3 | export type LingotSlice = { 4 | lingots: number; 5 | increaseLingots: (by: number) => void; 6 | }; 7 | 8 | export const createLingotSlice: BoundStateCreator = (set) => ({ 9 | lingots: 0, 10 | increaseLingots: (by: number) => 11 | set(({ lingots }) => ({ lingots: lingots + by })), 12 | }); 13 | -------------------------------------------------------------------------------- /src/stores/createSoundSettingsStore.ts: -------------------------------------------------------------------------------- 1 | import type { BoundStateCreator } from "~/hooks/useBoundStore"; 2 | 3 | export type SoundSettingsSlice = { 4 | soundEffects: boolean; 5 | speakingExercises: boolean; 6 | listeningExercises: boolean; 7 | setSoundEffects: (isOn: boolean) => void; 8 | setSpeakingExercises: (isOn: boolean) => void; 9 | setListeningExercises: (isOn: boolean) => void; 10 | }; 11 | 12 | export const createSoundSettingsSlice: BoundStateCreator = ( 13 | set, 14 | ) => ({ 15 | soundEffects: true, 16 | speakingExercises: true, 17 | listeningExercises: true, 18 | setSoundEffects: (isOn: boolean) => set(() => ({ soundEffects: isOn })), 19 | setSpeakingExercises: (isOn: boolean) => 20 | set(() => ({ speakingExercises: isOn })), 21 | setListeningExercises: (isOn: boolean) => 22 | set(() => ({ listeningExercises: isOn })), 23 | }); 24 | -------------------------------------------------------------------------------- /src/stores/createStreakStore.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import type { BoundStateCreator } from "~/hooks/useBoundStore"; 3 | import type { DateString } from "~/utils/dateString"; 4 | import { toDateString } from "~/utils/dateString"; 5 | 6 | type ActiveDays = Set; 7 | 8 | const addActiveDay = (activeDays: ActiveDays, day: dayjs.Dayjs): ActiveDays => { 9 | return new Set([...activeDays, toDateString(day)]); 10 | }; 11 | 12 | const isActiveDay = (activeDays: ActiveDays, day: dayjs.Dayjs): boolean => { 13 | return activeDays.has(toDateString(day)); 14 | }; 15 | 16 | const getCurrentStreak = (activeDays: ActiveDays): number => { 17 | let daysBack = 0; 18 | let day = dayjs(); 19 | while (isActiveDay(activeDays, day)) { 20 | day = day.add(-1, "day"); 21 | daysBack += 1; 22 | } 23 | return daysBack; 24 | }; 25 | 26 | export type StreakSlice = { 27 | activeDays: ActiveDays; 28 | streak: number; 29 | isActiveDay: (day: dayjs.Dayjs) => boolean; 30 | addToday: () => void; 31 | }; 32 | 33 | export const createStreakSlice: BoundStateCreator = ( 34 | set, 35 | get, 36 | ) => ({ 37 | activeDays: new Set(), 38 | streak: 0, 39 | isActiveDay: (day: dayjs.Dayjs) => isActiveDay(get().activeDays, day), 40 | addToday: () => { 41 | const activeDays = addActiveDay(get().activeDays, dayjs()); 42 | set({ activeDays, streak: getCurrentStreak(activeDays) }); 43 | }, 44 | }); 45 | -------------------------------------------------------------------------------- /src/stores/createUserStore.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import type { BoundStateCreator } from "~/hooks/useBoundStore"; 3 | 4 | export type UserSlice = { 5 | name: string; 6 | username: string; 7 | joinedAt: dayjs.Dayjs; 8 | loggedIn: boolean; 9 | setName: (name: string) => void; 10 | setUsername: (username: string) => void; 11 | logIn: () => void; 12 | logOut: () => void; 13 | }; 14 | 15 | export const createUserSlice: BoundStateCreator = (set) => ({ 16 | name: "", 17 | username: "", 18 | joinedAt: dayjs(), 19 | loggedIn: false, 20 | setName: (name: string) => set(() => ({ name })), 21 | setUsername: (username: string) => set(() => ({ username })), 22 | logIn: () => set(() => ({ loggedIn: true })), 23 | logOut: () => set(() => ({ loggedIn: false })), 24 | }); 25 | -------------------------------------------------------------------------------- /src/stores/createXpStore.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import type { BoundStateCreator } from "~/hooks/useBoundStore"; 3 | import type { DateString } from "~/utils/dateString"; 4 | import { toDateString } from "~/utils/dateString"; 5 | import { range, sum } from "~/utils/array-utils"; 6 | 7 | const addXpToday = (xpByDate: XpByDate, xp: number): XpByDate => { 8 | return addXp(xpByDate, xp, dayjs()); 9 | }; 10 | 11 | const addXp = (xpByDate: XpByDate, xp: number, date: dayjs.Dayjs): XpByDate => { 12 | return { 13 | ...xpByDate, 14 | [toDateString(date)]: xpAt(xpByDate, date) + xp, 15 | }; 16 | }; 17 | 18 | const xpAt = (xpByDate: XpByDate, date: dayjs.Dayjs): number => { 19 | return xpByDate[toDateString(date)] ?? 0; 20 | }; 21 | 22 | type XpByDate = Record; 23 | 24 | export type XpSlice = { 25 | xpByDate: XpByDate; 26 | increaseXp: (by: number) => void; 27 | xpToday: () => number; 28 | xpThisWeek: () => number; 29 | }; 30 | 31 | export const createXpSlice: BoundStateCreator = (set, get) => ({ 32 | xpByDate: {}, 33 | increaseXp: (by: number) => set({ xpByDate: addXpToday(get().xpByDate, by) }), 34 | xpToday: () => xpAt(get().xpByDate, dayjs()), 35 | xpThisWeek: () => { 36 | return sum( 37 | range(0, dayjs().day() + 1).map((daysBack) => 38 | xpAt(get().xpByDate, dayjs().add(-daysBack)), 39 | ), 40 | ); 41 | }, 42 | }); 43 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/utils/array-utils.ts: -------------------------------------------------------------------------------- 1 | export const range = (lo: number, hi: number): number[] => { 2 | const result = Array(hi - lo); 3 | for (let i = lo; i < hi; i++) { 4 | result[i - lo] = i; 5 | } 6 | return result; 7 | }; 8 | 9 | export const sum = (numbers: number[]): number => { 10 | let total = 0; 11 | for (const number of numbers) { 12 | total += number; 13 | } 14 | return total; 15 | }; 16 | -------------------------------------------------------------------------------- /src/utils/dateString.ts: -------------------------------------------------------------------------------- 1 | import type dayjs from "dayjs"; 2 | 3 | const DATE_STRING_FORMAT = "YYYY-MM-DD"; 4 | 5 | export type DateString = 6 | `${number}${number}${number}${number}-${number}${number}-${number}${number}`; 7 | 8 | export const toDateString = (day: dayjs.Dayjs): DateString => { 9 | return day.format(DATE_STRING_FORMAT) as DateString; 10 | }; 11 | -------------------------------------------------------------------------------- /src/utils/fakeUsers.ts: -------------------------------------------------------------------------------- 1 | export const fakeUsers = [ 2 | { name: "Aaron", xp: 424, isCurrentUser: false }, 3 | { name: "Bobby", xp: 378, isCurrentUser: false }, 4 | { name: "Cathy", xp: 249, isCurrentUser: false }, 5 | { name: "Derek", xp: 216, isCurrentUser: false }, 6 | { name: "Ellen", xp: 211, isCurrentUser: false }, 7 | { name: "Freddy", xp: 177, isCurrentUser: false }, 8 | { name: "George", xp: 152, isCurrentUser: false }, 9 | { name: "Helen", xp: 87, isCurrentUser: false }, 10 | { name: "Isaac", xp: 82, isCurrentUser: false }, 11 | { name: "Jacob", xp: 77, isCurrentUser: false }, 12 | { name: "Kevin", xp: 72, isCurrentUser: false }, 13 | { name: "Luke", xp: 71, isCurrentUser: false }, 14 | { name: "Mark", xp: 65, isCurrentUser: false }, 15 | { name: "Norm", xp: 62, isCurrentUser: false }, 16 | { name: "Olivia", xp: 59, isCurrentUser: false }, 17 | { name: "Perry", xp: 52, isCurrentUser: false }, 18 | { name: "Quentin", xp: 51, isCurrentUser: false }, 19 | { name: "Ryan", xp: 45, isCurrentUser: false }, 20 | { name: "Steve", xp: 40, isCurrentUser: false }, 21 | { name: "Theo", xp: 30, isCurrentUser: false }, 22 | { name: "Uma", xp: 20, isCurrentUser: false }, 23 | { name: "Vincent", xp: 10, isCurrentUser: false }, 24 | { name: "Will", xp: 10, isCurrentUser: false }, 25 | { name: "Xavier", xp: 10, isCurrentUser: false }, 26 | { name: "Yan", xp: 10, isCurrentUser: false }, 27 | { name: "Zachary", xp: 10, isCurrentUser: false }, 28 | { name: "Arnold", xp: 10, isCurrentUser: false }, 29 | { name: "Bruno", xp: 10, isCurrentUser: false }, 30 | { name: "Carl", xp: 10, isCurrentUser: false }, 31 | ] as const; 32 | -------------------------------------------------------------------------------- /src/utils/languages.ts: -------------------------------------------------------------------------------- 1 | export type Language = (typeof languages)[number]; 2 | 3 | const languages = [ 4 | { 5 | name: "Arabic", 6 | nativeName: "العربية", 7 | viewBox: "0 2178 82 66", 8 | code: "ar", 9 | }, 10 | { name: "Bengali", nativeName: "বাংলা", viewBox: "0 1914 82 66", code: "bn" }, 11 | { name: "Czech", nativeName: "Čeština", viewBox: "0 1848 82 66", code: "cs" }, 12 | { name: "German", nativeName: "Deutsch", viewBox: "0 198 82 66", code: "de" }, 13 | { name: "Greek", nativeName: "Ελληνικά", viewBox: "0 924 82 66", code: "el" }, 14 | { name: "English", nativeName: "English", viewBox: "0 0 82 66", code: "en" }, 15 | { name: "Spanish", nativeName: "Español", viewBox: "0 66 82 66", code: "es" }, 16 | { 17 | name: "French", 18 | nativeName: "Français", 19 | viewBox: "0 132 82 66", 20 | code: "fr", 21 | }, 22 | { name: "Hindi", nativeName: "हिंदी", viewBox: "0 1914 82 66", code: "hi" }, 23 | { 24 | name: "Hungarian", 25 | nativeName: "Magyar", 26 | viewBox: "0 1584 82 66", 27 | code: "hu", 28 | }, 29 | { 30 | name: "Indonesian", 31 | nativeName: "Bahasa Indonesia", 32 | viewBox: "0 1980 82 66", 33 | code: "id", 34 | }, 35 | { 36 | name: "Italian", 37 | nativeName: "Italiano", 38 | viewBox: "0 330 82 66", 39 | code: "it", 40 | }, 41 | { 42 | name: "Japanese", 43 | nativeName: "日本語", 44 | viewBox: "0 264 82 66", 45 | code: "ja", 46 | }, 47 | { name: "Korean", nativeName: "한국어", viewBox: "0 396 82 66", code: "ko" }, 48 | { 49 | name: "Dutch", 50 | nativeName: "Nederlands", 51 | viewBox: "0 726 82 66", 52 | code: "code-NL", 53 | }, 54 | { name: "Polish", nativeName: "Polski", viewBox: "0 1056 82 66", code: "pl" }, 55 | { 56 | name: "Portuguese", 57 | nativeName: "Português", 58 | viewBox: "0 594 82 66", 59 | code: "pt", 60 | }, 61 | { 62 | name: "Romanian", 63 | nativeName: "Română", 64 | viewBox: "0 1386 82 66", 65 | code: "ro", 66 | }, 67 | { 68 | name: "Russian", 69 | nativeName: "Русский", 70 | viewBox: "0 528 82 66", 71 | code: "ru", 72 | }, 73 | { name: "Thai", nativeName: "ภาษาไทย", viewBox: "0 2310 82 66", code: "th" }, 74 | { 75 | name: "Tagalog", 76 | nativeName: "Tagalog", 77 | viewBox: "0 3036 82 66", 78 | code: "tl", 79 | }, 80 | { name: "Turkish", nativeName: "Türkçe", viewBox: "0 660 82 66", code: "tr" }, 81 | { 82 | name: "Ukrainian", 83 | nativeName: "Українською", 84 | viewBox: "0 1716 82 66", 85 | code: "uk", 86 | }, 87 | { 88 | name: "Vietnamese", 89 | nativeName: "Tiếng Việt", 90 | viewBox: "0 1188 82 66", 91 | code: "vi", 92 | }, 93 | { 94 | name: "Chinese", 95 | nativeName: "中文", 96 | viewBox: "0 462 82 66", 97 | code: "code-CN", 98 | }, 99 | ] as const; 100 | 101 | export default languages; 102 | -------------------------------------------------------------------------------- /src/utils/units.ts: -------------------------------------------------------------------------------- 1 | export type Unit = { 2 | unitNumber: number; 3 | description: string; 4 | backgroundColor: `bg-${string}`; 5 | textColor: `text-${string}`; 6 | borderColor: `border-${string}`; 7 | tiles: Tile[]; 8 | }; 9 | 10 | export type Tile = 11 | | { 12 | type: "star" | "dumbbell" | "book" | "trophy" | "fast-forward"; 13 | description: string; 14 | } 15 | | { type: "treasure" }; 16 | 17 | export type TileType = Tile["type"]; 18 | 19 | export const units: readonly Unit[] = [ 20 | { 21 | unitNumber: 1, 22 | description: "Form basic sentences, greet people", 23 | backgroundColor: "bg-[#58cc02]", 24 | textColor: "text-[#58cc02]", 25 | borderColor: "border-[#46a302]", 26 | tiles: [ 27 | { 28 | type: "star", 29 | description: "Form basic sentences", 30 | }, 31 | { 32 | type: "book", 33 | description: "Good morning", 34 | }, 35 | { 36 | type: "star", 37 | description: "Greet people", 38 | }, 39 | { type: "treasure" }, 40 | { type: "book", description: "A date" }, 41 | { type: "trophy", description: "Unit 1 review" }, 42 | ], 43 | }, 44 | { 45 | unitNumber: 2, 46 | description: "Get around in a city", 47 | backgroundColor: "bg-[#ce82ff]", 48 | textColor: "text-[#ce82ff]", 49 | borderColor: "border-[#a568cc]", 50 | tiles: [ 51 | { type: "fast-forward", description: "Get around in a city" }, 52 | { type: "dumbbell", description: "Personalized practice" }, 53 | { type: "book", description: "One thing" }, 54 | { type: "treasure" }, 55 | { type: "star", description: "Get around in a city" }, 56 | { type: "book", description: "A very big family" }, 57 | { type: "star", description: "Greet people" }, 58 | { type: "book", description: "The red jacket" }, 59 | { type: "treasure" }, 60 | { type: "dumbbell", description: "Personalized practice" }, 61 | { type: "trophy", description: "Unit 2 review" }, 62 | ], 63 | }, 64 | { 65 | unitNumber: 3, 66 | description: "Order food and drink", 67 | backgroundColor: "bg-[#00cd9c]", 68 | textColor: "text-[#00cd9c]", 69 | borderColor: "border-[#00a47d]", 70 | tiles: [ 71 | { type: "fast-forward", description: "Order food and drink" }, 72 | { type: "book", description: "The passport" }, 73 | { type: "star", description: "Order food and drinks" }, 74 | { type: "treasure" }, 75 | { type: "book", description: "The honeymoon" }, 76 | { type: "star", description: "Get around in a city" }, 77 | { type: "treasure" }, 78 | { type: "dumbbell", description: "Personalized practice" }, 79 | { type: "book", description: "Doctor Eddy" }, 80 | { type: "trophy", description: "Unit 2 review" }, 81 | ], 82 | }, 83 | ]; 84 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import { type Config } from "tailwindcss"; 2 | 3 | export default { 4 | content: ["./src/**/*.{js,ts,jsx,tsx}"], 5 | theme: { 6 | extend: {}, 7 | }, 8 | plugins: [], 9 | } satisfies Config; 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "checkJs": true, 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve", 17 | "incremental": true, 18 | "noUncheckedIndexedAccess": true, 19 | "baseUrl": ".", 20 | "paths": { 21 | "~/*": ["./src/*"] 22 | } 23 | }, 24 | "include": [ 25 | ".eslintrc.cjs", 26 | "next-env.d.ts", 27 | "**/*.ts", 28 | "**/*.tsx", 29 | "**/*.cjs", 30 | "**/*.mjs" 31 | ], 32 | "exclude": ["node_modules"] 33 | } 34 | --------------------------------------------------------------------------------