diff --git a/components/Settings.tsx b/components/Settings.tsx index 1969c1ff..eed044b8 100644 --- a/components/Settings.tsx +++ b/components/Settings.tsx @@ -1,87 +1,71 @@ -import ActiveLink from '../components/ActiveLink'; -import {CodeIcon, CreditCardIcon, KeyIcon, UserCircleIcon, UserGroupIcon} from '@heroicons/react/outline'; +import Link from 'next/link'; +import { CreditCardIcon, UserIcon, CodeIcon, KeyIcon, UserGroupIcon } from "@heroicons/react/solid"; +import { useRouter } from "next/router"; + +function classNames(...classes) { + return classes.filter(Boolean).join(" "); +} export default function SettingsShell(props) { - return ( -
-
-
-
-
- - - {props.children} -
-
-
-
+ return ( +
+
+
+ +
- ); +
+
+ +
+
+
+
{props.children}
+
+ ); } diff --git a/components/Shell.tsx b/components/Shell.tsx index beafc8ed..f9b951e6 100644 --- a/components/Shell.tsx +++ b/components/Shell.tsx @@ -1,31 +1,68 @@ import Link from "next/link"; -import { useEffect, useState } from "react"; +import { Fragment, useEffect, useState } from "react"; import { useRouter } from "next/router"; import { signOut, useSession } from "next-auth/client"; -import { MenuIcon, XIcon } from "@heroicons/react/outline"; +import { Dialog, Menu, Transition } from "@headlessui/react"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../lib/telemetry"; +import { MenuIcon, SelectorIcon, XIcon } from "@heroicons/react/outline"; +import { + CalendarIcon, + ClockIcon, + CogIcon, + PuzzleIcon, + SupportIcon, + ChatAltIcon, + LogoutIcon, + ExternalLinkIcon, + LinkIcon, +} from "@heroicons/react/solid"; + +function classNames(...classes) { + return classes.filter(Boolean).join(" "); +} + export default function Shell(props) { const router = useRouter(); const [session, loading] = useSession(); - const [profileDropdownExpanded, setProfileDropdownExpanded] = useState(false); - const [mobileMenuExpanded, setMobileMenuExpanded] = useState(false); const telemetry = useTelemetry(); + const navigation = [ + { + name: "Event Types", + href: "/event-types", + icon: LinkIcon, + current: router.pathname.startsWith("/event-types"), + }, + { + name: "Bookings", + href: "/bookings", + icon: ClockIcon, + current: router.pathname.startsWith("/bookings"), + }, + { + name: "Availability", + href: "/availability", + icon: CalendarIcon, + current: router.pathname.startsWith("/availability"), + }, + { + name: "Integrations", + href: "/integrations", + icon: PuzzleIcon, + current: router.pathname.startsWith("/integrations"), + }, + { name: "Settings", href: "/settings", icon: CogIcon, current: router.pathname.startsWith("/settings") }, + ]; + + const [sidebarOpen, setSidebarOpen] = useState(false); + useEffect(() => { telemetry.withJitsu((jitsu) => { return jitsu.track(telemetryEventTypes.pageView, collectPageParameters(router.pathname)); }); }, [telemetry]); - const toggleProfileDropdown = () => { - setProfileDropdownExpanded(!profileDropdownExpanded); - }; - - const toggleMobileMenu = () => { - setMobileMenuExpanded(!mobileMenuExpanded); - }; - const logoutHandler = () => { signOut({ redirect: false }).then(() => router.push("/auth/logout")); }; @@ -35,243 +72,283 @@ export default function Shell(props) { } return session ? ( -
-
- +
+
+ +
+
+ +
+
+

+ Tom Cook +

+

+ View profile +

+
+
+
- )} - -
-
-

{props.heading}

-
-
-
+ +
{/* Force sidebar to shrink to fit close icon */}
+ + -
-
{props.children}
-
+ {/* Static sidebar for desktop */} +
+
+ {/* Sidebar component, swap this element with another sidebar if you like */} +
+
+
+ Calendso +
+ +
+
+ {/* User account dropdown */} + + {({ open }) => ( + <> +
+ + + + + + + {session.user.name} + + {session.user.username} + + + + +
+ + + +
+ + {({ active }) => ( + + + )} + + + {({ active }) => ( + + + )} + +
+
+ + {({ active }) => ( + + + )} + +
+
+
+ + )} +
+
+
+
+
+
+
+ +
+
+
+
+

{props.heading}

+
+
{props.children}
+
+
+
) : null; } diff --git a/components/team/TeamList.tsx b/components/team/TeamList.tsx index 7f04d322..2015bd79 100644 --- a/components/team/TeamList.tsx +++ b/components/team/TeamList.tsx @@ -22,7 +22,7 @@ export default function TeamList(props) { }; return (
-
); -} \ No newline at end of file +} diff --git a/components/team/TeamListItem.tsx b/components/team/TeamListItem.tsx index a7413d72..5aaf2bc8 100644 --- a/components/team/TeamListItem.tsx +++ b/components/team/TeamListItem.tsx @@ -26,25 +26,25 @@ export default function TeamListItem(props) {
- {props.team.name} + {props.team.name} {props.team.role.toLowerCase()}
{props.team.role === 'INVITEE' &&
- +
} {props.team.role === 'MEMBER' &&
- +
} {props.team.role === 'OWNER' &&
- -
} - - + + }*/} ); -} \ No newline at end of file +} diff --git a/components/ui/Scheduler.tsx b/components/ui/Scheduler.tsx index fcb4688c..f60cb551 100644 --- a/components/ui/Scheduler.tsx +++ b/components/ui/Scheduler.tsx @@ -95,9 +95,9 @@ export const Scheduler = ({ return (
-
+
-
+
diff --git a/components/ui/UsernameInput.tsx b/components/ui/UsernameInput.tsx index bc611937..8bef983b 100644 --- a/components/ui/UsernameInput.tsx +++ b/components/ui/UsernameInput.tsx @@ -7,7 +7,7 @@ const UsernameInput = React.forwardRef((props, ref) => ( Username
- + {typeof window !== "undefined" && window.location.hostname}/ ( autoComplete="username" required {...props} - className="focus:ring-blue-500 focus:border-blue-500 flex-grow block w-full min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300 lowercase" + className="focus:ring-blue-500 focus:border-blue-500 flex-grow block w-full min-w-0 rounded-none rounded-r-sm sm:text-sm border-gray-300 lowercase" />
diff --git a/pages/auth/error.tsx b/pages/auth/error.tsx index edfef292..0c930650 100644 --- a/pages/auth/error.tsx +++ b/pages/auth/error.tsx @@ -33,7 +33,7 @@ export default function Error() {
- + Go back to the login page @@ -42,4 +42,4 @@ export default function Error() {
); -} \ No newline at end of file +} diff --git a/pages/auth/login.tsx b/pages/auth/login.tsx index 76514aa4..b7a79aff 100644 --- a/pages/auth/login.tsx +++ b/pages/auth/login.tsx @@ -4,21 +4,22 @@ import { getCsrfToken } from "next-auth/client"; export default function Login({ csrfToken }) { return ( -
+
Login
-

Sign in to your account

+ Calendso Logo +

Sign in to your account

-
+
-
- +
+
+ +
+
+ + Forgot? + +
+
@@ -54,19 +62,15 @@ export default function Login({ csrfToken }) {
- - -
+
+ Don't have an account? Create an account +
); diff --git a/pages/availability/index.tsx b/pages/availability/index.tsx index 7a7824db..4070b161 100644 --- a/pages/availability/index.tsx +++ b/pages/availability/index.tsx @@ -1,424 +1,348 @@ -import Head from 'next/head'; -import Link from 'next/link'; -import prisma from '../../lib/prisma'; -import Modal from '../../components/Modal'; -import Shell from '../../components/Shell'; -import {useRouter} from 'next/router'; -import {useRef, useState} from 'react'; -import {getSession, useSession} from 'next-auth/client'; -import {ClockIcon, PlusIcon} from '@heroicons/react/outline'; +import Head from "next/head"; +import Link from "next/link"; +import prisma from "../../lib/prisma"; +import Modal from "../../components/Modal"; +import Shell from "../../components/Shell"; +import { useRouter } from "next/router"; +import { useRef, useState } from "react"; +import { getSession, useSession } from "next-auth/client"; +import { ClockIcon, PlusIcon } from "@heroicons/react/outline"; export default function Availability(props) { - const [ session, loading ] = useSession(); - const router = useRouter(); - const [showAddModal, setShowAddModal] = useState(false); - const [successModalOpen, setSuccessModalOpen] = useState(false); - const [showChangeTimesModal, setShowChangeTimesModal] = useState(false); - const titleRef = useRef(); - const slugRef = useRef(); - const descriptionRef = useRef(); - const lengthRef = useRef(); - const isHiddenRef = useRef(); + const [session, loading] = useSession(); + const router = useRouter(); + const [showAddModal, setShowAddModal] = useState(false); + const [successModalOpen, setSuccessModalOpen] = useState(false); + const [showChangeTimesModal, setShowChangeTimesModal] = useState(false); + const titleRef = useRef(); + const slugRef = useRef(); + const descriptionRef = useRef(); + const lengthRef = useRef(); + const isHiddenRef = useRef(); - const startHoursRef = useRef(); - const startMinsRef = useRef(); - const endHoursRef = useRef(); - const endMinsRef = useRef(); - const bufferHoursRef = useRef(); - const bufferMinsRef = useRef(); + const startHoursRef = useRef(); + const startMinsRef = useRef(); + const endHoursRef = useRef(); + const endMinsRef = useRef(); + const bufferHoursRef = useRef(); + const bufferMinsRef = useRef(); - if (loading) { - return
; + if (loading) { + return
; + } + + function toggleAddModal() { + setShowAddModal(!showAddModal); + } + + function toggleChangeTimesModal() { + setShowChangeTimesModal(!showChangeTimesModal); + } + + const closeSuccessModal = () => { + setSuccessModalOpen(false); + router.replace(router.asPath); + }; + + function convertMinsToHrsMins(mins) { + let h = Math.floor(mins / 60); + let m = mins % 60; + h = h < 10 ? "0" + h : h; + m = m < 10 ? "0" + m : m; + return `${h}:${m}`; + } + + async function createEventTypeHandler(event) { + event.preventDefault(); + + const enteredTitle = titleRef.current.value; + const enteredSlug = slugRef.current.value; + const enteredDescription = descriptionRef.current.value; + const enteredLength = lengthRef.current.value; + const enteredIsHidden = isHiddenRef.current.checked; + + // TODO: Add validation + + const response = await fetch("/api/availability/eventtype", { + method: "POST", + body: JSON.stringify({ + title: enteredTitle, + slug: enteredSlug, + description: enteredDescription, + length: enteredLength, + hidden: enteredIsHidden, + }), + headers: { + "Content-Type": "application/json", + }, + }); + + if (enteredTitle && enteredLength) { + router.replace(router.asPath); + toggleAddModal(); } + } - function toggleAddModal() { - setShowAddModal(!showAddModal); - } + async function updateStartEndTimesHandler(event) { + event.preventDefault(); - function toggleChangeTimesModal() { - setShowChangeTimesModal(!showChangeTimesModal); - } + const enteredStartHours = parseInt(startHoursRef.current.value); + const enteredStartMins = parseInt(startMinsRef.current.value); + const enteredEndHours = parseInt(endHoursRef.current.value); + const enteredEndMins = parseInt(endMinsRef.current.value); + const enteredBufferHours = parseInt(bufferHoursRef.current.value); + const enteredBufferMins = parseInt(bufferMinsRef.current.value); - const closeSuccessModal = () => { setSuccessModalOpen(false); router.replace(router.asPath); } + const startMins = enteredStartHours * 60 + enteredStartMins; + const endMins = enteredEndHours * 60 + enteredEndMins; + const bufferMins = enteredBufferHours * 60 + enteredBufferMins; - function convertMinsToHrsMins (mins) { - let h = Math.floor(mins / 60); - let m = mins % 60; - h = h < 10 ? '0' + h : h; - m = m < 10 ? '0' + m : m; - return `${h}:${m}`; - } + // TODO: Add validation - async function createEventTypeHandler(event) { - event.preventDefault(); + const response = await fetch("/api/availability/day", { + method: "PATCH", + body: JSON.stringify({ start: startMins, end: endMins, buffer: bufferMins }), + headers: { + "Content-Type": "application/json", + }, + }); - const enteredTitle = titleRef.current.value; - const enteredSlug = slugRef.current.value; - const enteredDescription = descriptionRef.current.value; - const enteredLength = lengthRef.current.value; - const enteredIsHidden = isHiddenRef.current.checked; + setShowChangeTimesModal(false); + setSuccessModalOpen(true); + } - // TODO: Add validation - - const response = await fetch('/api/availability/eventtype', { - method: 'POST', - body: JSON.stringify({title: enteredTitle, slug: enteredSlug, description: enteredDescription, length: enteredLength, hidden: enteredIsHidden}), - headers: { - 'Content-Type': 'application/json' - } - }); - - if (enteredTitle && enteredLength) { - router.replace(router.asPath); - toggleAddModal(); - } - } - - async function updateStartEndTimesHandler(event) { - event.preventDefault(); - - const enteredStartHours = parseInt(startHoursRef.current.value); - const enteredStartMins = parseInt(startMinsRef.current.value); - const enteredEndHours = parseInt(endHoursRef.current.value); - const enteredEndMins = parseInt(endMinsRef.current.value); - const enteredBufferHours = parseInt(bufferHoursRef.current.value); - const enteredBufferMins = parseInt(bufferMinsRef.current.value); - - const startMins = enteredStartHours * 60 + enteredStartMins; - const endMins = enteredEndHours * 60 + enteredEndMins; - const bufferMins = enteredBufferHours * 60 + enteredBufferMins; - - // TODO: Add validation - - const response = await fetch('/api/availability/day', { - method: 'PATCH', - body: JSON.stringify({start: startMins, end: endMins, buffer: bufferMins}), - headers: { - 'Content-Type': 'application/json' - } - }); - - setShowChangeTimesModal(false); - setSuccessModalOpen(true); - } - - return( -
- - Availability | Calendso - - - -
-

- Event Types -

-
- -
-
-
-
-
-
- - - - - - - - - - - {props.types.map((eventType) => - - - - - - - )} - -
- Name - - Description - - Length - - Edit -
- {eventType.title} - {eventType.hidden && - - Hidden - - } - - {eventType.description} - - {eventType.length} minutes - - View - Edit -
-
-
-
-
- -
-
-
-

- Change the start and end times of your day -

-
-

- Currently, your day is set to start at {convertMinsToHrsMins(props.user.startTime)} and end at {convertMinsToHrsMins(props.user.endTime)}. -

-
-
- -
-
-
- -
-
-

- Something doesn't look right? -

-
-

- Troubleshoot your availability to explore why your times are showing as they are. -

-
- -
-
-
- {showAddModal && -
-
- - - - -
-
-
- -
-
- -
-

- Create a new event type for people to book times with. -

-
-
-
-
-
-
- -
- -
-
-
- -
-
- - {location.hostname}/{props.user.username}/ - - -
-
-
-
- -
- -
-
-
- -
- -
- minutes -
-
-
-
-
-
-
- -
-
- -

Hide the event type from your page, so it can only be booked through it's URL.

-
-
-
- {/* TODO: Add an error message when required input fields empty*/} -
- - -
-
-
-
-
- } - {showChangeTimesModal && -
-
- - - - -
-
-
- -
-
- -
-

- Set the start and end time of your day and a minimum buffer between your meetings. -

-
-
-
-
-
- -
- - -
- : -
- - -
-
-
- -
- - -
- : -
- - -
-
-
- -
- - -
- : -
- - -
-
-
- - -
-
-
-
-
- } - -
+ return ( +
+ + Availability | Calendso + + + +
+

Configure times when you are available for bookings.

- ); + +
+
+
+

+ Change the start and end times of your day +

+
+

+ Currently, your day is set to start at {convertMinsToHrsMins(props.user.startTime)} and end + at {convertMinsToHrsMins(props.user.endTime)}. +

+
+
+ +
+
+
+ +
+
+

+ Something doesn't look right? +

+
+

Troubleshoot your availability to explore why your times are showing as they are.

+
+ +
+
+
+ {showChangeTimesModal && ( +
+
+ + + + +
+
+
+ +
+
+ +
+

+ Set the start and end time of your day and a minimum buffer between your meetings. +

+
+
+
+
+
+ +
+ + +
+ : +
+ + +
+
+
+ +
+ + +
+ : +
+ + +
+
+
+ +
+ + +
+ : +
+ + +
+
+
+ + +
+
+
+
+
+ )} + +
+
+ ); } export async function getServerSideProps(context) { - const session = await getSession(context); - if (!session) { - return { redirect: { permanent: false, destination: '/auth/login' } }; - } + const session = await getSession(context); + if (!session) { + return { redirect: { permanent: false, destination: "/auth/login" } }; + } - const user = await prisma.user.findFirst({ - where: { - email: session.user.email, - }, - select: { - id: true, - username: true, - startTime: true, - endTime: true, - bufferTime: true - } - }); + const user = await prisma.user.findFirst({ + where: { + email: session.user.email, + }, + select: { + id: true, + username: true, + startTime: true, + endTime: true, + bufferTime: true, + }, + }); - const types = await prisma.eventType.findMany({ - where: { - userId: user.id, - }, - select: { - id: true, - title: true, - slug: true, - description: true, - length: true, - hidden: true - } - }); - return { - props: {user, types}, // will be passed to the page component as props - } + const types = await prisma.eventType.findMany({ + where: { + userId: user.id, + }, + select: { + id: true, + title: true, + slug: true, + description: true, + length: true, + hidden: true, + }, + }); + return { + props: { user, types }, // will be passed to the page component as props + }; } diff --git a/pages/availability/troubleshoot.tsx b/pages/availability/troubleshoot.tsx index f6f16972..fe07c869 100644 --- a/pages/availability/troubleshoot.tsx +++ b/pages/availability/troubleshoot.tsx @@ -51,25 +51,30 @@ export default function Troubleshoot({ user }) { -
+
+

+ Understand why certain times are available and others are blocked. +

+
+
Here is an overview of your day on {selectedDate.format("D MMMM YYYY")}: - Tip: Hover over the bold times for a full timestamp + Tip: Hover over the bold times for a full timestamp
-
+
Your day starts at {convertMinsToHrsMins(user.startTime)}
{availability.map((slot) => ( -
-
- Your calendar shows you as busy between {dayjs(slot.start).format("HH:mm")} and {dayjs(slot.end).format("HH:mm")} on {dayjs(slot.start).format("D MMMM YYYY")} +
+
+ Your calendar shows you as busy between {dayjs(slot.start).format("HH:mm")} and {dayjs(slot.end).format("HH:mm")} on {dayjs(slot.start).format("D MMMM YYYY")}
))} {availability.length === 0 &&
} -
+
Your day ends at {convertMinsToHrsMins(user.endTime)}
diff --git a/pages/bookings/index.tsx b/pages/bookings/index.tsx index dbcaadf8..65c14bef 100644 --- a/pages/bookings/index.tsx +++ b/pages/bookings/index.tsx @@ -32,33 +32,16 @@ export default function Bookings({ bookings }) { +
+

+ See upcoming and past events booked through your event type links. +

+
-
+
- - - - - {/* */} - - - {bookings .filter((booking) => !booking.confirmed && !booking.rejected) @@ -70,7 +53,7 @@ export default function Bookings({ bookings }) { "px-6 py-4 whitespace-nowrap" + (booking.rejected ? " line-through" : "") }> {!booking.confirmed && !booking.rejected && ( - + Unconfirmed )} @@ -83,8 +66,8 @@ export default function Bookings({ bookings }) { className={ "px-6 py-4 max-w-20 w-full" + (booking.rejected ? " line-through" : "") }> -
{booking.title}
-
{booking.description}
+
{booking.title}
+
You and {booking.attendees[0].name}
{/*
- Person - - Event - - Date - - Actions -
@@ -109,14 +92,14 @@ export default function Bookings({ bookings }) { {booking.confirmed && !booking.rejected && ( <> - Reschedule + href={window.location.href + "/../cancel/" + booking.uid} + className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-sm shadow-sm text-neutral-700 bg-white hover:bg-neutral-100 border border-neutral-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 mr-2"> + Cancel - Cancel + href={window.location.href + "/../reschedule/" + booking.uid} + className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-sm shadow-sm text-neutral-700 bg-white hover:bg-neutral-100 border border-neutral-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 mr-2"> + Reschedule )} diff --git a/pages/event-types/[type].tsx b/pages/event-types/[type].tsx new file mode 100644 index 00000000..8a2f1823 --- /dev/null +++ b/pages/event-types/[type].tsx @@ -0,0 +1,1201 @@ +import { GetServerSideProps } from "next"; +import Head from "next/head"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useEffect, useRef, useState } from "react"; +import Select, { OptionBase } from "react-select"; +import prisma from "@lib/prisma"; +import { LocationType } from "@lib/location"; +import Shell from "@components/Shell"; +import { getSession, useSession } from "next-auth/client"; +import { Scheduler } from "@components/ui/Scheduler"; +import { Disclosure } from "@headlessui/react"; + +import { PhoneIcon, PlusCircleIcon, XIcon } from "@heroicons/react/outline"; +import { EventTypeCustomInput, EventTypeCustomInputType } from "@lib/eventTypeInput"; +import { + LocationMarkerIcon, + LinkIcon, + PencilIcon, + PlusIcon, + DocumentIcon, + ChevronRightIcon, + ClockIcon, + TrashIcon, + ExternalLinkIcon, +} from "@heroicons/react/solid"; + +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import timezone from "dayjs/plugin/timezone"; +import { Availability, EventType, User } from "@prisma/client"; +import { validJson } from "@lib/jsonUtils"; +import Text from "@components/ui/Text"; +import { RadioGroup } from "@headlessui/react"; +import classnames from "classnames"; +import throttle from "lodash.throttle"; +import "react-dates/initialize"; +import "react-dates/lib/css/_datepicker.css"; +import { DateRangePicker, OrientationShape, toMomentObject } from "react-dates"; + +dayjs.extend(utc); +dayjs.extend(timezone); + +type Props = { + user: User; + eventType: EventType; + locationOptions: OptionBase[]; + availability: Availability[]; +}; + +type OpeningHours = { + days: number[]; + startTime: number; + endTime: number; +}; + +type DateOverride = { + date: string; + startTime: number; + endTime: number; +}; + +type EventTypeInput = { + id: number; + title: string; + slug: string; + description: string; + length: number; + hidden: boolean; + locations: unknown; + eventName: string; + customInputs: EventTypeCustomInput[]; + timeZone: string; + availability?: { openingHours: OpeningHours[]; dateOverrides: DateOverride[] }; + periodType?: string; + periodDays?: number; + periodStartDate?: Date | string; + periodEndDate?: Date | string; + periodCountCalendarDays?: boolean; + enteredRequiresConfirmation: boolean; +}; + +const PERIOD_TYPES = [ + { + type: "rolling", + suffix: "into the future", + }, + { + type: "range", + prefix: "Within a date range", + }, + { + type: "unlimited", + prefix: "Indefinitely into the future", + }, +]; + +export default function EventTypePage({ + user, + eventType, + locationOptions, + availability, + }: Props): JSX.Element { + const router = useRouter(); + const [session, loading] = useSession(); + + console.log(eventType); + const inputOptions: OptionBase[] = [ + { value: EventTypeCustomInputType.Text, label: "Text" }, + { value: EventTypeCustomInputType.TextLong, label: "Multiline Text" }, + { value: EventTypeCustomInputType.Number, label: "Number" }, + { value: EventTypeCustomInputType.Bool, label: "Checkbox" }, + ]; + + const [DATE_PICKER_ORIENTATION, setDatePickerOrientation] = useState("horizontal"); + const [contentSize, setContentSize] = useState({ width: 0, height: 0 }); + + const handleResizeEvent = () => { + const elementWidth = parseFloat(getComputedStyle(document.body).width); + const elementHeight = parseFloat(getComputedStyle(document.body).height); + + setContentSize({ + width: elementWidth, + height: elementHeight, + }); + }; + + const throttledHandleResizeEvent = throttle(handleResizeEvent, 100); + + useEffect(() => { + handleResizeEvent(); + + window.addEventListener("resize", throttledHandleResizeEvent); + + return () => { + window.removeEventListener("resize", throttledHandleResizeEvent); + }; + }, []); + + useEffect(() => { + if (contentSize.width < 500) { + setDatePickerOrientation("vertical"); + } else { + setDatePickerOrientation("horizontal"); + } + }, [contentSize]); + + const [enteredAvailability, setEnteredAvailability] = useState(); + const [showLocationModal, setShowLocationModal] = useState(false); + const [showAddCustomModal, setShowAddCustomModal] = useState(false); + const [selectedTimeZone, setSelectedTimeZone] = useState(""); + const [selectedLocation, setSelectedLocation] = useState(undefined); + const [selectedInputOption, setSelectedInputOption] = useState(inputOptions[0]); + const [locations, setLocations] = useState(eventType.locations || []); + const [selectedCustomInput, setSelectedCustomInput] = useState(undefined); + const [customInputs, setCustomInputs] = useState( + eventType.customInputs.sort((a, b) => a.id - b.id) || [] + ); + + const [periodStartDate, setPeriodStartDate] = useState(() => { + if (eventType.periodType === "range" && eventType?.periodStartDate) { + return toMomentObject(new Date(eventType.periodStartDate)); + } + + return null; + }); + + const [periodEndDate, setPeriodEndDate] = useState(() => { + if (eventType.periodType === "range" && eventType.periodEndDate) { + return toMomentObject(new Date(eventType?.periodEndDate)); + } + + return null; + }); + const [focusedInput, setFocusedInput] = useState(null); + const [periodType, setPeriodType] = useState(() => { + return ( + PERIOD_TYPES.find((s) => s.type === eventType.periodType) || + PERIOD_TYPES.find((s) => s.type === "unlimited") + ); + }); + + const titleRef = useRef(); + const slugRef = useRef(); + const descriptionRef = useRef(); + const lengthRef = useRef(); + const isHiddenRef = useRef(); + const requiresConfirmationRef = useRef(); + const eventNameRef = useRef(); + const periodDaysRef = useRef(); + const periodDaysTypeRef = useRef(); + + useEffect(() => { + setSelectedTimeZone(eventType.timeZone || user.timeZone); + }, []); + + async function updateEventTypeHandler(event) { + event.preventDefault(); + + const enteredTitle: string = titleRef.current.value; + const enteredSlug: string = slugRef.current.value; + const enteredDescription: string = descriptionRef.current.value; + const enteredLength: number = parseInt(lengthRef.current.value); + const enteredIsHidden: boolean = isHiddenRef.current.checked; + const enteredRequiresConfirmation: boolean = requiresConfirmationRef.current.checked; + const enteredEventName: string = eventNameRef.current.value; + + const type = periodType.type; + const enteredPeriodDays = parseInt(periodDaysRef?.current?.value); + const enteredPeriodDaysType = Boolean(parseInt(periodDaysTypeRef?.current.value)); + + const enteredPeriodStartDate = periodStartDate ? periodStartDate.toDate() : null; + const enteredPeriodEndDate = periodEndDate ? periodEndDate.toDate() : null; + + console.log("values", { + type, + periodDaysTypeRef, + enteredPeriodDays, + enteredPeriodDaysType, + enteredPeriodStartDate, + enteredPeriodEndDate, + }); + // TODO: Add validation + + const payload: EventTypeInput = { + id: eventType.id, + title: enteredTitle, + slug: enteredSlug, + description: enteredDescription, + length: enteredLength, + hidden: enteredIsHidden, + locations, + eventName: enteredEventName, + customInputs, + timeZone: selectedTimeZone, + periodType: type, + periodDays: enteredPeriodDays, + periodStartDate: enteredPeriodStartDate, + periodEndDate: enteredPeriodEndDate, + periodCountCalendarDays: enteredPeriodDaysType, + requiresConfirmation: enteredRequiresConfirmation, + }; + + if (enteredAvailability) { + payload.availability = enteredAvailability; + } + + await fetch("/api/availability/eventtype", { + method: "PATCH", + body: JSON.stringify(payload), + headers: { + "Content-Type": "application/json", + }, + }); + + router.push("/availability"); + } + + async function deleteEventTypeHandler(event) { + event.preventDefault(); + + await fetch("/api/availability/eventtype", { + method: "DELETE", + body: JSON.stringify({ id: eventType.id }), + headers: { + "Content-Type": "application/json", + }, + }); + + router.push("/availability"); + } + + const openLocationModal = (type: LocationType) => { + setSelectedLocation(locationOptions.find((option) => option.value === type)); + setShowLocationModal(true); + }; + + const closeLocationModal = () => { + setSelectedLocation(undefined); + setShowLocationModal(false); + }; + + const closeAddCustomModal = () => { + setSelectedInputOption(inputOptions[0]); + setShowAddCustomModal(false); + setSelectedCustomInput(undefined); + }; + + const updateLocations = (e) => { + e.preventDefault(); + + let details = {}; + if (e.target.location.value === LocationType.InPerson) { + details = { address: e.target.address.value }; + } + + const existingIdx = locations.findIndex((loc) => e.target.location.value === loc.type); + if (existingIdx !== -1) { + const copy = locations; + copy[existingIdx] = { ...locations[existingIdx], ...details }; + setLocations(copy); + } else { + setLocations(locations.concat({ type: e.target.location.value, ...details })); + } + + setShowLocationModal(false); + }; + + const removeLocation = (selectedLocation) => { + setLocations(locations.filter((location) => location.type !== selectedLocation.type)); + }; + + const openEditCustomModel = (customInput: EventTypeCustomInput) => { + setSelectedCustomInput(customInput); + setSelectedInputOption(inputOptions.find((e) => e.value === customInput.type)); + setShowAddCustomModal(true); + }; + + const LocationOptions = () => { + if (!selectedLocation) { + return null; + } + switch (selectedLocation.value) { + case LocationType.InPerson: + return ( +
+ +
+ location.type === LocationType.InPerson)?.address} + /> +
+
+ ); + case LocationType.Phone: + return ( +

Calendso will ask your invitee to enter a phone number before scheduling.

+ ); + case LocationType.GoogleMeet: + return

Calendso will provide a Google Meet location.

; + case LocationType.Zoom: + return

Calendso will provide a Zoom meeting URL.

; + } + return null; + }; + + const updateCustom = (e) => { + e.preventDefault(); + + const customInput: EventTypeCustomInput = { + label: e.target.label.value, + required: e.target.required.checked, + type: e.target.type.value, + }; + + if (e.target.id?.value) { + const index = customInputs.findIndex((inp) => inp.id === +e.target.id?.value); + if (index >= 0) { + const input = customInputs[index]; + input.label = customInput.label; + input.required = customInput.required; + input.type = customInput.type; + setCustomInputs(customInputs); + } + } else { + setCustomInputs(customInputs.concat(customInput)); + } + closeAddCustomModal(); + }; + + const removeCustom = (customInput, e) => { + e.preventDefault(); + const index = customInputs.findIndex((inp) => inp.id === customInput.id); + if (index >= 0) { + customInputs.splice(index, 1); + setCustomInputs([...customInputs]); + } + }; + + return ( +
+ + {eventType.title} | Event Type | Calendso + + + +
+

+ {eventType.description} +

+
+
+
+
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+
+ + {typeof location !== "undefined" ? location.hostname : ""}/{user.username}/ + + +
+
+
+
+
+ +
+
+ {locations.length === 0 && ( +
+
+ +
+ + mins + +
+
+
+
+
+
+ +
+
+ +
+
+
+ + {({ open }) => ( + <> + + + Show advanced settings + + +
+
+ +
+
+
+ +
+
+
+
+
+ +
+
+
    + {customInputs.map((customInput) => ( +
  • +
    +
    +
    + Label: {customInput.label} +
    +
    + Type: {customInput.type} +
    +
    + + {customInput.required ? "Required" : "Optional"} + +
    +
    +
    + + +
    +
    +
  • + ))} +
  • + +
  • +
+
+
+
+
+ +
+
+
+
+ +
+
+

+ Hide the event type from your page, so it can only be booked through its + URL. +

+
+
+
+
+
+
+ +
+
+
+
+ +
+
+

+ The booking needs to be manually confirmed before it is pushed to the + integrations and a integrations and a confirmation mail is sent. +

+
+
+
+
+
+
+ +
+
+ + Date Range +
+ {PERIOD_TYPES.map((period) => ( + + classnames( + checked ? "border-secondary-200 z-10" : "border-gray-200", + "relative min-h-14 lg:flex items-center cursor-pointer focus:outline-none" + ) + }> + {({ active, checked }) => ( + <> + + ))} +
+
+
+
+
+
+ +
+
+ +
+
+
+ + )} +
+
+ + + Cancel + + + +
+ +
+
+
+
+ + + + +
+
+
+ {showLocationModal && ( +
+
+ + + + +
+
+
+ +
+
+ +
+
+
+ +
+
+ +
+ +
+
+
+ + +
+ +
+ + +
+ +
+
+
+ )} +
+
+ ); +} + +export const getServerSideProps: GetServerSideProps = async ({ req, query }) => { + const session = await getSession({ req }); + if (!session) { + return { + redirect: { + permanent: false, + destination: "/auth/login", + }, + }; + } + + const user: User = await prisma.user.findFirst({ + where: { + email: session.user.email, + }, + select: { + username: true, + timeZone: true, + startTime: true, + endTime: true, + availability: true, + }, + }); + + const eventType: EventType | null = await prisma.eventType.findUnique({ + where: { + id: parseInt(query.type as string), + }, + select: { + id: true, + title: true, + slug: true, + description: true, + length: true, + hidden: true, + locations: true, + eventName: true, + availability: true, + customInputs: true, + timeZone: true, + periodType: true, + periodDays: true, + periodStartDate: true, + periodEndDate: true, + periodCountCalendarDays: true, + requiresConfirmation: true, + }, + }); + + if (!eventType) { + return { + notFound: true, + }; + } + + const credentials = await prisma.credential.findMany({ + where: { + userId: user.id, + }, + select: { + id: true, + type: true, + key: true, + }, + }); + + const integrations = [ + { + installed: !!(process.env.GOOGLE_API_CREDENTIALS && validJson(process.env.GOOGLE_API_CREDENTIALS)), + enabled: credentials.find((integration) => integration.type === "google_calendar") != null, + type: "google_calendar", + title: "Google Calendar", + imageSrc: "integrations/google-calendar.png", + description: "For personal and business accounts", + }, + { + installed: !!(process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET), + type: "office365_calendar", + enabled: credentials.find((integration) => integration.type === "office365_calendar") != null, + title: "Office 365 / Outlook.com Calendar", + imageSrc: "integrations/office-365.png", + description: "For personal and business accounts", + }, + ]; + + const locationOptions: OptionBase[] = [ + { value: LocationType.InPerson, label: "In-person meeting" }, + { value: LocationType.Phone, label: "Phone call" }, + { value: LocationType.Zoom, label: "Zoom Video" }, + ]; + + const hasGoogleCalendarIntegration = integrations.find( + (i) => i.type === "google_calendar" && i.installed === true && i.enabled + ); + if (hasGoogleCalendarIntegration) { + locationOptions.push({ value: LocationType.GoogleMeet, label: "Google Meet" }); + } + + const hasOfficeIntegration = integrations.find( + (i) => i.type === "office365_calendar" && i.installed === true && i.enabled + ); + if (hasOfficeIntegration) { + // TODO: Add default meeting option of the office integration. + // Assuming it's Microsoft Teams. + } + + const getAvailability = (providesAvailability) => + providesAvailability.availability && providesAvailability.availability.length + ? providesAvailability.availability + : null; + + const availability: Availability[] = getAvailability(eventType) || + getAvailability(user) || [ + { + days: [0, 1, 2, 3, 4, 5, 6], + startTime: user.startTime, + endTime: user.endTime, + }, + ]; + + availability.sort((a, b) => a.startTime - b.startTime); + + const eventTypeObject = Object.assign({}, eventType, { + periodStartDate: eventType.periodStartDate?.toString() ?? null, + periodEndDate: eventType.periodEndDate?.toString() ?? null, + }); + + return { + props: { + user, + eventType: eventTypeObject, + locationOptions, + availability, + }, + }; +}; diff --git a/pages/event-types/index.tsx b/pages/event-types/index.tsx new file mode 100644 index 00000000..36eb5c27 --- /dev/null +++ b/pages/event-types/index.tsx @@ -0,0 +1,662 @@ +import Head from "next/head"; +import Link from "next/link"; +import prisma from "../../lib/prisma"; +import Shell from "../../components/Shell"; +import { useRouter } from "next/router"; +import { getSession, useSession } from "next-auth/client"; +import { Fragment, useRef, useState } from "react"; +import { Menu, Transition } from "@headlessui/react"; + +function classNames(...classes) { + return classes.filter(Boolean).join(" "); +} + +import { + ClockIcon, + DotsHorizontalIcon, + ExternalLinkIcon, + InformationCircleIcon, + LinkIcon, + PlusIcon, + UserIcon, +} from "@heroicons/react/solid"; + +export default function Availability({ user, types }) { + const [session, loading] = useSession(); + const router = useRouter(); + const [showAddModal, setShowAddModal] = useState(false); + + const titleRef = useRef(); + const slugRef = useRef(); + const descriptionRef = useRef(); + const lengthRef = useRef(); + + async function createEventTypeHandler(event) { + event.preventDefault(); + + const enteredTitle = titleRef.current.value; + const enteredSlug = slugRef.current.value; + const enteredDescription = descriptionRef.current.value; + const enteredLength = lengthRef.current.value; + + // TODO: Add validation + + const response = await fetch("/api/availability/eventtype", { + method: "POST", + body: JSON.stringify({ + title: enteredTitle, + slug: enteredSlug, + description: enteredDescription, + length: enteredLength, + }), + headers: { + "Content-Type": "application/json", + }, + }); + + if (enteredTitle && enteredLength) { + router.replace(router.asPath); + toggleAddModal(); + } + } + + function toggleAddModal() { + setShowAddModal(!showAddModal); + } + + if (loading) { + return
; + } + + return ( +
+ + Event Types | Calendso + + + +
+

+ Create events to share for people to book on your calendar. +

+
+ +
+
    + {types.map((type) => ( +
  • + + +
    +
    +
    +
    +

    {type.title}

    + {type.hidden && ( + + Hidden + + )} +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + + + + +
    +
    +
    +
    + + {({ open }) => ( + <> +
    + + Open options + +
    + + + +
    + + {({ active }) => ( + + + )} + + + {({ active }) => ( + + )} + + {/**/} + {/* {({ active }) => (*/} + {/* */} + {/* */} +
    + {/*
    */} + {/* */} + {/* {({ active }) => (*/} + {/* */} + {/* */} + {/*
    */} +
    +
    + + )} +
    +
    +
    + + +
  • + ))} +
+
+ {types.length === 0 && ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Create your first event type

+

+ Event types enable you to share links that show available times on your calendar and allow + people to make bookings with you. +

+
+ )} + {showAddModal && ( +
+
+ + + + +
+
+ +
+

+ Create a new event type for people to book times with. +

+
+
+
+
+
+ +
+ +
+
+
+ +
+
+ + {location.hostname}/{user.username}/ + + +
+
+
+
+ +
+ +
+
+
+ +
+ +
+ minutes +
+
+
+
+ {/* TODO: Add an error message when required input fields empty*/} +
+ + +
+
+
+
+
+ )} +
+
+ ); +} + +export async function getServerSideProps(context) { + const session = await getSession(context); + if (!session) { + return { redirect: { permanent: false, destination: "/auth/login" } }; + } + + const user = await prisma.user.findFirst({ + where: { + email: session.user.email, + }, + select: { + id: true, + username: true, + startTime: true, + endTime: true, + bufferTime: true, + }, + }); + + const types = await prisma.eventType.findMany({ + where: { + userId: user.id, + }, + select: { + id: true, + title: true, + slug: true, + description: true, + length: true, + hidden: true, + }, + }); + return { + props: { user, types }, // will be passed to the page component as props + }; +} diff --git a/pages/integrations/[integration].tsx b/pages/integrations/[integration].tsx index 62f21224..ba89ebbb 100644 --- a/pages/integrations/[integration].tsx +++ b/pages/integrations/[integration].tsx @@ -1,130 +1,131 @@ -import Head from 'next/head'; -import prisma from '../../lib/prisma'; -import { getIntegrationName, getIntegrationType } from '../../lib/integrations'; -import Shell from '../../components/Shell'; -import { useState } from 'react'; -import { useRouter } from 'next/router'; -import { useSession, getSession } from 'next-auth/client'; +import Head from "next/head"; +import prisma from "../../lib/prisma"; +import { getIntegrationName, getIntegrationType } from "../../lib/integrations"; +import Shell from "../../components/Shell"; +import { useState } from "react"; +import { useRouter } from "next/router"; +import { useSession, getSession } from "next-auth/client"; export default function integration(props) { - const router = useRouter(); - const [session, loading] = useSession(); - const [showAPIKey, setShowAPIKey] = useState(false); + const router = useRouter(); + const [session, loading] = useSession(); + const [showAPIKey, setShowAPIKey] = useState(false); - if (loading) { - return
; - } + if (loading) { + return
; + } - function toggleShowAPIKey() { - setShowAPIKey(!showAPIKey); - } + function toggleShowAPIKey() { + setShowAPIKey(!showAPIKey); + } - async function deleteIntegrationHandler(event) { - event.preventDefault(); + async function deleteIntegrationHandler(event) { + event.preventDefault(); - const response = await fetch('/api/integrations', { - method: 'DELETE', - body: JSON.stringify({id: props.integration.id}), - headers: { - 'Content-Type': 'application/json' - } - }); + const response = await fetch("/api/integrations", { + method: "DELETE", + body: JSON.stringify({ id: props.integration.id }), + headers: { + "Content-Type": "application/json", + }, + }); - router.push('/integrations'); - } + router.push("/integrations"); + } - return( -
- - {getIntegrationName(props.integration.type)} | Integrations | Calendso - - + return ( +
+ + {getIntegrationName(props.integration.type)} | Integrations | Calendso + + - -
-
-
-

- Integration Details -

-

- Information about your {getIntegrationName(props.integration.type)} integration. -

-
-
-
-
-
- Integration name -
-
- {getIntegrationName(props.integration.type)} -
-
-
-
- Integration type -
-
- {getIntegrationType(props.integration.type)} -
-
-
-
- API Key -
-
- {!showAPIKey ? - •••••••• - : -
- -
} - -
-
-
-
-
-
-
-
-

- Delete this integration -

-
-

- Once you delete this integration, it will be permanently removed. -

-
-
- -
-
-
-
-
-
+ +
+

Manage and delete integrations.

- ); +
+
+
+

Integration Details

+

+ Information about your {getIntegrationName(props.integration.type)} integration. +

+
+
+
+
+
Integration name
+
{getIntegrationName(props.integration.type)}
+
+
+
Integration type
+
{getIntegrationType(props.integration.type)}
+
+
+
API Key
+
+ {!showAPIKey ? ( + •••••••• + ) : ( +
+ +
+ )} + +
+
+
+
+
+
+
+
+

Delete this integration

+
+

Once you delete this integration, it will be permanently removed.

+
+
+ +
+
+
+
+
+
+
+ ); } export async function getServerSideProps(context) { - const session = await getSession(context); + const session = await getSession(context); - const integration = await prisma.credential.findFirst({ - where: { - id: parseInt(context.query.integration), - }, - select: { - id: true, - type: true, - key: true - } - }); - return { - props: {integration}, // will be passed to the page component as props - } -} \ No newline at end of file + const integration = await prisma.credential.findFirst({ + where: { + id: parseInt(context.query.integration), + }, + select: { + id: true, + type: true, + key: true, + }, + }); + return { + props: { integration }, // will be passed to the page component as props + }; +} diff --git a/pages/integrations/index.tsx b/pages/integrations/index.tsx index 25ff03e8..891fc4ef 100644 --- a/pages/integrations/index.tsx +++ b/pages/integrations/index.tsx @@ -1,387 +1,461 @@ -import Head from 'next/head'; -import Link from 'next/link'; -import prisma from '../../lib/prisma'; -import Shell from '../../components/Shell'; -import {useEffect, useState} from 'react'; -import {getSession, useSession} from 'next-auth/client'; -import {CalendarIcon, CheckCircleIcon, ChevronRightIcon, PlusIcon, XCircleIcon} from '@heroicons/react/solid'; -import {InformationCircleIcon} from '@heroicons/react/outline'; -import {Switch} from '@headlessui/react' +import Head from "next/head"; +import Link from "next/link"; +import prisma from "../../lib/prisma"; +import Shell from "../../components/Shell"; +import { useEffect, useState } from "react"; +import { getSession, useSession } from "next-auth/client"; +import { + CalendarIcon, + CheckCircleIcon, + ChevronRightIcon, + PlusIcon, + XCircleIcon, +} from "@heroicons/react/solid"; +import { InformationCircleIcon } from "@heroicons/react/outline"; +import { Switch } from "@headlessui/react"; export default function Home({ integrations }) { - const [session, loading] = useSession(); - const [showAddModal, setShowAddModal] = useState(false); - const [showSelectCalendarModal, setShowSelectCalendarModal] = useState(false); - const [selectableCalendars, setSelectableCalendars] = useState([]); + const [session, loading] = useSession(); + const [showAddModal, setShowAddModal] = useState(false); + const [showSelectCalendarModal, setShowSelectCalendarModal] = useState(false); + const [selectableCalendars, setSelectableCalendars] = useState([]); - function toggleAddModal() { - setShowAddModal(!showAddModal); + function toggleAddModal() { + setShowAddModal(!showAddModal); + } + + function toggleShowCalendarModal() { + setShowSelectCalendarModal(!showSelectCalendarModal); + } + + function loadCalendars() { + fetch("api/availability/calendar") + .then((response) => response.json()) + .then((data) => { + setSelectableCalendars(data); + }); + } + + function integrationHandler(type) { + fetch("/api/integrations/" + type.replace("_", "") + "/add") + .then((response) => response.json()) + .then((data) => (window.location.href = data.url)); + } + + function calendarSelectionHandler(calendar) { + return (selected) => { + const cals = [...selectableCalendars]; + const i = cals.findIndex((c) => c.externalId === calendar.externalId); + cals[i].selected = selected; + setSelectableCalendars(cals); + if (selected) { + fetch("api/availability/calendar", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(cals[i]), + }).then((response) => response.json()); + } else { + fetch("api/availability/calendar", { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(cals[i]), + }).then((response) => response.json()); + } + }; + } + + function getCalendarIntegrationImage(integrationType: string) { + switch (integrationType) { + case "google_calendar": + return "integrations/google-calendar.png"; + case "office365_calendar": + return "integrations/office-365.png"; + default: + return ""; } + } - function toggleShowCalendarModal() { - setShowSelectCalendarModal(!showSelectCalendarModal); - } + function classNames(...classes) { + return classes.filter(Boolean).join(" "); + } - function loadCalendars() { - fetch('api/availability/calendar') - .then((response) => response.json()) - .then(data => { - setSelectableCalendars(data) - }); - } + useEffect(loadCalendars, [integrations]); - function integrationHandler(type) { - fetch('/api/integrations/' + type.replace('_', '') + '/add') - .then((response) => response.json()) - .then((data) => window.location.href = data.url); - } + if (loading) { + return
; + } - function calendarSelectionHandler(calendar) { - return (selected) => { - let cals = [...selectableCalendars]; - let i = cals.findIndex(c => c.externalId === calendar.externalId); - cals[i].selected = selected; - setSelectableCalendars(cals); - if (selected) { - fetch('api/availability/calendar', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(cals[i]) - }).then((response) => response.json()); - } else { - fetch('api/availability/calendar', { - method: 'DELETE', headers: { - 'Content-Type': 'application/json' - }, body: JSON.stringify(cals[i]) - }).then((response) => response.json()); - } - } - } + return ( +
+ + Integrations | Calendso + + - function getCalendarIntegrationImage(integrationType: string){ - switch (integrationType) { - case "google_calendar": return "integrations/google-calendar.png"; - case "office365_calendar": return "integrations/office-365.png"; - default: return ""; - } - } - - function classNames(...classes) { - return classes.filter(Boolean).join(' ') - } - - useEffect(loadCalendars, [integrations]); - - if (loading) { - return
; - } - - return ( -
- - Integrations | Calendso - - - - -
- -
-
- {integrations.filter( (ig) => ig.credential ).length !== 0 ? - : -
-
-
- -
-
-

- You don't have any integrations added. -

-
-

- You currently do not have any integrations set up. Add your first integration to get started. -

-
-
- -
-
-
-
- } -
- {showAddModal && -
-
- {/* */} - - - {/* */} -
-
-
- -
-
- -
-

- Link a new integration to your account. -

-
-
-
-
-
    - {integrations.filter( (integration) => integration.installed ).map( (integration) => (
  • -
    - {integration.title} -
    -
    -

    { integration.title }

    -

    { integration.description }

    -
    -
    - -
    -
  • ))} -
-
-
- -
-
-
-
- } -
-
-

- Select calendars -

-
-

- Select which calendars are checked for availability to prevent double bookings. -

-
-
- -
-
-
- {showSelectCalendarModal && -
-
- {/* */} - - - {/* */} -
-
-
- -
-
- -
-

- If no entry is selected, all calendars will be checked -

-
-
-
-
-
    - {selectableCalendars.map( (calendar) => (
  • -
    - {calendar.integration} -
    -
    -

    { calendar.name }

    -
    -
    - - Select calendar - -
    -
  • ))} -
-
-
- -
-
-
-
- } -
+ +
+

Connect your favourite apps.

- ); +
+ +
+
+ {integrations.filter((ig) => ig.credential).length !== 0 ? ( + + ) : ( +
+
+
+ +
+
+

+ You don't have any integrations added. +

+
+

+ You currently do not have any integrations set up. Add your first integration to get + started. +

+
+
+ +
+
+
+
+ )} +
+ {showAddModal && ( +
+
+ {/* */} + + + {/* */} +
+
+
+ +
+
+ +
+

Link a new integration to your account.

+
+
+
+
+
    + {integrations + .filter((integration) => integration.installed) + .map((integration) => ( +
  • +
    + {integration.title} +
    +
    +

    {integration.title}

    +

    {integration.description}

    +
    +
    + +
    +
  • + ))} +
+
+
+ +
+
+
+
+ )} +
+
+

Select calendars

+
+

Select which calendars are checked for availability to prevent double bookings.

+
+
+ +
+
+
+ {showSelectCalendarModal && ( +
+
+ {/* */} + + + {/* */} +
+
+
+ +
+
+ +
+

+ If no entry is selected, all calendars will be checked +

+
+
+
+
+
    + {selectableCalendars.map((calendar) => ( +
  • +
    + {calendar.integration} +
    +
    +

    {calendar.name}

    +
    +
    + + Select calendar + +
    +
  • + ))} +
+
+
+ +
+
+
+
+ )} +
+
+ ); } const validJson = (jsonString: string) => { - try { - const o = JSON.parse(jsonString); - if (o && typeof o === "object") { - return o; - } + try { + const o = JSON.parse(jsonString); + if (o && typeof o === "object") { + return o; } - catch (e) { console.error(e); } - return false; -} + } catch (e) { + console.error(e); + } + return false; +}; export async function getServerSideProps(context) { - const session = await getSession(context); - if (!session) { - return { redirect: { permanent: false, destination: '/auth/login' } }; - } - const user = await prisma.user.findFirst({ - where: { - email: session.user.email, - }, - select: { - id: true - } - }); + const session = await getSession(context); + if (!session) { + return { redirect: { permanent: false, destination: "/auth/login" } }; + } + const user = await prisma.user.findFirst({ + where: { + email: session.user.email, + }, + select: { + id: true, + }, + }); - const credentials = await prisma.credential.findMany({ - where: { - userId: user.id, - }, - select: { - id: true, - type: true, - key: true - } - }); + const credentials = await prisma.credential.findMany({ + where: { + userId: user.id, + }, + select: { + id: true, + type: true, + key: true, + }, + }); - const integrations = [ { - installed: !!(process.env.GOOGLE_API_CREDENTIALS && validJson(process.env.GOOGLE_API_CREDENTIALS)), - credential: credentials.find( (integration) => integration.type === "google_calendar" ) || null, - type: "google_calendar", - title: "Google Calendar", - imageSrc: "integrations/google-calendar.png", - description: "For personal and business calendars", - }, { - installed: !!(process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET), - type: "office365_calendar", - credential: credentials.find( (integration) => integration.type === "office365_calendar" ) || null, - title: "Office 365 / Outlook.com Calendar", - imageSrc: "integrations/office-365.png", - description: "For personal and business calendars", - }, { - installed: !!(process.env.ZOOM_CLIENT_ID && process.env.ZOOM_CLIENT_SECRET), - type: "zoom_video", - credential: credentials.find( (integration) => integration.type === "zoom_video" ) || null, - title: "Zoom", - imageSrc: "integrations/zoom.png", - description: "Video Conferencing", - } ]; + const integrations = [ + { + installed: !!(process.env.GOOGLE_API_CREDENTIALS && validJson(process.env.GOOGLE_API_CREDENTIALS)), + credential: credentials.find((integration) => integration.type === "google_calendar") || null, + type: "google_calendar", + title: "Google Calendar", + imageSrc: "integrations/google-calendar.png", + description: "For personal and business calendars", + }, + { + installed: !!(process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET), + type: "office365_calendar", + credential: credentials.find((integration) => integration.type === "office365_calendar") || null, + title: "Office 365 / Outlook.com Calendar", + imageSrc: "integrations/office-365.png", + description: "For personal and business calendars", + }, + { + installed: !!(process.env.ZOOM_CLIENT_ID && process.env.ZOOM_CLIENT_SECRET), + type: "zoom_video", + credential: credentials.find((integration) => integration.type === "zoom_video") || null, + title: "Zoom", + imageSrc: "integrations/zoom.png", + description: "Video Conferencing", + }, + ]; - return { - props: {integrations}, - } + return { + props: { integrations }, + }; } diff --git a/pages/settings/billing.tsx b/pages/settings/billing.tsx index 4f171776..e5c04318 100644 --- a/pages/settings/billing.tsx +++ b/pages/settings/billing.tsx @@ -13,11 +13,16 @@ export default function Billing(props) { return ( +
+

+ Manage your billing information and cancel your subscription. +

+
Billing | Calendso -
+

Change your Subscription @@ -63,4 +68,4 @@ export async function getServerSideProps(context) { return { props: {user}, // will be passed to the page component as props } -} \ No newline at end of file +} diff --git a/pages/settings/embed.tsx b/pages/settings/embed.tsx index 99ec1c89..8fd371d4 100644 --- a/pages/settings/embed.tsx +++ b/pages/settings/embed.tsx @@ -1,105 +1,120 @@ -import Head from 'next/head'; -import Link from 'next/link'; -import { useState } from 'react'; -import { useRouter } from 'next/router'; -import prisma from '../../lib/prisma'; -import Modal from '../../components/Modal'; -import Shell from '../../components/Shell'; -import SettingsShell from '../../components/Settings'; -import Avatar from '../../components/Avatar'; -import { signIn, useSession, getSession } from 'next-auth/client'; -import TimezoneSelect from 'react-timezone-select'; +import Head from "next/head"; +import Link from "next/link"; +import { useState } from "react"; +import { useRouter } from "next/router"; +import prisma from "../../lib/prisma"; +import Modal from "../../components/Modal"; +import Shell from "../../components/Shell"; +import SettingsShell from "../../components/Settings"; +import Avatar from "../../components/Avatar"; +import { signIn, useSession, getSession } from "next-auth/client"; +import TimezoneSelect from "react-timezone-select"; export default function Embed(props) { - const [ session, loading ] = useSession(); - const router = useRouter(); + const [session, loading] = useSession(); + const router = useRouter(); - if (loading) { - return
; - } + if (loading) { + return
; + } - return( - - - Embed | Calendso - - - -
-
-

Iframe Embed

-

- The easiest way to embed Calendso on your website. -

-
-
-
- -
- + className="shadow-sm focus:ring-neutral-500 focus:border-neutral-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-sm">
@@ -152,7 +152,7 @@ export default function Settings(props) { id="timeZone" value={selectedTimeZone} onChange={setSelectedTimeZone} - className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md" + className="shadow-sm focus:ring-neutral-500 focus:border-neutral-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-sm" />
@@ -165,7 +165,7 @@ export default function Settings(props) { id="weekStart" value={selectedWeekStartDay} onChange={setSelectedWeekStartDay} - className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md" + className="shadow-sm focus:ring-neutral-500 focus:border-neutral-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-sm" options={[ { value: "Sunday", label: "Sunday" }, { value: "Monday", label: "Monday" }, @@ -183,11 +183,11 @@ export default function Settings(props) { isDisabled={!selectedTheme} defaultValue={selectedTheme || themeOptions[0]} onChange={setSelectedTheme} - className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md" + className="shadow-sm focus:ring-neutral-500 focus:border-neutral-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-sm" options={themeOptions} />
-
+
setSelectedTheme(e.target.checked ? null : themeOptions[0])} defaultChecked={!selectedTheme} - className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded" + className="focus:ring-neutral-500 h-4 w-4 text-neutral-900 border-gray-300 rounded-sm" />
@@ -214,7 +214,7 @@ export default function Settings(props) { type="checkbox" ref={hideBrandingRef} defaultChecked={props.user.hideBranding} - className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded" + className="focus:ring-neutral-500 h-4 w-4 text-neutral-900 border-gray-300 rounded-sm" />
@@ -238,13 +238,13 @@ export default function Settings(props) { aria-hidden="true">
- {/*
-
+ {/*
+
- +
*/}
@@ -254,12 +254,12 @@ export default function Settings(props) {
} + fallback={
} /> {/* */}
@@ -272,7 +272,7 @@ export default function Settings(props) { name="avatar" id="avatar" placeholder="URL" - className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" + className="mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm" defaultValue={props.user.avatar} />
@@ -282,7 +282,7 @@ export default function Settings(props) {
diff --git a/pages/settings/teams.tsx b/pages/settings/teams.tsx index c129939f..78f8e95e 100644 --- a/pages/settings/teams.tsx +++ b/pages/settings/teams.tsx @@ -58,21 +58,22 @@ export default function Teams() { return ( +
+

+ Create and manage teams to use collaborative features. +

+
Teams | Calendso
-
+
-

Your teams

-

- View, edit and create teams to organise relationships between users -

{!(invites.length || teams.length) && ( -
+

Create a team to get started @@ -94,7 +95,7 @@ export default function Teams() {

{!!(invites.length || teams.length) && (
-
@@ -143,10 +144,10 @@ export default function Teams() { ​ -
+
-
- +
+
diff --git a/public/calendso-logo-word.svg b/public/calendso-logo-word.svg index 4a01ade1..cfa67015 100644 --- a/public/calendso-logo-word.svg +++ b/public/calendso-logo-word.svg @@ -1,41 +1,11 @@ - - - - - + + + + + + + + + + diff --git a/public/integrations/google-calendar.png b/public/integrations/google-calendar.png deleted file mode 100644 index a600dcb4..00000000 Binary files a/public/integrations/google-calendar.png and /dev/null differ diff --git a/styles/globals.css b/styles/globals.css index b7dcbf0f..b2e17113 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -7,84 +7,84 @@ @layer components { /* Primary buttons */ .btn-xs.btn-primary { - @apply inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500; + @apply inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded shadow-sm text-white bg-neutral-900 hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500; } .btn-sm.btn-primary { - @apply inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500; + @apply inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-sm shadow-sm text-white bg-neutral-900 hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500; } .btn.btn-primary { - @apply inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500; + @apply inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-sm shadow-sm text-white bg-neutral-900 hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500; } .btn-lg.btn-primary { - @apply inline-flex items-center px-4 py-2 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500; + @apply inline-flex items-center px-4 py-2 border border-transparent text-base font-medium rounded-sm shadow-sm text-white bg-neutral-900 hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500; } .btn-xl.btn-primary { - @apply inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500; + @apply inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-sm shadow-sm text-white bg-neutral-900 hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500; } .btn-wide.btn-primary { - @apply w-full text-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500; + @apply w-full text-center px-4 py-2 border border-transparent text-sm font-medium rounded-sm shadow-sm text-white bg-neutral-900 hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500; } /* Secondary buttons */ .btn-xs.btn-secondary { - @apply inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-blue-700 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500; + @apply inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-neutral-700 bg-neutral-100 hover:bg-neutral-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500; } .btn-sm.btn-secondary { - @apply inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500; + @apply inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-sm text-neutral-700 bg-neutral-100 hover:bg-neutral-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500; } .btn.btn-secondary { - @apply inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500; + @apply inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-sm text-neutral-700 bg-neutral-100 hover:bg-neutral-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500; } .btn-lg.btn-secondary { - @apply inline-flex items-center px-4 py-2 border border-transparent text-base font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500; + @apply inline-flex items-center px-4 py-2 border border-transparent text-base font-medium rounded-sm text-neutral-700 bg-neutral-100 hover:bg-neutral-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500; } .btn-xl.btn-secondary { - @apply inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500; + @apply inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-sm text-neutral-700 bg-neutral-100 hover:bg-neutral-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500; } .btn-wide.btn-secondary { - @apply w-full text-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500; + @apply w-full text-center px-4 py-2 border border-transparent text-sm font-medium rounded-sm text-neutral-700 bg-neutral-100 hover:bg-neutral-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500; } /* White buttons */ .btn-xs.btn-white { - @apply inline-flex items-center px-2.5 py-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500; + @apply inline-flex items-center px-2.5 py-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500; } .btn-sm.btn-white { - @apply inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500; + @apply inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500; } .btn.btn-white { - @apply inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500; + @apply inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500; } .btn-lg.btn-white { - @apply inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500; + @apply inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-base font-medium rounded-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500; } .btn-xl.btn-white { - @apply inline-flex items-center px-6 py-3 border border-gray-300 shadow-sm text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500; + @apply inline-flex items-center px-6 py-3 border border-gray-300 shadow-sm text-base font-medium rounded-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500; } .btn-wide.btn-white { - @apply w-full text-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500; + @apply w-full text-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500; } } .loader { margin: 80px auto; border: 8px solid #f3f3f3; /* Light grey */ - border-top: 8px solid #039be5; /* Blue */ + border-top: 8px solid #039be5; /* neutral */ border-radius: 50%; width: 60px; height: 60px; @@ -105,11 +105,11 @@ nav#nav--settings > a svg { } nav#nav--settings > a.active { - @apply bg-blue-50 border-blue-500 text-blue-700 hover:bg-blue-50 hover:text-blue-700; + @apply bg-neutral-50 border-neutral-500 text-neutral-700 hover:bg-neutral-50 hover:text-neutral-700; } nav#nav--settings > a.active svg { - @apply text-blue-500; + @apply text-neutral-500; } @@ -155,4 +155,4 @@ body { rgba(3, 169, 244, var(--tw-border-opacity)) rgba(3, 169, 244, var(--tw-border-opacity)) white; -} \ No newline at end of file +} diff --git a/tailwind.config.js b/tailwind.config.js index a6063b5d..ae0c2adc 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -5,27 +5,77 @@ module.exports = { theme: { extend: { colors: { - gray: { - 100: "#EBF1F5", - 200: "#D9E3EA", - 300: "#C5D2DC", - 400: "#9BA9B4", - 500: "#707D86", - 600: "#55595F", - 700: "#33363A", - 800: "#25282C", - 900: "#151719", + neutral: { + 50: "#F7F8F9", + 100: "#F4F5F6", + 200: "#EAEEF2", + 300: "#C6CCD5", + 400: "#9BA6B6", + 500: "#708097", + 600: "#657388", + 700: "#373F4A", + 800: "#1F2937", + 900: "#1A1A1A", }, - blue: { - 100: "#b3e5fc", - 200: "#81d4fa", - 300: "#4fc3f7", - 400: "#29b6f6", - 500: "#03a9f4", - 600: "#039be5", - 700: "#0288d1", - 800: "#0277bd", - 900: "#01579b", + primary: { + 50: "#F4F4F4", + 100: "#E8E8E8", + 200: "#C6C6C6", + 300: "#A3A3A3", + 400: "#5F5F5F", + 500: "#1A1A1A", + 600: "#171717", + 700: "#141414", + 800: "#101010", + 900: "#0D0D0D", + }, + secondary: { + 50: "#F5F8F7", + 100: "#EBF0F0", + 200: "#CDDAD9", + 300: "#AEC4C2", + 400: "#729894", + 500: "#356C66", + 600: "#30615C", + 700: "#28514D", + 800: "#20413D", + 900: "#223B41", + }, + red: { + 50: "#FEF2F2", + 100: "#FEE2E2", + 200: "#FECACA", + 300: "#FCA5A5", + 400: "#F87171", + 500: "#EF4444", + 600: "#DC2626", + 700: "#B91C1C", + 800: "#991B1B", + 900: "#7F1D1D", + }, + orange: { + 50: "#FFF7ED", + 100: "#FFEDD5", + 200: "#FED7AA", + 300: "#FDBA74", + 400: "#FB923C", + 500: "#F97316", + 600: "#EA580C", + 700: "#C2410C", + 800: "#9A3412", + 900: "#7C2D12", + }, + green: { + 50: "#ECFDF5", + 100: "#D1FAE5", + 200: "#A7F3D0", + 300: "#6EE7B7", + 400: "#34D399", + 500: "#10B981", + 600: "#059669", + 700: "#047857", + 800: "#065F46", + 900: "#064E3B", }, }, maxHeight: (theme) => ({