├── .eslintrc.json
├── .gitignore
├── README.md
├── bun.lockb
├── next.config.mjs
├── package.json
├── postcss.config.mjs
├── public
├── background.jpg
├── next.svg
└── vercel.svg
├── src
├── app
│ ├── (booking)
│ │ ├── [username]
│ │ │ └── [booking-uri]
│ │ │ │ ├── [booking-time]
│ │ │ │ └── page.tsx
│ │ │ │ ├── layout.tsx
│ │ │ │ └── page.tsx
│ │ └── layout.tsx
│ ├── (site)
│ │ ├── dashboard
│ │ │ ├── booked-events
│ │ │ │ └── page.tsx
│ │ │ ├── event-types
│ │ │ │ ├── edit
│ │ │ │ │ └── [id]
│ │ │ │ │ │ └── page.tsx
│ │ │ │ ├── new
│ │ │ │ │ └── page.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── layout.tsx
│ │ │ └── page.tsx
│ │ ├── features
│ │ │ └── page.tsx
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── api
│ │ ├── auth
│ │ │ └── route.ts
│ │ ├── bookings
│ │ │ └── route.ts
│ │ ├── busy
│ │ │ └── route.ts
│ │ ├── event-types
│ │ │ └── route.ts
│ │ ├── logout
│ │ │ └── route.ts
│ │ ├── oauth
│ │ │ └── exchange
│ │ │ │ └── route.ts
│ │ └── profile
│ │ │ └── route.ts
│ ├── components
│ │ ├── DashboardNav.tsx
│ │ ├── EventTypeDelete.tsx
│ │ ├── EventTypeForm.tsx
│ │ ├── Header.tsx
│ │ ├── Hero.tsx
│ │ ├── ProfileForm.tsx
│ │ ├── RightNav.tsx
│ │ ├── TimePicker.tsx
│ │ └── TimeSelect.tsx
│ ├── favicon.ico
│ └── globals.css
├── libs
│ ├── nylas.ts
│ ├── session.ts
│ ├── shared.ts
│ └── types.ts
└── models
│ ├── Booking.ts
│ ├── EventType.ts
│ └── Profile.ts
├── tailwind.config.ts
└── tsconfig.json
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
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 | .idea
21 | .DS_Store
22 | *.pem
23 |
24 | # debug
25 | npm-debug.log*
26 | yarn-debug.log*
27 | yarn-error.log*
28 |
29 | # local env files
30 | .env*.local
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://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 |
--------------------------------------------------------------------------------
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dejwid/calendix/33542b7bacd43c9c522c37161bb4ea41bba98758/bun.lockb
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {};
3 |
4 | export default nextConfig;
5 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "calendix",
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 | },
11 | "dependencies": {
12 | "axios": "^1.7.2",
13 | "clsx": "^2.1.1",
14 | "date-fns": "^3.6.0",
15 | "lucide": "^0.412.0",
16 | "lucide-react": "^0.412.0",
17 | "mongoose": "^8.5.1",
18 | "next": "14.2.5",
19 | "next-app-session": "^1.0.7",
20 | "nylas": "^7.5.2",
21 | "react": "^18",
22 | "react-dom": "^18",
23 | "react-spinners": "^0.14.1"
24 | },
25 | "devDependencies": {
26 | "typescript": "^5",
27 | "@types/node": "^20",
28 | "@types/react": "^18",
29 | "@types/react-dom": "^18",
30 | "postcss": "^8",
31 | "tailwindcss": "^3.4.1",
32 | "eslint": "^8",
33 | "eslint-config-next": "14.2.5"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/public/background.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dejwid/calendix/33542b7bacd43c9c522c37161bb4ea41bba98758/public/background.jpg
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/(booking)/[username]/[booking-uri]/[booking-time]/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import axios from "axios";
3 | import {format} from "date-fns";
4 | import {FormEvent, useState} from "react";
5 |
6 | type PageProps = {
7 | params: {
8 | username: string;
9 | "booking-uri": string;
10 | "booking-time": string;
11 | };
12 | };
13 | export default function BookingFormPage(props:PageProps) {
14 | const [guestName, setGuestName] = useState('');
15 | const [guestEmail, setGuestEmail] = useState('');
16 | const [guestNotes, setGuestNotes] = useState('');
17 | const [confirmed, setConfirmed] = useState(false);
18 |
19 | const username = props.params.username;
20 | const bookingUri = props.params["booking-uri"];
21 | const bookingTime = new Date(decodeURIComponent(props.params["booking-time"]));
22 |
23 | async function handleFormSubmit(ev:FormEvent) {
24 | ev.preventDefault();
25 | const data = {guestName, guestEmail, guestNotes, username, bookingUri, bookingTime};
26 | await axios.post('/api/bookings', data);
27 | setConfirmed(true);
28 | }
29 |
30 | return (
31 |
32 |
33 | {format(bookingTime, 'EEEE, MMMM d, HH:mm')}
34 |
35 | {confirmed && (
36 |
Thanks for you booking!
37 | )}
38 | {!confirmed && (
39 |
68 | )}
69 |
70 | );
71 | }
--------------------------------------------------------------------------------
/src/app/(booking)/[username]/[booking-uri]/layout.tsx:
--------------------------------------------------------------------------------
1 | import TimePicker from "@/app/components/TimePicker";
2 | import {EventTypeModel} from "@/models/EventType";
3 | import {ProfileModel} from "@/models/Profile";
4 | import {Clock, Info} from "lucide-react";
5 | import mongoose from "mongoose";
6 | import {ReactNode} from "react";
7 |
8 | type LayoutProps = {
9 | children: ReactNode;
10 | params: {
11 | username: string;
12 | "booking-uri": string;
13 | };
14 | };
15 |
16 | export default async function BookingBoxLayout(props: LayoutProps) {
17 | await mongoose.connect(process.env.MONGODB_URI as string);
18 | const profileDoc = await ProfileModel.findOne({
19 | username: props.params.username,
20 | });
21 | if (!profileDoc) {
22 | return '404';
23 | }
24 | const etDoc = await EventTypeModel.findOne({
25 | email: profileDoc.email,
26 | uri: props.params?.['booking-uri'],
27 | });
28 | if (!etDoc) {
29 | return '404';
30 | }
31 | return (
32 |
33 |
34 |
35 |
36 |
37 | {etDoc.title}
38 |
39 |
40 |
41 |
{etDoc.length}min
42 |
43 |
44 | {etDoc.description}
45 |
46 |
47 |
48 |
49 | {props.children}
50 |
51 |
52 |
53 |
54 | );
55 | }
--------------------------------------------------------------------------------
/src/app/(booking)/[username]/[booking-uri]/page.tsx:
--------------------------------------------------------------------------------
1 | import TimePicker from "@/app/components/TimePicker";
2 | import {EventTypeModel} from "@/models/EventType";
3 | import {ProfileModel} from "@/models/Profile";
4 | import {Clock, Info} from "lucide-react";
5 | import mongoose from "mongoose";
6 |
7 | type PageProps = {
8 | params:{
9 | username:string;
10 | "booking-uri":string;
11 | };
12 | };
13 | export default async function BookingPage(props: PageProps) {
14 | await mongoose.connect(process.env.MONGODB_URI as string);
15 | const profileDoc = await ProfileModel.findOne({
16 | username: props.params.username,
17 | });
18 | if (!profileDoc) {
19 | return '404';
20 | }
21 | const etDoc = await EventTypeModel.findOne({
22 | email: profileDoc.email,
23 | uri: props.params?.['booking-uri'],
24 | });
25 | if (!etDoc) {
26 | return '404';
27 | }
28 | return (
29 |
35 | );
36 | }
--------------------------------------------------------------------------------
/src/app/(booking)/layout.tsx:
--------------------------------------------------------------------------------
1 | import "./../globals.css";
2 | import {Noto_Sans} from "next/font/google";
3 |
4 | export const metadata = {
5 | title: 'Next.js',
6 | description: 'Generated by Next.js',
7 | }
8 |
9 | const noto = Noto_Sans({subsets: ['latin'], weight: ['300', '400', '600', '700']});
10 |
11 | export default function RootLayout({
12 | children,
13 | }: {
14 | children: React.ReactNode
15 | }) {
16 | return (
17 |
18 | {children}
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/src/app/(site)/dashboard/booked-events/page.tsx:
--------------------------------------------------------------------------------
1 | import {session} from "@/libs/session";
2 | import {BookingModel} from "@/models/Booking";
3 | import {EventTypeModel} from "@/models/EventType";
4 | import {format} from "date-fns";
5 | import {Calendar, CircleUser, NotepadText, User} from "lucide-react";
6 | import mongoose from "mongoose";
7 |
8 |
9 | export default async function DashboardPage() {
10 | await mongoose.connect(process.env.MONGODB_URI as string);
11 | const email = await session().get('email');
12 | const eventTypeDocs = await EventTypeModel.find({email});
13 | const bookedEvents = await BookingModel.find({
14 | eventTypeId: eventTypeDocs.map(doc => doc._id),
15 | }, {}, {sort: 'when'});
16 | return (
17 | <>
18 |
19 | {bookedEvents.map(booking => {
20 | const eventTypeDoc = eventTypeDocs
21 | .find(etd => (etd._id as string).toString() === booking.eventTypeId);
22 | return (
23 |
26 |
27 | {eventTypeDoc?.title}
28 |
29 |
30 |
31 | {booking.guestName}
32 | {booking.guestEmail}
33 |
34 |
35 |
36 |
37 | {format(booking.when, 'EEEE, MMMM d, HH:mm')}
38 |
39 |
40 |
41 |
42 |
43 | {booking.guestNotes}
44 |
45 |
46 |
47 | );
48 | })}
49 |
50 | >
51 | );
52 | }
--------------------------------------------------------------------------------
/src/app/(site)/dashboard/event-types/edit/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import EventTypeForm from "@/app/components/EventTypeForm";
2 | import {session} from "@/libs/session";
3 | import {EventTypeModel} from "@/models/EventType";
4 | import {ProfileModel} from "@/models/Profile";
5 | import mongoose from "mongoose";
6 |
7 | type PageProps = {
8 | params: {
9 | id: string;
10 | };
11 | };
12 |
13 | export default async function EditEventTypePage({params}: PageProps) {
14 | await mongoose.connect(process.env.MONGODB_URI as string);
15 | const email = await session().get('email');
16 | const eventTypeDoc = await EventTypeModel.findOne({_id: params.id});
17 | const profileDoc = await ProfileModel.findOne({email});
18 | if (!eventTypeDoc) {
19 | return '404';
20 | }
21 | return (
22 |
23 |
26 |
27 | );
28 | }
--------------------------------------------------------------------------------
/src/app/(site)/dashboard/event-types/new/page.tsx:
--------------------------------------------------------------------------------
1 | import DashboardNav from "@/app/components/DashboardNav";
2 | import EventTypeForm from "@/app/components/EventTypeForm";
3 |
4 | export default function NewEventTypePage() {
5 | return (
6 |
11 | );
12 | }
--------------------------------------------------------------------------------
/src/app/(site)/dashboard/event-types/page.tsx:
--------------------------------------------------------------------------------
1 | 'use server'
2 | import DashboardNav from "@/app/components/DashboardNav";
3 | import {session} from "@/libs/session";
4 | import {EventTypeModel} from "@/models/EventType";
5 | import {ProfileModel} from "@/models/Profile";
6 | import {Plus} from "lucide-react";
7 | import mongoose from "mongoose";
8 | import Link from "next/link";
9 |
10 | export default async function EventTypesPage() {
11 | await mongoose.connect(process.env.MONGODB_URI as string);
12 | const email = await session().get('email');
13 | const eventTypes = await EventTypeModel.find({email});
14 | const profile = await ProfileModel.findOne({email});
15 | return (
16 |
17 |
18 | {eventTypes.map(et => (
19 |
22 |
23 | {et.title}
24 |
25 |
26 | {process.env.NEXT_PUBLIC_URL}/{profile.username}/{et.uri}
27 |
28 |
29 | ))}
30 |
31 |
33 |
34 | New event type
35 |
36 |
37 | );
38 | }
--------------------------------------------------------------------------------
/src/app/(site)/dashboard/layout.tsx:
--------------------------------------------------------------------------------
1 | import DashboardNav from "@/app/components/DashboardNav";
2 | import {session} from "@/libs/session";
3 | import {ProfileModel} from "@/models/Profile";
4 | import mongoose from "mongoose";
5 | import {ReactNode} from "react";
6 |
7 | export default async function DashboardLayout({children}:{children:ReactNode}) {
8 | const email = await session().get('email');
9 | if (!email) {
10 | return 'not logged in';
11 | }
12 | await mongoose.connect(process.env.MONGODB_URI as string);
13 | const profileDoc = await ProfileModel.findOne({email});
14 | return (
15 |
16 |
17 | {children}
18 |
19 | );
20 | }
--------------------------------------------------------------------------------
/src/app/(site)/dashboard/page.tsx:
--------------------------------------------------------------------------------
1 | import DashboardNav from "@/app/components/DashboardNav";
2 | import ProfileForm from "@/app/components/ProfileForm";
3 | import {session} from "@/libs/session";
4 | import {ProfileModel} from "@/models/Profile";
5 | import mongoose from "mongoose";
6 |
7 | export default async function DashboardPage() {
8 | const email = await session().get('email');
9 | await mongoose.connect(process.env.MONGODB_URI as string);
10 | const profileDoc = await ProfileModel.findOne({email});
11 | return (
12 |
15 | );
16 | }
--------------------------------------------------------------------------------
/src/app/(site)/features/page.tsx:
--------------------------------------------------------------------------------
1 | export default function FeaturesPage() {
2 | return (
3 | features
4 | );
5 | }
--------------------------------------------------------------------------------
/src/app/(site)/layout.tsx:
--------------------------------------------------------------------------------
1 | import Header from "@/app/components/Header";
2 | import type { Metadata } from "next";
3 | import {Noto_Sans} from "next/font/google";
4 | import "./../globals.css";
5 |
6 | const noto = Noto_Sans({subsets: ['latin'], weight: ['300', '400', '700']});
7 |
8 | export const metadata: Metadata = {
9 | title: "Calendlix",
10 | description: "Generated by create next app",
11 | };
12 |
13 | export default function RootLayout({
14 | children,
15 | }: Readonly<{
16 | children: React.ReactNode;
17 | }>) {
18 | return (
19 |
20 |
21 |
22 |
23 | {children}
24 |
25 |
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/src/app/(site)/page.tsx:
--------------------------------------------------------------------------------
1 | import Hero from "@/app/components/Hero";
2 |
3 | export default function Home() {
4 | return (
5 | <>
6 |
7 |
8 | Trusted by those companies:
9 |
15 |
16 | >
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/app/api/auth/route.ts:
--------------------------------------------------------------------------------
1 | import {nylas, nylasConfig} from "@/libs/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 | }
--------------------------------------------------------------------------------
/src/app/api/bookings/route.ts:
--------------------------------------------------------------------------------
1 | import {nylas} from "@/libs/nylas";
2 | import {BookingModel} from "@/models/Booking";
3 | import {EventTypeModel} from "@/models/EventType";
4 | import {ProfileModel} from "@/models/Profile";
5 | import {addMinutes} from "date-fns";
6 | import mongoose from "mongoose";
7 | import {NextRequest} from "next/server";
8 | import {WhenType} from "nylas";
9 |
10 | type JsonData = {
11 | guestName:string;
12 | guestEmail:string;
13 | guestNotes:string;
14 | username:string;
15 | bookingUri:string;
16 | bookingTime:string;
17 | };
18 |
19 | export async function POST(req: NextRequest) {
20 | const data:JsonData = await req.json();
21 | const {guestEmail, guestName, guestNotes, bookingTime} = data;
22 | await mongoose.connect(process.env.MONGODB_URI as string);
23 | const profileDoc = await ProfileModel.findOne({
24 | username: data.username,
25 | });
26 | if (!profileDoc) {
27 | return Response.json('invalid url', {status:404});
28 | }
29 | const etDoc = await EventTypeModel.findOne({
30 | email: profileDoc.email,
31 | uri: data.bookingUri,
32 | });
33 | if (!etDoc) {
34 | return Response.json('invalid url', {status:404});
35 | }
36 | await BookingModel.create({
37 | guestName,
38 | guestNotes,
39 | guestEmail,
40 | when: bookingTime,
41 | eventTypeId: etDoc._id,
42 | });
43 |
44 | // create this event in calendar
45 | const grantId = profileDoc.grantId;
46 | const startDate = new Date(bookingTime)
47 | await nylas.events.create({
48 | identifier: grantId,
49 | requestBody: {
50 | title: etDoc.title,
51 | description: etDoc.description,
52 | when: {
53 | startTime: Math.round(startDate.getTime() / 1000),
54 | endTime: Math.round(addMinutes(startDate, etDoc.length).getTime() / 1000),
55 | },
56 | conferencing: {
57 | autocreate: {},
58 | provider: 'Google Meet',
59 | },
60 | participants: [
61 | {
62 | name: guestName,
63 | email: guestEmail,
64 | status: 'yes',
65 | },
66 | ],
67 | },
68 | queryParams: {
69 | calendarId: etDoc.email,
70 | },
71 | });
72 |
73 | return Response.json(true, {status:201});
74 | }
--------------------------------------------------------------------------------
/src/app/api/busy/route.ts:
--------------------------------------------------------------------------------
1 | import {nylas} from "@/libs/nylas";
2 | import {ProfileModel} from "@/models/Profile";
3 | import mongoose from "mongoose";
4 | import {NextRequest} from "next/server";
5 | import {TimeSlot} from "nylas";
6 |
7 | export async function GET(req: NextRequest) {
8 | const url = new URL(req.url);
9 | const username = url.searchParams.get('username');
10 | const from = new Date(url.searchParams.get('from') as string);
11 | const to = new Date(url.searchParams.get('to') as string);
12 |
13 | await mongoose.connect(process.env.MONGODB_URI as string);
14 | const profileDoc = await ProfileModel.findOne({username});
15 |
16 | if (!profileDoc) {
17 | return Response.json('invalid username and/or bookingUri', {status: 404});
18 | }
19 |
20 | const nylasBusyResult = await nylas.calendars.getFreeBusy({
21 | identifier: profileDoc.grantId,
22 | requestBody: {
23 | emails: [profileDoc.email],
24 | startTime: Math.round(from.getTime() / 1000),
25 | endTime: Math.round(to.getTime() / 1000),
26 | },
27 | });
28 |
29 | let busySlots:TimeSlot[] = [];
30 | if (nylasBusyResult.data?.[0]) {
31 | // @ts-ignore
32 | const slots = nylasBusyResult.data?.[0]?.timeSlots as TimeSlot[];
33 | // @ts-ignore
34 | busySlots = slots.filter(slot => slot.status === 'busy');
35 | }
36 |
37 | return Response.json(busySlots);
38 | }
--------------------------------------------------------------------------------
/src/app/api/event-types/route.ts:
--------------------------------------------------------------------------------
1 | import {session} from "@/libs/session";
2 | import {EventTypeModel} from "@/models/EventType";
3 | import mongoose from "mongoose";
4 | import {revalidatePath} from "next/cache";
5 | import {NextRequest} from "next/server";
6 |
7 | function uriFromTitle(title: string): string {
8 | return title.toLowerCase().replaceAll(/[^a-z0-9]/g, '-');
9 | }
10 |
11 | export async function POST(req: NextRequest) {
12 | await mongoose.connect(process.env.MONGODB_URI as string);
13 | const data = await req.json();
14 | data.uri = uriFromTitle(data.title);
15 | const email = await session().get('email');
16 | if (email) {
17 | const eventTypeDoc = await EventTypeModel.create({email, ...data});
18 | revalidatePath('/dashboard/event-types');
19 | return Response.json(eventTypeDoc);
20 | }
21 | return Response.json(false);
22 | }
23 |
24 | export async function PUT(req: NextRequest) {
25 | await mongoose.connect(process.env.MONGODB_URI as string);
26 | const data = await req.json();
27 | data.uri = uriFromTitle(data.title);
28 | const email = await session().get('email');
29 | const id = data.id;
30 | if (email && id) {
31 | const eventTypeDoc = await EventTypeModel.updateOne(
32 | {email, _id: id},
33 | data,
34 | );
35 | revalidatePath('/dashboard/event-types');
36 | return Response.json(eventTypeDoc);
37 | }
38 | return Response.json(false);
39 | }
40 |
41 | export async function DELETE(req: NextRequest) {
42 | const url = new URL(req.url);
43 | const id = url.searchParams.get('id');
44 | await mongoose.connect(process.env.MONGODB_URI as string);
45 | await EventTypeModel.deleteOne({_id: id});
46 | return Response.json(true);
47 | }
48 |
49 |
50 |
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/src/app/api/logout/route.ts:
--------------------------------------------------------------------------------
1 | import {session} from "@/libs/session";
2 | import {revalidatePath} from "next/cache";
3 | import {redirect} from "next/navigation";
4 |
5 | export async function GET() {
6 | await session().set('grantId', null);
7 | await session().set('email', null);
8 | await session().destroy();
9 | revalidatePath('/');
10 | redirect('/?logged-out=1');
11 | }
--------------------------------------------------------------------------------
/src/app/api/oauth/exchange/route.ts:
--------------------------------------------------------------------------------
1 | import {nylas, nylasConfig} from "@/libs/nylas";
2 | import {session} from "@/libs/session";
3 | import {ProfileModel} from "@/models/Profile";
4 | import mongoose from "mongoose";
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 url = new URL(req.url as string);
11 | const code = url.searchParams.get('code');
12 |
13 | if (!code) {
14 | return Response.json("No authorization code returned from Nylas", {status: 400});
15 | }
16 |
17 | const codeExchangePayload = {
18 | clientSecret: nylasConfig.apiKey,
19 | clientId: nylasConfig.clientId as string,
20 | redirectUri: nylasConfig.callbackUri,
21 | code,
22 | };
23 |
24 | const response = await nylas.auth.exchangeCodeForToken(codeExchangePayload);
25 | const { grantId, email } = response;
26 |
27 | await mongoose.connect(process.env.MONGODB_URI as string);
28 |
29 | const profileDoc = await ProfileModel.findOne({email});
30 | if (profileDoc) {
31 | profileDoc.grantId = grantId;
32 | await profileDoc.save();
33 | } else {
34 | await ProfileModel.create({email, grantId});
35 | }
36 |
37 | await session().set('email', email);
38 |
39 | redirect('/');
40 | }
--------------------------------------------------------------------------------
/src/app/api/profile/route.ts:
--------------------------------------------------------------------------------
1 | import {session} from "@/libs/session";
2 | import {ProfileModel} from "@/models/Profile";
3 | import mongoose from "mongoose";
4 | import {NextRequest} from "next/server";
5 |
6 | export async function PUT(req: NextRequest) {
7 | await mongoose.connect(process.env.MONGODB_URI as string);
8 | const body = await req.json();
9 | const {username} = body;
10 | const email = await session().get('email');
11 | if (email && username) {
12 | const profileDoc = await ProfileModel.findOne({email});
13 | if (profileDoc) {
14 | profileDoc.username = username;
15 | await profileDoc.save();
16 | } else {
17 | await ProfileModel.create({email, username});
18 | }
19 | return Response.json(true);
20 | } else {
21 | return Response.json(false);
22 | }
23 | }
--------------------------------------------------------------------------------
/src/app/components/DashboardNav.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import {clsx} from "clsx";
3 | import Link from "next/link";
4 | import {usePathname} from "next/navigation";
5 |
6 | export default function DashboardNav({username}:{username?:string}) {
7 | const pathname = usePathname();
8 | const isEventTypesPage = pathname.includes('event-types');
9 | const isBookedEventsPage = pathname.includes('booked-events');
10 | return (
11 |
12 |
20 | Profile
21 |
22 | {username && (
23 | <>
24 |
32 | Booked events
33 |
34 |
42 | Event types
43 |
44 | >
45 | )}
46 |
47 | );
48 | }
--------------------------------------------------------------------------------
/src/app/components/EventTypeDelete.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import axios from "axios";
3 | import {Trash} from "lucide-react";
4 | import {useRouter} from "next/navigation";
5 | import {useState} from "react";
6 |
7 | export default function EventTypeDelete({id}:{id:string}) {
8 | const [showConfirmation, setShowConfirmation] = useState(false);
9 | const router = useRouter();
10 | async function handleDelete() {
11 | await axios.delete('/api/event-types?id='+id);
12 | router.push('/dashboard/event-types');
13 | router.refresh();
14 | }
15 | return (
16 |
17 | {!showConfirmation && (
18 |
24 | )}
25 | {showConfirmation && (
26 |
27 |
32 |
37 |
38 | )}
39 |
40 | );
41 | }
--------------------------------------------------------------------------------
/src/app/components/EventTypeForm.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import EventTypeDelete from "@/app/components/EventTypeDelete";
3 | import TimeSelect from "@/app/components/TimeSelect";
4 | import {weekdaysNames} from "@/libs/shared";
5 | import {BookingTimes, WeekdayName} from "@/libs/types";
6 | import {IEventType} from "@/models/EventType";
7 | import axios from "axios";
8 | import {clsx} from "clsx";
9 | import {useRouter} from "next/navigation";
10 | import {FormEvent, useState} from "react";
11 |
12 | export default function EventTypeForm({
13 | doc,
14 | username = ''
15 | }:{
16 | doc?:IEventType;
17 | username?:string;
18 | }) {
19 | const [title, setTitle] = useState(doc?.title || '');
20 | const [description, setDescription] = useState(doc?.description ||'');
21 | const [length, setLength] = useState(doc?.length || 30);
22 | const [bookingTimes, setBookingTimes] = useState(doc?.bookingTimes || {});
23 | const router = useRouter();
24 | async function handleSubmit(ev:FormEvent) {
25 | ev.preventDefault();
26 | const id = doc?._id;
27 | const request = id ? axios.put : axios.post;
28 | const data = {title, description, length, bookingTimes};
29 | const response = await request('/api/event-types', {...data, id});
30 | if (response.data) {
31 | router.push('/dashboard/event-types');
32 | router.refresh();
33 | }
34 | }
35 | function handleBookingTimeChange(
36 | day: WeekdayName,
37 | val: string | boolean,
38 | prop: 'from' | 'to' | 'active'
39 | ) {
40 | setBookingTimes(oldBookingTimes => {
41 | const newBookingTimes:BookingTimes = {...oldBookingTimes};
42 | if (!newBookingTimes[day]) {
43 | newBookingTimes[day] = {from:'00:00', to:'00:00', active: false};
44 | }
45 |
46 | // @ts-ignore
47 | newBookingTimes[day][prop] = val;
48 |
49 | return newBookingTimes;
50 | });
51 | }
52 | return (
53 |
134 | );
135 | }
--------------------------------------------------------------------------------
/src/app/components/Header.tsx:
--------------------------------------------------------------------------------
1 | 'use server';
2 | import RightNav from "@/app/components/RightNav";
3 | import {session} from "@/libs/session";
4 | import {CalendarDays} from "lucide-react";
5 | import Link from "next/link";
6 |
7 | export default async function Header() {
8 | const email = await session().get('email');
9 | return (
10 |
24 | );
25 | }
--------------------------------------------------------------------------------
/src/app/components/Hero.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import {Play} from "lucide-react";
3 | import Link from "next/link";
4 | import {useEffect, useState} from "react";
5 |
6 | export default function Hero() {
7 | const [showLine, setShowLine] = useState(false);
8 | useEffect(() => {
9 | setShowLine(true);
10 | }, []);
11 | return (
12 |
13 |
14 | Scheduling{' '}
15 |
16 | made simple
17 |
18 |
19 | for people like you
20 |
21 |
22 | Most scheduling apps are simple but ours is even more simple.
23 | On top of this, it's open source and you can see the code.
24 |
25 |
26 |
27 | Get started for free
28 |
29 |
30 |
31 | Watch video
32 |
33 |
34 |
35 | );
36 | }
--------------------------------------------------------------------------------
/src/app/components/ProfileForm.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import axios from "axios";
3 | import {useRouter} from "next/navigation";
4 | import {FormEvent, useState} from "react";
5 |
6 | export default function ProfileForm({
7 | existingUsername = ''
8 | }:{
9 | existingUsername?:string
10 | }) {
11 | const [username, setUsername] = useState(existingUsername);
12 | const [isSaved, setIsSaved] = useState(false);
13 | const [isError, setIsError] = useState(false);
14 | const router = useRouter();
15 | async function handleSubmit(ev: FormEvent) {
16 | ev.preventDefault();
17 | setIsSaved(false);
18 | setIsError(false);
19 | const response = await axios.put('/api/profile', {username});
20 | if (response.data) {
21 | setIsSaved(true);
22 | if (!existingUsername && username) {
23 | router.push('/dashboard/event-types');
24 | router.refresh();
25 | }
26 | } else {
27 | setIsError(true);
28 | }
29 | }
30 | return (
31 |
53 | );
54 | }
--------------------------------------------------------------------------------
/src/app/components/RightNav.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import Link from "next/link";
3 |
4 | export default function RightNav({email}:{email:string}) {
5 | const hasLoggedOut = typeof window !== 'undefined' && window.location.href.includes('logged-out');
6 | if (email && !hasLoggedOut) {
7 | return (
8 |
14 | );
15 | }
16 | else {
17 | return (
18 |
22 | );
23 | }
24 | }
--------------------------------------------------------------------------------
/src/app/components/TimePicker.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import {weekdaysNames, weekdaysShortNames} from "@/libs/shared";
3 | import {BookingTimes, WeekdayName} from "@/libs/types";
4 | import axios from "axios";
5 | import {clsx} from "clsx";
6 | import {
7 | addDays, addMinutes,
8 | addMonths, endOfDay,
9 | format,
10 | getDay, isAfter,
11 | isBefore,
12 | isEqual,
13 | isFuture,
14 | isLastDayOfMonth,
15 | isToday, startOfDay,
16 | subMonths
17 | } from "date-fns";
18 | import {ChevronLeft, ChevronRight} from "lucide-react";
19 | import Link from "next/link";
20 | import {TimeSlot} from "nylas";
21 | import {useEffect, useState} from "react";
22 | import {BounceLoader, PulseLoader} from "react-spinners";
23 |
24 | export default function TimePicker({
25 | bookingTimes,
26 | length,
27 | meetingUri,
28 | username,
29 | }:{
30 | bookingTimes:BookingTimes
31 | length:number;
32 | meetingUri:string;
33 | username:string;
34 | }) {
35 | const currentDate = new Date();
36 | const [activeMonthDate, setActiveMonthDate] = useState(currentDate);
37 | const [activeMonthIndex, setActiveMonthIndex] = useState(activeMonthDate.getMonth());
38 | const [activeYear, setActiveYear] = useState(activeMonthDate.getFullYear());
39 | const [selectedDay, setSelectedDay] = useState(null);
40 | const [busySlots, setBusySlots] = useState([]);
41 | const [busySlotsLoaded, setBusySlotsLoaded] = useState(false);
42 |
43 | useEffect(() => {
44 | if (selectedDay) {
45 | setBusySlots([]);
46 | setBusySlotsLoaded(false);
47 | const params = new URLSearchParams();
48 | params.set('username', username);
49 | params.set('from', startOfDay(selectedDay).toISOString());
50 | params.set('to', endOfDay(selectedDay).toISOString());
51 | axios
52 | .get(`/api/busy?`+params.toString())
53 | .then(response => {
54 | setBusySlots(response.data);
55 | setBusySlotsLoaded(true);
56 | });
57 | }
58 | }, [selectedDay]);
59 |
60 | function withinBusySlots(time: Date) {
61 | const bookingFrom = time;
62 | const bookingTo = addMinutes(new Date(time), length);
63 |
64 | for (let busySlot of busySlots) {
65 | const busyFrom = new Date(parseInt(busySlot.startTime) * 1000);
66 | const busyTo = new Date(parseInt(busySlot.endTime) * 1000);
67 | if (isAfter(bookingTo, busyFrom) && isBefore(bookingTo, busyTo)) {
68 | return true;
69 | }
70 | if (isAfter(bookingFrom, busyFrom) && isBefore(bookingFrom, busyTo)) {
71 | return true;
72 | }
73 | if (isEqual(bookingFrom, busyFrom)) {
74 | return true;
75 | }
76 | if (isEqual(bookingTo, busyTo)) {
77 | return true;
78 | }
79 | }
80 |
81 | return false;
82 | }
83 |
84 | const firstDayOfCurrentMonth = new Date(activeYear, activeMonthIndex, 1);
85 | const firstDayOfCurrentMonthWeekdayIndex = getDay(firstDayOfCurrentMonth);
86 | const emptyDaysCount = firstDayOfCurrentMonthWeekdayIndex === 0
87 | ? 6
88 | : firstDayOfCurrentMonthWeekdayIndex - 1;
89 | const emptyDaysArr = (new Array(emptyDaysCount)).fill('', 0, emptyDaysCount);
90 | const daysNumbers = [firstDayOfCurrentMonth];
91 | do {
92 | const lastAddedDay = daysNumbers[daysNumbers.length - 1];
93 | daysNumbers.push(addDays(lastAddedDay, 1));
94 | } while (!isLastDayOfMonth(daysNumbers[daysNumbers.length - 1]));
95 |
96 | let selectedDayConfig = null;
97 | const bookingHours = [];
98 | if (selectedDay) {
99 | const weekdayNameIndex = format(selectedDay, "EEEE").toLowerCase() as WeekdayName;
100 | selectedDayConfig = bookingTimes?.[weekdayNameIndex];
101 | if (selectedDayConfig) {
102 | const [hoursFrom,minutesFrom] = selectedDayConfig.from.split(':');
103 | const selectedDayFrom = new Date(selectedDay);
104 | selectedDayFrom.setHours(parseInt(hoursFrom));
105 | selectedDayFrom.setMinutes(parseInt(minutesFrom));
106 | const selectedDayTo = new Date(selectedDay);
107 | const [hoursTo,minutesTo] = selectedDayConfig.to.split(':');
108 | selectedDayTo.setHours(parseInt(hoursTo));
109 | selectedDayTo.setMinutes(parseInt(minutesTo));
110 | let a = selectedDayFrom;
111 | do {
112 | if (!withinBusySlots(a)) {
113 | bookingHours.push(a);
114 | }
115 | a = addMinutes(a, 30);
116 | } while(isBefore(addMinutes(a, length), selectedDayTo));
117 | }
118 | }
119 |
120 | function prevMonth() {
121 | setActiveMonthDate(prev => {
122 | const newActiveMonthDate = subMonths(prev, 1);
123 | setActiveMonthIndex(newActiveMonthDate.getMonth());
124 | setActiveYear(newActiveMonthDate.getFullYear());
125 | return newActiveMonthDate;
126 | });
127 | }
128 |
129 | function nextMonth() {
130 | setActiveMonthDate(prev => {
131 | const newActiveMonthDate = addMonths(prev, 1);
132 | setActiveMonthIndex(newActiveMonthDate.getMonth());
133 | setActiveYear(newActiveMonthDate.getFullYear());
134 | return newActiveMonthDate;
135 | });
136 | }
137 |
138 | function handleDayClick(day:Date) {
139 | setSelectedDay(day);
140 | }
141 |
142 |
143 | return (
144 |
145 |
146 |
147 |
148 | {format(new Date(activeYear, activeMonthIndex, 1), "MMMM")} {activeYear}
149 |
150 |
153 |
156 |
157 |
158 | {weekdaysShortNames.map((weekdayShortName) => (
159 |
162 | {weekdayShortName}
163 |
164 | ))}
165 | {emptyDaysArr.map((empty, emptyIndex) => (
166 |
167 | ))}
168 | {daysNumbers.map(n => {
169 | const weekdayNameIndex = format(n, "EEEE").toLowerCase() as WeekdayName;
170 | const weekdayConfig = bookingTimes?.[weekdayNameIndex];
171 | const isActiveInBookingTimes = weekdayConfig?.active;
172 | const canBeBooked = isFuture(n) && isActiveInBookingTimes;
173 | const isSelected = selectedDay && isEqual(n, selectedDay);
174 | return (
175 |
178 |
190 |
191 | );
192 | })}
193 |
194 |
195 | {selectedDay && (
196 |
197 |
198 | {format(selectedDay, "EEEE, MMMM d")}
199 |
200 |
201 | {!busySlotsLoaded && (
202 |
205 | )}
206 | {busySlotsLoaded && bookingHours.map(bookingTime => (
207 |
208 |
211 | {format(bookingTime, 'HH:mm')}
212 |
213 |
214 | ))}
215 |
216 |
217 |
218 | )}
219 |
220 | );
221 | }
--------------------------------------------------------------------------------
/src/app/components/TimeSelect.tsx:
--------------------------------------------------------------------------------
1 | export default function TimeSelect({
2 | step = 30,
3 | value,
4 | onChange,
5 | }:{
6 | step: 30 | 60;
7 | value: string;
8 | onChange: (val:string) => void,
9 | }) {
10 | const times = [];
11 | for (let i = 0; i < 24; i++) {
12 | times.push((i<10 ? '0'+i : i) + ':00');
13 | if (step === 30) {
14 | times.push((i<10 ? '0'+i : i) + ':30');
15 | }
16 | }
17 | return (
18 |
23 | );
24 | }
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dejwid/calendix/33542b7bacd43c9c522c37161bb4ea41bba98758/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | .cool-underline{
6 | position: relative;
7 | }
8 | .cool-underline::after{
9 | content: '';
10 | height: 10px;
11 | background-color: #DC008366;
12 | display: block;
13 | position: absolute;
14 | left: -8px;
15 | width: 0;
16 | bottom: 10px;
17 | z-index: -1;
18 | transform: skew(-21deg);
19 | transition: all 0.5s ease-in-out;
20 | }
21 | .cool-underline.show-underline::after{
22 | width: calc(100% + 16px);
23 | }
24 |
25 | form input[type="text"], form input[type="email"], form input[type="number"], form select, form textarea{
26 | @apply border rounded-md;
27 | }
28 | form input[type="text"], form input[type="email"], form input[type="number"], form textarea{
29 | @apply w-full block p-2;
30 | }
31 | form label{
32 | @apply mb-2 block p-1;
33 | }
34 | form label span, span.label{
35 | @apply text-gray-600 text-xs font-bold uppercase;
36 | }
37 | .btn-gray{
38 | @apply inline-flex gap-1 items-center bg-gray-200 rounded-full px-4 py-2;
39 | }
40 | .btn-blue{
41 | @apply inline-flex gap-1 items-center bg-blue-600 text-white rounded-full px-4 py-2;
42 | }
43 | .btn-red{
44 | @apply inline-flex gap-1 items-center bg-red-600 text-white rounded-full px-4 py-2;
45 | }
--------------------------------------------------------------------------------
/src/libs/nylas.ts:
--------------------------------------------------------------------------------
1 | import Nylas from "nylas";
2 |
3 | export const nylasConfig = {
4 | clientId: process.env.NYLAS_CLIENT_ID,
5 | callbackUri: process.env.NEXT_PUBLIC_URL + "/api/oauth/exchange",
6 | apiKey: process.env.NYLAS_API_KEY,
7 | apiUri: process.env.NYLAS_API_URI,
8 | };
9 |
10 | export const nylas = new Nylas({
11 | apiKey: nylasConfig.apiKey as string,
12 | apiUri: nylasConfig.apiUri,
13 | });
--------------------------------------------------------------------------------
/src/libs/session.ts:
--------------------------------------------------------------------------------
1 | import nextAppSession from 'next-app-session';
2 |
3 | // Your session data type
4 | type MySessionData = {
5 | grantId?: string;
6 | email?: string;
7 | }
8 |
9 | export const session = nextAppSession({
10 | name: 'calendix_session',
11 | secret: process.env.SECRET,
12 | cookie: {
13 | httpOnly: false,
14 | },
15 | });
--------------------------------------------------------------------------------
/src/libs/shared.ts:
--------------------------------------------------------------------------------
1 | import {WeekdayName} from "@/libs/types";
2 |
3 | export const weekdaysNames:WeekdayName[] = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
4 | export const weekdaysShortNames:string[] = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
5 |
--------------------------------------------------------------------------------
/src/libs/types.ts:
--------------------------------------------------------------------------------
1 | export type FromTo = {
2 | from: string;
3 | to: string;
4 | active: boolean;
5 | };
6 |
7 | export type WeekdayName = 'monday' | 'tuesday'
8 | | 'wednesday' | 'thursday' | 'friday'
9 | | 'saturday' | 'sunday';
10 |
11 | export type BookingTimes = {
12 | monday?: FromTo;
13 | tuesday?: FromTo;
14 | wednesday?: FromTo;
15 | thursday?: FromTo;
16 | friday?: FromTo;
17 | saturday?: FromTo;
18 | sunday?: FromTo;
19 | };
--------------------------------------------------------------------------------
/src/models/Booking.ts:
--------------------------------------------------------------------------------
1 | import mongoose, {model, models, Schema} from "mongoose";
2 |
3 | interface IBooking extends mongoose.Document {
4 | guestName: string;
5 | guestEmail: string;
6 | guestNotes: string;
7 | when: Date;
8 | eventTypeId: string;
9 | };
10 |
11 | const BookingSchema = new Schema({
12 | guestName: String,
13 | guestEmail: String,
14 | guestNotes: String,
15 | when: Date,
16 | eventTypeId: String,
17 | });
18 |
19 | export const BookingModel = models?.Booking || model('Booking', BookingSchema);
--------------------------------------------------------------------------------
/src/models/EventType.ts:
--------------------------------------------------------------------------------
1 | import {BookingTimes, FromTo, WeekdayName} from "@/libs/types";
2 | import mongoose, {Model} from 'mongoose';
3 |
4 | const FromToSchema = new mongoose.Schema({
5 | from: String,
6 | to: String,
7 | active: Boolean,
8 | });
9 |
10 | export interface IEventType extends mongoose.Document {
11 | email: string;
12 | uri: string;
13 | title: string;
14 | description: string;
15 | length: number;
16 | bookingTimes: BookingTimes;
17 | createdAt: Date;
18 | updatedAt: Date;
19 | }
20 |
21 | const BookingSchema = new mongoose.Schema>({
22 | monday: FromToSchema,
23 | tuesday: FromToSchema,
24 | wednesday: FromToSchema,
25 | thursday: FromToSchema,
26 | friday: FromToSchema,
27 | saturday: FromToSchema,
28 | sunday: FromToSchema,
29 | });
30 |
31 | const EventTypeSchema = new mongoose.Schema({
32 | email: String,
33 | uri: {type: String},
34 | title: String,
35 | description: String,
36 | length: Number,
37 | bookingTimes: BookingSchema,
38 | }, {
39 | timestamps: true,
40 | });
41 |
42 | export const EventTypeModel = mongoose.models?.EventType as Model || mongoose.model('EventType', EventTypeSchema);
43 |
--------------------------------------------------------------------------------
/src/models/Profile.ts:
--------------------------------------------------------------------------------
1 | import mongoose, {model, models, Schema} from "mongoose";
2 |
3 | interface IProfile extends mongoose.Document{
4 | email: string;
5 | username: string;
6 | grantId: string;
7 | }
8 |
9 | const ProfileSchema = new Schema({
10 | email: {type: String, required: true, unique: true},
11 | username: {type: String, unique: true},
12 | grantId: {type: String},
13 | });
14 |
15 | export const ProfileModel = models?.Profile || model('Profile', ProfileSchema);
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config: Config = {
4 | content: [
5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
8 | ],
9 | theme: {
10 | container: {
11 | center: true,
12 | padding: '2rem',
13 | screens: {
14 | xs: '780px',
15 | },
16 | },
17 | },
18 | plugins: [],
19 | };
20 | export default config;
21 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------