├── .eslintrc.json
├── src
├── app
│ ├── (auth)
│ │ ├── sign-in
│ │ │ └── [[...sign-in]]
│ │ │ │ └── page.tsx
│ │ ├── sign-up
│ │ │ └── [[...sign-up]]
│ │ │ │ └── page.tsx
│ │ └── layout.tsx
│ ├── (public)
│ │ ├── layout.tsx
│ │ └── book
│ │ │ └── [clerkUserId]
│ │ │ ├── [eventId]
│ │ │ ├── loading.tsx
│ │ │ ├── success
│ │ │ │ └── page.tsx
│ │ │ └── page.tsx
│ │ │ └── page.tsx
│ ├── (private)
│ │ ├── events
│ │ │ ├── new
│ │ │ │ └── page.tsx
│ │ │ ├── [eventId]
│ │ │ │ └── edit
│ │ │ │ │ └── page.tsx
│ │ │ └── page.tsx
│ │ ├── schedule
│ │ │ └── page.tsx
│ │ └── layout.tsx
│ ├── page.tsx
│ ├── layout.tsx
│ └── globals.css
├── data
│ └── constants.ts
├── drizzle
│ ├── db.ts
│ ├── migrations
│ │ ├── meta
│ │ │ ├── _journal.json
│ │ │ └── 0000_snapshot.json
│ │ └── 0000_dazzling_penance.sql
│ └── schema.ts
├── lib
│ ├── utils.ts
│ ├── formatters.ts
│ └── getValidTimesFromSchedule.ts
├── schema
│ ├── events.ts
│ ├── meetings.ts
│ └── schedule.ts
├── components
│ ├── NavLink.tsx
│ ├── ui
│ │ ├── label.tsx
│ │ ├── textarea.tsx
│ │ ├── input.tsx
│ │ ├── switch.tsx
│ │ ├── popover.tsx
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── calendar.tsx
│ │ ├── form.tsx
│ │ ├── alert-dialog.tsx
│ │ └── select.tsx
│ ├── CopyEventButton.tsx
│ └── forms
│ │ ├── EventForm.tsx
│ │ ├── ScheduleForm.tsx
│ │ └── MeetingForm.tsx
├── middleware.ts
└── server
│ ├── actions
│ ├── meetings.ts
│ ├── schedule.ts
│ └── events.ts
│ └── googleCalendar.ts
├── postcss.config.mjs
├── next.config.mjs
├── .env.example
├── drizzle.config.ts
├── components.json
├── .gitignore
├── public
├── vercel.svg
└── next.svg
├── tsconfig.json
├── LICENSE
├── README.md
├── package.json
└── tailwind.config.ts
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx:
--------------------------------------------------------------------------------
1 | import { SignIn } from "@clerk/nextjs"
2 |
3 | export default function Page() {
4 | return
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx:
--------------------------------------------------------------------------------
1 | import { SignUp } from "@clerk/nextjs"
2 |
3 | export default function Page() {
4 | return
5 | }
6 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/src/data/constants.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 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | experimental: {
4 | staleTimes: {
5 | dynamic: 0,
6 | },
7 | },
8 | }
9 |
10 | export default nextConfig
11 |
--------------------------------------------------------------------------------
/src/app/(public)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from "react"
2 |
3 | export default function PublicLayout({ children }: { children: ReactNode }) {
4 | return {children}
5 | }
6 |
--------------------------------------------------------------------------------
/src/drizzle/db.ts:
--------------------------------------------------------------------------------
1 | import { neon } from "@neondatabase/serverless"
2 | import { drizzle } from "drizzle-orm/neon-http"
3 | import * as schema from "./schema"
4 |
5 | const sql = neon(process.env.DATABASE_URL!)
6 | export const db = drizzle(sql, { schema })
7 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | CLERK_SECRET_KEY=
2 | DATABASE_URL=
3 | GOOGLE_OAUTH_CLIENT_ID=
4 | GOOGLE_OAUTH_CLIENT_SECRET=
5 | GOOGLE_OAUTH_REDIRECT_URL=
6 |
7 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
8 | NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
9 | NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
--------------------------------------------------------------------------------
/src/drizzle/migrations/meta/_journal.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "7",
3 | "dialect": "postgresql",
4 | "entries": [
5 | {
6 | "idx": 0,
7 | "version": "7",
8 | "when": 1725382070346,
9 | "tag": "0000_dazzling_penance",
10 | "breakpoints": true
11 | }
12 | ]
13 | }
--------------------------------------------------------------------------------
/src/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 | export function timeToInt(time: string) {
9 | return parseFloat(time.replace(":", "."))
10 | }
11 |
--------------------------------------------------------------------------------
/drizzle.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "drizzle-kit"
2 |
3 | export default defineConfig({
4 | schema: "./src/drizzle/schema.ts",
5 | out: "./src/drizzle/migrations",
6 | dialect: "postgresql",
7 | strict: true,
8 | verbose: true,
9 | dbCredentials: {
10 | url: process.env.DATABASE_URL as string,
11 | },
12 | })
13 |
--------------------------------------------------------------------------------
/src/schema/events.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod"
2 |
3 | export const eventFormSchema = z.object({
4 | name: z.string().min(1, "Required"),
5 | description: z.string().optional(),
6 | isActive: z.boolean().default(true),
7 | durationInMinutes: z.coerce
8 | .number()
9 | .int()
10 | .positive("Duration must be greater than 0")
11 | .max(60 * 12, `Duration must be less than 12 hours (${60 * 12} minutes)`),
12 | })
13 |
--------------------------------------------------------------------------------
/src/app/(auth)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { auth } from "@clerk/nextjs/server"
2 | import { redirect } from "next/navigation"
3 | import { ReactNode } from "react"
4 |
5 | export default function AuthLayout({ children }: { children: ReactNode }) {
6 | const { userId } = auth()
7 | if (userId != null) redirect("/")
8 |
9 | return (
10 |
11 | {children}
12 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/src/app/(private)/events/new/page.tsx:
--------------------------------------------------------------------------------
1 | import { EventForm } from "@/components/forms/EventForm"
2 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
3 |
4 | export default function NewEventPage() {
5 | return (
6 |
7 |
8 | New Event
9 |
10 |
11 |
12 |
13 |
14 | )
15 | }
16 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "src/app/globals.css",
9 | "baseColor": "slate",
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 | }
--------------------------------------------------------------------------------
/src/app/(public)/book/[clerkUserId]/[eventId]/loading.tsx:
--------------------------------------------------------------------------------
1 | import { LoaderCircle } from "lucide-react"
2 |
3 | export default function Loading() {
4 | return (
5 |
6 |
7 | Loading...
8 |
9 |
10 |
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/.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 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env
30 | .env*.local
31 |
32 | # vercel
33 | .vercel
34 |
35 | # typescript
36 | *.tsbuildinfo
37 | next-env.d.ts
38 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/NavLink.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { cn } from "@/lib/utils"
4 | import Link from "next/link"
5 | import { usePathname } from "next/navigation"
6 | import { ComponentProps } from "react"
7 |
8 | export function NavLink({ className, ...props }: ComponentProps) {
9 | const path = usePathname()
10 | const isActive = path === props.href
11 |
12 | return (
13 |
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./src/*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------
/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"
2 |
3 | const isPublicRoute = createRouteMatcher([
4 | "/",
5 | "/sign-in(.*)",
6 | "/sign-up(.*)",
7 | "/book(.*)",
8 | ])
9 |
10 | export default clerkMiddleware((auth, req) => {
11 | if (!isPublicRoute(req)) {
12 | 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 | }
24 |
--------------------------------------------------------------------------------
/src/schema/meetings.ts:
--------------------------------------------------------------------------------
1 | import { startOfDay } from "date-fns"
2 | import { z } from "zod"
3 |
4 | const meetingSchemaBase = z.object({
5 | startTime: z.date().min(new Date()),
6 | guestEmail: z.string().email().min(1, "Required"),
7 | guestName: z.string().min(1, "Required"),
8 | guestNotes: z.string().optional(),
9 | timezone: z.string().min(1, "Required"),
10 | })
11 |
12 | export const meetingFormSchema = z
13 | .object({
14 | date: z.date().min(startOfDay(new Date()), "Must be in the future"),
15 | })
16 | .merge(meetingSchemaBase)
17 |
18 | export const meetingActionSchema = z
19 | .object({
20 | eventId: z.string().min(1, "Required"),
21 | clerkUserId: z.string().min(1, "Required"),
22 | })
23 | .merge(meetingSchemaBase)
24 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button"
2 | import { SignInButton, SignUpButton, UserButton } from "@clerk/nextjs"
3 | import { auth } from "@clerk/nextjs/server"
4 | import { redirect } from "next/navigation"
5 |
6 | export default function HomePage() {
7 | const { userId } = auth()
8 | if (userId != null) redirect("/events")
9 |
10 | return (
11 |
12 |
Fancy Home Page
13 |
14 |
17 |
20 |
21 |
22 |
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | )
20 | }
21 | )
22 | Textarea.displayName = "Textarea"
23 |
24 | export { Textarea }
25 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next"
2 | import { Inter } from "next/font/google"
3 | import "./globals.css"
4 | import { cn } from "@/lib/utils"
5 | import { ClerkProvider } from "@clerk/nextjs"
6 |
7 | const inter = Inter({ subsets: ["latin"], variable: "--font-sans" })
8 |
9 | export const metadata: Metadata = {
10 | title: "Create Next App",
11 | description: "Generated by create next app",
12 | }
13 |
14 | export default function RootLayout({
15 | children,
16 | }: Readonly<{
17 | children: React.ReactNode
18 | }>) {
19 | return (
20 |
21 |
22 |
28 | {children}
29 |
30 |
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | }
22 | )
23 | Input.displayName = "Input"
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/src/app/(private)/schedule/page.tsx:
--------------------------------------------------------------------------------
1 | import { EventForm } from "@/components/forms/EventForm"
2 | import { ScheduleForm } from "@/components/forms/ScheduleForm"
3 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
4 | import { db } from "@/drizzle/db"
5 | import { auth } from "@clerk/nextjs/server"
6 |
7 | export const revalidate = 0
8 |
9 | export default async function SchedulePage() {
10 | const { userId, redirectToSignIn } = auth()
11 | if (userId == null) return redirectToSignIn()
12 |
13 | const schedule = await db.query.ScheduleTable.findFirst({
14 | where: ({ clerkUserId }, { eq }) => eq(clerkUserId, userId),
15 | with: { availabilities: true },
16 | })
17 |
18 | return (
19 |
20 |
21 | Schedule
22 |
23 |
24 |
25 |
26 |
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/src/app/(private)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { NavLink } from "@/components/NavLink"
2 | import { UserButton } from "@clerk/nextjs"
3 | import { CalendarRange } from "lucide-react"
4 | import { ReactNode } from "react"
5 |
6 | export default function PrivateLayout({ children }: { children: ReactNode }) {
7 | return (
8 | <>
9 |
10 |
23 |
24 | {children}
25 | >
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 WebDevSimplified
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 |
--------------------------------------------------------------------------------
/src/app/(private)/events/[eventId]/edit/page.tsx:
--------------------------------------------------------------------------------
1 | import { EventForm } from "@/components/forms/EventForm"
2 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
3 | import { db } from "@/drizzle/db"
4 | import { auth } from "@clerk/nextjs/server"
5 | import { notFound } from "next/navigation"
6 |
7 | export const revalidate = 0
8 |
9 | export default async function EditEventPage({
10 | params: { eventId },
11 | }: {
12 | params: { eventId: string }
13 | }) {
14 | const { userId, redirectToSignIn } = auth()
15 | if (userId == null) return redirectToSignIn()
16 |
17 | const event = await db.query.EventTable.findFirst({
18 | where: ({ id, clerkUserId }, { and, eq }) =>
19 | and(eq(clerkUserId, userId), eq(id, eventId)),
20 | })
21 |
22 | if (event == null) return notFound()
23 |
24 | return (
25 |
26 |
27 | Edit Event
28 |
29 |
30 |
33 |
34 |
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SwitchPrimitives from "@radix-ui/react-switch"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Switch = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
25 |
26 | ))
27 | Switch.displayName = SwitchPrimitives.Root.displayName
28 |
29 | export { Switch }
30 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as PopoverPrimitive from "@radix-ui/react-popover"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Popover = PopoverPrimitive.Root
9 |
10 | const PopoverTrigger = PopoverPrimitive.Trigger
11 |
12 | const PopoverContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
16 |
17 |
27 |
28 | ))
29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName
30 |
31 | export { Popover, PopoverTrigger, PopoverContent }
32 |
--------------------------------------------------------------------------------
/src/lib/formatters.ts:
--------------------------------------------------------------------------------
1 | export function formatEventDescription(durationInMinutes: number) {
2 | const hours = Math.floor(durationInMinutes / 60)
3 | const minutes = durationInMinutes % 60
4 | const minutesString = `${minutes} ${minutes > 1 ? "mins" : "min"}`
5 | const hoursString = `${hours} ${hours > 1 ? "hrs" : "hr"}`
6 |
7 | if (hours === 0) return minutesString
8 | if (minutes === 0) return hoursString
9 | return `${hoursString} ${minutesString}`
10 | }
11 |
12 | export function formatTimezoneOffset(timezone: string) {
13 | return new Intl.DateTimeFormat(undefined, {
14 | timeZone: timezone,
15 | timeZoneName: "shortOffset",
16 | })
17 | .formatToParts(new Date())
18 | .find(part => part.type == "timeZoneName")?.value
19 | }
20 |
21 | const dateFormatter = new Intl.DateTimeFormat(undefined, {
22 | dateStyle: "medium",
23 | })
24 |
25 | export function formatDate(date: Date) {
26 | return dateFormatter.format(date)
27 | }
28 |
29 | const timeFormatter = new Intl.DateTimeFormat(undefined, {
30 | timeStyle: "short",
31 | })
32 |
33 | export function formatTimeString(date: Date) {
34 | return timeFormatter.format(date)
35 | }
36 |
37 | const dateTimeFormatter = new Intl.DateTimeFormat(undefined, {
38 | dateStyle: "medium",
39 | timeStyle: "short",
40 | })
41 |
42 | export function formatDateTime(date: Date) {
43 | return dateTimeFormatter.format(date)
44 | }
45 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
2 |
3 | ## Getting Started
4 |
5 | First, run the development server:
6 |
7 | ```bash
8 | npm run dev
9 | # or
10 | yarn dev
11 | # or
12 | pnpm dev
13 | # or
14 | bun dev
15 | ```
16 |
17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18 |
19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20 |
21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
22 |
23 | ## Learn More
24 |
25 | To learn more about Next.js, take a look at the following resources:
26 |
27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29 |
30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
31 |
32 | ## Deploy on Vercel
33 |
34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35 |
36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
37 |
--------------------------------------------------------------------------------
/src/server/actions/meetings.ts:
--------------------------------------------------------------------------------
1 | "use server"
2 | import { db } from "@/drizzle/db"
3 | import { getValidTimesFromSchedule } from "@/lib/getValidTimesFromSchedule"
4 | import { meetingActionSchema } from "@/schema/meetings"
5 | import "use-server"
6 | import { z } from "zod"
7 | import { createCalendarEvent } from "../googleCalendar"
8 | import { redirect } from "next/navigation"
9 | import { fromZonedTime } from "date-fns-tz"
10 |
11 | export async function createMeeting(
12 | unsafeData: z.infer
13 | ) {
14 | const { success, data } = meetingActionSchema.safeParse(unsafeData)
15 |
16 | if (!success) return { error: true }
17 |
18 | const event = await db.query.EventTable.findFirst({
19 | where: ({ clerkUserId, isActive, id }, { eq, and }) =>
20 | and(
21 | eq(isActive, true),
22 | eq(clerkUserId, data.clerkUserId),
23 | eq(id, data.eventId)
24 | ),
25 | })
26 |
27 | if (event == null) return { error: true }
28 | const startInTimezone = fromZonedTime(data.startTime, data.timezone)
29 |
30 | const validTimes = await getValidTimesFromSchedule([startInTimezone], event)
31 | if (validTimes.length === 0) return { error: true }
32 |
33 | await createCalendarEvent({
34 | ...data,
35 | startTime: startInTimezone,
36 | durationInMinutes: event.durationInMinutes,
37 | eventName: event.name,
38 | })
39 |
40 | redirect(
41 | `/book/${data.clerkUserId}/${
42 | data.eventId
43 | }/success?startTime=${data.startTime.toISOString()}`
44 | )
45 | }
46 |
--------------------------------------------------------------------------------
/src/app/(public)/book/[clerkUserId]/[eventId]/success/page.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Card,
3 | CardContent,
4 | CardDescription,
5 | CardHeader,
6 | CardTitle,
7 | } from "@/components/ui/card"
8 | import { db } from "@/drizzle/db"
9 | import { formatDateTime } from "@/lib/formatters"
10 | import { clerkClient } from "@clerk/nextjs/server"
11 | import { notFound } from "next/navigation"
12 |
13 | export const revalidate = 0
14 |
15 | export default async function SuccessPage({
16 | params: { clerkUserId, eventId },
17 | searchParams: { startTime },
18 | }: {
19 | params: { clerkUserId: string; eventId: string }
20 | searchParams: { startTime: string }
21 | }) {
22 | const event = await db.query.EventTable.findFirst({
23 | where: ({ clerkUserId: userIdCol, isActive, id }, { eq, and }) =>
24 | and(eq(isActive, true), eq(userIdCol, clerkUserId), eq(id, eventId)),
25 | })
26 |
27 | if (event == null) notFound()
28 |
29 | const calendarUser = await clerkClient().users.getUser(clerkUserId)
30 | const startTimeDate = new Date(startTime)
31 |
32 | return (
33 |
34 |
35 |
36 | Successfully Booked {event.name} with {calendarUser.fullName}
37 |
38 | {formatDateTime(startTimeDate)}
39 |
40 |
41 | You should receive an email confirmation shortly. You can safely close
42 | this page now.
43 |
44 |
45 | )
46 | }
47 |
--------------------------------------------------------------------------------
/src/server/actions/schedule.ts:
--------------------------------------------------------------------------------
1 | "use server"
2 |
3 | import { db } from "@/drizzle/db"
4 | import { ScheduleAvailabilityTable, ScheduleTable } from "@/drizzle/schema"
5 | import { scheduleFormSchema } from "@/schema/schedule"
6 | import { auth } from "@clerk/nextjs/server"
7 | import { eq } from "drizzle-orm"
8 | import { BatchItem } from "drizzle-orm/batch"
9 | import "use-server"
10 | import { z } from "zod"
11 |
12 | export async function saveSchedule(
13 | unsafeData: z.infer
14 | ) {
15 | const { userId } = auth()
16 | const { success, data } = scheduleFormSchema.safeParse(unsafeData)
17 |
18 | if (!success || userId == null) {
19 | return { error: true }
20 | }
21 |
22 | const { availabilities, ...scheduleData } = data
23 |
24 | const [{ id: scheduleId }] = await db
25 | .insert(ScheduleTable)
26 | .values({ ...scheduleData, clerkUserId: userId })
27 | .onConflictDoUpdate({
28 | target: ScheduleTable.clerkUserId,
29 | set: scheduleData,
30 | })
31 | .returning({ id: ScheduleTable.id })
32 |
33 | const statements: [BatchItem<"pg">] = [
34 | db
35 | .delete(ScheduleAvailabilityTable)
36 | .where(eq(ScheduleAvailabilityTable.scheduleId, scheduleId)),
37 | ]
38 |
39 | if (availabilities.length > 0) {
40 | statements.push(
41 | db.insert(ScheduleAvailabilityTable).values(
42 | availabilities.map(availability => ({
43 | ...availability,
44 | scheduleId,
45 | }))
46 | )
47 | )
48 | }
49 |
50 | await db.batch(statements)
51 | }
52 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "calendly-clone",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "db:generate": "drizzle-kit generate",
11 | "db:migrate": "drizzle-kit migrate",
12 | "db:studio": "drizzle-kit studio"
13 | },
14 | "dependencies": {
15 | "@clerk/nextjs": "^5.4.1",
16 | "@hookform/resolvers": "^3.9.0",
17 | "@neondatabase/serverless": "^0.9.5",
18 | "@radix-ui/react-alert-dialog": "^1.1.1",
19 | "@radix-ui/react-label": "^2.1.0",
20 | "@radix-ui/react-popover": "^1.1.1",
21 | "@radix-ui/react-select": "^2.1.1",
22 | "@radix-ui/react-slot": "^1.1.0",
23 | "@radix-ui/react-switch": "^1.1.0",
24 | "class-variance-authority": "^0.7.0",
25 | "clsx": "^2.1.1",
26 | "date-fns": "^3.6.0",
27 | "date-fns-tz": "^3.1.3",
28 | "drizzle-orm": "^0.33.0",
29 | "googleapis": "^144.0.0",
30 | "lucide-react": "^0.438.0",
31 | "next": "14.2.7",
32 | "react": "^18",
33 | "react-day-picker": "^8.10.1",
34 | "react-dom": "^18",
35 | "react-hook-form": "^7.53.0",
36 | "tailwind-merge": "^2.5.2",
37 | "tailwindcss-animate": "^1.0.7",
38 | "use-server": "^0.4.9",
39 | "zod": "^3.23.8"
40 | },
41 | "devDependencies": {
42 | "@types/node": "^20",
43 | "@types/react": "^18",
44 | "@types/react-dom": "^18",
45 | "drizzle-kit": "^0.24.2",
46 | "eslint": "^8",
47 | "eslint-config-next": "14.2.7",
48 | "postcss": "^8",
49 | "tailwindcss": "^3.4.1",
50 | "typescript": "^5"
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/CopyEventButton.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useState } from "react"
4 | import { Button, ButtonProps } from "./ui/button"
5 | import { Copy, CopyCheck, CopyX } from "lucide-react"
6 |
7 | type CopyState = "idle" | "copied" | "error"
8 |
9 | export function CopyEventButton({
10 | eventId,
11 | clerkUserId,
12 | ...buttonProps
13 | }: Omit & {
14 | eventId: string
15 | clerkUserId: string
16 | }) {
17 | const [copyState, setCopyState] = useState("idle")
18 |
19 | const CopyIcon = getCopyIcon(copyState)
20 |
21 | return (
22 |
40 | )
41 | }
42 |
43 | function getCopyIcon(copyState: CopyState) {
44 | switch (copyState) {
45 | case "idle":
46 | return Copy
47 | case "copied":
48 | return CopyCheck
49 | case "error":
50 | return CopyX
51 | }
52 | }
53 |
54 | function getChildren(copyState: CopyState) {
55 | switch (copyState) {
56 | case "idle":
57 | return "Copy Link"
58 | case "copied":
59 | return "Copied!"
60 | case "error":
61 | return "Error"
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/schema/schedule.ts:
--------------------------------------------------------------------------------
1 | import { DAYS_OF_WEEK_IN_ORDER } from "@/data/constants"
2 | import { timeToInt } from "@/lib/utils"
3 | import { z } from "zod"
4 |
5 | export const scheduleFormSchema = z.object({
6 | timezone: z.string().min(1, "Required"),
7 | availabilities: z
8 | .array(
9 | z.object({
10 | dayOfWeek: z.enum(DAYS_OF_WEEK_IN_ORDER),
11 | startTime: z
12 | .string()
13 | .regex(
14 | /^([0-9]|0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$/,
15 | "Time must be in the format HH:MM"
16 | ),
17 | endTime: z
18 | .string()
19 | .regex(
20 | /^([0-9]|0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$/,
21 | "Time must be in the format HH:MM"
22 | ),
23 | })
24 | )
25 | .superRefine((availabilities, ctx) => {
26 | availabilities.forEach((availability, index) => {
27 | const overlaps = availabilities.some((a, i) => {
28 | return (
29 | i !== index &&
30 | a.dayOfWeek === availability.dayOfWeek &&
31 | timeToInt(a.startTime) < timeToInt(availability.endTime) &&
32 | timeToInt(a.endTime) > timeToInt(availability.startTime)
33 | )
34 | })
35 |
36 | if (overlaps) {
37 | ctx.addIssue({
38 | code: "custom",
39 | message: "Availability overlaps with another",
40 | path: [index],
41 | })
42 | }
43 |
44 | if (
45 | timeToInt(availability.startTime) >= timeToInt(availability.endTime)
46 | ) {
47 | ctx.addIssue({
48 | code: "custom",
49 | message: "End time must be after start time",
50 | path: [index],
51 | })
52 | }
53 | })
54 | }),
55 | })
56 |
--------------------------------------------------------------------------------
/src/drizzle/migrations/0000_dazzling_penance.sql:
--------------------------------------------------------------------------------
1 | DO $$ BEGIN
2 | CREATE TYPE "public"."day" AS ENUM('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday');
3 | EXCEPTION
4 | WHEN duplicate_object THEN null;
5 | END $$;
6 | --> statement-breakpoint
7 | CREATE TABLE IF NOT EXISTS "events" (
8 | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
9 | "name" text NOT NULL,
10 | "description" text,
11 | "durationInMinutes" integer NOT NULL,
12 | "clerkUserId" text NOT NULL,
13 | "isActive" boolean DEFAULT true NOT NULL,
14 | "createdAt" timestamp DEFAULT now() NOT NULL,
15 | "updatedAt" timestamp DEFAULT now() NOT NULL
16 | );
17 | --> statement-breakpoint
18 | CREATE TABLE IF NOT EXISTS "scheduleAvailabilities" (
19 | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
20 | "scheduleId" uuid NOT NULL,
21 | "startTime" text NOT NULL,
22 | "endTime" text NOT NULL,
23 | "dayOfWeek" "day" NOT NULL
24 | );
25 | --> statement-breakpoint
26 | CREATE TABLE IF NOT EXISTS "schedules" (
27 | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
28 | "timezone" text NOT NULL,
29 | "clerkUserId" text NOT NULL,
30 | "createdAt" timestamp DEFAULT now() NOT NULL,
31 | "updatedAt" timestamp DEFAULT now() NOT NULL,
32 | CONSTRAINT "schedules_clerkUserId_unique" UNIQUE("clerkUserId")
33 | );
34 | --> statement-breakpoint
35 | DO $$ BEGIN
36 | ALTER TABLE "scheduleAvailabilities" ADD CONSTRAINT "scheduleAvailabilities_scheduleId_schedules_id_fk" FOREIGN KEY ("scheduleId") REFERENCES "public"."schedules"("id") ON DELETE cascade ON UPDATE no action;
37 | EXCEPTION
38 | WHEN duplicate_object THEN null;
39 | END $$;
40 | --> statement-breakpoint
41 | CREATE INDEX IF NOT EXISTS "clerkUserIdIndex" ON "events" USING btree ("clerkUserId");--> statement-breakpoint
42 | CREATE INDEX IF NOT EXISTS "scheduleIdIndex" ON "scheduleAvailabilities" USING btree ("scheduleId");
--------------------------------------------------------------------------------
/src/server/actions/events.ts:
--------------------------------------------------------------------------------
1 | "use server"
2 |
3 | import { db } from "@/drizzle/db"
4 | import { EventTable } from "@/drizzle/schema"
5 | import { eventFormSchema } from "@/schema/events"
6 | import { auth } from "@clerk/nextjs/server"
7 | import { and, eq } from "drizzle-orm"
8 | import { redirect } from "next/navigation"
9 | import "use-server"
10 | import { z } from "zod"
11 |
12 | export async function createEvent(
13 | unsafeData: z.infer
14 | ): Promise<{ error: boolean } | undefined> {
15 | const { userId } = auth()
16 | const { success, data } = eventFormSchema.safeParse(unsafeData)
17 |
18 | if (!success || userId == null) {
19 | return { error: true }
20 | }
21 |
22 | await db.insert(EventTable).values({ ...data, clerkUserId: userId })
23 |
24 | redirect("/events")
25 | }
26 |
27 | export async function updateEvent(
28 | id: string,
29 | unsafeData: z.infer
30 | ): Promise<{ error: boolean } | undefined> {
31 | const { userId } = auth()
32 | const { success, data } = eventFormSchema.safeParse(unsafeData)
33 |
34 | if (!success || userId == null) {
35 | return { error: true }
36 | }
37 |
38 | const { rowCount } = await db
39 | .update(EventTable)
40 | .set({ ...data })
41 | .where(and(eq(EventTable.id, id), eq(EventTable.clerkUserId, userId)))
42 |
43 | if (rowCount === 0) {
44 | return { error: true }
45 | }
46 |
47 | redirect("/events")
48 | }
49 |
50 | export async function deleteEvent(
51 | id: string
52 | ): Promise<{ error: boolean } | undefined> {
53 | const { userId } = auth()
54 |
55 | if (userId == null) {
56 | return { error: true }
57 | }
58 |
59 | const { rowCount } = await db
60 | .delete(EventTable)
61 | .where(and(eq(EventTable.id, id), eq(EventTable.clerkUserId, userId)))
62 |
63 | if (rowCount === 0) {
64 | return { error: true }
65 | }
66 |
67 | redirect("/events")
68 | }
69 |
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 210 40% 99%;
8 | --foreground: 222.2 84% 4.9%;
9 | --card: 0 0% 100%;
10 | --card-foreground: 222.2 84% 4.9%;
11 | --popover: 0 0% 100%;
12 | --popover-foreground: 222.2 84% 4.9%;
13 | --primary: 201 96% 32%;
14 | --primary-foreground: 210 40% 98%;
15 | --secondary: 210 40% 96.1%;
16 | --secondary-foreground: 222.2 47.4% 11.2%;
17 | --muted: 210 40% 96.1%;
18 | --muted-foreground: 215.4 16.3% 46.9%;
19 | --accent: 210 40% 96.1%;
20 | --accent-foreground: 222.2 47.4% 11.2%;
21 | --destructive: 0 84.2% 60.2%;
22 | --destructive-foreground: 210 40% 98%;
23 | --border: 214.3 31.8% 91.4%;
24 | --input: 214.3 31.8% 91.4%;
25 | --ring: 222.2 84% 4.9%;
26 | --chart-1: 12 76% 61%;
27 | --chart-2: 173 58% 39%;
28 | --chart-3: 197 37% 24%;
29 | --chart-4: 43 74% 66%;
30 | --chart-5: 27 87% 67%;
31 | --radius: 0.5rem;
32 | }
33 | .dark {
34 | --background: 222 47% 6%;
35 | --foreground: 210 40% 98%;
36 | --card: 222.2 84% 4.9%;
37 | --card-foreground: 210 40% 98%;
38 | --popover: 222.2 84% 4.9%;
39 | --popover-foreground: 210 40% 98%;
40 | --primary: 199 89% 48%;
41 | --primary-foreground: 222.2 47.4% 11.2%;
42 | --secondary: 217.2 32.6% 17.5%;
43 | --secondary-foreground: 210 40% 98%;
44 | --muted: 217.2 32.6% 17.5%;
45 | --muted-foreground: 215 20.2% 65.1%;
46 | --accent: 217.2 32.6% 17.5%;
47 | --accent-foreground: 210 40% 98%;
48 | --destructive: 0 62.8% 30.6%;
49 | --destructive-foreground: 210 40% 98%;
50 | --border: 217.2 32.6% 17.5%;
51 | --input: 217.2 32.6% 17.5%;
52 | --ring: 212.7 26.8% 83.9%;
53 | --chart-1: 220 70% 50%;
54 | --chart-2: 160 60% 45%;
55 | --chart-3: 30 80% 55%;
56 | --chart-4: 280 65% 60%;
57 | --chart-5: 340 75% 55%;
58 | }
59 | }
60 |
61 | @layer base {
62 | * {
63 | @apply border-border;
64 | }
65 | body {
66 | @apply bg-background text-foreground;
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | destructiveGhost:
16 | "text-destructive hover:bg-destructive hover:text-destructive-foreground",
17 | outline:
18 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
19 | secondary:
20 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
21 | ghost: "hover:bg-accent hover:text-accent-foreground",
22 | link: "text-primary underline-offset-4 hover:underline",
23 | },
24 | size: {
25 | default: "h-10 px-4 py-2",
26 | sm: "h-9 rounded-md px-3",
27 | lg: "h-11 rounded-md px-8",
28 | icon: "h-10 w-10",
29 | },
30 | },
31 | defaultVariants: {
32 | variant: "default",
33 | size: "default",
34 | },
35 | }
36 | )
37 |
38 | export interface ButtonProps
39 | extends React.ButtonHTMLAttributes,
40 | VariantProps {
41 | asChild?: boolean
42 | }
43 |
44 | const Button = React.forwardRef(
45 | ({ className, variant, size, asChild = false, ...props }, ref) => {
46 | const Comp = asChild ? Slot : "button"
47 | return (
48 |
53 | )
54 | }
55 | )
56 | Button.displayName = "Button"
57 |
58 | export { Button, buttonVariants }
59 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
44 | ))
45 | CardTitle.displayName = "CardTitle"
46 |
47 | const CardDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | CardDescription.displayName = "CardDescription"
58 |
59 | const CardContent = React.forwardRef<
60 | HTMLDivElement,
61 | React.HTMLAttributes
62 | >(({ className, ...props }, ref) => (
63 |
64 | ))
65 | CardContent.displayName = "CardContent"
66 |
67 | const CardFooter = React.forwardRef<
68 | HTMLDivElement,
69 | React.HTMLAttributes
70 | >(({ className, ...props }, ref) => (
71 |
76 | ))
77 | CardFooter.displayName = "CardFooter"
78 |
79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
80 |
--------------------------------------------------------------------------------
/src/drizzle/schema.ts:
--------------------------------------------------------------------------------
1 | import { DAYS_OF_WEEK_IN_ORDER } from "@/data/constants"
2 | import { relations } from "drizzle-orm"
3 | import {
4 | boolean,
5 | index,
6 | integer,
7 | pgEnum,
8 | pgTable,
9 | text,
10 | timestamp,
11 | uuid,
12 | } from "drizzle-orm/pg-core"
13 |
14 | const createdAt = timestamp("createdAt").notNull().defaultNow()
15 | const updatedAt = timestamp("updatedAt")
16 | .notNull()
17 | .defaultNow()
18 | .$onUpdate(() => new Date())
19 |
20 | export const EventTable = pgTable(
21 | "events",
22 | {
23 | id: uuid("id").primaryKey().defaultRandom(),
24 | name: text("name").notNull(),
25 | description: text("description"),
26 | durationInMinutes: integer("durationInMinutes").notNull(),
27 | clerkUserId: text("clerkUserId").notNull(),
28 | isActive: boolean("isActive").notNull().default(true),
29 | createdAt,
30 | updatedAt,
31 | },
32 | table => ({
33 | clerkUserIdIndex: index("clerkUserIdIndex").on(table.clerkUserId),
34 | })
35 | )
36 |
37 | export const ScheduleTable = pgTable("schedules", {
38 | id: uuid("id").primaryKey().defaultRandom(),
39 | timezone: text("timezone").notNull(),
40 | clerkUserId: text("clerkUserId").notNull().unique(),
41 | createdAt,
42 | updatedAt,
43 | })
44 |
45 | export const scheduleRelations = relations(ScheduleTable, ({ many }) => ({
46 | availabilities: many(ScheduleAvailabilityTable),
47 | }))
48 |
49 | export const scheduleDayOfWeekEnum = pgEnum("day", DAYS_OF_WEEK_IN_ORDER)
50 |
51 | export const ScheduleAvailabilityTable = pgTable(
52 | "scheduleAvailabilities",
53 | {
54 | id: uuid("id").primaryKey().defaultRandom(),
55 | scheduleId: uuid("scheduleId")
56 | .notNull()
57 | .references(() => ScheduleTable.id, { onDelete: "cascade" }),
58 | startTime: text("startTime").notNull(),
59 | endTime: text("endTime").notNull(),
60 | dayOfWeek: scheduleDayOfWeekEnum("dayOfWeek").notNull(),
61 | },
62 | table => ({
63 | scheduleIdIndex: index("scheduleIdIndex").on(table.scheduleId),
64 | })
65 | )
66 |
67 | export const ScheduleAvailabilityRelations = relations(
68 | ScheduleAvailabilityTable,
69 | ({ one }) => ({
70 | schedule: one(ScheduleTable, {
71 | fields: [ScheduleAvailabilityTable.scheduleId],
72 | references: [ScheduleTable.id],
73 | }),
74 | })
75 | )
76 |
--------------------------------------------------------------------------------
/src/app/(public)/book/[clerkUserId]/page.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button"
2 | import {
3 | Card,
4 | CardContent,
5 | CardDescription,
6 | CardFooter,
7 | CardHeader,
8 | CardTitle,
9 | } from "@/components/ui/card"
10 | import { db } from "@/drizzle/db"
11 | import { formatEventDescription } from "@/lib/formatters"
12 | import { clerkClient } from "@clerk/nextjs/server"
13 | import Link from "next/link"
14 | import { notFound } from "next/navigation"
15 |
16 | export const revalidate = 0
17 |
18 | export default async function BookingPage({
19 | params: { clerkUserId },
20 | }: {
21 | params: { clerkUserId: string }
22 | }) {
23 | const events = await db.query.EventTable.findMany({
24 | where: ({ clerkUserId: userIdCol, isActive }, { eq, and }) =>
25 | and(eq(userIdCol, clerkUserId), eq(isActive, true)),
26 | orderBy: ({ name }, { asc, sql }) => asc(sql`lower(${name})`),
27 | })
28 |
29 | if (events.length === 0) return notFound()
30 |
31 | const { fullName } = await clerkClient().users.getUser(clerkUserId)
32 |
33 | return (
34 |
35 |
36 | {fullName}
37 |
38 |
39 | Welcome to my scheduling page. Please follow the instructions to add an
40 | event to my calendar.
41 |
42 |
43 | {events.map(event => (
44 |
45 | ))}
46 |
47 |
48 | )
49 | }
50 |
51 | type EventCardProps = {
52 | id: string
53 | name: string
54 | clerkUserId: string
55 | description: string | null
56 | durationInMinutes: number
57 | }
58 |
59 | function EventCard({
60 | id,
61 | name,
62 | description,
63 | clerkUserId,
64 | durationInMinutes,
65 | }: EventCardProps) {
66 | return (
67 |
68 |
69 | {name}
70 |
71 | {formatEventDescription(durationInMinutes)}
72 |
73 |
74 | {description != null && {description}}
75 |
76 |
79 |
80 |
81 | )
82 | }
83 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss"
2 | import { fontFamily } from "tailwindcss/defaultTheme"
3 |
4 | const config: Config = {
5 | darkMode: ["class"],
6 | content: [
7 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
8 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
9 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
10 | ],
11 | theme: {
12 | container: {
13 | center: true,
14 | padding: "2rem",
15 | screens: {
16 | "2xl": "1400px",
17 | },
18 | },
19 | extend: {
20 | fontFamily: {
21 | sans: ["var(--font-sans)", ...fontFamily.sans],
22 | },
23 | backgroundImage: {
24 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
25 | "gradient-conic":
26 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
27 | },
28 | borderRadius: {
29 | lg: "var(--radius)",
30 | md: "calc(var(--radius) - 2px)",
31 | sm: "calc(var(--radius) - 4px)",
32 | },
33 | colors: {
34 | background: "hsl(var(--background))",
35 | foreground: "hsl(var(--foreground))",
36 | card: {
37 | DEFAULT: "hsl(var(--card))",
38 | foreground: "hsl(var(--card-foreground))",
39 | },
40 | popover: {
41 | DEFAULT: "hsl(var(--popover))",
42 | foreground: "hsl(var(--popover-foreground))",
43 | },
44 | primary: {
45 | DEFAULT: "hsl(var(--primary))",
46 | foreground: "hsl(var(--primary-foreground))",
47 | },
48 | secondary: {
49 | DEFAULT: "hsl(var(--secondary))",
50 | foreground: "hsl(var(--secondary-foreground))",
51 | },
52 | muted: {
53 | DEFAULT: "hsl(var(--muted))",
54 | foreground: "hsl(var(--muted-foreground))",
55 | },
56 | accent: {
57 | DEFAULT: "hsl(var(--accent))",
58 | foreground: "hsl(var(--accent-foreground))",
59 | },
60 | destructive: {
61 | DEFAULT: "hsl(var(--destructive))",
62 | foreground: "hsl(var(--destructive-foreground))",
63 | },
64 | border: "hsl(var(--border))",
65 | input: "hsl(var(--input))",
66 | ring: "hsl(var(--ring))",
67 | chart: {
68 | "1": "hsl(var(--chart-1))",
69 | "2": "hsl(var(--chart-2))",
70 | "3": "hsl(var(--chart-3))",
71 | "4": "hsl(var(--chart-4))",
72 | "5": "hsl(var(--chart-5))",
73 | },
74 | },
75 | },
76 | },
77 | plugins: [require("tailwindcss-animate")],
78 | }
79 | export default config
80 |
--------------------------------------------------------------------------------
/src/components/ui/calendar.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { ChevronLeft, ChevronRight } from "lucide-react"
5 | import { DayPicker } from "react-day-picker"
6 |
7 | import { cn } from "@/lib/utils"
8 | import { buttonVariants } from "@/components/ui/button"
9 |
10 | export type CalendarProps = React.ComponentProps
11 |
12 | function Calendar({
13 | className,
14 | classNames,
15 | showOutsideDays = true,
16 | ...props
17 | }: CalendarProps) {
18 | return (
19 | ,
58 | IconRight: ({ ...props }) => ,
59 | }}
60 | {...props}
61 | />
62 | )
63 | }
64 | Calendar.displayName = "Calendar"
65 |
66 | export { Calendar }
67 |
--------------------------------------------------------------------------------
/src/app/(public)/book/[clerkUserId]/[eventId]/page.tsx:
--------------------------------------------------------------------------------
1 | import { MeetingForm } from "@/components/forms/MeetingForm"
2 | import { Button } from "@/components/ui/button"
3 | import {
4 | Card,
5 | CardContent,
6 | CardDescription,
7 | CardFooter,
8 | CardHeader,
9 | CardTitle,
10 | } from "@/components/ui/card"
11 | import { db } from "@/drizzle/db"
12 | import { getValidTimesFromSchedule } from "@/lib/getValidTimesFromSchedule"
13 | import { clerkClient } from "@clerk/nextjs/server"
14 | import {
15 | addMonths,
16 | eachMinuteOfInterval,
17 | endOfDay,
18 | roundToNearestMinutes,
19 | } from "date-fns"
20 | import Link from "next/link"
21 | import { notFound } from "next/navigation"
22 |
23 | export const revalidate = 0
24 |
25 | export default async function BookEventPage({
26 | params: { clerkUserId, eventId },
27 | }: {
28 | params: { clerkUserId: string; eventId: string }
29 | }) {
30 | const event = await db.query.EventTable.findFirst({
31 | where: ({ clerkUserId: userIdCol, isActive, id }, { eq, and }) =>
32 | and(eq(isActive, true), eq(userIdCol, clerkUserId), eq(id, eventId)),
33 | })
34 |
35 | if (event == null) return notFound()
36 |
37 | const calendarUser = await clerkClient().users.getUser(clerkUserId)
38 | const startDate = roundToNearestMinutes(new Date(), {
39 | nearestTo: 15,
40 | roundingMethod: "ceil",
41 | })
42 | const endDate = endOfDay(addMonths(startDate, 2))
43 |
44 | const validTimes = await getValidTimesFromSchedule(
45 | eachMinuteOfInterval({ start: startDate, end: endDate }, { step: 15 }),
46 | event
47 | )
48 |
49 | if (validTimes.length === 0) {
50 | return
51 | }
52 |
53 | return (
54 |
55 |
56 |
57 | Book {event.name} with {calendarUser.fullName}
58 |
59 | {event.description && (
60 | {event.description}
61 | )}
62 |
63 |
64 |
69 |
70 |
71 | )
72 | }
73 |
74 | function NoTimeSlots({
75 | event,
76 | calendarUser,
77 | }: {
78 | event: { name: string; description: string | null }
79 | calendarUser: { id: string; fullName: string | null }
80 | }) {
81 | return (
82 |
83 |
84 |
85 | Book {event.name} with {calendarUser.fullName}
86 |
87 | {event.description && (
88 | {event.description}
89 | )}
90 |
91 |
92 | {calendarUser.fullName} is currently booked up. Please check back later
93 | or choose a shorter event.
94 |
95 |
96 |
99 |
100 |
101 | )
102 | }
103 |
--------------------------------------------------------------------------------
/src/server/googleCalendar.ts:
--------------------------------------------------------------------------------
1 | import "use-server"
2 | import { clerkClient } from "@clerk/nextjs/server"
3 | import { google } from "googleapis"
4 | import { addMinutes, endOfDay, startOfDay } from "date-fns"
5 |
6 | export async function getCalendarEventTimes(
7 | clerkUserId: string,
8 | { start, end }: { start: Date; end: Date }
9 | ) {
10 | const oAuthClient = await getOAuthClient(clerkUserId)
11 |
12 | const events = await google.calendar("v3").events.list({
13 | calendarId: "primary",
14 | eventTypes: ["default"],
15 | singleEvents: true,
16 | timeMin: start.toISOString(),
17 | timeMax: end.toISOString(),
18 | maxResults: 2500,
19 | auth: oAuthClient,
20 | })
21 |
22 | return (
23 | events.data.items
24 | ?.map(event => {
25 | if (event.start?.date != null && event.end?.date != null) {
26 | return {
27 | start: startOfDay(event.start.date),
28 | end: endOfDay(event.end.date),
29 | }
30 | }
31 |
32 | if (event.start?.dateTime != null && event.end?.dateTime != null) {
33 | return {
34 | start: new Date(event.start.dateTime),
35 | end: new Date(event.end.dateTime),
36 | }
37 | }
38 | })
39 | .filter(date => date != null) || []
40 | )
41 | }
42 |
43 | export async function createCalendarEvent({
44 | clerkUserId,
45 | guestName,
46 | guestEmail,
47 | startTime,
48 | guestNotes,
49 | durationInMinutes,
50 | eventName,
51 | }: {
52 | clerkUserId: string
53 | guestName: string
54 | guestEmail: string
55 | startTime: Date
56 | guestNotes?: string | null
57 | durationInMinutes: number
58 | eventName: string
59 | }) {
60 | const oAuthClient = await getOAuthClient(clerkUserId)
61 | const calendarUser = await clerkClient().users.getUser(clerkUserId)
62 | if (calendarUser.primaryEmailAddress == null) {
63 | throw new Error("Clerk user has no email")
64 | }
65 |
66 | const calendarEvent = await google.calendar("v3").events.insert({
67 | calendarId: "primary",
68 | auth: oAuthClient,
69 | sendUpdates: "all",
70 | requestBody: {
71 | attendees: [
72 | { email: guestEmail, displayName: guestName },
73 | {
74 | email: calendarUser.primaryEmailAddress.emailAddress,
75 | displayName: calendarUser.fullName,
76 | responseStatus: "accepted",
77 | },
78 | ],
79 | description: guestNotes ? `Additional Details: ${guestNotes}` : undefined,
80 | start: {
81 | dateTime: startTime.toISOString(),
82 | },
83 | end: {
84 | dateTime: addMinutes(startTime, durationInMinutes).toISOString(),
85 | },
86 | summary: `${guestName} + ${calendarUser.fullName}: ${eventName}`,
87 | },
88 | })
89 |
90 | return calendarEvent.data
91 | }
92 |
93 | async function getOAuthClient(clerkUserId: string) {
94 | const token = await clerkClient().users.getUserOauthAccessToken(
95 | clerkUserId,
96 | "oauth_google"
97 | )
98 |
99 | if (token.data.length === 0 || token.data[0].token == null) {
100 | return
101 | }
102 |
103 | const client = new google.auth.OAuth2(
104 | process.env.GOOGLE_OAUTH_CLIENT_ID,
105 | process.env.GOOGLE_OAUTH_CLIENT_SECRET,
106 | process.env.GOOGLE_OAUTH_REDIRECT_URL
107 | )
108 |
109 | client.setCredentials({ access_token: token.data[0].token })
110 |
111 | return client
112 | }
113 |
--------------------------------------------------------------------------------
/src/app/(private)/events/page.tsx:
--------------------------------------------------------------------------------
1 | import { CopyEventButton } from "@/components/CopyEventButton"
2 | import { Button } from "@/components/ui/button"
3 | import {
4 | Card,
5 | CardContent,
6 | CardDescription,
7 | CardFooter,
8 | CardHeader,
9 | CardTitle,
10 | } from "@/components/ui/card"
11 | import { db } from "@/drizzle/db"
12 | import { formatEventDescription } from "@/lib/formatters"
13 | import { cn } from "@/lib/utils"
14 | import { auth } from "@clerk/nextjs/server"
15 | import { CalendarPlus, CalendarRange } from "lucide-react"
16 | import Link from "next/link"
17 |
18 | export const revalidate = 0
19 |
20 | export default async function EventsPage() {
21 | const { userId, redirectToSignIn } = auth()
22 |
23 | if (userId == null) return redirectToSignIn()
24 |
25 | const events = await db.query.EventTable.findMany({
26 | where: ({ clerkUserId }, { eq }) => eq(clerkUserId, userId),
27 | orderBy: ({ createdAt }, { desc }) => desc(createdAt),
28 | })
29 |
30 | return (
31 | <>
32 |
33 |
34 | Events
35 |
36 |
41 |
42 | {events.length > 0 ? (
43 |
44 | {events.map(event => (
45 |
46 | ))}
47 |
48 | ) : (
49 |
50 |
51 | You do not have any events yet. Create your first event to get
52 | started!
53 |
58 |
59 | )}
60 | >
61 | )
62 | }
63 |
64 | type EventCardProps = {
65 | id: string
66 | isActive: boolean
67 | name: string
68 | description: string | null
69 | durationInMinutes: number
70 | clerkUserId: string
71 | }
72 |
73 | function EventCard({
74 | id,
75 | isActive,
76 | name,
77 | description,
78 | durationInMinutes,
79 | clerkUserId,
80 | }: EventCardProps) {
81 | return (
82 |
83 |
84 | {name}
85 |
86 | {formatEventDescription(durationInMinutes)}
87 |
88 |
89 | {description != null && (
90 |
91 | {description}
92 |
93 | )}
94 |
95 | {isActive && (
96 |
101 | )}
102 |
105 |
106 |
107 | )
108 | }
109 |
--------------------------------------------------------------------------------
/src/lib/getValidTimesFromSchedule.ts:
--------------------------------------------------------------------------------
1 | import { DAYS_OF_WEEK_IN_ORDER } from "@/data/constants"
2 | import { db } from "@/drizzle/db"
3 | import { ScheduleAvailabilityTable } from "@/drizzle/schema"
4 | import { getCalendarEventTimes } from "@/server/googleCalendar"
5 | import {
6 | addMinutes,
7 | areIntervalsOverlapping,
8 | isFriday,
9 | isMonday,
10 | isSaturday,
11 | isSunday,
12 | isThursday,
13 | isTuesday,
14 | isWednesday,
15 | isWithinInterval,
16 | setHours,
17 | setMinutes,
18 | } from "date-fns"
19 | import { fromZonedTime } from "date-fns-tz"
20 |
21 | export async function getValidTimesFromSchedule(
22 | timesInOrder: Date[],
23 | event: { clerkUserId: string; durationInMinutes: number }
24 | ) {
25 | const start = timesInOrder[0]
26 | const end = timesInOrder.at(-1)
27 |
28 | if (start == null || end == null) return []
29 |
30 | const schedule = await db.query.ScheduleTable.findFirst({
31 | where: ({ clerkUserId: userIdCol }, { eq }) =>
32 | eq(userIdCol, event.clerkUserId),
33 | with: { availabilities: true },
34 | })
35 |
36 | if (schedule == null) return []
37 |
38 | const groupedAvailabilities = Object.groupBy(
39 | schedule.availabilities,
40 | a => a.dayOfWeek
41 | )
42 |
43 | const eventTimes = await getCalendarEventTimes(event.clerkUserId, {
44 | start,
45 | end,
46 | })
47 |
48 | return timesInOrder.filter(intervalDate => {
49 | const availabilities = getAvailabilities(
50 | groupedAvailabilities,
51 | intervalDate,
52 | schedule.timezone
53 | )
54 | const eventInterval = {
55 | start: intervalDate,
56 | end: addMinutes(intervalDate, event.durationInMinutes),
57 | }
58 |
59 | return (
60 | eventTimes.every(eventTime => {
61 | return !areIntervalsOverlapping(eventTime, eventInterval)
62 | }) &&
63 | availabilities.some(availability => {
64 | return (
65 | isWithinInterval(eventInterval.start, availability) &&
66 | isWithinInterval(eventInterval.end, availability)
67 | )
68 | })
69 | )
70 | })
71 | }
72 |
73 | function getAvailabilities(
74 | groupedAvailabilities: Partial<
75 | Record<
76 | (typeof DAYS_OF_WEEK_IN_ORDER)[number],
77 | (typeof ScheduleAvailabilityTable.$inferSelect)[]
78 | >
79 | >,
80 | date: Date,
81 | timezone: string
82 | ) {
83 | let availabilities:
84 | | (typeof ScheduleAvailabilityTable.$inferSelect)[]
85 | | undefined
86 |
87 | if (isMonday(date)) {
88 | availabilities = groupedAvailabilities.monday
89 | }
90 | if (isTuesday(date)) {
91 | availabilities = groupedAvailabilities.tuesday
92 | }
93 | if (isWednesday(date)) {
94 | availabilities = groupedAvailabilities.wednesday
95 | }
96 | if (isThursday(date)) {
97 | availabilities = groupedAvailabilities.thursday
98 | }
99 | if (isFriday(date)) {
100 | availabilities = groupedAvailabilities.friday
101 | }
102 | if (isSaturday(date)) {
103 | availabilities = groupedAvailabilities.saturday
104 | }
105 | if (isSunday(date)) {
106 | availabilities = groupedAvailabilities.sunday
107 | }
108 |
109 | if (availabilities == null) return []
110 |
111 | return availabilities.map(({ startTime, endTime }) => {
112 | const start = fromZonedTime(
113 | setMinutes(
114 | setHours(date, parseInt(startTime.split(":")[0])),
115 | parseInt(startTime.split(":")[1])
116 | ),
117 | timezone
118 | )
119 |
120 | const end = fromZonedTime(
121 | setMinutes(
122 | setHours(date, parseInt(endTime.split(":")[0])),
123 | parseInt(endTime.split(":")[1])
124 | ),
125 | timezone
126 | )
127 |
128 | return { start, end }
129 | })
130 | }
131 |
--------------------------------------------------------------------------------
/src/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { Slot } from "@radix-ui/react-slot"
6 | import {
7 | Controller,
8 | ControllerProps,
9 | FieldPath,
10 | FieldValues,
11 | FormProvider,
12 | useFormContext,
13 | } from "react-hook-form"
14 |
15 | import { cn } from "@/lib/utils"
16 | import { Label } from "@/components/ui/label"
17 |
18 | const Form = FormProvider
19 |
20 | type FormFieldContextValue<
21 | TFieldValues extends FieldValues = FieldValues,
22 | TName extends FieldPath = FieldPath
23 | > = {
24 | name: TName
25 | }
26 |
27 | const FormFieldContext = React.createContext(
28 | {} as FormFieldContextValue
29 | )
30 |
31 | const FormField = <
32 | TFieldValues extends FieldValues = FieldValues,
33 | TName extends FieldPath = FieldPath
34 | >({
35 | ...props
36 | }: ControllerProps) => {
37 | return (
38 |
39 |
40 |
41 | )
42 | }
43 |
44 | const useFormField = () => {
45 | const fieldContext = React.useContext(FormFieldContext)
46 | const itemContext = React.useContext(FormItemContext)
47 | const { getFieldState, formState } = useFormContext()
48 |
49 | const fieldState = getFieldState(fieldContext.name, formState)
50 |
51 | if (!fieldContext) {
52 | throw new Error("useFormField should be used within ")
53 | }
54 |
55 | const { id } = itemContext
56 |
57 | return {
58 | id,
59 | name: fieldContext.name,
60 | formItemId: `${id}-form-item`,
61 | formDescriptionId: `${id}-form-item-description`,
62 | formMessageId: `${id}-form-item-message`,
63 | ...fieldState,
64 | }
65 | }
66 |
67 | type FormItemContextValue = {
68 | id: string
69 | }
70 |
71 | const FormItemContext = React.createContext(
72 | {} as FormItemContextValue
73 | )
74 |
75 | const FormItem = React.forwardRef<
76 | HTMLDivElement,
77 | React.HTMLAttributes
78 | >(({ className, ...props }, ref) => {
79 | const id = React.useId()
80 |
81 | return (
82 |
83 |
84 |
85 | )
86 | })
87 | FormItem.displayName = "FormItem"
88 |
89 | const FormLabel = React.forwardRef<
90 | React.ElementRef,
91 | React.ComponentPropsWithoutRef
92 | >(({ className, ...props }, ref) => {
93 | const { error, formItemId } = useFormField()
94 |
95 | return (
96 |
102 | )
103 | })
104 | FormLabel.displayName = "FormLabel"
105 |
106 | const FormControl = React.forwardRef<
107 | React.ElementRef,
108 | React.ComponentPropsWithoutRef
109 | >(({ ...props }, ref) => {
110 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
111 |
112 | return (
113 |
124 | )
125 | })
126 | FormControl.displayName = "FormControl"
127 |
128 | const FormDescription = React.forwardRef<
129 | HTMLParagraphElement,
130 | React.HTMLAttributes
131 | >(({ className, ...props }, ref) => {
132 | const { formDescriptionId } = useFormField()
133 |
134 | return (
135 |
141 | )
142 | })
143 | FormDescription.displayName = "FormDescription"
144 |
145 | const FormMessage = React.forwardRef<
146 | HTMLParagraphElement,
147 | React.HTMLAttributes
148 | >(({ className, children, ...props }, ref) => {
149 | const { error, formMessageId } = useFormField()
150 | const body = error ? String(error?.message) : children
151 |
152 | if (!body) {
153 | return null
154 | }
155 |
156 | return (
157 |
163 | {body}
164 |
165 | )
166 | })
167 | FormMessage.displayName = "FormMessage"
168 |
169 | export {
170 | useFormField,
171 | Form,
172 | FormItem,
173 | FormLabel,
174 | FormControl,
175 | FormDescription,
176 | FormMessage,
177 | FormField,
178 | }
179 |
--------------------------------------------------------------------------------
/src/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
5 |
6 | import { cn } from "@/lib/utils"
7 | import { buttonVariants } from "@/components/ui/button"
8 | import { VariantProps } from "class-variance-authority"
9 |
10 | const AlertDialog = AlertDialogPrimitive.Root
11 |
12 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger
13 |
14 | const AlertDialogPortal = AlertDialogPrimitive.Portal
15 |
16 | const AlertDialogOverlay = React.forwardRef<
17 | React.ElementRef,
18 | React.ComponentPropsWithoutRef
19 | >(({ className, ...props }, ref) => (
20 |
28 | ))
29 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
30 |
31 | const AlertDialogContent = React.forwardRef<
32 | React.ElementRef,
33 | React.ComponentPropsWithoutRef
34 | >(({ className, ...props }, ref) => (
35 |
36 |
37 |
45 |
46 | ))
47 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
48 |
49 | const AlertDialogHeader = ({
50 | className,
51 | ...props
52 | }: React.HTMLAttributes) => (
53 |
60 | )
61 | AlertDialogHeader.displayName = "AlertDialogHeader"
62 |
63 | const AlertDialogFooter = ({
64 | className,
65 | ...props
66 | }: React.HTMLAttributes) => (
67 |
74 | )
75 | AlertDialogFooter.displayName = "AlertDialogFooter"
76 |
77 | const AlertDialogTitle = React.forwardRef<
78 | React.ElementRef,
79 | React.ComponentPropsWithoutRef
80 | >(({ className, ...props }, ref) => (
81 |
86 | ))
87 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
88 |
89 | const AlertDialogDescription = React.forwardRef<
90 | React.ElementRef,
91 | React.ComponentPropsWithoutRef
92 | >(({ className, ...props }, ref) => (
93 |
98 | ))
99 | AlertDialogDescription.displayName =
100 | AlertDialogPrimitive.Description.displayName
101 |
102 | const AlertDialogAction = React.forwardRef<
103 | React.ElementRef,
104 | React.ComponentPropsWithoutRef & {
105 | variant?: VariantProps["variant"]
106 | }
107 | >(({ className, variant, ...props }, ref) => (
108 |
113 | ))
114 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
115 |
116 | const AlertDialogCancel = React.forwardRef<
117 | React.ElementRef,
118 | React.ComponentPropsWithoutRef
119 | >(({ className, ...props }, ref) => (
120 |
129 | ))
130 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
131 |
132 | export {
133 | AlertDialog,
134 | AlertDialogPortal,
135 | AlertDialogOverlay,
136 | AlertDialogTrigger,
137 | AlertDialogContent,
138 | AlertDialogHeader,
139 | AlertDialogFooter,
140 | AlertDialogTitle,
141 | AlertDialogDescription,
142 | AlertDialogAction,
143 | AlertDialogCancel,
144 | }
145 |
--------------------------------------------------------------------------------
/src/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SelectPrimitive from "@radix-ui/react-select"
5 | import { Check, ChevronDown, ChevronUp } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Select = SelectPrimitive.Root
10 |
11 | const SelectGroup = SelectPrimitive.Group
12 |
13 | const SelectValue = SelectPrimitive.Value
14 |
15 | const SelectTrigger = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, children, ...props }, ref) => (
19 | span]:line-clamp-1",
23 | className
24 | )}
25 | {...props}
26 | >
27 | {children}
28 |
29 |
30 |
31 |
32 | ))
33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
34 |
35 | const SelectScrollUpButton = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 |
48 |
49 | ))
50 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
51 |
52 | const SelectScrollDownButton = React.forwardRef<
53 | React.ElementRef,
54 | React.ComponentPropsWithoutRef
55 | >(({ className, ...props }, ref) => (
56 |
64 |
65 |
66 | ))
67 | SelectScrollDownButton.displayName =
68 | SelectPrimitive.ScrollDownButton.displayName
69 |
70 | const SelectContent = React.forwardRef<
71 | React.ElementRef,
72 | React.ComponentPropsWithoutRef
73 | >(({ className, children, position = "popper", ...props }, ref) => (
74 |
75 |
86 |
87 |
94 | {children}
95 |
96 |
97 |
98 |
99 | ))
100 | SelectContent.displayName = SelectPrimitive.Content.displayName
101 |
102 | const SelectLabel = React.forwardRef<
103 | React.ElementRef,
104 | React.ComponentPropsWithoutRef
105 | >(({ className, ...props }, ref) => (
106 |
111 | ))
112 | SelectLabel.displayName = SelectPrimitive.Label.displayName
113 |
114 | const SelectItem = React.forwardRef<
115 | React.ElementRef,
116 | React.ComponentPropsWithoutRef
117 | >(({ className, children, ...props }, ref) => (
118 |
126 |
127 |
128 |
129 |
130 |
131 |
132 | {children}
133 |
134 | ))
135 | SelectItem.displayName = SelectPrimitive.Item.displayName
136 |
137 | const SelectSeparator = React.forwardRef<
138 | React.ElementRef,
139 | React.ComponentPropsWithoutRef
140 | >(({ className, ...props }, ref) => (
141 |
146 | ))
147 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName
148 |
149 | export {
150 | Select,
151 | SelectGroup,
152 | SelectValue,
153 | SelectTrigger,
154 | SelectContent,
155 | SelectLabel,
156 | SelectItem,
157 | SelectSeparator,
158 | SelectScrollUpButton,
159 | SelectScrollDownButton,
160 | }
161 |
--------------------------------------------------------------------------------
/src/drizzle/migrations/meta/0000_snapshot.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "3b5be868-bd6f-4867-b175-f4ff93d36939",
3 | "prevId": "00000000-0000-0000-0000-000000000000",
4 | "version": "7",
5 | "dialect": "postgresql",
6 | "tables": {
7 | "public.events": {
8 | "name": "events",
9 | "schema": "",
10 | "columns": {
11 | "id": {
12 | "name": "id",
13 | "type": "uuid",
14 | "primaryKey": true,
15 | "notNull": true,
16 | "default": "gen_random_uuid()"
17 | },
18 | "name": {
19 | "name": "name",
20 | "type": "text",
21 | "primaryKey": false,
22 | "notNull": true
23 | },
24 | "description": {
25 | "name": "description",
26 | "type": "text",
27 | "primaryKey": false,
28 | "notNull": false
29 | },
30 | "durationInMinutes": {
31 | "name": "durationInMinutes",
32 | "type": "integer",
33 | "primaryKey": false,
34 | "notNull": true
35 | },
36 | "clerkUserId": {
37 | "name": "clerkUserId",
38 | "type": "text",
39 | "primaryKey": false,
40 | "notNull": true
41 | },
42 | "isActive": {
43 | "name": "isActive",
44 | "type": "boolean",
45 | "primaryKey": false,
46 | "notNull": true,
47 | "default": true
48 | },
49 | "createdAt": {
50 | "name": "createdAt",
51 | "type": "timestamp",
52 | "primaryKey": false,
53 | "notNull": true,
54 | "default": "now()"
55 | },
56 | "updatedAt": {
57 | "name": "updatedAt",
58 | "type": "timestamp",
59 | "primaryKey": false,
60 | "notNull": true,
61 | "default": "now()"
62 | }
63 | },
64 | "indexes": {
65 | "clerkUserIdIndex": {
66 | "name": "clerkUserIdIndex",
67 | "columns": [
68 | {
69 | "expression": "clerkUserId",
70 | "isExpression": false,
71 | "asc": true,
72 | "nulls": "last"
73 | }
74 | ],
75 | "isUnique": false,
76 | "concurrently": false,
77 | "method": "btree",
78 | "with": {}
79 | }
80 | },
81 | "foreignKeys": {},
82 | "compositePrimaryKeys": {},
83 | "uniqueConstraints": {}
84 | },
85 | "public.scheduleAvailabilities": {
86 | "name": "scheduleAvailabilities",
87 | "schema": "",
88 | "columns": {
89 | "id": {
90 | "name": "id",
91 | "type": "uuid",
92 | "primaryKey": true,
93 | "notNull": true,
94 | "default": "gen_random_uuid()"
95 | },
96 | "scheduleId": {
97 | "name": "scheduleId",
98 | "type": "uuid",
99 | "primaryKey": false,
100 | "notNull": true
101 | },
102 | "startTime": {
103 | "name": "startTime",
104 | "type": "text",
105 | "primaryKey": false,
106 | "notNull": true
107 | },
108 | "endTime": {
109 | "name": "endTime",
110 | "type": "text",
111 | "primaryKey": false,
112 | "notNull": true
113 | },
114 | "dayOfWeek": {
115 | "name": "dayOfWeek",
116 | "type": "day",
117 | "typeSchema": "public",
118 | "primaryKey": false,
119 | "notNull": true
120 | }
121 | },
122 | "indexes": {
123 | "scheduleIdIndex": {
124 | "name": "scheduleIdIndex",
125 | "columns": [
126 | {
127 | "expression": "scheduleId",
128 | "isExpression": false,
129 | "asc": true,
130 | "nulls": "last"
131 | }
132 | ],
133 | "isUnique": false,
134 | "concurrently": false,
135 | "method": "btree",
136 | "with": {}
137 | }
138 | },
139 | "foreignKeys": {
140 | "scheduleAvailabilities_scheduleId_schedules_id_fk": {
141 | "name": "scheduleAvailabilities_scheduleId_schedules_id_fk",
142 | "tableFrom": "scheduleAvailabilities",
143 | "tableTo": "schedules",
144 | "columnsFrom": [
145 | "scheduleId"
146 | ],
147 | "columnsTo": [
148 | "id"
149 | ],
150 | "onDelete": "cascade",
151 | "onUpdate": "no action"
152 | }
153 | },
154 | "compositePrimaryKeys": {},
155 | "uniqueConstraints": {}
156 | },
157 | "public.schedules": {
158 | "name": "schedules",
159 | "schema": "",
160 | "columns": {
161 | "id": {
162 | "name": "id",
163 | "type": "uuid",
164 | "primaryKey": true,
165 | "notNull": true,
166 | "default": "gen_random_uuid()"
167 | },
168 | "timezone": {
169 | "name": "timezone",
170 | "type": "text",
171 | "primaryKey": false,
172 | "notNull": true
173 | },
174 | "clerkUserId": {
175 | "name": "clerkUserId",
176 | "type": "text",
177 | "primaryKey": false,
178 | "notNull": true
179 | },
180 | "createdAt": {
181 | "name": "createdAt",
182 | "type": "timestamp",
183 | "primaryKey": false,
184 | "notNull": true,
185 | "default": "now()"
186 | },
187 | "updatedAt": {
188 | "name": "updatedAt",
189 | "type": "timestamp",
190 | "primaryKey": false,
191 | "notNull": true,
192 | "default": "now()"
193 | }
194 | },
195 | "indexes": {},
196 | "foreignKeys": {},
197 | "compositePrimaryKeys": {},
198 | "uniqueConstraints": {
199 | "schedules_clerkUserId_unique": {
200 | "name": "schedules_clerkUserId_unique",
201 | "nullsNotDistinct": false,
202 | "columns": [
203 | "clerkUserId"
204 | ]
205 | }
206 | }
207 | }
208 | },
209 | "enums": {
210 | "public.day": {
211 | "name": "day",
212 | "schema": "public",
213 | "values": [
214 | "monday",
215 | "tuesday",
216 | "wednesday",
217 | "thursday",
218 | "friday",
219 | "saturday",
220 | "sunday"
221 | ]
222 | }
223 | },
224 | "schemas": {},
225 | "sequences": {},
226 | "_meta": {
227 | "columns": {},
228 | "schemas": {},
229 | "tables": {}
230 | }
231 | }
--------------------------------------------------------------------------------
/src/components/forms/EventForm.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useForm } from "react-hook-form"
4 | import { z } from "zod"
5 | import { zodResolver } from "@hookform/resolvers/zod"
6 | import { eventFormSchema } from "@/schema/events"
7 | import {
8 | Form,
9 | FormControl,
10 | FormDescription,
11 | FormField,
12 | FormItem,
13 | FormLabel,
14 | FormMessage,
15 | } from "../ui/form"
16 | import { Input } from "../ui/input"
17 | import Link from "next/link"
18 | import { Button } from "../ui/button"
19 | import { Textarea } from "../ui/textarea"
20 | import { Switch } from "../ui/switch"
21 | import { createEvent, deleteEvent, updateEvent } from "@/server/actions/events"
22 | import {
23 | AlertDialog,
24 | AlertDialogContent,
25 | AlertDialogDescription,
26 | AlertDialogHeader,
27 | AlertDialogTrigger,
28 | AlertDialogTitle,
29 | AlertDialogFooter,
30 | AlertDialogCancel,
31 | AlertDialogAction,
32 | } from "../ui/alert-dialog"
33 | import { useState, useTransition } from "react"
34 |
35 | export function EventForm({
36 | event,
37 | }: {
38 | event?: {
39 | id: string
40 | name: string
41 | description?: string
42 | durationInMinutes: number
43 | isActive: boolean
44 | }
45 | }) {
46 | const [isDeletePending, startDeleteTransition] = useTransition()
47 | const form = useForm>({
48 | resolver: zodResolver(eventFormSchema),
49 | defaultValues: event ?? {
50 | isActive: true,
51 | durationInMinutes: 30,
52 | },
53 | })
54 |
55 | async function onSubmit(values: z.infer) {
56 | const action =
57 | event == null ? createEvent : updateEvent.bind(null, event.id)
58 | const data = await action(values)
59 |
60 | if (data?.error) {
61 | form.setError("root", {
62 | message: "There was an error saving your event",
63 | })
64 | }
65 | }
66 |
67 | return (
68 |
203 |
204 | )
205 | }
206 |
--------------------------------------------------------------------------------
/src/components/forms/ScheduleForm.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useFieldArray, useForm } from "react-hook-form"
4 | import { z } from "zod"
5 | import { zodResolver } from "@hookform/resolvers/zod"
6 | import {
7 | Form,
8 | FormControl,
9 | FormDescription,
10 | FormField,
11 | FormItem,
12 | FormLabel,
13 | FormMessage,
14 | } from "../ui/form"
15 | import { Button } from "../ui/button"
16 | import { createEvent, deleteEvent, updateEvent } from "@/server/actions/events"
17 | import { DAYS_OF_WEEK_IN_ORDER } from "@/data/constants"
18 | import { scheduleFormSchema } from "@/schema/schedule"
19 | import { timeToInt } from "@/lib/utils"
20 | import {
21 | Select,
22 | SelectContent,
23 | SelectItem,
24 | SelectTrigger,
25 | SelectValue,
26 | } from "../ui/select"
27 | import { formatTimezoneOffset } from "@/lib/formatters"
28 | import { Fragment, useState } from "react"
29 | import { Plus, X } from "lucide-react"
30 | import { Input } from "../ui/input"
31 | import { saveSchedule } from "@/server/actions/schedule"
32 |
33 | type Availability = {
34 | startTime: string
35 | endTime: string
36 | dayOfWeek: (typeof DAYS_OF_WEEK_IN_ORDER)[number]
37 | }
38 |
39 | export function ScheduleForm({
40 | schedule,
41 | }: {
42 | schedule?: {
43 | timezone: string
44 | availabilities: Availability[]
45 | }
46 | }) {
47 | const [successMessage, setSuccessMessage] = useState()
48 | const form = useForm>({
49 | resolver: zodResolver(scheduleFormSchema),
50 | defaultValues: {
51 | timezone:
52 | schedule?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
53 | availabilities: schedule?.availabilities.toSorted((a, b) => {
54 | return timeToInt(a.startTime) - timeToInt(b.startTime)
55 | }),
56 | },
57 | })
58 |
59 | const {
60 | append: addAvailability,
61 | remove: removeAvailability,
62 | fields: availabilityFields,
63 | } = useFieldArray({ name: "availabilities", control: form.control })
64 |
65 | const groupedAvailabilityFields = Object.groupBy(
66 | availabilityFields.map((field, index) => ({ ...field, index })),
67 | availability => availability.dayOfWeek
68 | )
69 |
70 | async function onSubmit(values: z.infer) {
71 | const data = await saveSchedule(values)
72 |
73 | if (data?.error) {
74 | form.setError("root", {
75 | message: "There was an error saving your schedule",
76 | })
77 | } else {
78 | setSuccessMessage("Schedule saved!")
79 | }
80 | }
81 |
82 | return (
83 |
226 |
227 | )
228 | }
229 |
--------------------------------------------------------------------------------
/src/components/forms/MeetingForm.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useForm } from "react-hook-form"
4 | import { z } from "zod"
5 | import { zodResolver } from "@hookform/resolvers/zod"
6 | import {
7 | Form,
8 | FormControl,
9 | FormDescription,
10 | FormField,
11 | FormItem,
12 | FormLabel,
13 | FormMessage,
14 | } from "../ui/form"
15 | import { Input } from "../ui/input"
16 | import Link from "next/link"
17 | import { Button } from "../ui/button"
18 | import { Textarea } from "../ui/textarea"
19 | import { meetingFormSchema } from "@/schema/meetings"
20 | import {
21 | Select,
22 | SelectContent,
23 | SelectItem,
24 | SelectTrigger,
25 | SelectValue,
26 | } from "../ui/select"
27 | import {
28 | formatDate,
29 | formatTimeString,
30 | formatTimezoneOffset,
31 | } from "@/lib/formatters"
32 | import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"
33 | import { CalendarIcon } from "lucide-react"
34 | import { Calendar } from "../ui/calendar"
35 | import { isSameDay } from "date-fns"
36 | import { cn } from "@/lib/utils"
37 | import { useMemo } from "react"
38 | import { toZonedTime } from "date-fns-tz"
39 | import { createMeeting } from "@/server/actions/meetings"
40 |
41 | export function MeetingForm({
42 | validTimes,
43 | eventId,
44 | clerkUserId,
45 | }: {
46 | validTimes: Date[]
47 | eventId: string
48 | clerkUserId: string
49 | }) {
50 | const form = useForm>({
51 | resolver: zodResolver(meetingFormSchema),
52 | defaultValues: {
53 | timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
54 | },
55 | })
56 |
57 | const timezone = form.watch("timezone")
58 | const date = form.watch("date")
59 | const validTimesInTimezone = useMemo(() => {
60 | return validTimes.map(date => toZonedTime(date, timezone))
61 | }, [validTimes, timezone])
62 |
63 | async function onSubmit(values: z.infer) {
64 | const data = await createMeeting({
65 | ...values,
66 | eventId,
67 | clerkUserId,
68 | })
69 |
70 | if (data?.error) {
71 | form.setError("root", {
72 | message: "There was an error saving your event",
73 | })
74 | }
75 | }
76 |
77 | return (
78 |
256 |
257 | )
258 | }
259 |
--------------------------------------------------------------------------------