diff --git a/lib/location.ts b/lib/location.ts index b27f497702..3bd8f71fcf 100644 --- a/lib/location.ts +++ b/lib/location.ts @@ -1,7 +1,6 @@ - export enum LocationType { - InPerson = 'inPerson', - Phone = 'phone', - GoogleMeet = 'integrations:google:meet' + InPerson = "inPerson", + Phone = "phone", + GoogleMeet = "integrations:google:meet", + Zoom = "integrations:zoom", } - diff --git a/pages/[user]/book.tsx b/pages/[user]/book.tsx index b44a91f81f..ccf2844ca0 100644 --- a/pages/[user]/book.tsx +++ b/pages/[user]/book.tsx @@ -1,316 +1,415 @@ -import Head from 'next/head'; -import Link from 'next/link'; -import {useRouter} from 'next/router'; -import {CalendarIcon, ClockIcon, ExclamationIcon, LocationMarkerIcon} from '@heroicons/react/solid'; -import prisma from '../../lib/prisma'; -import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../../lib/telemetry"; -import {useEffect, useState} from "react"; -import dayjs from 'dayjs'; -import utc from 'dayjs/plugin/utc'; -import timezone from 'dayjs/plugin/timezone'; -import 'react-phone-number-input/style.css'; -import PhoneInput from 'react-phone-number-input'; -import {LocationType} from '../../lib/location'; -import Avatar from '../../components/Avatar'; -import Button from '../../components/ui/Button'; -import {EventTypeCustomInputType} from "../../lib/eventTypeInput"; +import Head from "next/head"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { CalendarIcon, ClockIcon, ExclamationIcon, LocationMarkerIcon } from "@heroicons/react/solid"; +import prisma from "../../lib/prisma"; +import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry"; +import { useEffect, useState } from "react"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import timezone from "dayjs/plugin/timezone"; +import "react-phone-number-input/style.css"; +import PhoneInput from "react-phone-number-input"; +import { LocationType } from "../../lib/location"; +import Avatar from "../../components/Avatar"; +import Button from "../../components/ui/Button"; +import { EventTypeCustomInputType } from "../../lib/eventTypeInput"; dayjs.extend(utc); dayjs.extend(timezone); -export default function Book(props) { - const router = useRouter(); - const { date, user, rescheduleUid } = router.query; +export default function Book(props: any): JSX.Element { + const router = useRouter(); + const { date, user, rescheduleUid } = router.query; - const [ is24h, setIs24h ] = useState(false); - const [ preferredTimeZone, setPreferredTimeZone ] = useState(''); - const [ loading, setLoading ] = useState(false); - const [ error, setError ] = useState(false); + const [is24h, setIs24h] = useState(false); + const [preferredTimeZone, setPreferredTimeZone] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); - const locations = props.eventType.locations || []; + const locations = props.eventType.locations || []; - const [ selectedLocation, setSelectedLocation ] = useState(locations.length === 1 ? locations[0].type : ''); - const telemetry = useTelemetry(); - useEffect(() => { + const [selectedLocation, setSelectedLocation] = useState( + locations.length === 1 ? locations[0].type : "" + ); + const telemetry = useTelemetry(); + useEffect(() => { + setPreferredTimeZone(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()); + setIs24h(!!localStorage.getItem("timeOption.is24hClock")); - setPreferredTimeZone(localStorage.getItem('timeOption.preferredTimeZone') || dayjs.tz.guess()); - setIs24h(!!localStorage.getItem('timeOption.is24hClock')); + telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.timeSelected, collectPageParameters())); + }); - telemetry.withJitsu(jitsu => jitsu.track(telemetryEventTypes.timeSelected, collectPageParameters())); - }); + const locationInfo = (type: LocationType) => locations.find((location) => location.type === type); - const locationInfo = (type: LocationType) => locations.find( - (location) => location.type === type - ); + // TODO: Move to translations + const locationLabels = { + [LocationType.InPerson]: "In-person meeting", + [LocationType.Phone]: "Phone call", + [LocationType.GoogleMeet]: "Google Meet", + [LocationType.Zoom]: "Zoom Video", + }; - // TODO: Move to translations - const locationLabels = { - [LocationType.InPerson]: 'In-person meeting', - [LocationType.Phone]: 'Phone call', - [LocationType.GoogleMeet]: 'Google Meet', - }; - - const bookingHandler = event => { - const book = async () => { - setLoading(true); - setError(false); - let notes = ""; - if (props.eventType.customInputs) { - notes = props.eventType.customInputs.map(input => { - const data = event.target["custom_" + input.id]; - if (!!data) { - if (input.type === EventTypeCustomInputType.Bool) { - return input.label + "\n" + (data.value ? "Yes" : "No") - } else { - return input.label + "\n" + data.value - } - } - }).join("\n\n") - } - if (!!notes && !!event.target.notes.value) { - notes += "\n\nAdditional notes:\n" + event.target.notes.value; - } else { - notes += event.target.notes.value; - } - - let payload = { - start: dayjs(date).format(), - end: dayjs(date).add(props.eventType.length, 'minute').format(), - name: event.target.name.value, - email: event.target.email.value, - notes: notes, - timeZone: preferredTimeZone, - eventTypeId: props.eventType.id, - rescheduleUid: rescheduleUid - }; - - if (selectedLocation) { - switch (selectedLocation) { - case LocationType.Phone: - payload['location'] = event.target.phone.value - break - - case LocationType.InPerson: - payload['location'] = locationInfo(selectedLocation).address - break - - case LocationType.GoogleMeet: - payload['location'] = LocationType.GoogleMeet - break - } - } - - telemetry.withJitsu(jitsu => jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters())); - - /*const res = await */fetch( - '/api/book/' + user, - { - body: JSON.stringify(payload), - headers: { - 'Content-Type': 'application/json' - }, - method: 'POST' + const bookingHandler = (event) => { + const book = async () => { + setLoading(true); + setError(false); + let notes = ""; + if (props.eventType.customInputs) { + notes = props.eventType.customInputs + .map((input) => { + const data = event.target["custom_" + input.id]; + if (data) { + if (input.type === EventTypeCustomInputType.Bool) { + return input.label + "\n" + (data.value ? "Yes" : "No"); + } else { + return input.label + "\n" + data.value; } - ); - // TODO When the endpoint is fixed, change this to await the result again - //if (res.ok) { - let successUrl = `/success?date=${date}&type=${props.eventType.id}&user=${props.user.username}&reschedule=${!!rescheduleUid}&name=${payload.name}`; - if (payload['location']) { - if (payload['location'].includes('integration')) { - successUrl += "&location=" + encodeURIComponent("Web conferencing details to follow."); - } - else { - successUrl += "&location=" + encodeURIComponent(payload['location']); - } - } + } + }) + .join("\n\n"); + } + if (!!notes && !!event.target.notes.value) { + notes += "\n\nAdditional notes:\n" + event.target.notes.value; + } else { + notes += event.target.notes.value; + } - await router.push(successUrl); - /*} else { + const payload = { + start: dayjs(date).format(), + end: dayjs(date).add(props.eventType.length, "minute").format(), + name: event.target.name.value, + email: event.target.email.value, + notes: notes, + timeZone: preferredTimeZone, + eventTypeId: props.eventType.id, + rescheduleUid: rescheduleUid, + }; + + if (selectedLocation) { + switch (selectedLocation) { + case LocationType.Phone: + payload["location"] = event.target.phone.value; + break; + + case LocationType.InPerson: + payload["location"] = locationInfo(selectedLocation).address; + break; + + // Catches all other location types, such as Google Meet, Zoom etc. + default: + payload["location"] = selectedLocation; + } + } + + telemetry.withJitsu((jitsu) => + jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters()) + ); + + /*const res = await */ fetch("/api/book/" + user, { + body: JSON.stringify(payload), + headers: { + "Content-Type": "application/json", + }, + method: "POST", + }); + // TODO When the endpoint is fixed, change this to await the result again + //if (res.ok) { + let successUrl = `/success?date=${date}&type=${props.eventType.id}&user=${ + props.user.username + }&reschedule=${!!rescheduleUid}&name=${payload.name}`; + if (payload["location"]) { + if (payload["location"].includes("integration")) { + successUrl += "&location=" + encodeURIComponent("Web conferencing details to follow."); + } else { + successUrl += "&location=" + encodeURIComponent(payload["location"]); + } + } + + await router.push(successUrl); + /*} else { setLoading(false); setError(true); }*/ - } + }; - event.preventDefault(); - book(); - } + event.preventDefault(); + book(); + }; - return ( -
- - {rescheduleUid ? 'Reschedule' : 'Confirm'} your {props.eventType.title} with {props.user.name || props.user.username} | Calendso - - + return ( +
+ + + {rescheduleUid ? "Reschedule" : "Confirm"} your {props.eventType.title} with{" "} + {props.user.name || props.user.username} | Calendso + + + -
-
-
-
- -

{props.user.name}

-

{props.eventType.title}

-

- - {props.eventType.length} minutes -

- {selectedLocation === LocationType.InPerson &&

- - {locationInfo(selectedLocation).address} -

} -

- - {preferredTimeZone && dayjs(date).tz(preferredTimeZone).format( (is24h ? "H:mm" : "h:mma") + ", dddd DD MMMM YYYY")} -

-

{props.eventType.description}

-
-
-
-
- -
- -
-
-
- -
- -
-
- {locations.length > 1 && ( -
- Location - {locations.map( (location) => ( - - ))} -
- )} - {selectedLocation === LocationType.Phone && (
- -
- {}} /> -
-
)} - {props.eventType.customInputs && props.eventType.customInputs.sort((a,b) => a.id - b.id).map(input => ( -
- {input.type !== EventTypeCustomInputType.Bool && - } - {input.type === EventTypeCustomInputType.TextLong && -
-
- -
- +
+
+ +
+ +
+ minutes
-
- -
- -
- minutes -
-
+
+
+ +
+
-
- -
- -
-
-
- -
    - {customInputs.map( (customInput) => ( -
  • -
    +
    +
    + +
      + {customInputs.map((customInput) => ( +
    • +
      +
      -
      - Label: {customInput.label} -
      -
      - Type: {customInput.type} -
      -
      - {customInput.required ? "Required" : "Optional"} -
      + Label: {customInput.label}
      -
      - - +
      + Type: {customInput.type} +
      +
      + + {customInput.required ? "Required" : "Optional"} +
      -
    • - ))} -
    • - +
      + + +
      +
  • -
-
-
-
-
- -
-
- -

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

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

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

    - - Cancel - -
    +
    + + + Cancel + +
    -
    -
    -
    -

    - Delete this event type -

    -
    -

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

    -
    -
    - -
    +
    +
    +
    +
    +

    Delete this event type

    +
    +

    Once you delete this event type, it will be permanently removed.

    +
    +
    +
    - {showLocationModal && -
    +
    + {showLocationModal && ( +
    - + - +
    @@ -398,7 +547,9 @@ export default function EventType(props) {
    - +
    @@ -423,166 +574,201 @@ export default function EventType(props) {
    - } - {showAddCustomModal && -
    -
    - - ); + )} + +
    + ); } const validJson = (jsonString: string) => { try { - const o = JSON.parse(jsonString); - if (o && typeof o === "object") { - return o; - } + const o = JSON.parse(jsonString); + if (o && typeof o === "object") { + return o; + } + } catch (e) { + console.log("Invalid JSON:", e); } - catch (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: { - username: 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: { + username: 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)), - 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 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", + }, + ]; - let locationOptions: OptionBase[] = [ - { value: LocationType.InPerson, label: 'In-person meeting' }, - { value: LocationType.Phone, label: 'Phone call', }, - ]; - - 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 locationOptions: OptionBase[] = [ + { value: LocationType.InPerson, label: "In-person meeting" }, + { value: LocationType.Phone, label: "Phone call" }, + { value: LocationType.Zoom, label: "Zoom Video" }, + ]; - 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 hasGoogleCalendarIntegration = integrations.find( + (i) => i.type === "google_calendar" && i.installed === true && i.enabled + ); + if (hasGoogleCalendarIntegration) { + locationOptions.push({ value: LocationType.GoogleMeet, label: "Google Meet" }); + } - const eventType = await prisma.eventType.findUnique({ - where: { - id: parseInt(context.query.type), - }, - select: { - id: true, - title: true, - slug: true, - description: true, - length: true, - hidden: true, - locations: true, - eventName: true, - customInputs: true - } - }); + 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. + } - return { - props: { - user, - eventType, - locationOptions - }, - } + const eventType = await prisma.eventType.findUnique({ + where: { + id: parseInt(context.query.type), + }, + select: { + id: true, + title: true, + slug: true, + description: true, + length: true, + hidden: true, + locations: true, + eventName: true, + customInputs: true, + }, + }); + + return { + props: { + user, + eventType, + locationOptions, + }, + }; }