Requires Confirmation Threshold (#5825)

* WIP + react errors fixed

* Booking and Availability page treatment

* Update BookingPage.tsx

Co-authored-by: Bailey Pumfleet <bailey@pumfleet.co.uk>
This commit is contained in:
Leo Giovanetti
2022-12-05 12:12:14 +00:00
committed by GitHub
co-authored by Bailey Pumfleet
parent 58270fa653
commit 1cdcebac01
11 changed files with 228 additions and 44 deletions
@@ -1,6 +1,7 @@
import { SchedulingType } from "@prisma/client";
import { FC, ReactNode, useEffect } from "react";
import dayjs from "@calcom/dayjs";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon, Badge } from "@calcom/ui";
@@ -41,6 +42,7 @@ interface Props {
const BookingDescription: FC<Props> = (props) => {
const { profile, eventType, isBookingPage = false, children } = props;
const { date: bookingDate } = useRouterQuery("date");
const { t } = useLocale();
const { duration, setQuery: setDuration } = useRouterQuery("duration");
useEffect(() => {
@@ -54,6 +56,21 @@ const BookingDescription: FC<Props> = (props) => {
}
}
}, []);
let requiresConfirmation = eventType?.requiresConfirmation;
let requiresConfirmationText = t("requires_confirmation");
const rcThreshold = eventType?.metadata?.requiresConfirmationThreshold;
if (rcThreshold) {
if (isBookingPage) {
if (dayjs(bookingDate).diff(dayjs(), rcThreshold.unit) > rcThreshold.time) {
requiresConfirmation = false;
}
} else {
requiresConfirmationText = t("requires_confirmation_threshold", {
...rcThreshold,
unit: rcThreshold.unit.slice(0, -1),
});
}
}
return (
<>
<UserAvatars
@@ -89,16 +106,16 @@ const BookingDescription: FC<Props> = (props) => {
</div>
</div>
)}
{eventType?.requiresConfirmation && (
{requiresConfirmation && (
<div
className={classNames(
"flex items-center",
"items-top flex",
isBookingPage && "dark:text-darkgray-600 text-sm font-medium text-gray-600"
)}>
<div>
<Icon.FiCheckSquare className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 " />
<Icon.FiCheckSquare className="mr-[10px] ml-[2px] inline-block h-4 w-4 " />
</div>
{t("requires_confirmation")}
{requiresConfirmationText}
</div>
)}
<AvailableEventLocations
@@ -237,7 +237,7 @@ function TimezoneDropdown({
return (
<Popover.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
<Popover.Trigger className="min-w-32 dark:text-darkgray-600 radix-state-open:bg-gray-200 dark:radix-state-open:bg-darkgray-200 group relative mb-2 -ml-2 inline-block rounded-md px-2 py-2 text-left text-gray-600">
<Popover.Trigger className="min-w-32 dark:text-darkgray-600 radix-state-open:bg-gray-200 dark:radix-state-open:bg-darkgray-200 group relative mb-2 -ml-2 !mt-2 inline-block self-start rounded-md px-2 py-2 text-left text-gray-600">
<p className="flex items-center text-sm font-medium">
<Icon.FiGlobe className="min-h-4 min-w-4 mr-[10px] ml-[2px] -mt-[2px] inline-block" />
{timeZone}
@@ -27,6 +27,8 @@ import {
import CustomInputTypeForm from "@components/eventtype/CustomInputTypeForm";
import RequiresConfirmationController from "./RequiresConfirmationController";
const generateHashedLink = (id: number) => {
const translator = short();
const seed = `${id}:${new Date().getTime()}`;
@@ -47,6 +49,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
);
const [selectedCustomInput, setSelectedCustomInput] = useState<CustomInputParsed | undefined>(undefined);
const [selectedCustomInputModalOpen, setSelectedCustomInputModalOpen] = useState(false);
const [requiresConfirmation, setRequiresConfirmation] = useState(eventType.requiresConfirmation);
const placeholderHashedLink = `${CAL_URL}/d/${hashedUrl}/${eventType.slug}`;
const seatsEnabled = formMethods.getValues("seatsPerTimeSlotEnabled");
@@ -159,18 +162,11 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
</SettingsToggle>
</div>
<hr />
<Controller
name="requiresConfirmation"
defaultValue={eventType.requiresConfirmation}
render={({ field: { value, onChange } }) => (
<SettingsToggle
title={t("requires_confirmation")}
description={t("requires_confirmation_description")}
checked={value}
onCheckedChange={(e) => onChange(e)}
disabled={seatsEnabled}
/>
)}
<RequiresConfirmationController
seatsEnabled={seatsEnabled}
metadata={eventType.metadata}
requiresConfirmation={requiresConfirmation}
onRequiresConfirmation={setRequiresConfirmation}
/>
<hr />
<Controller
@@ -306,6 +302,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
if (e) {
formMethods.setValue("disableGuests", true);
formMethods.setValue("requiresConfirmation", false);
setRequiresConfirmation(false);
formMethods.setValue("seatsPerTimeSlot", 2);
} else {
formMethods.setValue("seatsPerTimeSlot", null);
@@ -33,22 +33,10 @@ export const EventSetupTab = (
const [selectedLocation, setSelectedLocation] = useState<OptionTypeBase | undefined>(undefined);
const [multipleDuration, setMultipleDuration] = useState(eventType.metadata.multipleDuration);
const multipleDurationOptions = [
{ value: 5, label: t("multiple_duration_mins", { count: 5 }) },
{ value: 10, label: t("multiple_duration_mins", { count: 10 }) },
{ value: 15, label: t("multiple_duration_mins", { count: 15 }) },
{ value: 20, label: t("multiple_duration_mins", { count: 20 }) },
{ value: 25, label: t("multiple_duration_mins", { count: 25 }) },
{ value: 30, label: t("multiple_duration_mins", { count: 30 }) },
{ value: 45, label: t("multiple_duration_mins", { count: 45 }) },
{ value: 50, label: t("multiple_duration_mins", { count: 50 }) },
{ value: 60, label: t("multiple_duration_mins", { count: 60 }) },
{ value: 75, label: t("multiple_duration_mins", { count: 75 }) },
{ value: 80, label: t("multiple_duration_mins", { count: 80 }) },
{ value: 90, label: t("multiple_duration_mins", { count: 90 }) },
{ value: 120, label: t("multiple_duration_mins", { count: 120 }) },
{ value: 180, label: t("multiple_duration_mins", { count: 180 }) },
];
const multipleDurationOptions = [5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 180].map((mins) => ({
value: mins,
label: t("multiple_duration_mins", { count: mins }),
}));
const [selectedMultipleDuration, setSelectedMultipleDuration] = useState<
MultiValue<{
@@ -0,0 +1,156 @@
import * as RadioGroup from "@radix-ui/react-radio-group";
import { UnitTypeLongPlural } from "dayjs";
import { Trans } from "next-i18next";
import type { FormValues } from "pages/event-types/[type]";
import { Dispatch, SetStateAction, useEffect, useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import z from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { Alert, Input, Label, SettingsToggle } from "@calcom/ui";
type RequiresConfirmationControllerProps = {
metadata: z.infer<typeof EventTypeMetaDataSchema>;
requiresConfirmation: boolean;
onRequiresConfirmation: Dispatch<SetStateAction<boolean>>;
seatsEnabled: boolean;
};
export default function RequiresConfirmationController({
metadata,
requiresConfirmation,
onRequiresConfirmation,
seatsEnabled,
}: RequiresConfirmationControllerProps) {
const { t } = useLocale();
const [requiresConfirmationSetup, setRequiresConfirmationSetup] = useState(
metadata?.requiresConfirmationThreshold
);
const defaultRequiresConfirmationSetup = { time: 30, unit: "minutes" as UnitTypeLongPlural };
const formMethods = useFormContext<FormValues>();
useEffect(() => {
if (!requiresConfirmation) {
formMethods.setValue("metadata.requiresConfirmationThreshold", undefined);
}
}, [requiresConfirmation]);
return (
<div className="block items-start sm:flex">
<div className={!seatsEnabled ? "w-full" : ""}>
{seatsEnabled ? (
<Alert severity="warning" title="Seats option doesn't support confirmation requirement" />
) : (
<Controller
name="requiresConfirmation"
control={formMethods.control}
render={() => (
<SettingsToggle
title={t("requires_confirmation")}
description={t("requires_confirmation_description")}
checked={requiresConfirmation}
onCheckedChange={(val) => {
formMethods.setValue("requiresConfirmation", val);
onRequiresConfirmation(val);
}}>
<RadioGroup.Root
defaultValue={
requiresConfirmation
? requiresConfirmationSetup === undefined
? "always"
: "notice"
: undefined
}
onValueChange={(val) => {
if (val === "always") {
formMethods.setValue("requiresConfirmation", true);
onRequiresConfirmation(true);
formMethods.setValue("metadata.requiresConfirmationThreshold", undefined);
setRequiresConfirmationSetup(undefined);
} else if (val === "notice") {
formMethods.setValue("requiresConfirmation", true);
onRequiresConfirmation(true);
formMethods.setValue(
"metadata.requiresConfirmationThreshold",
requiresConfirmationSetup || defaultRequiresConfirmationSetup
);
}
}}>
<div className="-ml-2 flex flex-col flex-wrap justify-start gap-y-2 space-y-2">
<div className="flex items-center">
<RadioGroup.Item
id="always"
value="always"
className="min-w-4 flex h-4 w-4 cursor-pointer items-center rounded-full border border-black bg-white focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
<RadioGroup.Indicator className="relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full after:bg-black" />
</RadioGroup.Item>
<Label htmlFor="always" className="!m-0 flex items-center">
{t("always_requires_confirmation")}
</Label>
</div>
<div className="flex items-center">
<RadioGroup.Item
id="notice"
value="notice"
className="min-w-4 flex h-4 w-4 cursor-pointer items-center rounded-full border border-black bg-white focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
<RadioGroup.Indicator className="relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full after:bg-black" />
</RadioGroup.Item>
<Label htmlFor="notice" className="!m-0 flex items-center">
<Trans
i18nKey="when_booked_with_less_than_notice"
defaults="When booked with less than <time></time> notice"
components={{
time: (
<div className="mx-2 flex">
<Input
type="number"
min={1}
onChange={(evt) => {
const val = Number(evt.target?.value);
setRequiresConfirmationSetup({
unit:
requiresConfirmationSetup?.unit ??
defaultRequiresConfirmationSetup.unit,
time: val,
});
formMethods.setValue("metadata.requiresConfirmationThreshold.time", val);
}}
className="!m-0 block w-16 rounded-md border-gray-300 text-sm [appearance:textfield]"
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
/>
<select
onChange={(evt) => {
const val = evt.target.value as UnitTypeLongPlural;
setRequiresConfirmationSetup({
time:
requiresConfirmationSetup?.time ??
defaultRequiresConfirmationSetup.time,
unit: val,
});
formMethods.setValue("metadata.requiresConfirmationThreshold.unit", val);
}}
className="ml-2 block h-9 rounded-md border-gray-300 py-2 pl-3 pr-10 text-sm focus:outline-none"
defaultValue={
metadata?.requiresConfirmationThreshold?.unit ||
defaultRequiresConfirmationSetup.unit
}>
<option value="minutes">{t("minute_timeUnit")}</option>
<option value="hours">{t("hour_timeUnit")}</option>
</select>
</div>
),
}}
/>
</Label>
</div>
</div>
</RadioGroup.Root>
</SettingsToggle>
)}
/>
)}
</div>
</div>
);
}
@@ -704,6 +704,7 @@
"hide_event_type": "Hide event type",
"edit_location": "Edit location",
"into_the_future": "into the future",
"when_booked_with_less_than_notice": "When booked with less than <time></time> notice",
"within_date_range": "Within a date range",
"indefinitely_into_future": "Indefinitely into the future",
"add_new_custom_input_field": "Add new custom input field",
@@ -1028,6 +1029,9 @@
"error_removing_app": "Error removing app",
"web_conference": "Web conference",
"requires_confirmation": "Requires confirmation",
"always_requires_confirmation": "Always",
"requires_confirmation_threshold": "Requires confirmation if booked with < {{time}} $t({{unit}}_timeUnit) notice",
"may_require_confirmation": "May require confirmation",
"nr_event_type_one": "{{count}} event type",
"nr_event_type_other": "{{count}} event types",
"add_action": "Add action",
@@ -11,11 +11,15 @@ export default function UnconfirmedBookingBadge() {
else
return (
<Link href="/bookings/unconfirmed">
<a title={t("unconfirmed_bookings_tooltip")}>
<Badge rounded variant="orange" className="hover:bg-orange-800 hover:text-orange-100">
<>
<Badge
rounded
title={t("unconfirmed_bookings_tooltip")}
variant="orange"
className="cursor-pointer hover:bg-orange-800 hover:text-orange-100">
{unconfirmedBookingCount}
</Badge>
</a>
</>
</Link>
);
}
@@ -480,6 +480,14 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
const additionalNotes = reqBody.notes;
let requiresConfirmation = eventType?.requiresConfirmation;
const rcThreshold = eventType?.metadata?.requiresConfirmationThreshold;
if (rcThreshold) {
if (dayjs(dayjs(reqBody.start).utc().format()).diff(dayjs(), rcThreshold.unit) > rcThreshold.time) {
requiresConfirmation = false;
}
}
let evt: CalendarEvent = {
type: eventType.title,
title: getEventName(eventNameObject), //this needs to be either forced in english, or fetched for each attendee and organizer separately
@@ -499,7 +507,7 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
/** For team events & dynamic collective events, we will need to handle each member destinationCalendar eventually */
destinationCalendar: eventType.destinationCalendar || organizerUser.destinationCalendar,
hideCalendarNotes: eventType.hideCalendarNotes,
requiresConfirmation: eventType.requiresConfirmation ?? false,
requiresConfirmation: requiresConfirmation ?? false,
eventTypeId: eventType.id,
seatsShowAttendees: !!eventType.seatsShowAttendees,
};
@@ -651,8 +659,7 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
// Otherwise, an owner rescheduling should be always accepted.
// Before comparing make sure that userId is set, otherwise undefined === undefined
const userReschedulingIsOwner = userId && originalRescheduledBooking?.user?.id === userId;
const isConfirmedByDefault =
(!eventType.requiresConfirmation && !stripeAppData.price) || userReschedulingIsOwner;
const isConfirmedByDefault = (!requiresConfirmation && !stripeAppData.price) || userReschedulingIsOwner;
async function createBooking() {
if (originalRescheduledBooking) {
@@ -875,7 +882,7 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
}
// If it's not a reschedule, doesn't require confirmation and there's no price,
// Create a booking
} else if (!eventType.requiresConfirmation && !stripeAppData.price) {
} else if (!requiresConfirmation && !stripeAppData.price) {
// Use EventManager to conditionally use all needed integrations.
const createManager = await eventManager.create(evt);
@@ -981,7 +988,7 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
const eventTypeInfo: EventTypeInfo = {
eventTitle: eventType.title,
eventDescription: eventType.description,
requiresConfirmation: eventType.requiresConfirmation || null,
requiresConfirmation: requiresConfirmation || null,
price: stripeAppData.price,
currency: eventType.currency,
length: eventType.length,
+9
View File
@@ -1,4 +1,5 @@
import { EventTypeCustomInputType } from "@prisma/client";
import { UnitTypeLongPlural } from "dayjs";
import z, { ZodNullable, ZodObject, ZodOptional } from "zod";
/* eslint-disable no-underscore-dangle */
@@ -26,6 +27,8 @@ export enum Frequency {
SECONDLY = 6,
}
export const RequiresConfirmationThresholdUnits: z.ZodType<UnitTypeLongPlural> = z.enum(["hours", "minutes"]);
export const EventTypeMetaDataSchema = z
.object({
smartContractAddress: z.string().optional(),
@@ -34,6 +37,12 @@ export const EventTypeMetaDataSchema = z
giphyThankYouPage: z.string().optional(),
apps: z.object(appDataSchemas).partial().optional(),
additionalNotesRequired: z.boolean().optional(),
requiresConfirmationThreshold: z
.object({
time: z.number(),
unit: RequiresConfirmationThresholdUnits,
})
.optional(),
config: z
.object({
useHostSchedulesForTeamEvent: z.boolean().optional(),
+2 -2
View File
@@ -14,11 +14,11 @@ const Switch = (
fitToHeight?: boolean;
}
) => {
const { label, ...primitiveProps } = props;
const { label, fitToHeight, ...primitiveProps } = props;
const id = useId();
return (
<div className={classNames("flex h-auto w-auto flex-row items-center", props.fitToHeight && "h-fit")}>
<div className={classNames("flex h-auto w-auto flex-row items-center", fitToHeight && "h-fit")}>
<PrimitiveSwitch.Root
className={classNames(
props.checked ? "bg-gray-900" : "bg-gray-200",
@@ -95,7 +95,9 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript
{eventType.requiresConfirmation && (
<li className="hidden xl:block">
<Badge variant="gray" size="lg" StartIcon={Icon.FiClipboard}>
{t("requires_confirmation")}
{eventType.metadata?.requiresConfirmationThreshold
? t("may_require_confirmation")
: t("requires_confirmation")}
</Badge>
</li>
)}