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:
parent
8d1d3fcc7a
commit
8c1b69cc0f
5 changed files with 310 additions and 237 deletions
|
@ -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,114 +125,106 @@ 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) {
|
// can be shortened using .filter(), except TypeScript doesn't know what to make of the types.
|
||||||
switch (selectedLocation) {
|
const guests = router.query.guest
|
||||||
case LocationType.Phone:
|
? Array.isArray(router.query.guest)
|
||||||
payload["location"] = event.target.phone.value;
|
? router.query.guest
|
||||||
break;
|
: [router.query.guest]
|
||||||
|
: [];
|
||||||
|
|
||||||
case LocationType.InPerson:
|
const bookingForm = useForm<BookingFormValues>({
|
||||||
payload["location"] = locationInfo(selectedLocation).address;
|
defaultValues: {
|
||||||
break;
|
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.
|
// Catches all other location types, such as Google Meet, Zoom etc.
|
||||||
default:
|
default:
|
||||||
payload["location"] = selectedLocation;
|
return selectedLocation;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bookEvent = (booking: BookingFormValues) => {
|
||||||
telemetry.withJitsu((jitsu) =>
|
telemetry.withJitsu((jitsu) =>
|
||||||
jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters())
|
jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters())
|
||||||
);
|
);
|
||||||
|
|
||||||
const content = await createBooking(payload).catch((e) => {
|
// "metadata" is a reserved key to allow for connecting external users without relying on the email address.
|
||||||
console.error(e.message);
|
// <...url>&metadata[user_id]=123 will be send as a custom input field as the hidden type.
|
||||||
setLoading(false);
|
const metadata = Object.keys(router.query)
|
||||||
setError(true);
|
.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],
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Head>
|
<Head>
|
||||||
|
@ -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,11 +363,10 @@ 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-" + input.label.toLowerCase}>
|
<div className="mb-4" key={input.id}>
|
||||||
{input.type !== EventTypeCustomInputType.BOOL && (
|
{input.type !== EventTypeCustomInputType.BOOL && (
|
||||||
<label
|
<label
|
||||||
htmlFor={"custom_" + input.id}
|
htmlFor={"custom_" + input.id}
|
||||||
|
@ -338,9 +376,10 @@ const BookingPage = (props: BookingPageProps) => {
|
||||||
)}
|
)}
|
||||||
{input.type === EventTypeCustomInputType.TEXTLONG && (
|
{input.type === EventTypeCustomInputType.TEXTLONG && (
|
||||||
<textarea
|
<textarea
|
||||||
name={"custom_" + input.id}
|
{...bookingForm.register(`customInputs.${input.id}`, {
|
||||||
|
required: input.required,
|
||||||
|
})}
|
||||||
id={"custom_" + input.id}
|
id={"custom_" + input.id}
|
||||||
required={input.required}
|
|
||||||
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={input.placeholder}
|
placeholder={input.placeholder}
|
||||||
|
@ -349,9 +388,10 @@ const BookingPage = (props: BookingPageProps) => {
|
||||||
{input.type === EventTypeCustomInputType.TEXT && (
|
{input.type === EventTypeCustomInputType.TEXT && (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name={"custom_" + input.id}
|
{...bookingForm.register(`customInputs.${input.id}`, {
|
||||||
|
required: input.required,
|
||||||
|
})}
|
||||||
id={"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"
|
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}
|
placeholder={input.placeholder}
|
||||||
/>
|
/>
|
||||||
|
@ -359,9 +399,10 @@ const BookingPage = (props: BookingPageProps) => {
|
||||||
{input.type === EventTypeCustomInputType.NUMBER && (
|
{input.type === EventTypeCustomInputType.NUMBER && (
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
name={"custom_" + input.id}
|
{...bookingForm.register(`customInputs.${input.id}`, {
|
||||||
|
required: input.required,
|
||||||
|
})}
|
||||||
id={"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"
|
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=""
|
placeholder=""
|
||||||
/>
|
/>
|
||||||
|
@ -370,11 +411,12 @@ const BookingPage = (props: BookingPageProps) => {
|
||||||
<div className="flex items-center h-5">
|
<div className="flex items-center h-5">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name={"custom_" + input.id}
|
{...bookingForm.register(`customInputs.${input.id}`, {
|
||||||
|
required: input.required,
|
||||||
|
})}
|
||||||
id={"custom_" + input.id}
|
id={"custom_" + input.id}
|
||||||
className="w-4 h-4 mr-2 text-black border-gray-300 rounded focus:ring-black"
|
className="w-4 h-4 mr-2 text-black border-gray-300 rounded focus:ring-black"
|
||||||
placeholder=""
|
placeholder=""
|
||||||
required={input.required}
|
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
htmlFor={"custom_" + input.id}
|
htmlFor={"custom_" + input.id}
|
||||||
|
@ -389,9 +431,10 @@ const BookingPage = (props: BookingPageProps) => {
|
||||||
<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,13 +445,15 @@ 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>
|
||||||
|
<Controller
|
||||||
|
control={bookingForm.control}
|
||||||
|
name="guests"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
<ReactMultiEmail
|
<ReactMultiEmail
|
||||||
className="relative"
|
className="relative"
|
||||||
placeholder="guest@example.com"
|
placeholder="guest@example.com"
|
||||||
emails={guestEmails}
|
emails={value}
|
||||||
onChange={(_emails: string[]) => {
|
onChange={onChange}
|
||||||
setGuestEmails(_emails);
|
|
||||||
}}
|
|
||||||
getLabel={(
|
getLabel={(
|
||||||
email: string,
|
email: string,
|
||||||
index: number,
|
index: number,
|
||||||
|
@ -424,6 +469,8 @@ const BookingPage = (props: BookingPageProps) => {
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</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">
|
||||||
|
|
|
@ -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[];
|
||||||
};
|
};
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,
|
||||||
|
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);
|
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${sub.subscriberUrl}`, e);
|
||||||
}
|
})
|
||||||
)
|
|
||||||
);
|
);
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue