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:
co-authored by
Bailey Pumfleet
parent
58270fa653
commit
1cdcebac01
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user