├── .env.example ├── .eslintrc.cjs ├── .gitignore ├── .npmrc ├── .prettierrc.json ├── .vscode └── settings.json ├── .yarnrc ├── Dockerfile ├── README.md ├── docker-compose.yml ├── media ├── auth.jpg ├── crm_youtube.jpg └── lms.jpg ├── next.config.mjs ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── src ├── app │ ├── (app) │ │ ├── (authenticated) │ │ │ ├── _actions │ │ │ │ ├── getUser.ts │ │ │ │ └── logout.ts │ │ │ ├── _components │ │ │ │ ├── LogoutButton.tsx │ │ │ │ └── Navbar.tsx │ │ │ ├── dashboard │ │ │ │ └── page.tsx │ │ │ └── template.tsx │ │ ├── _components │ │ │ └── SubmitButton.tsx │ │ ├── layout.tsx │ │ ├── login │ │ │ ├── _actions │ │ │ │ └── login.ts │ │ │ ├── _components │ │ │ │ └── LoginForm.tsx │ │ │ └── page.tsx │ │ ├── page.tsx │ │ ├── signup │ │ │ ├── _actions │ │ │ │ └── signup.ts │ │ │ ├── _components │ │ │ │ └── SignupForm.tsx │ │ │ └── page.tsx │ │ └── styles.css │ ├── (payload) │ │ ├── admin │ │ │ ├── [[...segments]] │ │ │ │ ├── not-found.tsx │ │ │ │ └── page.tsx │ │ │ └── importMap.js │ │ ├── api │ │ │ ├── [...slug] │ │ │ │ └── route.ts │ │ │ ├── graphql-playground │ │ │ │ └── route.ts │ │ │ └── graphql │ │ │ │ └── route.ts │ │ ├── custom.css │ │ └── layout.tsx │ └── my-route │ │ └── route.ts ├── assets │ └── logo.svg ├── collections │ ├── Courses │ │ ├── Blocks │ │ │ ├── QuizBlock.ts │ │ │ └── VideoBlock.ts │ │ ├── Courses.ts │ │ └── Participation.ts │ ├── Customers.ts │ ├── Media.ts │ └── Users.ts ├── payload-types.ts ├── payload.config.ts └── utils │ └── brevoAdapter.ts ├── tailwind.config.js └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URI=mongodb://127.0.0.1/payload-template-blank-3-0 2 | PAYLOAD_SECRET=YOUR_SECRET_HERE 3 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | module.exports = { 3 | extends: ['next/core-web-vitals'], 4 | parserOptions: { 5 | project: ['./tsconfig.json'], 6 | tsconfigRootDir: __dirname, 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | /.idea/* 10 | !/.idea/runConfigurations 11 | 12 | # testing 13 | /coverage 14 | 15 | # next.js 16 | /.next/ 17 | /out/ 18 | 19 | # production 20 | /build 21 | 22 | # misc 23 | .DS_Store 24 | *.pem 25 | 26 | # debug 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | 31 | # local env files 32 | .env*.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | # typescript 38 | *.tsbuildinfo 39 | next-env.d.ts 40 | 41 | .env -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 100, 5 | "semi": false 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "dotenv.enableAutocloaking": true 3 | } -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | --install.ignore-engines true 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # From https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile 2 | 3 | FROM node:18-alpine AS base 4 | 5 | # Install dependencies only when needed 6 | FROM base AS deps 7 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 8 | RUN apk add --no-cache libc6-compat 9 | WORKDIR /app 10 | 11 | # Install dependencies based on the preferred package manager 12 | COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./ 13 | RUN \ 14 | if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ 15 | elif [ -f package-lock.json ]; then npm ci; \ 16 | elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ 17 | else echo "Lockfile not found." && exit 1; \ 18 | fi 19 | 20 | 21 | # Rebuild the source code only when needed 22 | FROM base AS builder 23 | WORKDIR /app 24 | COPY --from=deps /app/node_modules ./node_modules 25 | COPY . . 26 | 27 | # Next.js collects completely anonymous telemetry data about general usage. 28 | # Learn more here: https://nextjs.org/telemetry 29 | # Uncomment the following line in case you want to disable telemetry during the build. 30 | # ENV NEXT_TELEMETRY_DISABLED 1 31 | 32 | RUN \ 33 | if [ -f yarn.lock ]; then yarn run build; \ 34 | elif [ -f package-lock.json ]; then npm run build; \ 35 | elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ 36 | else echo "Lockfile not found." && exit 1; \ 37 | fi 38 | 39 | # Production image, copy all the files and run next 40 | FROM base AS runner 41 | WORKDIR /app 42 | 43 | ENV NODE_ENV production 44 | # Uncomment the following line in case you want to disable telemetry during runtime. 45 | # ENV NEXT_TELEMETRY_DISABLED 1 46 | 47 | RUN addgroup --system --gid 1001 nodejs 48 | RUN adduser --system --uid 1001 nextjs 49 | 50 | COPY --from=builder /app/public ./public 51 | 52 | # Set the correct permission for prerender cache 53 | RUN mkdir .next 54 | RUN chown nextjs:nodejs .next 55 | 56 | # Automatically leverage output traces to reduce image size 57 | # https://nextjs.org/docs/advanced-features/output-file-tracing 58 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 59 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 60 | 61 | USER nextjs 62 | 63 | EXPOSE 3000 64 | 65 | ENV PORT 3000 66 | 67 | # server.js is created by next build from the standalone output 68 | # https://nextjs.org/docs/pages/api-reference/next-config-js/output 69 | CMD HOSTNAME="0.0.0.0" node server.js 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🚀 Course Membership Platform with Next.js and Payload CMS 2 | 3 | This project is a **Course Membership Platform** built using **Next.js** and **Payload CMS**. The platform allows for user authentication, secure content access, and media handling with services like **MongoDB**, **TailwindCSS**, **Linode S3**, and **Brevo** for email services. 4 | 5 | ## 🚀 Features 6 | 7 | - **User Authentication** with secure login and protected routes. 8 | - **Admin Dashboard** for managing courses and users. 9 | - **Media Storage** using Linode S3 for file uploads. 10 | - **Email Service** integrated via Brevo API (formerly Sendinblue). 11 | - **Styled Frontend** using TailwindCSS for modern, responsive design. 12 | - **MongoDB Integration** for data storage. 13 | 14 | --- 15 | 16 | ## 🛠️ Technologies Used 17 | 18 | - **Next.js** 19 | - **Payload CMS** 20 | - **MongoDB** 21 | - **TailwindCSS** 22 | - **Linode S3** (Object Storage) 23 | - **Brevo API** (Email Service) 24 | 25 | --- 26 | 27 | ## 📦 Getting Started 28 | 29 | ### Prerequisites 30 | 31 | Before running this project, make sure you have the following installed: 32 | 33 | - **Node.js** (v16 or higher) 34 | - **PNPM** (recommended) or **NPM** 35 | - **MongoDB Atlas** or a local MongoDB instance 36 | 37 | ### Installation 38 | 39 | 1. **Clone the Repository:** 40 | 41 | ```bash 42 | git clone https://github.com/yourusername/course-membership-platform.git 43 | cd course-membership-platform 44 | 45 | ### Environment Variables 46 | 47 | ### MongoDB Connection 48 | MONGODB_URI=your_mongodb_connection_string 49 | PAYLOAD_SECRET=your_secret 50 | 51 | ### Linode S3 Configuration 52 | S3_BUCKET_NAME=your_bucket_name 53 | S3_ACCESS_KEY=your_access_key 54 | S3_SECRET_KEY=your_secret_key 55 | S3_REGION=your_region 56 | S3_ENDPOINT=https://your_s3_endpoint 57 | 58 | ### Brevo Email Configuration 59 | BREVO_API_KEY=your_brevo_api_key 60 | BREVO_EMAIL_ACTIVE=true 61 | BREVO_SENDER_NAME=Your Name 62 | BREVO_SENDER_EMAIL=info@yourdomain.com 63 | 64 | ### Next.js Secret 65 | NEXT_PUBLIC_SECRET=your_nextjs_secret -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | payload: 5 | image: node:18-alpine 6 | ports: 7 | - '3000:3000' 8 | volumes: 9 | - .:/home/node/app 10 | - node_modules:/home/node/app/node_modules 11 | working_dir: /home/node/app/ 12 | command: sh -c "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm dev" 13 | depends_on: 14 | - mongo 15 | # - postgres 16 | env_file: 17 | - .env 18 | 19 | # Ensure your DATABASE_URI uses 'mongo' as the hostname ie. mongodb://mongo/my-db-name 20 | mongo: 21 | image: mongo:latest 22 | ports: 23 | - '27017:27017' 24 | command: 25 | - --storageEngine=wiredTiger 26 | volumes: 27 | - data:/data/db 28 | logging: 29 | driver: none 30 | 31 | # Uncomment the following to use postgres 32 | # postgres: 33 | # restart: always 34 | # image: postgres:latest 35 | # volumes: 36 | # - pgdata:/var/lib/postgresql/data 37 | # ports: 38 | # - "5432:5432" 39 | 40 | volumes: 41 | data: 42 | # pgdata: 43 | node_modules: 44 | -------------------------------------------------------------------------------- /media/auth.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/10x-media/payload-course-membership-nextjs-tutorial/bb8e5df40a0363f7e62c10a9f2cbe3eff394af25/media/auth.jpg -------------------------------------------------------------------------------- /media/crm_youtube.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/10x-media/payload-course-membership-nextjs-tutorial/bb8e5df40a0363f7e62c10a9f2cbe3eff394af25/media/crm_youtube.jpg -------------------------------------------------------------------------------- /media/lms.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/10x-media/payload-course-membership-nextjs-tutorial/bb8e5df40a0363f7e62c10a9f2cbe3eff394af25/media/lms.jpg -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | import { withPayload } from '@payloadcms/next/withPayload' 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = { 5 | // Your Next.js config here 6 | } 7 | 8 | export default withPayload(nextConfig) 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lms-youtube", 3 | "version": "1.0.0", 4 | "description": "A blank template to get started with Payload 3.0", 5 | "license": "MIT", 6 | "type": "module", 7 | "scripts": { 8 | "build": "cross-env NODE_OPTIONS=--no-deprecation next build", 9 | "dev": "cross-env NODE_OPTIONS=--no-deprecation next dev", 10 | "devsafe": "rm -rf .next && cross-env NODE_OPTIONS=--no-deprecation next dev", 11 | "generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap", 12 | "generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types", 13 | "lint": "cross-env NODE_OPTIONS=--no-deprecation next lint", 14 | "payload": "cross-env NODE_OPTIONS=--no-deprecation payload", 15 | "start": "cross-env NODE_OPTIONS=--no-deprecation next start" 16 | }, 17 | "dependencies": { 18 | "@payloadcms/db-mongodb": "latest", 19 | "@payloadcms/next": "latest", 20 | "@payloadcms/payload-cloud": "latest", 21 | "@payloadcms/richtext-lexical": "latest", 22 | "@payloadcms/storage-s3": "^3.2.2", 23 | "axios": "^1.7.8", 24 | "cross-env": "^7.0.3", 25 | "graphql": "^16.8.1", 26 | "next": "15.0.0", 27 | "payload": "latest", 28 | "react": "19.0.0-rc-65a56d0e-20241020", 29 | "react-dom": "19.0.0-rc-65a56d0e-20241020", 30 | "react-icons": "^5.3.0", 31 | "sharp": "0.32.6" 32 | }, 33 | "devDependencies": { 34 | "@types/node": "^22.5.4", 35 | "@types/react": "npm:types-react@19.0.0-rc.1", 36 | "@types/react-dom": "npm:types-react-dom@19.0.0-rc.1", 37 | "autoprefixer": "^10.4.20", 38 | "eslint": "^8", 39 | "eslint-config-next": "15.0.0", 40 | "postcss": "^8.4.49", 41 | "tailwindcss": "^3.4.15", 42 | "typescript": "5.7.2" 43 | }, 44 | "engines": { 45 | "node": "^18.20.2 || >=20.9.0" 46 | }, 47 | "pnpm": { 48 | "overrides": { 49 | "@types/react": "npm:types-react@19.0.0-rc.1", 50 | "@types/react-dom": "npm:types-react-dom@19.0.0-rc.1" 51 | } 52 | }, 53 | "overrides": { 54 | "@types/react": "npm:types-react@19.0.0-rc.1", 55 | "@types/react-dom": "npm:types-react-dom@19.0.0-rc.1" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/app/(app)/(authenticated)/_actions/getUser.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { headers as getHeaders } from 'next/headers'; 4 | import { getPayload } from 'payload'; 5 | import configPromise from '@payload-config'; 6 | import type { Payload } from 'payload'; 7 | import { Customer } from '@/payload-types'; 8 | 9 | export async function getUser(): Promise { 10 | const headers = await getHeaders(); 11 | const payload: Payload = await getPayload({ config: await configPromise }); 12 | const { user } = await payload.auth({ headers }); 13 | return user || null; 14 | } 15 | -------------------------------------------------------------------------------- /src/app/(app)/(authenticated)/_actions/logout.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { cookies } from "next/headers"; 4 | 5 | interface LogoutResponse { 6 | success: boolean; 7 | error?: string; 8 | } 9 | 10 | export async function logout(): Promise { 11 | try { 12 | const cookieStore = await cookies(); 13 | cookieStore.delete("payload-token"); // Deletes the HTTP-only cookie 14 | 15 | return { success: true }; // Indicate success 16 | } catch (error) { 17 | console.error("Logout error:", error); 18 | return { success: false, error: "An error occurred during logout" }; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/app/(app)/(authenticated)/_components/LogoutButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRouter } from "next/navigation"; 4 | import { useState } from "react"; 5 | import { logout } from "../_actions/logout"; 6 | import { AiOutlineLogout } from 'react-icons/ai'; 7 | 8 | 9 | export default function LogoutButton() { 10 | const [isPending, setIsPending] = useState(false); 11 | const [error, setError] = useState(null); 12 | const router = useRouter(); 13 | 14 | async function handleLogout() { 15 | setIsPending(true); 16 | setError(null); 17 | 18 | const result = await logout(); 19 | 20 | setIsPending(false); 21 | 22 | if (result.success) { 23 | // Redirect to home page after successful logout 24 | router.push("/"); 25 | } else { 26 | // Display error message 27 | setError(result.error || "Logout failed"); 28 | } 29 | } 30 | 31 | return ( 32 | <> 33 | {error &&

{error}

} 34 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/app/(app)/(authenticated)/_components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import LogoutButton from './LogoutButton'; 3 | 4 | const Navbar: React.FC = () => { 5 | return ( 6 | 22 | ); 23 | }; 24 | 25 | export default Navbar; -------------------------------------------------------------------------------- /src/app/(app)/(authenticated)/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | 'use server' 2 | import { headers as getHeaders } from 'next/headers.js' 3 | import { getPayload } from "payload"; 4 | import React, { Suspense } from "react"; 5 | import configPromise from '@payload-config' 6 | import Image from 'next/image'; 7 | import { Course } from '@/payload-types'; 8 | import Link from 'next/link'; 9 | import { getUser } from '../_actions/getUser'; 10 | 11 | 12 | const page = async () => { 13 | 14 | const payload = await getPayload({ config: configPromise }); 15 | 16 | // get the user 17 | const user = await getUser(); 18 | 19 | // get courses 20 | let courses: Course[] = []; 21 | 22 | try { 23 | let coursesRes = await payload.find({ collection: 'courses', limit: 10, overrideAccess: false, user: user }) 24 | courses = coursesRes.docs; 25 | } catch (e) { 26 | console.log(e) 27 | } 28 | 29 | 30 | return
31 |
Welcome {user?.email}
32 |
All Courses
33 |
34 | Loading...
}> 35 | {courses.map((course) => { 36 | return ( 37 | 38 |
{`${course.title}
39 | 40 | ) 41 | })} 42 | 43 |
44 | ; 45 | }; 46 | 47 | export default page; -------------------------------------------------------------------------------- /src/app/(app)/(authenticated)/template.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation'; 2 | import React, { FC, ReactNode } from 'react'; 3 | import { getUser } from './_actions/getUser'; 4 | import Navbar from './_components/Navbar'; 5 | 6 | interface LayoutProps { 7 | children: ReactNode; 8 | } 9 | 10 | const Template: FC = async ({ children }) => { 11 | const user = await getUser(); 12 | if (!user) { 13 | redirect('/login'); 14 | return null; 15 | } 16 | return
17 | 18 | {children}
; 19 | } 20 | 21 | export default Template; -------------------------------------------------------------------------------- /src/app/(app)/_components/SubmitButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import { AiOutlineLoading3Quarters } from "react-icons/ai"; 3 | 4 | export default function SubmitButton({ loading, text }: { loading: boolean, text: string }): ReactElement { 5 | return 14 | } -------------------------------------------------------------------------------- /src/app/(app)/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./styles.css"; 2 | import React, { ReactElement, ReactNode } from "react"; 3 | 4 | interface RootLayoutProps { 5 | children: ReactNode; 6 | } 7 | 8 | export default function RootLayout({ children }: RootLayoutProps): ReactElement { 9 | return 10 | 11 | {children} 12 | 13 | ; 14 | } -------------------------------------------------------------------------------- /src/app/(app)/login/_actions/login.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { getPayload } from "payload"; 4 | import config from "@payload-config"; 5 | import { cookies } from "next/headers"; 6 | import { Customer } from "@/payload-types"; 7 | 8 | interface LoginParams { 9 | email: string; 10 | password: string; 11 | } 12 | 13 | export interface LoginResponse { 14 | success: boolean; 15 | error?: string; 16 | } 17 | 18 | export type Result = { 19 | exp?: number; 20 | token?: string; 21 | user?: Customer; 22 | } 23 | 24 | export async function login({email, password}: LoginParams): Promise { 25 | const payload = await getPayload({ config }); 26 | try { 27 | const result: Result = await payload.login({ 28 | collection: "customers", 29 | data: { email, password }, 30 | }); 31 | 32 | if (result.token){ 33 | const cookieStore = await cookies(); 34 | cookieStore.set("payload-token", result.token, { 35 | httpOnly: true, 36 | secure: process.env.NODE_ENV === "production", 37 | path: "/", 38 | }) 39 | 40 | return { success: true } 41 | }else{ 42 | return { success: false, error: "Invalid email or password" } 43 | } 44 | } catch (error) { 45 | console.error("Login error", error); 46 | return { success: false, error: "An error occurred"} 47 | } 48 | } -------------------------------------------------------------------------------- /src/app/(app)/login/_components/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRouter } from "next/navigation"; 4 | import React, { FormEvent, ReactElement, useState } from "react"; 5 | import SubmitButton from "../../_components/SubmitButton"; 6 | import { login, LoginResponse } from "../_actions/login"; 7 | import Link from "next/link"; 8 | 9 | export default function LoginForm(): ReactElement { 10 | const [isPending, setIsPending] = useState(false); 11 | const [error, setError] = useState(null); 12 | const router = useRouter(); 13 | 14 | async function handleSubmit(event: FormEvent) { 15 | event.preventDefault(); 16 | setIsPending(true); 17 | setError(null); 18 | 19 | const formData = new FormData(event.currentTarget); 20 | const email = formData.get("email") as string; 21 | const password = formData.get("password") as string; 22 | 23 | const result: LoginResponse = await login({ email, password }); 24 | 25 | setIsPending(false); 26 | 27 | if (result.success) { 28 | // Redirect manually after successful login 29 | router.push("/dashboard"); 30 | } else { 31 | // Display the error message 32 | setError(result.error || "Login failed"); 33 | } 34 | } 35 | 36 | return
37 |
38 | Login 39 |
40 |
41 |
42 |
43 | 44 | 45 |
46 |
47 | 48 | 49 |
50 | {error &&
{error}
} 51 | 52 | 53 |

54 | Don't have an account?{' '} 55 | 56 | Sign Up 57 | 58 |

59 |
60 |
61 | } -------------------------------------------------------------------------------- /src/app/(app)/login/page.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import LoginForm from "./_components/LoginForm"; 3 | 4 | export default async function page(): Promise { 5 | return
; 6 | } -------------------------------------------------------------------------------- /src/app/(app)/page.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import Logo from "@/assets/logo.svg" 3 | import Image from "next/image"; 4 | import Link from "next/link"; 5 | 6 | export default function page(): ReactElement{ 7 | return
8 |
9 | Logo 10 | Login 11 |
12 |
13 |
14 |

Learn Payload CMS

15 |

16 | Build modern applications with our Payload CMS course. 17 |

18 | 19 | Get Started 20 | 21 |
22 |
23 | 24 | {/* Features Section */} 25 |
26 |
27 |

Why Choose Us?

28 |
29 |
30 |

Practical Learning

31 |

32 | Work on real-world projects and build hands-on experience. 33 |

34 |
35 |
36 |

Modern Techniques

37 |

38 | Stay ahead with up-to-date content and best practices. 39 |

40 |
41 |
42 |

Community Support

43 |

44 | Join a network of developers sharing tips and resources. 45 |

46 |
47 |
48 |
49 |
50 | 51 | {/* Call-to-Action Section */} 52 |
53 |
54 |

Ready to Dive In?

55 |

56 | Take the first step towards mastering Payload CMS today. 57 |

58 | 59 | Enroll Now 60 | 61 |
62 |
63 | 64 | {/* Footer Section */} 65 |
66 |
67 |

© 2025 10x Media GmbH. All rights reserved.

68 |
69 |
70 |
71 | } -------------------------------------------------------------------------------- /src/app/(app)/signup/_actions/signup.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import config from "@payload-config"; 4 | import { cookies } from "next/headers"; 5 | import { redirect } from "next/navigation" 6 | import { Customer } from "@/payload-types"; 7 | import { getPayload } from "payload"; 8 | 9 | // we cannot import the type from payload/dist.../login so we define it here 10 | export type Result = { 11 | exp?: number; 12 | token?: string; 13 | user?: Customer; 14 | }; 15 | 16 | export interface SignupResponse { 17 | success: boolean; 18 | error?: string; 19 | } 20 | 21 | interface SignupParams { 22 | email: string; 23 | password: string; 24 | } 25 | 26 | export async function signup({ email, password }: SignupParams): Promise { 27 | const payload = await getPayload({ config }); 28 | try { 29 | await payload.create({ 30 | collection: "customers", 31 | data: { 32 | email, 33 | password, 34 | }, 35 | }); 36 | 37 | const result: Result = await payload.login({ 38 | collection: "customers", 39 | data: { 40 | email, 41 | password, 42 | }, 43 | }); 44 | 45 | if (result.token) { 46 | let cookieStore = await cookies(); 47 | cookieStore.set({ 48 | name: 'payload-token', 49 | value: result.token, 50 | httpOnly: true, 51 | path: '/', 52 | }); 53 | 54 | return { success: true} 55 | }else{ 56 | console.log('Login failed: No token received'); 57 | return { success: false, error: "An error occurred"} 58 | } 59 | } catch (error) { 60 | console.log(error); 61 | return { success: false, error: "An error occurred"} 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/app/(app)/signup/_components/SignupForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useRouter } from "next/navigation"; 4 | import { useState, FormEvent, ReactElement } from "react"; 5 | import Link from "next/link"; 6 | import { signup, SignupResponse } from "../_actions/signup"; 7 | import SubmitButton from "../../_components/SubmitButton"; 8 | 9 | export default function SignupForm(): ReactElement { 10 | const [isPending, setIsPending] = useState(false); 11 | const [error, setError] = useState(null); 12 | const router = useRouter(); 13 | 14 | async function onSubmit(event: FormEvent): Promise { 15 | event.preventDefault(); 16 | setIsPending(true); 17 | setError(null); // Reset error state 18 | 19 | const formData = new FormData(event.currentTarget); 20 | const email = formData.get("email") as string; 21 | const password = formData.get("password") as string; 22 | const confirmPassword = formData.get("confirmPassword") as string; 23 | 24 | if (password !== confirmPassword) { 25 | setError("Passwords do not match"); 26 | setIsPending(false); 27 | return; 28 | } 29 | 30 | const result: SignupResponse = await signup({ email, password }); 31 | setIsPending(false); 32 | 33 | console.log(result); 34 | 35 | if (result.success) { 36 | // Redirect manually after successful login 37 | router.push("/dashboard"); 38 | } else { 39 | // Display the error message 40 | setError(result.error || "Login failed"); 41 | } 42 | } 43 | 44 | return ( 45 |
46 |
47 | Sign Up 48 |
49 |
50 |
51 |
52 | 53 | 60 |
61 | 62 |
63 | 64 | 71 |
72 | 73 |
74 | 75 | 82 |
83 | 84 | {error &&
{error}
} 85 | 86 | 87 | 88 | 89 |

90 | Already have an account?{' '} 91 | 92 | Sign In 93 | 94 |

95 |
96 |
97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /src/app/(app)/signup/page.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import SignupForm from './_components/SignupForm'; 3 | 4 | export default async function Page(): Promise { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | } -------------------------------------------------------------------------------- /src/app/(app)/styles.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .textInput { 6 | @apply bg-black text-white border border-white/50 rounded p-2; 7 | } -------------------------------------------------------------------------------- /src/app/(payload)/admin/[[...segments]]/not-found.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import type { Metadata } from 'next' 4 | 5 | import config from '@payload-config' 6 | import { NotFoundPage, generatePageMetadata } from '@payloadcms/next/views' 7 | import { importMap } from '../importMap' 8 | 9 | type Args = { 10 | params: Promise<{ 11 | segments: string[] 12 | }> 13 | searchParams: Promise<{ 14 | [key: string]: string | string[] 15 | }> 16 | } 17 | 18 | export const generateMetadata = ({ params, searchParams }: Args): Promise => 19 | generatePageMetadata({ config, params, searchParams }) 20 | 21 | const NotFound = ({ params, searchParams }: Args) => 22 | NotFoundPage({ config, params, searchParams, importMap }) 23 | 24 | export default NotFound 25 | -------------------------------------------------------------------------------- /src/app/(payload)/admin/[[...segments]]/page.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import type { Metadata } from 'next' 4 | 5 | import config from '@payload-config' 6 | import { RootPage, generatePageMetadata } from '@payloadcms/next/views' 7 | import { importMap } from '../importMap' 8 | 9 | type Args = { 10 | params: Promise<{ 11 | segments: string[] 12 | }> 13 | searchParams: Promise<{ 14 | [key: string]: string | string[] 15 | }> 16 | } 17 | 18 | export const generateMetadata = ({ params, searchParams }: Args): Promise => 19 | generatePageMetadata({ config, params, searchParams }) 20 | 21 | const Page = ({ params, searchParams }: Args) => 22 | RootPage({ config, params, searchParams, importMap }) 23 | 24 | export default Page 25 | -------------------------------------------------------------------------------- /src/app/(payload)/admin/importMap.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | export const importMap = { 4 | 5 | } 6 | -------------------------------------------------------------------------------- /src/app/(payload)/api/[...slug]/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import '@payloadcms/next/css' 5 | import { 6 | REST_DELETE, 7 | REST_GET, 8 | REST_OPTIONS, 9 | REST_PATCH, 10 | REST_POST, 11 | REST_PUT, 12 | } from '@payloadcms/next/routes' 13 | 14 | export const GET = REST_GET(config) 15 | export const POST = REST_POST(config) 16 | export const DELETE = REST_DELETE(config) 17 | export const PATCH = REST_PATCH(config) 18 | export const PUT = REST_PUT(config) 19 | export const OPTIONS = REST_OPTIONS(config) 20 | -------------------------------------------------------------------------------- /src/app/(payload)/api/graphql-playground/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import '@payloadcms/next/css' 5 | import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes' 6 | 7 | export const GET = GRAPHQL_PLAYGROUND_GET(config) 8 | -------------------------------------------------------------------------------- /src/app/(payload)/api/graphql/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes' 5 | 6 | export const POST = GRAPHQL_POST(config) 7 | 8 | export const OPTIONS = REST_OPTIONS(config) 9 | -------------------------------------------------------------------------------- /src/app/(payload)/custom.css: -------------------------------------------------------------------------------- 1 | @tailwind components; 2 | @tailwind utilities; -------------------------------------------------------------------------------- /src/app/(payload)/layout.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import '@payloadcms/next/css' 5 | import type { ServerFunctionClient } from 'payload' 6 | import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts' 7 | import React from 'react' 8 | 9 | import { importMap } from './admin/importMap.js' 10 | import './custom.css' 11 | 12 | type Args = { 13 | children: React.ReactNode 14 | } 15 | 16 | const serverFunction: ServerFunctionClient = async function (args) { 17 | 'use server' 18 | return handleServerFunctions({ 19 | ...args, 20 | config, 21 | importMap, 22 | }) 23 | } 24 | 25 | const Layout = ({ children }: Args) => ( 26 | 27 | {children} 28 | 29 | ) 30 | 31 | export default Layout 32 | -------------------------------------------------------------------------------- /src/app/my-route/route.ts: -------------------------------------------------------------------------------- 1 | import configPromise from '@payload-config' 2 | import { getPayload } from 'payload' 3 | 4 | export const GET = async () => { 5 | const payload = await getPayload({ 6 | config: configPromise, 7 | }) 8 | 9 | const data = await payload.find({ 10 | collection: 'users', 11 | }) 12 | 13 | return Response.json(data) 14 | } 15 | -------------------------------------------------------------------------------- /src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/collections/Courses/Blocks/QuizBlock.ts: -------------------------------------------------------------------------------- 1 | import { Block } from "payload"; 2 | 3 | export const QuizBlock: Block = { 4 | slug: "quiz", 5 | labels: { 6 | singular: "Quiz", 7 | plural: "Quizzes", 8 | }, 9 | fields: [ 10 | { 11 | name: "title", 12 | label: "Titel", 13 | type: "text", 14 | required: true, 15 | }, 16 | { 17 | name: "questions", 18 | label: "Questions", 19 | type: "array", 20 | required: true, 21 | fields: [ 22 | { 23 | name: "question", 24 | label: "Question", 25 | type: "text", 26 | required: true, 27 | }, 28 | { 29 | name: "answers", 30 | label: "Answers", 31 | type: "array", 32 | required: true, 33 | fields: [ 34 | { 35 | name: "answer", 36 | label: "Answer", 37 | type: "text", 38 | required: true, 39 | }, 40 | { 41 | name: "true", 42 | label: "Correct", 43 | type: "checkbox", 44 | }, 45 | ] 46 | }, 47 | ] 48 | }, 49 | ], 50 | } -------------------------------------------------------------------------------- /src/collections/Courses/Blocks/VideoBlock.ts: -------------------------------------------------------------------------------- 1 | import { Block } from "payload"; 2 | 3 | export const VideoBlock: Block = { 4 | slug: "video", 5 | labels: { 6 | singular: "Video", 7 | plural: "Videos", 8 | }, 9 | fields: [ 10 | { 11 | name: "title", 12 | label: "Titel", 13 | type: "text", 14 | required: true, 15 | }, 16 | { 17 | name: "duration", 18 | label: "Dauer (in Minuten)", 19 | type: "number", 20 | required: true, 21 | }, 22 | { 23 | name: "playerURL", 24 | label: "Bunny Player URL", 25 | type: "text", 26 | required: true, 27 | }, 28 | ], 29 | } -------------------------------------------------------------------------------- /src/collections/Courses/Courses.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionConfig } from 'payload'; 2 | import { QuizBlock } from './Blocks/QuizBlock'; 3 | import { VideoBlock } from './Blocks/VideoBlock'; 4 | 5 | export const Courses: CollectionConfig = { 6 | slug: "courses", 7 | access: { 8 | read: ({ req: { user } }) => { 9 | return Boolean(user); 10 | }, 11 | create: ({ req: { user } }) => { 12 | return user?.collection === "users"; 13 | }, 14 | update: ({ req: { user } }) => { 15 | return user?.collection === "users"; 16 | }, 17 | delete: ({ req: { user } }) => { 18 | return user?.collection === "users"; 19 | } 20 | }, 21 | admin: { 22 | useAsTitle: "title", 23 | }, 24 | fields: [ 25 | { 26 | name: "title", 27 | label: "Title", 28 | type: "text", 29 | required: true, 30 | }, 31 | { 32 | name: "description", 33 | label: "Description", 34 | type: "textarea", 35 | required: true, 36 | }, 37 | { 38 | name: "image", 39 | label: "Image", 40 | type: "relationship", 41 | relationTo: "media", 42 | required: true, 43 | }, 44 | { 45 | name: "curriculum", 46 | label: "Curriculum", 47 | type: "blocks", 48 | required: true, 49 | blocks: [ 50 | QuizBlock, 51 | VideoBlock 52 | ] 53 | } 54 | ] 55 | } -------------------------------------------------------------------------------- /src/collections/Courses/Participation.ts: -------------------------------------------------------------------------------- 1 | import { CollectionConfig } from "payload"; 2 | 3 | export const Participation: CollectionConfig = { 4 | slug: "participation", 5 | access: { 6 | read: ({ req: { user } }) => { 7 | return { customer: { equals: user?.id } }; 8 | }, 9 | create: ({ req: { user }, data }) => { 10 | if(user?.collection === "users"){ 11 | return true; 12 | } else if (user?.collection === "customers" && data?.customer === user?.id) { 13 | return true; 14 | } else { 15 | return false; 16 | } 17 | }, 18 | update: ({ req: { user } }) => { 19 | return { customer: { equals: user?.id } }; 20 | }, 21 | delete: ({ req: { user } }) => { 22 | return { customer: { equals: user?.id } }; 23 | } 24 | }, 25 | admin: { 26 | useAsTitle: "", 27 | }, 28 | fields: [ 29 | { 30 | name: "customer", 31 | label: "Customer", 32 | type: "relationship", 33 | relationTo: "customers", 34 | required: true, 35 | }, 36 | { 37 | name: "course", 38 | label: "Course", 39 | type: "relationship", 40 | relationTo: "courses", 41 | required: true, 42 | }, 43 | { 44 | name: "progress", 45 | label: "Progress", 46 | type: "number", 47 | } 48 | ] 49 | } -------------------------------------------------------------------------------- /src/collections/Customers.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionConfig } from 'payload'; 2 | 3 | export const Customers: CollectionConfig = { 4 | slug: "customers", 5 | admin: { 6 | useAsTitle: "email", 7 | }, 8 | access: { 9 | create: () => true, 10 | }, 11 | auth: true, 12 | fields: [ 13 | { 14 | name: "participation", 15 | label: "Participation", 16 | type: "relationship", 17 | relationTo: "courses", 18 | hasMany: true, 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /src/collections/Media.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionConfig } from 'payload' 2 | 3 | export const Media: CollectionConfig = { 4 | slug: 'media', 5 | access: { 6 | read: () => true, 7 | }, 8 | fields: [ 9 | { 10 | name: 'alt', 11 | type: 'text', 12 | required: true, 13 | }, 14 | ], 15 | upload: true, 16 | } 17 | -------------------------------------------------------------------------------- /src/collections/Users.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionConfig } from 'payload' 2 | 3 | export const Users: CollectionConfig = { 4 | slug: 'users', 5 | admin: { 6 | useAsTitle: 'email', 7 | }, 8 | auth: true, 9 | fields: [ 10 | // Email added by default 11 | // Add more fields as needed 12 | ], 13 | } 14 | -------------------------------------------------------------------------------- /src/payload-types.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * This file was automatically generated by Payload. 5 | * DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config, 6 | * and re-run `payload generate:types` to regenerate this file. 7 | */ 8 | 9 | export interface Config { 10 | auth: { 11 | users: UserAuthOperations; 12 | customers: CustomerAuthOperations; 13 | }; 14 | collections: { 15 | users: User; 16 | media: Media; 17 | customers: Customer; 18 | courses: Course; 19 | participation: Participation; 20 | 'payload-locked-documents': PayloadLockedDocument; 21 | 'payload-preferences': PayloadPreference; 22 | 'payload-migrations': PayloadMigration; 23 | }; 24 | collectionsJoins: {}; 25 | collectionsSelect: { 26 | users: UsersSelect | UsersSelect; 27 | media: MediaSelect | MediaSelect; 28 | customers: CustomersSelect | CustomersSelect; 29 | courses: CoursesSelect | CoursesSelect; 30 | participation: ParticipationSelect | ParticipationSelect; 31 | 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 32 | 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; 33 | 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; 34 | }; 35 | db: { 36 | defaultIDType: string; 37 | }; 38 | globals: {}; 39 | globalsSelect: {}; 40 | locale: null; 41 | user: 42 | | (User & { 43 | collection: 'users'; 44 | }) 45 | | (Customer & { 46 | collection: 'customers'; 47 | }); 48 | jobs: { 49 | tasks: unknown; 50 | workflows: unknown; 51 | }; 52 | } 53 | export interface UserAuthOperations { 54 | forgotPassword: { 55 | email: string; 56 | password: string; 57 | }; 58 | login: { 59 | email: string; 60 | password: string; 61 | }; 62 | registerFirstUser: { 63 | email: string; 64 | password: string; 65 | }; 66 | unlock: { 67 | email: string; 68 | password: string; 69 | }; 70 | } 71 | export interface CustomerAuthOperations { 72 | forgotPassword: { 73 | email: string; 74 | password: string; 75 | }; 76 | login: { 77 | email: string; 78 | password: string; 79 | }; 80 | registerFirstUser: { 81 | email: string; 82 | password: string; 83 | }; 84 | unlock: { 85 | email: string; 86 | password: string; 87 | }; 88 | } 89 | /** 90 | * This interface was referenced by `Config`'s JSON-Schema 91 | * via the `definition` "users". 92 | */ 93 | export interface User { 94 | id: string; 95 | updatedAt: string; 96 | createdAt: string; 97 | email: string; 98 | resetPasswordToken?: string | null; 99 | resetPasswordExpiration?: string | null; 100 | salt?: string | null; 101 | hash?: string | null; 102 | loginAttempts?: number | null; 103 | lockUntil?: string | null; 104 | password?: string | null; 105 | } 106 | /** 107 | * This interface was referenced by `Config`'s JSON-Schema 108 | * via the `definition` "media". 109 | */ 110 | export interface Media { 111 | id: string; 112 | alt: string; 113 | updatedAt: string; 114 | createdAt: string; 115 | url?: string | null; 116 | thumbnailURL?: string | null; 117 | filename?: string | null; 118 | mimeType?: string | null; 119 | filesize?: number | null; 120 | width?: number | null; 121 | height?: number | null; 122 | focalX?: number | null; 123 | focalY?: number | null; 124 | } 125 | /** 126 | * This interface was referenced by `Config`'s JSON-Schema 127 | * via the `definition` "customers". 128 | */ 129 | export interface Customer { 130 | id: string; 131 | participation?: (string | Course)[] | null; 132 | updatedAt: string; 133 | createdAt: string; 134 | email: string; 135 | resetPasswordToken?: string | null; 136 | resetPasswordExpiration?: string | null; 137 | salt?: string | null; 138 | hash?: string | null; 139 | loginAttempts?: number | null; 140 | lockUntil?: string | null; 141 | password?: string | null; 142 | } 143 | /** 144 | * This interface was referenced by `Config`'s JSON-Schema 145 | * via the `definition` "courses". 146 | */ 147 | export interface Course { 148 | id: string; 149 | title: string; 150 | description: string; 151 | image: string | Media; 152 | curriculum: ( 153 | | { 154 | title: string; 155 | questions: { 156 | question: string; 157 | answers: { 158 | answer: string; 159 | true?: boolean | null; 160 | id?: string | null; 161 | }[]; 162 | id?: string | null; 163 | }[]; 164 | id?: string | null; 165 | blockName?: string | null; 166 | blockType: 'quiz'; 167 | } 168 | | { 169 | title: string; 170 | duration: number; 171 | playerURL: string; 172 | id?: string | null; 173 | blockName?: string | null; 174 | blockType: 'video'; 175 | } 176 | )[]; 177 | updatedAt: string; 178 | createdAt: string; 179 | } 180 | /** 181 | * This interface was referenced by `Config`'s JSON-Schema 182 | * via the `definition` "participation". 183 | */ 184 | export interface Participation { 185 | id: string; 186 | customer: string | Customer; 187 | course: string | Course; 188 | progress?: number | null; 189 | updatedAt: string; 190 | createdAt: string; 191 | } 192 | /** 193 | * This interface was referenced by `Config`'s JSON-Schema 194 | * via the `definition` "payload-locked-documents". 195 | */ 196 | export interface PayloadLockedDocument { 197 | id: string; 198 | document?: 199 | | ({ 200 | relationTo: 'users'; 201 | value: string | User; 202 | } | null) 203 | | ({ 204 | relationTo: 'media'; 205 | value: string | Media; 206 | } | null) 207 | | ({ 208 | relationTo: 'customers'; 209 | value: string | Customer; 210 | } | null) 211 | | ({ 212 | relationTo: 'courses'; 213 | value: string | Course; 214 | } | null) 215 | | ({ 216 | relationTo: 'participation'; 217 | value: string | Participation; 218 | } | null); 219 | globalSlug?: string | null; 220 | user: 221 | | { 222 | relationTo: 'users'; 223 | value: string | User; 224 | } 225 | | { 226 | relationTo: 'customers'; 227 | value: string | Customer; 228 | }; 229 | updatedAt: string; 230 | createdAt: string; 231 | } 232 | /** 233 | * This interface was referenced by `Config`'s JSON-Schema 234 | * via the `definition` "payload-preferences". 235 | */ 236 | export interface PayloadPreference { 237 | id: string; 238 | user: 239 | | { 240 | relationTo: 'users'; 241 | value: string | User; 242 | } 243 | | { 244 | relationTo: 'customers'; 245 | value: string | Customer; 246 | }; 247 | key?: string | null; 248 | value?: 249 | | { 250 | [k: string]: unknown; 251 | } 252 | | unknown[] 253 | | string 254 | | number 255 | | boolean 256 | | null; 257 | updatedAt: string; 258 | createdAt: string; 259 | } 260 | /** 261 | * This interface was referenced by `Config`'s JSON-Schema 262 | * via the `definition` "payload-migrations". 263 | */ 264 | export interface PayloadMigration { 265 | id: string; 266 | name?: string | null; 267 | batch?: number | null; 268 | updatedAt: string; 269 | createdAt: string; 270 | } 271 | /** 272 | * This interface was referenced by `Config`'s JSON-Schema 273 | * via the `definition` "users_select". 274 | */ 275 | export interface UsersSelect { 276 | updatedAt?: T; 277 | createdAt?: T; 278 | email?: T; 279 | resetPasswordToken?: T; 280 | resetPasswordExpiration?: T; 281 | salt?: T; 282 | hash?: T; 283 | loginAttempts?: T; 284 | lockUntil?: T; 285 | } 286 | /** 287 | * This interface was referenced by `Config`'s JSON-Schema 288 | * via the `definition` "media_select". 289 | */ 290 | export interface MediaSelect { 291 | alt?: T; 292 | updatedAt?: T; 293 | createdAt?: T; 294 | url?: T; 295 | thumbnailURL?: T; 296 | filename?: T; 297 | mimeType?: T; 298 | filesize?: T; 299 | width?: T; 300 | height?: T; 301 | focalX?: T; 302 | focalY?: T; 303 | } 304 | /** 305 | * This interface was referenced by `Config`'s JSON-Schema 306 | * via the `definition` "customers_select". 307 | */ 308 | export interface CustomersSelect { 309 | participation?: T; 310 | updatedAt?: T; 311 | createdAt?: T; 312 | email?: T; 313 | resetPasswordToken?: T; 314 | resetPasswordExpiration?: T; 315 | salt?: T; 316 | hash?: T; 317 | loginAttempts?: T; 318 | lockUntil?: T; 319 | } 320 | /** 321 | * This interface was referenced by `Config`'s JSON-Schema 322 | * via the `definition` "courses_select". 323 | */ 324 | export interface CoursesSelect { 325 | title?: T; 326 | description?: T; 327 | image?: T; 328 | curriculum?: 329 | | T 330 | | { 331 | quiz?: 332 | | T 333 | | { 334 | title?: T; 335 | questions?: 336 | | T 337 | | { 338 | question?: T; 339 | answers?: 340 | | T 341 | | { 342 | answer?: T; 343 | true?: T; 344 | id?: T; 345 | }; 346 | id?: T; 347 | }; 348 | id?: T; 349 | blockName?: T; 350 | }; 351 | video?: 352 | | T 353 | | { 354 | title?: T; 355 | duration?: T; 356 | playerURL?: T; 357 | id?: T; 358 | blockName?: T; 359 | }; 360 | }; 361 | updatedAt?: T; 362 | createdAt?: T; 363 | } 364 | /** 365 | * This interface was referenced by `Config`'s JSON-Schema 366 | * via the `definition` "participation_select". 367 | */ 368 | export interface ParticipationSelect { 369 | customer?: T; 370 | course?: T; 371 | progress?: T; 372 | updatedAt?: T; 373 | createdAt?: T; 374 | } 375 | /** 376 | * This interface was referenced by `Config`'s JSON-Schema 377 | * via the `definition` "payload-locked-documents_select". 378 | */ 379 | export interface PayloadLockedDocumentsSelect { 380 | document?: T; 381 | globalSlug?: T; 382 | user?: T; 383 | updatedAt?: T; 384 | createdAt?: T; 385 | } 386 | /** 387 | * This interface was referenced by `Config`'s JSON-Schema 388 | * via the `definition` "payload-preferences_select". 389 | */ 390 | export interface PayloadPreferencesSelect { 391 | user?: T; 392 | key?: T; 393 | value?: T; 394 | updatedAt?: T; 395 | createdAt?: T; 396 | } 397 | /** 398 | * This interface was referenced by `Config`'s JSON-Schema 399 | * via the `definition` "payload-migrations_select". 400 | */ 401 | export interface PayloadMigrationsSelect { 402 | name?: T; 403 | batch?: T; 404 | updatedAt?: T; 405 | createdAt?: T; 406 | } 407 | /** 408 | * This interface was referenced by `Config`'s JSON-Schema 409 | * via the `definition` "auth". 410 | */ 411 | export interface Auth { 412 | [k: string]: unknown; 413 | } 414 | 415 | 416 | declare module 'payload' { 417 | export interface GeneratedTypes extends Config {} 418 | } -------------------------------------------------------------------------------- /src/payload.config.ts: -------------------------------------------------------------------------------- 1 | // storage-adapter-import-placeholder 2 | import { mongooseAdapter } from '@payloadcms/db-mongodb' 3 | import { payloadCloudPlugin } from '@payloadcms/payload-cloud' 4 | import { lexicalEditor } from '@payloadcms/richtext-lexical' 5 | import path from 'path' 6 | import { buildConfig } from 'payload' 7 | import { fileURLToPath } from 'url' 8 | import sharp from 'sharp' 9 | 10 | import { Users } from './collections/Users' 11 | import { Media } from './collections/Media' 12 | 13 | import { s3Storage } from '@payloadcms/storage-s3' 14 | import brevoAdapter from './utils/brevoAdapter' 15 | import { Customers } from './collections/Customers' 16 | import { Courses } from './collections/Courses/Courses' 17 | import { Participation } from './collections/Courses/Participation' 18 | 19 | const filename = fileURLToPath(import.meta.url) 20 | const dirname = path.dirname(filename) 21 | 22 | export default buildConfig({ 23 | admin: { 24 | user: Users.slug, 25 | importMap: { 26 | baseDir: path.resolve(dirname), 27 | }, 28 | }, 29 | email: brevoAdapter(), 30 | collections: [Users, Media, Customers, Courses, Participation], 31 | editor: lexicalEditor(), 32 | secret: process.env.PAYLOAD_SECRET || '', 33 | typescript: { 34 | outputFile: path.resolve(dirname, 'payload-types.ts'), 35 | }, 36 | db: mongooseAdapter({ 37 | url: process.env.DATABASE_URI || '', 38 | }), 39 | sharp, 40 | plugins: [ 41 | payloadCloudPlugin(), 42 | s3Storage({ 43 | collections: { 44 | media: true 45 | }, 46 | bucket: process.env.S3_BUCKET_NAME || "", 47 | config: { 48 | region: process.env.S3_REGION || "", 49 | endpoint: process.env.S3_ENDPOINT || "", 50 | credentials: { 51 | accessKeyId: process.env.S3_ACCESS_KEY || "", 52 | secretAccessKey: process.env.S3_SECRET_KEY || "" 53 | } 54 | } 55 | }) 56 | ], 57 | }) 58 | -------------------------------------------------------------------------------- /src/utils/brevoAdapter.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { EmailAdapter, SendEmailOptions } from "payload"; 3 | 4 | const brevoAdapter = (): EmailAdapter => { 5 | const adapter = () => ({ 6 | name: "brevo", 7 | defaultFromName: process.env.BREVO_SENDER_NAME as string, 8 | defaultFromAddress: process.env.BREVO_SENDER_EMAIL as string, 9 | sendEmail: async (message: SendEmailOptions): Promise => { 10 | if(!process.env.BREVO_EMAILS_ACTIVE){ 11 | console.log("Emails disabled, logging to console"); 12 | console.log(message); 13 | return; 14 | } 15 | try{ 16 | const res = await axios({ 17 | method: "post", 18 | url: "https://api.brevo.com/v3/smtp/email", 19 | headers: { 20 | "api-key": process.env.BREVO_API_KEY as string, 21 | "Content-Type": "application/json", 22 | "Accept": "application/json" 23 | }, 24 | data: { 25 | sender: { 26 | name: process.env.BREVO_SENDER_NAME as string, 27 | email: process.env.BREVO_SENDER_EMAIL as string 28 | }, 29 | to: [{ 30 | email: message.to 31 | }], 32 | subject: message.subject, 33 | htmlContent: message.html 34 | } 35 | }); 36 | return res.data 37 | }catch(error){ 38 | console.error("Error sending email with Brevo", error); 39 | } 40 | } 41 | }) 42 | 43 | return adapter; 44 | } 45 | 46 | export default brevoAdapter; -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | "./src/**/*.{js,ts,jsx,tsx,mdx}", 5 | ], 6 | theme: { 7 | extend: {}, 8 | }, 9 | plugins: [], 10 | } 11 | 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "lib": [ 5 | "DOM", 6 | "DOM.Iterable", 7 | "ES2022" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "noEmit": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "moduleResolution": "bundler", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "preserve", 19 | "incremental": true, 20 | "plugins": [ 21 | { 22 | "name": "next" 23 | } 24 | ], 25 | "paths": { 26 | "@/*": [ 27 | "./src/*" 28 | ], 29 | "@payload-config": [ 30 | "./src/payload.config.ts" 31 | ] 32 | }, 33 | "target": "ES2022", 34 | }, 35 | "include": [ 36 | "next-env.d.ts", 37 | "**/*.ts", 38 | "**/*.tsx", 39 | ".next/types/**/*.ts" 40 | ], 41 | "exclude": [ 42 | "node_modules" 43 | ] 44 | } 45 | --------------------------------------------------------------------------------