├── .eslintrc.json ├── .gitignore ├── README.md ├── app ├── (bookingPage) │ └── [username] │ │ └── [eventName] │ │ └── page.tsx ├── actions.ts ├── api │ ├── auth │ │ ├── [...nextauth] │ │ │ └── route.ts │ │ └── route.ts │ ├── oauth │ │ └── exchange │ │ │ └── route.ts │ └── uploadthing │ │ ├── core.ts │ │ └── route.ts ├── components │ ├── SubmitButton.tsx │ ├── TimeSlots.tsx │ ├── dashboard │ │ ├── CopyLinkMenuItem.tsx │ │ ├── DasboardLinks.tsx │ │ ├── EditEventTypeForm.tsx │ │ ├── EmptyState.tsx │ │ ├── EventTypeSwitcher.tsx │ │ ├── ThemeProvider.tsx │ │ ├── ThemeToggle.tsx │ │ └── settingsForm.tsx │ ├── demo │ │ ├── Calendar.tsx │ │ ├── CalendarButton.tsx │ │ ├── CalendarCell.tsx │ │ ├── CalendarGrid.tsx │ │ ├── CalendarHeader.tsx │ │ └── RenderCalendar.tsx │ └── landingPage │ │ ├── AuthModal.tsx │ │ ├── Cta.tsx │ │ ├── Features.tsx │ │ ├── Hero.tsx │ │ ├── Logos.tsx │ │ ├── Navbar.tsx │ │ ├── Testimonial.tsx │ │ └── oldhomepage.tsx ├── dashboard │ ├── availability │ │ └── page.tsx │ ├── event │ │ └── [eventTypeId] │ │ │ ├── delete │ │ │ └── page.tsx │ │ │ └── page.tsx │ ├── layout.tsx │ ├── meetings │ │ └── page.tsx │ ├── new │ │ └── page.tsx │ ├── page.tsx │ └── settings │ │ └── page.tsx ├── favicon.ico ├── fonts │ ├── GeistMonoVF.woff │ └── GeistVF.woff ├── globals.css ├── layout.tsx ├── lib │ ├── auth.ts │ ├── db.ts │ ├── hooks.ts │ ├── nylas.ts │ ├── times.ts │ ├── uploadthing.ts │ └── zodSchemas.ts ├── meeting │ └── [username] │ │ └── [meetingName] │ │ └── page.tsx ├── onboarding │ ├── grant-id │ │ └── page.tsx │ └── page.tsx ├── page.tsx ├── success │ └── page.tsx └── test │ └── page.tsx ├── components.json ├── components └── ui │ ├── ButtonGroup.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── calendar.tsx │ ├── card.tsx │ ├── dialog.tsx │ ├── dropdown-menu.tsx │ ├── input.tsx │ ├── label.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── sheet.tsx │ ├── sonner.tsx │ ├── switch.tsx │ ├── textarea.tsx │ └── tooltip.tsx ├── lib ├── prisma.ts └── utils.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── prisma └── schema.prisma ├── public ├── better.png ├── dashboard-new.png ├── face.jpeg ├── github.svg ├── google.svg ├── gradient.svg ├── hero.png ├── logo.png ├── meet.png ├── nextjs-logo.svg ├── nylas-logo.png ├── supabase.svg ├── teams.png ├── typescript-logo.png ├── vercel.svg └── work-is-almost-over-happy.gif ├── tailwind.config.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /.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*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/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/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 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/app/building-your-application/deploying) for more details. 37 | -------------------------------------------------------------------------------- /app/(bookingPage)/[username]/[eventName]/page.tsx: -------------------------------------------------------------------------------- 1 | import { createMeetingAction } from "@/app/actions"; 2 | import { RenderCalendar } from "@/app/components/demo/RenderCalendar"; 3 | import { SubmitButton } from "@/app/components/SubmitButton"; 4 | import { TimeSlots } from "@/app/components/TimeSlots"; 5 | import { Card, CardContent } from "@/components/ui/card"; 6 | import { Input } from "@/components/ui/input"; 7 | import { Label } from "@/components/ui/label"; 8 | import { Separator } from "@/components/ui/separator"; 9 | import prisma from "@/lib/prisma"; 10 | import { format } from "date-fns"; 11 | import { BookMarked, CalendarX2, Clock } from "lucide-react"; 12 | import Image from "next/image"; 13 | import { notFound } from "next/navigation"; 14 | import React from "react"; 15 | 16 | async function getData(username: string, eventName: string) { 17 | const eventType = await prisma.eventType.findFirst({ 18 | where: { 19 | url: eventName, 20 | user: { 21 | username: username, 22 | }, 23 | active: true, 24 | }, 25 | select: { 26 | id: true, 27 | description: true, 28 | title: true, 29 | duration: true, 30 | videoCallSoftware: true, 31 | 32 | user: { 33 | select: { 34 | image: true, 35 | name: true, 36 | Availability: { 37 | select: { 38 | day: true, 39 | isActive: true, 40 | }, 41 | }, 42 | }, 43 | }, 44 | }, 45 | }); 46 | 47 | if (!eventType) { 48 | return notFound(); 49 | } 50 | 51 | return eventType; 52 | } 53 | 54 | const BookingPage = async ({ 55 | params, 56 | searchParams, 57 | }: { 58 | params: { username: string; eventName: string }; 59 | searchParams: { date?: string; time?: string }; 60 | }) => { 61 | const selectedDate = searchParams.date 62 | ? new Date(searchParams.date) 63 | : new Date(); 64 | const eventType = await getData(params.username, params.eventName); 65 | 66 | const formattedDate = new Intl.DateTimeFormat("en-US", { 67 | weekday: "long", 68 | day: "numeric", 69 | month: "long", 70 | }).format(selectedDate); 71 | 72 | const showForm = !!searchParams.date && !!searchParams.time; 73 | 74 | return ( 75 |
76 | {showForm ? ( 77 | 78 | 79 |
80 | {`${eventType.user.name}'s 87 |

88 | {eventType.user.name} 89 |

90 |

{eventType.title}

91 |

92 | {eventType.description} 93 |

94 | 95 |
96 |

97 | 98 | 99 | {formattedDate} 100 | 101 |

102 |

103 | 104 | 105 | {eventType.duration} Mins 106 | 107 |

108 |

109 | 110 | 111 | {eventType.videoCallSoftware} 112 | 113 |

114 |
115 |
116 | 120 | 121 |
125 | 126 | 127 | 128 | 129 | 134 |
135 | 136 | 137 |
138 | 139 |
140 | 141 | 142 |
143 | 144 | 145 | 146 |
147 |
148 | ) : ( 149 | 150 | 151 |
152 | {`${eventType.user.name}'s 159 |

160 | {eventType.user.name} 161 |

162 |

{eventType.title}

163 |

164 | {eventType.description} 165 |

166 |
167 |

168 | 169 | 170 | {formattedDate} 171 | 172 |

173 |

174 | 175 | 176 | {eventType.duration} Mins 177 | 178 |

179 |

180 | 181 | 182 | Google Meet 183 | 184 |

185 |
186 |
187 | 188 | 192 | 193 |
194 | 195 |
196 | 197 | 201 | 202 | 207 |
208 |
209 | )} 210 |
211 | ); 212 | }; 213 | 214 | export default BookingPage; 215 | -------------------------------------------------------------------------------- /app/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { parseWithZod } from "@conform-to/zod"; 4 | import prisma from "./lib/db"; 5 | import { requireUser } from "./lib/hooks"; 6 | import { 7 | aboutSettingsSchema, 8 | eventTypeSchema, 9 | EventTypeServerSchema, 10 | onboardingSchema, 11 | } from "./lib/zodSchemas"; 12 | import { redirect } from "next/navigation"; 13 | 14 | import { revalidatePath } from "next/cache"; 15 | import { nylas } from "./lib/nylas"; 16 | 17 | export async function onboardingAction(prevState: any, formData: FormData) { 18 | const session = await requireUser(); 19 | 20 | const submission = await parseWithZod(formData, { 21 | schema: onboardingSchema({ 22 | async isUsernameUnique() { 23 | const exisitngSubDirectory = await prisma.user.findUnique({ 24 | where: { 25 | username: formData.get("username") as string, 26 | }, 27 | }); 28 | return !exisitngSubDirectory; 29 | }, 30 | }), 31 | 32 | async: true, 33 | }); 34 | 35 | if (submission.status !== "success") { 36 | return submission.reply(); 37 | } 38 | 39 | const OnboardingData = await prisma.user.update({ 40 | where: { 41 | id: session.user?.id, 42 | }, 43 | data: { 44 | username: submission.value.username, 45 | name: submission.value.fullName, 46 | Availability: { 47 | createMany: { 48 | data: [ 49 | { 50 | day: "Monday", 51 | fromTime: "08:00", 52 | tillTime: "18:00", 53 | }, 54 | { 55 | day: "Tuesday", 56 | fromTime: "08:00", 57 | tillTime: "18:00", 58 | }, 59 | { 60 | day: "Wednesday", 61 | fromTime: "08:00", 62 | tillTime: "18:00", 63 | }, 64 | { 65 | day: "Thursday", 66 | fromTime: "08:00", 67 | tillTime: "18:00", 68 | }, 69 | { 70 | day: "Friday", 71 | fromTime: "08:00", 72 | tillTime: "18:00", 73 | }, 74 | { 75 | day: "Saturday", 76 | fromTime: "08:00", 77 | tillTime: "18:00", 78 | }, 79 | { 80 | day: "Sunday", 81 | fromTime: "08:00", 82 | tillTime: "18:00", 83 | }, 84 | ], 85 | }, 86 | }, 87 | }, 88 | }); 89 | 90 | return redirect("/onboarding/grant-id"); 91 | } 92 | 93 | export async function SettingsAction(prevState: any, formData: FormData) { 94 | const session = await requireUser(); 95 | 96 | const submission = parseWithZod(formData, { 97 | schema: aboutSettingsSchema, 98 | }); 99 | 100 | if (submission.status !== "success") { 101 | return submission.reply(); 102 | } 103 | 104 | const user = await prisma.user.update({ 105 | where: { 106 | id: session.user?.id as string, 107 | }, 108 | data: { 109 | name: submission.value.fullName, 110 | image: submission.value.profileImage, 111 | }, 112 | }); 113 | 114 | return redirect("/dashboard"); 115 | } 116 | 117 | export async function CreateEventTypeAction( 118 | prevState: any, 119 | formData: FormData 120 | ) { 121 | const session = await requireUser(); 122 | 123 | const submission = await parseWithZod(formData, { 124 | schema: EventTypeServerSchema({ 125 | async isUrlUnique() { 126 | const data = await prisma.eventType.findFirst({ 127 | where: { 128 | userId: session.user?.id, 129 | url: formData.get("url") as string, 130 | }, 131 | }); 132 | return !data; 133 | }, 134 | }), 135 | 136 | async: true, 137 | }); 138 | if (submission.status !== "success") { 139 | return submission.reply(); 140 | } 141 | 142 | const data = await prisma.eventType.create({ 143 | data: { 144 | title: submission.value.title, 145 | duration: submission.value.duration, 146 | url: submission.value.url, 147 | description: submission.value.description, 148 | userId: session.user?.id as string, 149 | videoCallSoftware: submission.value.videoCallSoftware, 150 | }, 151 | }); 152 | 153 | return redirect("/dashboard"); 154 | } 155 | 156 | export async function EditEventTypeAction(prevState: any, formData: FormData) { 157 | const session = await requireUser(); 158 | 159 | const submission = await parseWithZod(formData, { 160 | schema: EventTypeServerSchema({ 161 | async isUrlUnique() { 162 | const data = await prisma.eventType.findFirst({ 163 | where: { 164 | userId: session.user?.id, 165 | url: formData.get("url") as string, 166 | }, 167 | }); 168 | return !data; 169 | }, 170 | }), 171 | 172 | async: true, 173 | }); 174 | 175 | if (submission.status !== "success") { 176 | return submission.reply(); 177 | } 178 | 179 | const data = await prisma.eventType.update({ 180 | where: { 181 | id: formData.get("id") as string, 182 | userId: session.user?.id as string, 183 | }, 184 | data: { 185 | title: submission.value.title, 186 | duration: submission.value.duration, 187 | url: submission.value.url, 188 | description: submission.value.description, 189 | videoCallSoftware: submission.value.videoCallSoftware, 190 | }, 191 | }); 192 | 193 | return redirect("/dashboard"); 194 | } 195 | 196 | export async function DeleteEventTypeAction(formData: FormData) { 197 | const session = await requireUser(); 198 | 199 | const data = await prisma.eventType.delete({ 200 | where: { 201 | id: formData.get("id") as string, 202 | userId: session.user?.id as string, 203 | }, 204 | }); 205 | 206 | return redirect("/dashboard"); 207 | } 208 | 209 | export async function updateEventTypeStatusAction( 210 | prevState: any, 211 | { 212 | eventTypeId, 213 | isChecked, 214 | }: { 215 | eventTypeId: string; 216 | isChecked: boolean; 217 | } 218 | ) { 219 | try { 220 | const session = await requireUser(); 221 | 222 | const data = await prisma.eventType.update({ 223 | where: { 224 | id: eventTypeId, 225 | userId: session.user?.id as string, 226 | }, 227 | data: { 228 | active: isChecked, 229 | }, 230 | }); 231 | 232 | revalidatePath(`/dashboard`); 233 | return { 234 | status: "success", 235 | message: "EventType Status updated successfully", 236 | }; 237 | } catch (error) { 238 | return { 239 | status: "error", 240 | message: "Something went wrong", 241 | }; 242 | } 243 | } 244 | 245 | export async function updateAvailabilityAction(formData: FormData) { 246 | const session = await requireUser(); 247 | 248 | const rawData = Object.fromEntries(formData.entries()); 249 | const availabilityData = Object.keys(rawData) 250 | .filter((key) => key.startsWith("id-")) 251 | .map((key) => { 252 | const id = key.replace("id-", ""); 253 | return { 254 | id, 255 | isActive: rawData[`isActive-${id}`] === "on", 256 | fromTime: rawData[`fromTime-${id}`] as string, 257 | tillTime: rawData[`tillTime-${id}`] as string, 258 | }; 259 | }); 260 | 261 | try { 262 | await prisma.$transaction( 263 | availabilityData.map((item) => 264 | prisma.availability.update({ 265 | where: { id: item.id }, 266 | data: { 267 | isActive: item.isActive, 268 | fromTime: item.fromTime, 269 | tillTime: item.tillTime, 270 | }, 271 | }) 272 | ) 273 | ); 274 | 275 | revalidatePath("/dashboard/availability"); 276 | return { status: "success", message: "Availability updated successfully" }; 277 | } catch (error) { 278 | console.error("Error updating availability:", error); 279 | return { status: "error", message: "Failed to update availability" }; 280 | } 281 | } 282 | 283 | export async function createMeetingAction(formData: FormData) { 284 | const getUserData = await prisma.user.findUnique({ 285 | where: { 286 | username: formData.get("username") as string, 287 | }, 288 | select: { 289 | grantEmail: true, 290 | grantId: true, 291 | }, 292 | }); 293 | 294 | if (!getUserData) { 295 | throw new Error("User not found"); 296 | } 297 | 298 | const eventTypeData = await prisma.eventType.findUnique({ 299 | where: { 300 | id: formData.get("eventTypeId") as string, 301 | }, 302 | select: { 303 | title: true, 304 | description: true, 305 | }, 306 | }); 307 | 308 | const formTime = formData.get("fromTime") as string; 309 | const meetingLength = Number(formData.get("meetingLength")); 310 | const eventDate = formData.get("eventDate") as string; 311 | 312 | const startDateTime = new Date(`${eventDate}T${formTime}:00`); 313 | 314 | // Calculate the end time by adding the meeting length (in minutes) to the start time 315 | const endDateTime = new Date(startDateTime.getTime() + meetingLength * 60000); 316 | 317 | await nylas.events.create({ 318 | identifier: getUserData?.grantId as string, 319 | requestBody: { 320 | title: eventTypeData?.title, 321 | description: eventTypeData?.description, 322 | when: { 323 | startTime: Math.floor(startDateTime.getTime() / 1000), 324 | endTime: Math.floor(endDateTime.getTime() / 1000), 325 | }, 326 | conferencing: { 327 | autocreate: {}, 328 | provider: "Google Meet", 329 | }, 330 | participants: [ 331 | { 332 | name: formData.get("name") as string, 333 | email: formData.get("email") as string, 334 | status: "yes", 335 | }, 336 | ], 337 | }, 338 | queryParams: { 339 | calendarId: getUserData?.grantEmail as string, 340 | notifyParticipants: true, 341 | }, 342 | }); 343 | 344 | return redirect(`/success`); 345 | } 346 | 347 | export async function cancelMeetingAction(formData: FormData) { 348 | const session = await requireUser(); 349 | 350 | const userData = await prisma.user.findUnique({ 351 | where: { 352 | id: session.user?.id as string, 353 | }, 354 | select: { 355 | grantEmail: true, 356 | grantId: true, 357 | }, 358 | }); 359 | 360 | if (!userData) { 361 | throw new Error("User not found"); 362 | } 363 | 364 | const data = await nylas.events.destroy({ 365 | eventId: formData.get("eventId") as string, 366 | identifier: userData?.grantId as string, 367 | queryParams: { 368 | calendarId: userData?.grantEmail as string, 369 | }, 370 | }); 371 | 372 | revalidatePath("/dashboard/meetings"); 373 | } 374 | -------------------------------------------------------------------------------- /app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import { handlers } from "@/app/lib/auth"; 2 | 3 | export const { GET, POST } = handlers; 4 | -------------------------------------------------------------------------------- /app/api/auth/route.ts: -------------------------------------------------------------------------------- 1 | import { nylas, nylasConfig } from "@/app/lib/nylas"; 2 | import { redirect } from "next/navigation"; 3 | 4 | export async function GET() { 5 | const authUrl = nylas.auth.urlForOAuth2({ 6 | clientId: nylasConfig.clientId as string, 7 | redirectUri: nylasConfig.callbackUri, 8 | }); 9 | return redirect(authUrl); 10 | } 11 | -------------------------------------------------------------------------------- /app/api/oauth/exchange/route.ts: -------------------------------------------------------------------------------- 1 | import prisma from "@/app/lib/db"; 2 | import { requireUser } from "@/app/lib/hooks"; 3 | import { nylas, nylasConfig } from "@/app/lib/nylas"; 4 | 5 | import { redirect } from "next/navigation"; 6 | import { NextRequest } from "next/server"; 7 | 8 | export async function GET(req: NextRequest) { 9 | console.log("Received callback from Nylas"); 10 | const session = await requireUser(); 11 | const url = new URL(req.url as string); 12 | const code = url.searchParams.get("code"); 13 | 14 | if (!code) { 15 | return Response.json("No authorization code returned from Nylas", { 16 | status: 400, 17 | }); 18 | } 19 | const codeExchangePayload = { 20 | clientSecret: nylasConfig.apiKey, 21 | clientId: nylasConfig.clientId as string, 22 | redirectUri: nylasConfig.callbackUri, 23 | code, 24 | }; 25 | 26 | try { 27 | const response = await nylas.auth.exchangeCodeForToken(codeExchangePayload); 28 | const { grantId, email } = response; 29 | 30 | await prisma.user.update({ 31 | where: { 32 | id: session.user?.id as string, 33 | }, 34 | data: { 35 | grantId: grantId, 36 | grantEmail: email, 37 | }, 38 | }); 39 | 40 | console.log({ grantId }); 41 | } catch (error) { 42 | console.error("Error exchanging code for token:", error); 43 | } 44 | 45 | redirect("/dashboard"); 46 | } 47 | -------------------------------------------------------------------------------- /app/api/uploadthing/core.ts: -------------------------------------------------------------------------------- 1 | import { createUploadthing, type FileRouter } from "uploadthing/next"; 2 | import { UploadThingError } from "uploadthing/server"; 3 | 4 | const f = createUploadthing(); 5 | 6 | const auth = (req: Request) => ({ id: "fakeId" }); // Fake auth function 7 | 8 | // FileRouter for your app, can contain multiple FileRoutes 9 | export const ourFileRouter = { 10 | // Define as many FileRoutes as you like, each with a unique routeSlug 11 | imageUploader: f({ image: { maxFileSize: "4MB" } }) 12 | // Set permissions and file types for this FileRoute 13 | .middleware(async ({ req }) => { 14 | // This code runs on your server before upload 15 | const user = await auth(req); 16 | 17 | // If you throw, the user will not be able to upload 18 | if (!user) throw new UploadThingError("Unauthorized"); 19 | 20 | // Whatever is returned here is accessible in onUploadComplete as `metadata` 21 | return { userId: user.id }; 22 | }) 23 | .onUploadComplete(async ({ metadata, file }) => { 24 | // This code RUNS ON YOUR SERVER after upload 25 | console.log("Upload complete for userId:", metadata.userId); 26 | 27 | console.log("file url", file.url); 28 | 29 | // !!! Whatever is returned here is sent to the clientside `onClientUploadComplete` callback 30 | return { uploadedBy: metadata.userId }; 31 | }), 32 | } satisfies FileRouter; 33 | 34 | export type OurFileRouter = typeof ourFileRouter; 35 | -------------------------------------------------------------------------------- /app/api/uploadthing/route.ts: -------------------------------------------------------------------------------- 1 | import { createRouteHandler } from "uploadthing/next"; 2 | 3 | import { ourFileRouter } from "./core"; 4 | 5 | // Export routes for Next App Router 6 | export const { GET, POST } = createRouteHandler({ 7 | router: ourFileRouter, 8 | 9 | // Apply an (optional) custom config: 10 | // config: { ... }, 11 | }); 12 | -------------------------------------------------------------------------------- /app/components/SubmitButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { cn } from "@/lib/utils"; 5 | import { Loader2 } from "lucide-react"; 6 | import Image from "next/image"; 7 | import { useFormStatus } from "react-dom"; 8 | import GithubLogo from "@/public/github.svg"; 9 | import GoogleLogo from "@/public/google.svg"; 10 | interface iAppProps { 11 | text: string; 12 | variant?: 13 | | "default" 14 | | "destructive" 15 | | "outline" 16 | | "secondary" 17 | | "ghost" 18 | | "link" 19 | | null 20 | | undefined; 21 | 22 | className?: string; 23 | } 24 | 25 | export function SubmitButton({ text, variant, className }: iAppProps) { 26 | const { pending } = useFormStatus(); 27 | 28 | return ( 29 | <> 30 | {pending ? ( 31 | 34 | ) : ( 35 | 42 | )} 43 | 44 | ); 45 | } 46 | 47 | export function GitHubAuthButton() { 48 | const { pending } = useFormStatus(); 49 | return ( 50 | <> 51 | {pending ? ( 52 | 55 | ) : ( 56 | 64 | )} 65 | 66 | ); 67 | } 68 | 69 | export function GoogleAuthButton() { 70 | const { pending } = useFormStatus(); 71 | return ( 72 | <> 73 | {pending ? ( 74 | 77 | ) : ( 78 | 82 | )} 83 | 84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /app/components/TimeSlots.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | addMinutes, 3 | format, 4 | fromUnixTime, 5 | isAfter, 6 | isBefore, 7 | parse, 8 | } from "date-fns"; 9 | import prisma from "../lib/db"; 10 | import { Prisma } from "@prisma/client"; 11 | import { nylas } from "../lib/nylas"; 12 | import Link from "next/link"; 13 | import { Button } from "@/components/ui/button"; 14 | import { NylasResponse, GetFreeBusyResponse } from "nylas"; 15 | 16 | interface iappProps { 17 | selectedDate: Date; 18 | userName: string; 19 | meetingDuration: number; 20 | } 21 | 22 | async function getAvailability(selectedDate: Date, userName: string) { 23 | const currentDay = format(selectedDate, "EEEE"); 24 | 25 | const startOfDay = new Date(selectedDate); 26 | startOfDay.setHours(0, 0, 0, 0); 27 | const endOfDay = new Date(selectedDate); 28 | endOfDay.setHours(23, 59, 59, 999); 29 | const data = await prisma.availability.findFirst({ 30 | where: { 31 | day: currentDay as Prisma.EnumDayFilter, 32 | User: { 33 | username: userName, 34 | }, 35 | }, 36 | select: { 37 | fromTime: true, 38 | tillTime: true, 39 | id: true, 40 | User: { 41 | select: { 42 | grantEmail: true, 43 | grantId: true, 44 | }, 45 | }, 46 | }, 47 | }); 48 | 49 | const nylasCalendarData = await nylas.calendars.getFreeBusy({ 50 | identifier: data?.User.grantId as string, 51 | requestBody: { 52 | startTime: Math.floor(startOfDay.getTime() / 1000), 53 | endTime: Math.floor(endOfDay.getTime() / 1000), 54 | emails: [data?.User.grantEmail as string], 55 | }, 56 | }); 57 | 58 | return { data, nylasCalendarData }; 59 | } 60 | 61 | function calculateAvailableTimeSlots( 62 | dbAvailability: { 63 | fromTime: string | undefined; 64 | tillTime: string | undefined; 65 | }, 66 | nylasData: NylasResponse, 67 | date: string, 68 | duration: number 69 | ) { 70 | const now = new Date(); // Get the current time 71 | 72 | // Convert DB availability to Date objects 73 | const availableFrom = parse( 74 | `${date} ${dbAvailability.fromTime}`, 75 | "yyyy-MM-dd HH:mm", 76 | new Date() 77 | ); 78 | const availableTill = parse( 79 | `${date} ${dbAvailability.tillTime}`, 80 | "yyyy-MM-dd HH:mm", 81 | new Date() 82 | ); 83 | 84 | // Extract busy slots from Nylas data 85 | const busySlots = nylasData.data[0].timeSlots.map((slot: any) => ({ 86 | start: fromUnixTime(slot.startTime), 87 | end: fromUnixTime(slot.endTime), 88 | })); 89 | 90 | // Generate all possible 30-minute slots within the available time 91 | const allSlots = []; 92 | let currentSlot = availableFrom; 93 | while (isBefore(currentSlot, availableTill)) { 94 | allSlots.push(currentSlot); 95 | currentSlot = addMinutes(currentSlot, duration); 96 | } 97 | 98 | // Filter out busy slots and slots before the current time 99 | const freeSlots = allSlots.filter((slot) => { 100 | const slotEnd = addMinutes(slot, duration); 101 | return ( 102 | isAfter(slot, now) && // Ensure the slot is after the current time 103 | !busySlots.some( 104 | (busy: { start: any; end: any }) => 105 | (!isBefore(slot, busy.start) && isBefore(slot, busy.end)) || 106 | (isAfter(slotEnd, busy.start) && !isAfter(slotEnd, busy.end)) || 107 | (isBefore(slot, busy.start) && isAfter(slotEnd, busy.end)) 108 | ) 109 | ); 110 | }); 111 | 112 | // Format the free slots 113 | return freeSlots.map((slot) => format(slot, "HH:mm")); 114 | } 115 | 116 | export async function TimeSlots({ 117 | selectedDate, 118 | userName, 119 | meetingDuration, 120 | }: iappProps) { 121 | const { data, nylasCalendarData } = await getAvailability( 122 | selectedDate, 123 | userName 124 | ); 125 | 126 | const dbAvailability = { fromTime: data?.fromTime, tillTime: data?.tillTime }; 127 | 128 | const formattedDate = format(selectedDate, "yyyy-MM-dd"); 129 | 130 | const availableSlots = calculateAvailableTimeSlots( 131 | dbAvailability, 132 | nylasCalendarData, 133 | formattedDate, 134 | meetingDuration 135 | ); 136 | 137 | return ( 138 |
139 |

140 | {format(selectedDate, "EEE")}.{" "} 141 | 142 | {format(selectedDate, "MMM. d")} 143 | 144 |

145 | 146 |
147 | {availableSlots.length > 0 ? ( 148 | availableSlots.map((slot, index) => ( 149 | 153 | 156 | 157 | )) 158 | ) : ( 159 |

No available time slots for this date.

160 | )} 161 |
162 |
163 | ); 164 | } 165 | -------------------------------------------------------------------------------- /app/components/dashboard/CopyLinkMenuItem.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; 4 | import { Link2 } from "lucide-react"; 5 | import { toast } from "sonner"; 6 | 7 | interface CopyLinkMenuItemProps { 8 | meetingUrl: string; 9 | } 10 | 11 | export function CopyLinkMenuItem({ meetingUrl }: CopyLinkMenuItemProps) { 12 | const handleCopy = async () => { 13 | try { 14 | await navigator.clipboard.writeText(meetingUrl); 15 | toast.success("URL copied to clipboard"); 16 | } catch (err) { 17 | console.error("Could not copy text: ", err); 18 | toast.error("Failed to copy URL"); 19 | } 20 | }; 21 | 22 | return ( 23 | 24 | 25 | Copy 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /app/components/dashboard/DasboardLinks.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import { CalendarCheck, HomeIcon, Settings, Users2 } from "lucide-react"; 5 | import Link from "next/link"; 6 | import { usePathname } from "next/navigation"; 7 | import React from "react"; 8 | 9 | export const dashboardLinks = [ 10 | { 11 | id: 0, 12 | name: "Event Types", 13 | href: "/dashboard", 14 | icon: HomeIcon, 15 | }, 16 | { 17 | id: 1, 18 | name: "Meetings", 19 | href: "/dashboard/meetings", 20 | icon: Users2, 21 | }, 22 | { 23 | id: 2, 24 | name: "Availablity", 25 | href: "/dashboard/availability", 26 | icon: CalendarCheck, 27 | }, 28 | { 29 | id: 3, 30 | name: "Settings", 31 | href: "/dashboard/settings", 32 | icon: Settings, 33 | }, 34 | ]; 35 | 36 | export function DasboardLinks() { 37 | const pathname = usePathname(); 38 | return ( 39 | <> 40 | {dashboardLinks.map((link) => ( 41 | 51 | 52 | {link.name} 53 | 54 | ))} 55 | 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /app/components/dashboard/EditEventTypeForm.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | Card, 6 | CardContent, 7 | CardDescription, 8 | CardFooter, 9 | CardHeader, 10 | CardTitle, 11 | } from "@/components/ui/card"; 12 | import { Input } from "@/components/ui/input"; 13 | import { Label } from "@/components/ui/label"; 14 | import { Textarea } from "@/components/ui/textarea"; 15 | import Link from "next/link"; 16 | import { SubmitButton } from "../SubmitButton"; 17 | import { useFormState } from "react-dom"; 18 | import { useForm } from "@conform-to/react"; 19 | import { parseWithZod } from "@conform-to/zod"; 20 | import { eventTypeSchema } from "@/app/lib/zodSchemas"; 21 | import { EditEventTypeAction } from "@/app/actions"; 22 | import { 23 | Select, 24 | SelectContent, 25 | SelectGroup, 26 | SelectItem, 27 | SelectLabel, 28 | SelectTrigger, 29 | SelectValue, 30 | } from "@/components/ui/select"; 31 | import { ButtonGroup } from "@/components/ui/ButtonGroup"; 32 | import { useState } from "react"; 33 | 34 | interface iAppProps { 35 | id: string; 36 | title: string; 37 | url: string; 38 | description: string; 39 | duration: number; 40 | callProvider: string; 41 | } 42 | 43 | type Platform = "Zoom Meeting" | "Google Meet" | "Microsoft Teams"; 44 | 45 | export function EditEventTypeForm({ 46 | description, 47 | duration, 48 | title, 49 | url, 50 | callProvider, 51 | id, 52 | }: iAppProps) { 53 | const [lastResult, action] = useFormState(EditEventTypeAction, undefined); 54 | const [form, fields] = useForm({ 55 | // Sync the result of last submission 56 | lastResult, 57 | 58 | // Reuse the validation logic on the client 59 | onValidate({ formData }) { 60 | return parseWithZod(formData, { schema: eventTypeSchema }); 61 | }, 62 | 63 | // Validate the form on blur event triggered 64 | shouldValidate: "onBlur", 65 | shouldRevalidate: "onInput", 66 | }); 67 | const [activePlatform, setActivePlatform] = useState( 68 | callProvider as Platform 69 | ); 70 | 71 | const togglePlatform = (platform: Platform) => { 72 | setActivePlatform(platform); 73 | }; 74 | return ( 75 |
76 | 77 | 78 | Add new appointment type 79 | 80 | Create a new appointment type that allows people to book times. 81 | 82 | 83 |
84 | 85 | 86 |
87 | 88 | 94 |

{fields.title.errors}

95 |
96 | 97 |
98 | 99 |
100 | 101 | CalMarshal.com/ 102 | 103 | 111 |
112 | 113 |

{fields.url.errors}

114 |
115 | 116 |
117 | 118 |