Feature/field prefills (#1249)

* Needs more testing, but looks functional

* Add metadata feature to booking create payload

* Forward URL parameters given in link

* Moved stringifying of custom inputs to backend
This commit is contained in:
Alex van Andel 2021-12-03 11:15:20 +01:00 committed by GitHub
parent 8d1d3fcc7a
commit 8c1b69cc0f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 310 additions and 237 deletions

View file

@ -9,10 +9,11 @@ import { EventTypeCustomInputType } from "@prisma/client";
import dayjs from "dayjs"; import dayjs from "dayjs";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { stringify } from "querystring"; import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useState } from "react"; import { Controller, useForm, useWatch } from "react-hook-form";
import { FormattedNumber, IntlProvider } from "react-intl"; import { FormattedNumber, IntlProvider } from "react-intl";
import { ReactMultiEmail } from "react-multi-email"; import { ReactMultiEmail } from "react-multi-email";
import { useMutation } from "react-query";
import { createPaymentLink } from "@ee/lib/stripe/client"; import { createPaymentLink } from "@ee/lib/stripe/client";
@ -23,10 +24,11 @@ import useTheme from "@lib/hooks/useTheme";
import { LocationType } from "@lib/location"; import { LocationType } from "@lib/location";
import createBooking from "@lib/mutations/bookings/create-booking"; import createBooking from "@lib/mutations/bookings/create-booking";
import { parseZone } from "@lib/parseZone"; import { parseZone } from "@lib/parseZone";
import slugify from "@lib/slugify";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
import { BookingCreateBody } from "@lib/types/booking";
import CustomBranding from "@components/CustomBranding"; import CustomBranding from "@components/CustomBranding";
import { Form } from "@components/form/fields";
import AvatarGroup from "@components/ui/AvatarGroup"; import AvatarGroup from "@components/ui/AvatarGroup";
import { Button } from "@components/ui/Button"; import { Button } from "@components/ui/Button";
import PhoneInput from "@components/ui/form/PhoneInput"; import PhoneInput from "@components/ui/form/PhoneInput";
@ -39,31 +41,78 @@ type BookingPageProps = BookPageProps | TeamBookingPageProps;
const BookingPage = (props: BookingPageProps) => { const BookingPage = (props: BookingPageProps) => {
const { t, i18n } = useLocale(); const { t, i18n } = useLocale();
const router = useRouter(); const router = useRouter();
const { rescheduleUid } = router.query; /*
* This was too optimistic
* I started, then I remembered what a beast book/event.ts is
* Gave up shortly after. One day. Maybe.
*
const mutation = trpc.useMutation("viewer.bookEvent", {
onSuccess: ({ booking }) => {
// go to success page.
},
});*/
const mutation = useMutation(createBooking, {
onSuccess: async ({ attendees, paymentUid, ...responseData }) => {
if (paymentUid) {
return await router.push(
createPaymentLink({
paymentUid,
date,
name: attendees[0].name,
absolute: false,
})
);
}
const location = (function humanReadableLocation(location) {
if (!location) {
return;
}
if (location.includes("integration")) {
return t("web_conferencing_details_to_follow");
}
return location;
})(responseData.location);
return router.push({
pathname: "/success",
query: {
date,
type: props.eventType.id,
user: props.profile.slug,
reschedule: !!rescheduleUid,
name: attendees[0].name,
email: attendees[0].email,
location,
},
});
},
});
const rescheduleUid = router.query.rescheduleUid as string;
const { isReady } = useTheme(props.profile.theme); const { isReady } = useTheme(props.profile.theme);
const date = asStringOrNull(router.query.date); const date = asStringOrNull(router.query.date);
const timeFormat = asStringOrNull(router.query.clock) === "24h" ? "H:mm" : "h:mma"; const timeFormat = asStringOrNull(router.query.clock) === "24h" ? "H:mm" : "h:mma";
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [guestToggle, setGuestToggle] = useState(false); const [guestToggle, setGuestToggle] = useState(false);
const [guestEmails, setGuestEmails] = useState([]);
const locations = props.eventType.locations || [];
const [selectedLocation, setSelectedLocation] = useState<LocationType>( // it would be nice if Prisma at some point in the future allowed for Json<Location>; as of now this is not the case.
locations.length === 1 ? locations[0].type : "" const locations: { type: LocationType }[] = useMemo(
() => (props.eventType.locations as { type: LocationType }[]) || [],
[props.eventType.locations]
); );
const telemetry = useTelemetry(); useEffect(() => {
if (router.query.guest) {
setGuestToggle(true);
}
}, [router.query.guest]);
const telemetry = useTelemetry();
useEffect(() => { useEffect(() => {
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.timeSelected, collectPageParameters())); telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.timeSelected, collectPageParameters()));
}, []); }, [telemetry]);
function toggleGuestEmailInput() {
setGuestToggle(!guestToggle);
}
const locationInfo = (type: LocationType) => locations.find((location) => location.type === type); const locationInfo = (type: LocationType) => locations.find((location) => location.type === type);
@ -76,113 +125,105 @@ const BookingPage = (props: BookingPageProps) => {
[LocationType.Daily]: "Daily.co Video", [LocationType.Daily]: "Daily.co Video",
}; };
const _bookingHandler = (event) => { type BookingFormValues = {
const book = async () => { name: string;
setLoading(true); email: string;
setError(false); notes?: string;
let notes = ""; locationType?: LocationType;
if (props.eventType.customInputs) { guests?: string[];
notes = props.eventType.customInputs phone?: string;
.map((input) => { customInputs?: {
const data = event.target["custom_" + input.id]; [key: string]: string;
if (data) {
if (input.type === EventTypeCustomInputType.BOOL) {
return input.label + "\n" + (data.checked ? t("yes") : t("no"));
} else {
return input.label + "\n" + data.value;
}
}
})
.join("\n\n");
}
if (!!notes && !!event.target.notes.value) {
notes += `\n\n${t("additional_notes")}:\n` + event.target.notes.value;
} else {
notes += event.target.notes.value;
}
const payload: BookingCreateBody = {
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,
guests: guestEmails,
eventTypeId: props.eventType.id,
timeZone: timeZone(),
language: i18n.language,
};
if (typeof rescheduleUid === "string") payload.rescheduleUid = rescheduleUid;
if (typeof router.query.user === "string") payload.user = router.query.user;
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 content = await createBooking(payload).catch((e) => {
console.error(e.message);
setLoading(false);
setError(true);
});
if (content?.id) {
const params: { [k: string]: any } = {
date,
type: props.eventType.id,
user: props.profile.slug,
reschedule: !!rescheduleUid,
name: payload.name,
email: payload.email,
};
if (payload["location"]) {
if (payload["location"].includes("integration")) {
params.location = t("web_conferencing_details_to_follow");
} else {
params.location = payload["location"];
}
}
const query = stringify(params);
let successUrl = `/success?${query}`;
if (content?.paymentUid) {
successUrl = createPaymentLink({
paymentUid: content?.paymentUid,
name: payload.name,
date,
absolute: false,
});
}
await router.push(successUrl);
} else {
setLoading(false);
setError(true);
}
}; };
event.preventDefault();
book();
}; };
const bookingHandler = useCallback(_bookingHandler, [guestEmails]); // can be shortened using .filter(), except TypeScript doesn't know what to make of the types.
const guests = router.query.guest
? Array.isArray(router.query.guest)
? router.query.guest
: [router.query.guest]
: [];
const bookingForm = useForm<BookingFormValues>({
defaultValues: {
name: (router.query.name as string) || "",
email: (router.query.email as string) || "",
notes: (router.query.notes as string) || "",
guests,
customInputs: props.eventType.customInputs.reduce(
(customInputs, input) => ({
...customInputs,
[input.id]: router.query[slugify(input.label)],
}),
{}
),
},
});
const selectedLocation = useWatch({
control: bookingForm.control,
name: "locationType",
defaultValue: ((): LocationType | undefined => {
if (router.query.location) {
return router.query.location as LocationType;
}
if (locations.length === 1) {
return locations[0]?.type;
}
})(),
});
const getLocationValue = (booking: Pick<BookingFormValues, "locationType" | "phone">) => {
const { locationType } = booking;
switch (locationType) {
case LocationType.Phone: {
return booking.phone;
}
case LocationType.InPerson: {
return locationInfo(locationType).address;
}
// Catches all other location types, such as Google Meet, Zoom etc.
default:
return selectedLocation;
}
};
const bookEvent = (booking: BookingFormValues) => {
telemetry.withJitsu((jitsu) =>
jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters())
);
// "metadata" is a reserved key to allow for connecting external users without relying on the email address.
// <...url>&metadata[user_id]=123 will be send as a custom input field as the hidden type.
const metadata = Object.keys(router.query)
.filter((key) => key.startsWith("metadata"))
.reduce(
(metadata, key) => ({
...metadata,
[key.substring("metadata[".length, key.length - 1)]: router.query[key],
}),
{}
);
mutation.mutate({
...booking,
start: dayjs(date).format(),
end: dayjs(date).add(props.eventType.length, "minute").format(),
eventTypeId: props.eventType.id,
timeZone: timeZone(),
language: i18n.language,
rescheduleUid,
user: router.query.user as string,
location: getLocationValue(booking),
metadata,
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
label: props.eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
value: booking.customInputs![inputId],
})),
});
};
return ( return (
<div> <div>
@ -253,20 +294,20 @@ const BookingPage = (props: BookingPageProps) => {
<p className="mb-8 text-gray-600 dark:text-white">{props.eventType.description}</p> <p className="mb-8 text-gray-600 dark:text-white">{props.eventType.description}</p>
</div> </div>
<div className="sm:w-1/2 sm:pl-8 sm:pr-4"> <div className="sm:w-1/2 sm:pl-8 sm:pr-4">
<form onSubmit={bookingHandler}> <Form form={bookingForm} handleSubmit={bookEvent}>
<div className="mb-4"> <div className="mb-4">
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-white"> <label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-white">
{t("your_name")} {t("your_name")}
</label> </label>
<div className="mt-1"> <div className="mt-1">
<input <input
{...bookingForm.register("name")}
type="text" type="text"
name="name" name="name"
id="name" id="name"
required required
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm" className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
placeholder="John Doe" placeholder="John Doe"
defaultValue={props.booking ? props.booking.attendees[0].name : ""}
/> />
</div> </div>
</div> </div>
@ -278,6 +319,7 @@ const BookingPage = (props: BookingPageProps) => {
</label> </label>
<div className="mt-1"> <div className="mt-1">
<input <input
{...bookingForm.register("email")}
type="email" type="email"
name="email" name="email"
id="email" id="email"
@ -285,7 +327,6 @@ const BookingPage = (props: BookingPageProps) => {
required required
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm" className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
placeholder="you@example.com" placeholder="you@example.com"
defaultValue={props.booking ? props.booking.attendees[0].email : ""}
/> />
</div> </div>
</div> </div>
@ -294,16 +335,14 @@ const BookingPage = (props: BookingPageProps) => {
<span className="block text-sm font-medium text-gray-700 dark:text-white"> <span className="block text-sm font-medium text-gray-700 dark:text-white">
{t("location")} {t("location")}
</span> </span>
{locations.map((location) => ( {locations.map((location, i) => (
<label key={location.type} className="block"> <label key={i} className="block">
<input <input
type="radio" type="radio"
required
onChange={(e) => setSelectedLocation(e.target.value)}
className="w-4 h-4 mr-2 text-black border-gray-300 location focus:ring-black" className="w-4 h-4 mr-2 text-black border-gray-300 location focus:ring-black"
name="location" {...bookingForm.register("locationType", { required: true })}
value={location.type} value={location.type}
checked={selectedLocation === location.type} defaultChecked={selectedLocation === location.type}
/> />
<span className="ml-2 text-sm dark:text-gray-500"> <span className="ml-2 text-sm dark:text-gray-500">
{locationLabels[location.type]} {locationLabels[location.type]}
@ -324,74 +363,78 @@ const BookingPage = (props: BookingPageProps) => {
</div> </div>
</div> </div>
)} )}
{props.eventType.customInputs && {props.eventType.customInputs
props.eventType.customInputs .sort((a, b) => a.id - b.id)
.sort((a, b) => a.id - b.id) .map((input) => (
.map((input) => ( <div className="mb-4" key={input.id}>
<div className="mb-4" key={"input-" + input.label.toLowerCase}> {input.type !== EventTypeCustomInputType.BOOL && (
{input.type !== EventTypeCustomInputType.BOOL && ( <label
htmlFor={"custom_" + input.id}
className="block mb-1 text-sm font-medium text-gray-700 dark:text-white">
{input.label}
</label>
)}
{input.type === EventTypeCustomInputType.TEXTLONG && (
<textarea
{...bookingForm.register(`customInputs.${input.id}`, {
required: input.required,
})}
id={"custom_" + input.id}
rows={3}
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
placeholder={input.placeholder}
/>
)}
{input.type === EventTypeCustomInputType.TEXT && (
<input
type="text"
{...bookingForm.register(`customInputs.${input.id}`, {
required: input.required,
})}
id={"custom_" + input.id}
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
placeholder={input.placeholder}
/>
)}
{input.type === EventTypeCustomInputType.NUMBER && (
<input
type="number"
{...bookingForm.register(`customInputs.${input.id}`, {
required: input.required,
})}
id={"custom_" + input.id}
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
placeholder=""
/>
)}
{input.type === EventTypeCustomInputType.BOOL && (
<div className="flex items-center h-5">
<input
type="checkbox"
{...bookingForm.register(`customInputs.${input.id}`, {
required: input.required,
})}
id={"custom_" + input.id}
className="w-4 h-4 mr-2 text-black border-gray-300 rounded focus:ring-black"
placeholder=""
/>
<label <label
htmlFor={"custom_" + input.id} htmlFor={"custom_" + input.id}
className="block mb-1 text-sm font-medium text-gray-700 dark:text-white"> className="block mb-1 text-sm font-medium text-gray-700 dark:text-white">
{input.label} {input.label}
</label> </label>
)} </div>
{input.type === EventTypeCustomInputType.TEXTLONG && ( )}
<textarea </div>
name={"custom_" + input.id} ))}
id={"custom_" + input.id}
required={input.required}
rows={3}
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
placeholder={input.placeholder}
/>
)}
{input.type === EventTypeCustomInputType.TEXT && (
<input
type="text"
name={"custom_" + input.id}
id={"custom_" + input.id}
required={input.required}
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
placeholder={input.placeholder}
/>
)}
{input.type === EventTypeCustomInputType.NUMBER && (
<input
type="number"
name={"custom_" + input.id}
id={"custom_" + input.id}
required={input.required}
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
placeholder=""
/>
)}
{input.type === EventTypeCustomInputType.BOOL && (
<div className="flex items-center h-5">
<input
type="checkbox"
name={"custom_" + input.id}
id={"custom_" + input.id}
className="w-4 h-4 mr-2 text-black border-gray-300 rounded focus:ring-black"
placeholder=""
required={input.required}
/>
<label
htmlFor={"custom_" + input.id}
className="block mb-1 text-sm font-medium text-gray-700 dark:text-white">
{input.label}
</label>
</div>
)}
</div>
))}
{!props.eventType.disableGuests && ( {!props.eventType.disableGuests && (
<div className="mb-4"> <div className="mb-4">
{!guestToggle && ( {!guestToggle && (
<label <label
onClick={toggleGuestEmailInput} onClick={() => setGuestToggle(!guestToggle)}
htmlFor="guests" htmlFor="guests"
className="block mb-1 text-sm font-medium text-blue-500 dark:text-white hover:cursor-pointer"> className="block mb-1 text-sm font-medium dark:text-white hover:cursor-pointer">
{/*<UserAddIcon className="inline-block w-5 h-5 mr-1 -mt-1" />*/}
{t("additional_guests")} {t("additional_guests")}
</label> </label>
)} )}
@ -402,27 +445,31 @@ const BookingPage = (props: BookingPageProps) => {
className="block mb-1 text-sm font-medium text-gray-700 dark:text-white"> className="block mb-1 text-sm font-medium text-gray-700 dark:text-white">
{t("guests")} {t("guests")}
</label> </label>
<ReactMultiEmail <Controller
className="relative" control={bookingForm.control}
placeholder="guest@example.com" name="guests"
emails={guestEmails} render={({ field: { onChange, value } }) => (
onChange={(_emails: string[]) => { <ReactMultiEmail
setGuestEmails(_emails); className="relative"
}} placeholder="guest@example.com"
getLabel={( emails={value}
email: string, onChange={onChange}
index: number, getLabel={(
removeEmail: (index: number) => void email: string,
) => { index: number,
return ( removeEmail: (index: number) => void
<div data-tag key={index}> ) => {
{email} return (
<span data-tag-handle onClick={() => removeEmail(index)}> <div data-tag key={index}>
× {email}
</span> <span data-tag-handle onClick={() => removeEmail(index)}>
</div> ×
); </span>
}} </div>
);
}}
/>
)}
/> />
</div> </div>
)} )}
@ -435,24 +482,23 @@ const BookingPage = (props: BookingPageProps) => {
{t("additional_notes")} {t("additional_notes")}
</label> </label>
<textarea <textarea
name="notes" {...bookingForm.register("notes")}
id="notes" id="notes"
rows={3} rows={3}
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm" className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
placeholder={t("share_additional_notes")} placeholder={t("share_additional_notes")}
defaultValue={props.booking ? props.booking.description : ""}
/> />
</div> </div>
<div className="flex items-start space-x-2"> <div className="flex items-start space-x-2">
<Button type="submit" loading={loading}> <Button type="submit" loading={mutation.isLoading}>
{rescheduleUid ? t("reschedule") : t("confirm")} {rescheduleUid ? t("reschedule") : t("confirm")}
</Button> </Button>
<Button color="secondary" type="button" onClick={() => router.back()}> <Button color="secondary" type="button" onClick={() => router.back()}>
{t("cancel")} {t("cancel")}
</Button> </Button>
</div> </div>
</form> </Form>
{error && ( {mutation.isError && (
<div className="p-4 mt-2 border-l-4 border-yellow-400 bg-yellow-50"> <div className="p-4 mt-2 border-l-4 border-yellow-400 bg-yellow-50">
<div className="flex"> <div className="flex">
<div className="flex-shrink-0"> <div className="flex-shrink-0">

View file

@ -1,6 +1,4 @@
import { Booking } from "@prisma/client"; import { Attendee, Booking } from "@prisma/client";
import { LocationType } from "@lib/location";
export type BookingConfirmBody = { export type BookingConfirmBody = {
confirmed: boolean; confirmed: boolean;
@ -11,18 +9,23 @@ export type BookingCreateBody = {
email: string; email: string;
end: string; end: string;
eventTypeId: number; eventTypeId: number;
guests: string[]; guests?: string[];
location: LocationType; location: string;
name: string; name: string;
notes: string; notes?: string;
rescheduleUid?: string; rescheduleUid?: string;
start: string; start: string;
timeZone: string; timeZone: string;
users?: string[]; users?: string[];
user?: string; user?: string;
language: string; language: string;
customInputs: { label: string; value: string }[];
metadata: {
[key: string]: string;
};
}; };
export type BookingResponse = Booking & { export type BookingResponse = Booking & {
paymentUid?: string; paymentUid?: string;
attendees: Attendee[];
}; };

View file

@ -25,7 +25,7 @@ const sendPayload = async (
triggerEvent: string, triggerEvent: string,
createdAt: string, createdAt: string,
subscriberUrl: string, subscriberUrl: string,
data: Omit<CalendarEvent, "language">, data: Omit<CalendarEvent, "language"> & { metadata?: { [key: string]: string } },
template?: string | null template?: string | null
) => { ) => {
if (!subscriberUrl || !data) { if (!subscriberUrl || !data) {

View file

@ -1,6 +1,7 @@
import { ArrowRightIcon } from "@heroicons/react/outline"; import { ArrowRightIcon } from "@heroicons/react/outline";
import { GetServerSidePropsContext } from "next"; import { GetServerSidePropsContext } from "next";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router";
import React from "react"; import React from "react";
import { useLocale } from "@lib/hooks/useLocale"; import { useLocale } from "@lib/hooks/useLocale";
@ -18,6 +19,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
const { isReady } = useTheme(props.user.theme); const { isReady } = useTheme(props.user.theme);
const { user, eventTypes } = props; const { user, eventTypes } = props;
const { t } = useLocale(); const { t } = useLocale();
const router = useRouter();
const nameOrUsername = user.name || user.username || ""; const nameOrUsername = user.name || user.username || "";
@ -30,15 +32,15 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
avatar={user.avatar || undefined} avatar={user.avatar || undefined}
/> />
{isReady && ( {isReady && (
<div className="bg-neutral-50 dark:bg-black h-screen"> <div className="h-screen bg-neutral-50 dark:bg-black">
<main className="max-w-3xl mx-auto py-24 px-4"> <main className="max-w-3xl px-4 py-24 mx-auto">
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<Avatar <Avatar
imageSrc={user.avatar} imageSrc={user.avatar}
className="mx-auto w-24 h-24 rounded-full mb-4" className="w-24 h-24 mx-auto mb-4 rounded-full"
alt={nameOrUsername} alt={nameOrUsername}
/> />
<h1 className="font-cal text-3xl font-bold text-neutral-900 dark:text-white mb-1"> <h1 className="mb-1 text-3xl font-bold font-cal text-neutral-900 dark:text-white">
{nameOrUsername} {nameOrUsername}
</h1> </h1>
<p className="text-neutral-500 dark:text-white">{user.bio}</p> <p className="text-neutral-500 dark:text-white">{user.bio}</p>
@ -47,9 +49,15 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
{eventTypes.map((type) => ( {eventTypes.map((type) => (
<div <div
key={type.id} key={type.id}
className="group relative dark:bg-neutral-900 dark:border-0 dark:hover:border-neutral-600 bg-white hover:bg-gray-50 border border-neutral-200 hover:border-brand rounded-sm"> className="relative bg-white border rounded-sm group dark:bg-neutral-900 dark:border-0 dark:hover:border-neutral-600 hover:bg-gray-50 border-neutral-200 hover:border-brand">
<ArrowRightIcon className="absolute transition-opacity h-4 w-4 right-3 top-3 text-black dark:text-white opacity-0 group-hover:opacity-100" /> <ArrowRightIcon className="absolute w-4 h-4 text-black transition-opacity opacity-0 right-3 top-3 dark:text-white group-hover:opacity-100" />
<Link href={`/${user.username}/${type.slug}`}> <Link
href={{
pathname: `/${user.username}/${type.slug}`,
query: {
...router.query,
},
}}>
<a className="block px-6 py-4" data-testid="event-type-link"> <a className="block px-6 py-4" data-testid="event-type-link">
<h2 className="font-semibold text-neutral-900 dark:text-white">{type.title}</h2> <h2 className="font-semibold text-neutral-900 dark:text-white">{type.title}</h2>
<EventTypeDescription eventType={type} /> <EventTypeDescription eventType={type} />
@ -59,9 +67,9 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
))} ))}
</div> </div>
{eventTypes.length === 0 && ( {eventTypes.length === 0 && (
<div className="shadow overflow-hidden rounded-sm"> <div className="overflow-hidden rounded-sm shadow">
<div className="p-8 text-center text-gray-400 dark:text-white"> <div className="p-8 text-center text-gray-400 dark:text-white">
<h2 className="font-cal font-semibold text-3xl text-gray-600 dark:text-white"> <h2 className="text-3xl font-semibold text-gray-600 font-cal dark:text-white">
{t("uh_oh")} {t("uh_oh")}
</h2> </h2>
<p className="max-w-md mx-auto">{t("no_event_types_have_been_setup")}</p> <p className="max-w-md mx-auto">{t("no_event_types_have_been_setup")}</p>

View file

@ -238,7 +238,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
} }
const invitee = [{ email: reqBody.email, name: reqBody.name, timeZone: reqBody.timeZone }]; const invitee = [{ email: reqBody.email, name: reqBody.name, timeZone: reqBody.timeZone }];
const guests = reqBody.guests.map((guest) => { const guests = (reqBody.guests || []).map((guest) => {
const g = { const g = {
email: guest, email: guest,
name: "", name: "",
@ -269,10 +269,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
t, t,
}; };
const description =
reqBody.customInputs.reduce((str, input) => str + input.label + "\n" + input.value + "\n\n", "") +
t("additional_notes") +
":\n" +
reqBody.notes;
const evt: CalendarEvent = { const evt: CalendarEvent = {
type: eventType.title, type: eventType.title,
title: getEventName(eventNameObject), title: getEventName(eventNameObject),
description: reqBody.notes, description,
startTime: reqBody.start, startTime: reqBody.start,
endTime: reqBody.end, endTime: reqBody.end,
organizer: { organizer: {
@ -513,13 +519,23 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const eventTrigger = rescheduleUid ? "BOOKING_RESCHEDULED" : "BOOKING_CREATED"; const eventTrigger = rescheduleUid ? "BOOKING_RESCHEDULED" : "BOOKING_CREATED";
// Send Webhook call if hooked to BOOKING_CREATED & BOOKING_RESCHEDULED // Send Webhook call if hooked to BOOKING_CREATED & BOOKING_RESCHEDULED
const subscribers = await getSubscribers(user.id, eventTrigger); const subscribers = await getSubscribers(user.id, eventTrigger);
console.log("evt:", evt); console.log("evt:", {
...evt,
metadata: reqBody.metadata,
});
const promises = subscribers.map((sub) => const promises = subscribers.map((sub) =>
sendPayload(eventTrigger, new Date().toISOString(), sub.subscriberUrl, evt, sub.payloadTemplate).catch( sendPayload(
(e) => { eventTrigger,
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${sub.subscriberUrl}`, e); new Date().toISOString(),
} sub.subscriberUrl,
) {
...evt,
metadata: reqBody.metadata,
},
sub.payloadTemplate
).catch((e) => {
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${sub.subscriberUrl}`, e);
})
); );
await Promise.all(promises); await Promise.all(promises);