├── app ├── favicon.ico ├── page.tsx ├── (auth) │ ├── login │ │ └── [[...login]] │ │ │ └── page.tsx │ └── register │ │ └── [[...register]] │ │ └── page.tsx ├── (main) │ ├── (public) │ │ └── book │ │ │ ├── [clerkUserId] │ │ │ ├── page.tsx │ │ │ └── [eventId] │ │ │ │ ├── page.tsx │ │ │ │ └── success │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ ├── layout.tsx │ └── (private) │ │ ├── events │ │ ├── new │ │ │ └── page.tsx │ │ ├── [eventId] │ │ │ └── edit │ │ │ │ └── page.tsx │ │ └── page.tsx │ │ └── schedule │ │ └── page.tsx ├── layout.tsx └── globals.css ├── postcss.config.mjs ├── next.config.ts ├── drizzle ├── migrations │ ├── meta │ │ ├── _journal.json │ │ └── 0000_snapshot.json │ └── 0000_careful_mesmero.sql ├── db.ts └── schema.ts ├── .env.example ├── components ├── Loading.tsx ├── Booking.tsx ├── ui │ ├── sonner.tsx │ ├── label.tsx │ ├── textarea.tsx │ ├── input.tsx │ ├── switch.tsx │ ├── popover.tsx │ ├── button.tsx │ ├── card.tsx │ ├── calendar.tsx │ ├── form.tsx │ ├── alert-dialog.tsx │ └── select.tsx ├── NoTimeSlots.tsx ├── LandingPage.tsx ├── PublicNavBar.tsx ├── PublicEventCard.tsx ├── cards │ └── EventCard.tsx ├── PrivateNavBar.tsx ├── CopyEventButton.tsx ├── PublicProfile.tsx └── forms │ ├── EventForm.tsx │ ├── ScheduleForm.tsx │ └── MeetingForm.tsx ├── components.json ├── README.md ├── constants └── index.ts ├── .gitignore ├── middleware.ts ├── tsconfig.json ├── lib ├── utils.ts └── formatters.ts ├── schema ├── events.ts ├── meetings.ts └── schedule.ts ├── drizzle.config.ts ├── public └── assets │ ├── public.svg │ ├── events.svg │ ├── schedule.svg │ ├── meeting.svg │ └── planning.svg ├── package.json └── server ├── actions ├── meetings.ts ├── events.ts └── schedule.ts └── google └── googleCalendar.ts /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Programming-Fluency/Calendra-Course/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /drizzle/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "postgresql", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "7", 8 | "when": 1746659219873, 9 | "tag": "0000_careful_mesmero", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= 2 | CLERK_SECRET_KEY= 3 | NEXT_PUBLIC_CLERK_SIGN_IN_URL=/login 4 | NEXT_PUBLIC_CLERK_SIGN_UP_URL=/register 5 | NEXT_PUBLIC_CLERK_SIGN_IN_FORCE_REDIRECT_URL= /events 6 | NEXT_PUBLIC_CLERK_SIGN_UP_FORCE_REDIRECT_URL=/events 7 | DATABASE_URL= 8 | GOOGLE_OAUTH_CLIENT_ID= 9 | GOOGLE_OAUTH_CLIENT_SECRET= 10 | GOOGLE_OAUTH_REDIRECT_URL= -------------------------------------------------------------------------------- /components/Loading.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import {Mosaic} from "react-loading-indicators" 3 | 4 | export default function Loading() { 5 | return ( 6 |
7 | 8 | 9 |
10 | ); 11 | }; 12 | 13 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import LandingPage from "@/components/LandingPage" 2 | import { currentUser } from "@clerk/nextjs/server" 3 | import { redirect } from "next/navigation" 4 | 5 | export default async function HomePage() { 6 | 7 | const user = await currentUser() 8 | 9 | // If no user is logged in, render the public landing page 10 | if (!user) return 11 | 12 | // If user is logged in, redirect them to the events page 13 | return redirect('/events') 14 | 15 | } 16 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /components/Booking.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { BlinkBlur } from "react-loading-indicators" 3 | 4 | const Booking = () => { 5 | return ( 6 |
7 | 13 |
14 | ) 15 | 16 | } 17 | 18 | export default Booking -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📅 Build and Deploy a Calendly Clone 2 | 3 | This project-based course walks you through building a full-stack Calendly clone from scratch using **Next.js 15**, **Clerk** for authentication, **Tailwind CSS**, and a modern tech stack. 4 | 5 | You’ll learn how to: 6 | - Create and manage event types 7 | - Implement user availability and scheduling 8 | - Handle full-stack logic using server actions 9 | - Deploy your app to the web using **Vercel** 10 | 11 | Perfect for developers looking to sharpen their full-stack skills with real-world functionality. 12 | -------------------------------------------------------------------------------- /app/(auth)/login/[[...login]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignIn } from "@clerk/nextjs"; 2 | import Image from "next/image"; 3 | 4 | export default function LoginPage() { 5 | return( 6 |
7 | Logo 13 | 14 |
15 | 16 |
17 |
18 | ) 19 | } -------------------------------------------------------------------------------- /app/(auth)/register/[[...register]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignUp } from "@clerk/nextjs"; 2 | import Image from "next/image"; 3 | 4 | export default function RegisterPage() { 5 | return ( 6 |
7 | Logo 13 |
14 | 15 |
16 |
17 | ) 18 | } -------------------------------------------------------------------------------- /app/(main)/(public)/book/[clerkUserId]/page.tsx: -------------------------------------------------------------------------------- 1 | import PublicProfile from "@/components/PublicProfile" 2 | import { clerkClient } from "@clerk/nextjs/server" 3 | 4 | 5 | export default async function PublicProfilePage({ 6 | params, 7 | }: { 8 | params: Promise<{ clerkUserId: string }> 9 | }) { 10 | const { clerkUserId } = await params 11 | const client = await clerkClient() 12 | const user = await client.users.getUser(clerkUserId) 13 | const { fullName } = user // Extract the user's full name 14 | 15 | // Render PublicProfile component 16 | return 17 | } 18 | -------------------------------------------------------------------------------- /constants/index.ts: -------------------------------------------------------------------------------- 1 | export const DAYS_OF_WEEK_IN_ORDER = [ 2 | "monday", 3 | "tuesday", 4 | "wednesday", 5 | "thursday", 6 | "friday", 7 | "saturday", 8 | "sunday", 9 | ] as const 10 | 11 | 12 | export const PrivateNavLinks = [ 13 | { 14 | imgURL: '/assets/events.svg', 15 | route: '/events', 16 | label: 'My Events', 17 | }, 18 | { 19 | imgURL: '/assets/schedule.svg', 20 | route: '/schedule', 21 | label: 'My Schedule', 22 | }, 23 | { 24 | imgURL: '/assets/public.svg', 25 | route: '/book', 26 | label: 'Public Profile', 27 | }, 28 | ] as const -------------------------------------------------------------------------------- /drizzle/db.ts: -------------------------------------------------------------------------------- 1 | // Import the Neon serverless client for PostgreSQL 2 | import { neon } from "@neondatabase/serverless" 3 | // Import Drizzle's Neon HTTP driver for ORM support 4 | import { drizzle } from "drizzle-orm/neon-http" 5 | 6 | // Import your database schema definitions (e.g., tables) from the local schema file 7 | import * as schema from "./schema" 8 | 9 | // Initialize the Neon client using the DATABASE_URL from your environment variables 10 | const sql = neon(process.env.DATABASE_URL!) 11 | 12 | // Create and export the Drizzle ORM instance, with the Neon client and schema for type-safe queries 13 | export const db = drizzle(sql, { schema }) 14 | 15 | -------------------------------------------------------------------------------- /.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.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 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 | # env files (can opt-in for committing if needed) 34 | .env 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useTheme } from "next-themes" 4 | import { Toaster as Sonner, ToasterProps } from "sonner" 5 | 6 | const Toaster = ({ ...props }: ToasterProps) => { 7 | const { theme = "system" } = useTheme() 8 | 9 | return ( 10 | 22 | ) 23 | } 24 | 25 | export { Toaster } 26 | -------------------------------------------------------------------------------- /app/(main)/(public)/book/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Loading from "@/components/Loading" 4 | import { useUser } from "@clerk/nextjs" 5 | import { redirect } from "next/navigation" 6 | 7 | export default function PublicPage() { 8 | const { user, isLoaded } = useUser() // Using `isLoaded` to check if user data is available 9 | 10 | if (!isLoaded) { 11 | // Display loading until user data is loaded 12 | return 13 | } 14 | 15 | if (!user) { 16 | // Redirect to login if no user is found 17 | return redirect('/login') 18 | } 19 | 20 | // Once user is available, redirect to the booking page [Public Profile Page] 21 | return redirect(`/book/${user.id}`) 22 | } -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'; 2 | 3 | const isPublicRoute = createRouteMatcher([ 4 | "/", 5 | "/login(.*)", 6 | "/register(.*)", 7 | "/book(.*)", 8 | ]) 9 | 10 | export default clerkMiddleware(async (auth, req) => { 11 | if (!isPublicRoute(req)) { 12 | await auth.protect() 13 | } 14 | }); 15 | 16 | export const config = { 17 | matcher: [ 18 | // Skip Next.js internals and all static files, unless found in search params 19 | '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)', 20 | // Always run for API routes 21 | '/(api|trpc)(.*)', 22 | ], 23 | }; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Label({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | ) 22 | } 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /app/(main)/layout.tsx: -------------------------------------------------------------------------------- 1 | import PrivateNavBar from "@/components/PrivateNavBar" 2 | import PublicNavBar from "@/components/PublicNavBar" 3 | import { currentUser } from "@clerk/nextjs/server" 4 | 5 | export default async function MainLayout({ 6 | children, 7 | }: { 8 | children: React.ReactNode 9 | }) { 10 | 11 | const user = await currentUser() 12 | 13 | return ( 14 |
15 | {/* Render PrivateNavBar if user exists, otherwise PublicNavBar */} 16 | {user ? : } 17 | 18 | 19 | {/* Render the children */} 20 |
21 | {children} 22 |
23 |
24 | ) 25 | } -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | 8 | 9 | // Converts a time string like "09:15" into a decimal number like 9.25 10 | // This is mainly for display purposes (not for precise time calculations) 11 | export function timeToFloat(time: string): number { 12 | // Split the time string by ":" into [hours, minutes] and convert both to numbers 13 | const [hours, minutes] = time.split(":").map(Number) 14 | // Note: .map(Number) is a shorthand way to convert an array of strings to numbers. 15 | 16 | // Convert minutes into a fraction of an hour and add it to the hour 17 | return hours + minutes / 60 18 | } -------------------------------------------------------------------------------- /components/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { 6 | return ( 7 |