* build basic database structure and basic design

* create simple workflow list

* add editing dots to list

* add mutation to create workflows

* add createMutation on submit + redirect to editing page

* redirect to edit page when clicking on row

* add functionality to delete workflow

* add timeUnit + input validation

* add empty screen view

* add time before it triggers to description

* add multi select with checkboxes

* remove getServerSideProps

* set default time period to 24

* fetch eventypes and display in dropdown

* add functionality to update workflows + many-to-many relationship

* fix all checked event types

* add SMS reminders

* fix bug with trigger + relocate sms template

* clean code

* add model for unscheduled reminders

* fix selected eventTypes

* fixing value to show how many event types selected

* fix plural of event types in select

* add onDelete cascade for all relations

* fix errors

* add functionality to send SMS to specific number

* fix type error for timeUnit

* set default value for time unit + fix type issues

* remove console.logs

* fix error in checking if scheduled date is more than 1h in advance

* fix build errors

* add migration for workflows

* add basic UI for editing workflow steps

* add formSchema

* improve functionality to update a step

* remove console logs

* fix issue with active event types

* allow null value for time and timeUnit

* sort steps asc step number

* add action to workflow (frontend)

* add  phone number input for SMS to specific number

* use PhoneInput for number input + input validation

* improve invalid input for phone number

* improve UI of phoneInput

* Improve design and validation

* fix undefined error

* set default action when adding action

* include all team event types

* fix phone number input for editing steps

* fix update muation to add steps

* remove console logs

* fix order of steps

* functionality to delete steps

* add trigger when event is cancelled

* add custom email body

* sms and email reminder updates

* add custom emails

* add custom email subject

* send reminder email to all attendees

* update migration

* fix default value for time and timeUnit

* save email reminders to database

* clean code

* add custom template to SMS actions

* schedule emails with sendgrid

* clean code

* add workflow templates

* keep custom template saved when changing templates

* create reminder template for email

* add dot at the end of sentace for email template

* fix merge error

* fix issue that template was not saved

* include sending emails for when event is cancelled

* fix bug that email was always sent

* add templates to sms reminders

* add info that sending sms to attendees won't trigger for already exisitng bookings

* only schedule sms for attendees when smsReminderNumber exists

* only schedule sms for attendees when smsReminderNumber exists

* set scheduled of workflow reminder to false when longer than 72 hours

* add cron for email scheduling + fixes for for sms an email scheduling

* adjust step number when deleting a step

* cast to boolean with !!

* update cron job for email reminders

* update sms template

* send reminder email not to guests

* remove sendTo from workflow reminder

* fixes sending sms without name + removing sendTo everywhere

* fix undefined name in sms template

* set user name to undefined for sending sms to a specific number

* fix singular and plural for time unit

* set to edit mode when changing action and custom template is selected

* delete reminders when booking cancelled or not active anymore

* fix type errors

* fix error that deleted reminders twice

* create booking reminders for existing bookings when eventType is set active

* improve email and sms templates

* use BookingInfo type instead of calendarEvent for reminder emails

* schedule emails for already existing bookings

* add and remove reminders for new active event types and cancelled events

* connect add action button with last step

* fix step container width for mobile view

* helper functions that return options for select

* fix typo and remove comment

* clean code

* add/improve error messages for forms

* fix typo

* clean code

* improve email template

* clean code

* fix missing prop

* save reference id when scheduling reminder

* fix step not added because of changed id for new steps

* small fixes + code cleanup

* code cleanup

* show error message when number is invalid

* fix typo

* fix phone number input when location is already phone

* set multi select checkbox to read only

* change email scheduling in cron job from 7 days to 72 hours

* show active event types in workflow list

* fix trigger information for workflow list

* improve layout for small screens in workflow list

* remove optional from zod type for workflow name

* order workflows by id

* use link icon to show active event types

* fix plural and add translation for showing nr of active eventtypes

* fix text for sms reminder template

* add reminders for added steps

* remove optional for activeOn

* improve reminder templates

* improve design of custom input fields

* set edit mode to false when phone number isn't needed anymore

* set sendTo in workflow step only for SMS_NUMBER action

* set email body and subject only when custom template

* only delete reminders that belong to workflow steps

* improve text for new event book trigger

* move reminders folder to workflows

* fix issue that save button was sometimes enabled in edit mode

* fix form issues for send to

* delete all scheduled reminders when workflow is deleted

* use enum for method

* fix imports for workflow methods

* add missing import

* fix edit mode

* create reminders when event is confirmed

* add reminderScheduler to reduce duplicate code

* make workflow enterprise and pro only feature

* move all files to /ee/ folder

* move package.json change to /ee/ folder

* add pro badge to shell

* set to edit mode to true if email subject is missing when action changes

* fix loading bug

* add migration

* fix old imports

* don't schedule reminders for opt-ins

* fix style of email body

* code clean up

* Update yarn.lock

* fix isLoading for active on dropdown

* update import for prisma

Co-authored-by: Omar López <zomars@me.com>

* update imports

* remove console

* use session to check if user has valid license

* use defaultHandler

* clean up code

* Create db-staging-snapshot.yml

* move LisenceRequired inside shell

* update import for FormValues

* fix phone input design

* fix disabled save button for edit mode

* squah all migration into a single one

* use isAfter and isBefore instead of isBetween

* import dayjs from @calcom

* validate phone number for sms reminders when booking event

* Allows auto approvals for crowdin

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: zomars <zomars@me.com>
pull/3346/head
Carina Wollendorfer 2022-07-13 20:10:45 -04:00 committed by GitHub
parent 44b8cedef0
commit 39199e515e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 3943 additions and 32 deletions

View File

@ -77,6 +77,17 @@ NEXT_PUBLIC_HELPSCOUT_KEY=
# Inbox to send user feedback
SEND_FEEDBACK_EMAIL=
# Sengrid
# Used for email reminders in workflows
SENDGRID_API_KEY=
SENDGRID_EMAIL=
# Twilio
# Used to send SMS reminders in workflows
TWILIO_SID=
TWILIO_TOKEN=
TWILIO_MESSAGING_SID=
# This is used so we can bypass emails in auth flows for E2E testing
# Set it to "1" if you need to run E2E tests locally
NEXT_PUBLIC_IS_E2E=

View File

@ -11,6 +11,7 @@ import {
MoonIcon,
ViewGridIcon,
QuestionMarkCircleIcon,
LightningBoltIcon,
} from "@heroicons/react/solid";
import { UserPlan } from "@prisma/client";
import { SessionContextValue, signOut, useSession } from "next-auth/react";
@ -43,6 +44,7 @@ import { trpc } from "@lib/trpc";
import CustomBranding from "@components/CustomBranding";
import Loader from "@components/Loader";
import { HeadSeo } from "@components/seo/head-seo";
import Badge from "@components/ui/Badge";
import ImpersonatingBanner from "@components/ui/ImpersonatingBanner";
import pkg from "../package.json";
@ -143,6 +145,13 @@ const Layout = ({
icon: ClockIcon,
current: router.asPath.startsWith("/availability"),
},
{
name: t("workflows"),
href: "/workflows",
icon: LightningBoltIcon,
current: router.asPath.startsWith("/workflows"),
pro: true,
},
{
name: t("apps"),
href: "/apps",
@ -225,6 +234,11 @@ const Layout = ({
aria-hidden="true"
/>
<span className="hidden lg:inline">{item.name}</span>
{item.pro && (
<span className="ml-1">
{plan === "FREE" && <Badge variant="default">PRO</Badge>}
</span>
)}
</a>
</Link>
{item.child &&

View File

@ -9,7 +9,7 @@ import {
RefreshIcon,
} from "@heroicons/react/solid";
import { zodResolver } from "@hookform/resolvers/zod";
import { EventTypeCustomInputType } from "@prisma/client";
import { EventTypeCustomInputType, WorkflowActions } from "@prisma/client";
import { useContracts } from "contexts/contractsContext";
import { isValidPhoneNumber } from "libphonenumber-js";
import { useSession } from "next-auth/react";
@ -88,6 +88,7 @@ type BookingFormValues = {
[key: string]: string | boolean;
};
rescheduleReason?: string;
smsReminderNumber?: string;
};
const BookingPage = ({
@ -283,6 +284,10 @@ const BookingPage = ({
.string()
.refine((val) => isValidPhoneNumber(val))
.optional(),
smsReminderNumber: z
.string()
.refine((val) => isValidPhoneNumber(val))
.optional(),
})
.passthrough();
@ -396,6 +401,8 @@ const BookingPage = ({
})),
hasHashedBookingLink,
hashedLink,
smsReminderNumber:
selectedLocation === LocationType.Phone ? booking.phone : booking.smsReminderNumber,
}));
recurringMutation.mutate(recurringBookings);
} else {
@ -421,6 +428,8 @@ const BookingPage = ({
})),
hasHashedBookingLink,
hashedLink,
smsReminderNumber:
selectedLocation === LocationType.Phone ? booking.phone : booking.smsReminderNumber,
});
}
};
@ -430,6 +439,21 @@ const BookingPage = ({
const inputClassName =
"focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black disabled:bg-gray-200 disabled:hover:cursor-not-allowed dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 disabled:dark:text-gray-500 sm:text-sm";
let isSmsReminderNumberNeeded = false;
if (eventType.workflows.length > 0) {
eventType.workflows.forEach((workflowReference) => {
if (workflowReference.workflow.steps.length > 0) {
workflowReference.workflow.steps.forEach((step) => {
if (step.action === WorkflowActions.SMS_ATTENDEE) {
isSmsReminderNumberNeeded = true;
return;
}
});
}
});
}
return (
<div>
<Theme />
@ -803,6 +827,31 @@ const BookingPage = ({
)}
</div>
)}
{isSmsReminderNumberNeeded && selectedLocation !== LocationType.Phone && (
<div className="mb-4">
<label
htmlFor="smsReminderNumber"
className="block text-sm font-medium text-gray-700 dark:text-white">
{t("number_for_sms_reminders")}
</label>
<div className="mt-1">
<PhoneInput<BookingFormValues>
control={bookingForm.control}
name="smsReminderNumber"
placeholder={t("enter_phone_number")}
id="smsReminderNumber"
required
disabled={disableInput}
/>
</div>
{bookingForm.formState.errors.smsReminderNumber && (
<div className="mt-2 flex items-center text-sm text-red-700 ">
<ExclamationCircleIcon className="mr-2 h-3 w-3" />
<p>{t("invalid_number")}</p>
</div>
)}
</div>
)}
<div className="mb-4">
<label
htmlFor="notes"

View File

@ -0,0 +1,94 @@
import React, { Dispatch, SetStateAction } from "react";
import { components, GroupBase, OptionProps } from "react-select";
import { Props } from "react-select";
import Select from "@calcom/ui/form/Select";
import { useLocale } from "@lib/hooks/useLocale";
export type Option = {
value: string;
label: string;
};
const InputOption = ({
isDisabled,
isFocused,
isSelected,
children,
innerProps,
...rest
}: OptionProps<any, boolean, GroupBase<any>>) => {
const style = {
alignItems: "center",
backgroundColor: isFocused ? "rgba(244, 245, 246, var(--tw-bg-opacity))" : "transparent",
color: "inherit",
display: "flex ",
};
const props = {
...innerProps,
style,
};
return (
<components.Option
{...rest}
isDisabled={isDisabled}
isFocused={isFocused}
isSelected={isSelected}
innerProps={props}>
<input
type="checkbox"
className="text-primary-600 focus:ring-primary-500 mr-2 h-4 w-4 rounded border-gray-300"
checked={isSelected}
readOnly
/>
{children}
</components.Option>
);
};
type MultiSelectionCheckboxesProps = {
options: { label: string; value: string }[];
setSelected: Dispatch<SetStateAction<Option[]>>;
selected: Option[];
setValue: (s: Option[]) => unknown;
};
const MultiValue = ({ index, getValue }: { index: number; getValue: any }) => {
const { t } = useLocale();
return <>{!index && <div>{t("nr_event_type", { count: getValue().length })}</div>}</>;
};
export default function MultiSelectCheckboxes({
options,
isLoading,
selected,
setSelected,
setValue,
}: Omit<Props, "options"> & MultiSelectionCheckboxesProps) {
const additonalComponents = { MultiValue };
return (
<Select
value={selected}
onChange={(s: any) => {
setSelected(s);
setValue(s);
}}
options={options}
isMulti
className="w-64 text-sm"
isSearchable={false}
closeMenuOnSelect={false}
hideSelectedOptions={false}
isLoading={isLoading}
components={{
...additonalComponents,
Option: InputOption,
}}
/>
);
}

View File

@ -0,0 +1,140 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { WorkflowActions } from "@prisma/client";
import { isValidPhoneNumber } from "libphonenumber-js";
import React, { useState, Dispatch, SetStateAction } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import Button from "@calcom/ui/Button";
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
import Select from "@calcom/ui/form/Select";
import { Form } from "@calcom/ui/form/fields";
import { WORKFLOW_ACTIONS } from "@ee/lib/workflows/constants";
import { getWorkflowActionOptions } from "@ee/lib/workflows/getOptions";
import PhoneInput from "@components/ui/form/PhoneInput";
interface IAddActionDialog {
isOpenDialog: boolean;
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
addAction: (action: WorkflowActions, sendTo?: string) => void;
}
type AddActionFormValues = {
action: WorkflowActions;
sendTo?: string;
};
export const AddActionDialog = (props: IAddActionDialog) => {
const { t } = useLocale();
const { isOpenDialog, setIsOpenDialog, addAction } = props;
const [isPhoneNumberNeeded, setIsPhoneNumberNeeded] = useState(false);
const actionOptions = getWorkflowActionOptions(t);
const formSchema = z.object({
action: z.enum(WORKFLOW_ACTIONS),
sendTo: z
.string()
.refine((val) => isValidPhoneNumber(val))
.optional(),
});
const form = useForm<AddActionFormValues>({
mode: "onSubmit",
defaultValues: {
action: WorkflowActions.EMAIL_HOST,
},
resolver: zodResolver(formSchema),
});
return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent>
<div className="space-x-3 ">
<div className="pt-1">
<DialogHeader title={t("add_action")} />
<Form
form={form}
handleSubmit={(values) => {
addAction(values.action, values.sendTo);
form.unregister("sendTo");
form.unregister("action");
setIsOpenDialog(false);
setIsPhoneNumberNeeded(false);
}}>
<div className="space-y-1">
<label htmlFor="label" className="mt-5 block text-sm font-medium text-gray-700">
{t("action")}:
</label>
<Controller
name="action"
control={form.control}
render={() => {
return (
<Select
isSearchable={false}
className="block w-full min-w-0 flex-1 rounded-sm sm:text-sm"
defaultValue={actionOptions[0]}
onChange={(val) => {
if (val) {
form.setValue("action", val.value);
if (val.value === WorkflowActions.SMS_NUMBER) {
setIsPhoneNumberNeeded(true);
} else {
setIsPhoneNumberNeeded(false);
form.unregister("sendTo");
}
form.clearErrors("action");
}
}}
options={actionOptions}
/>
);
}}
/>
{form.formState.errors.action && (
<p className="mt-1 text-sm text-red-500">{form.formState.errors.action.message}</p>
)}
</div>
{isPhoneNumberNeeded && (
<div className="mt-5 space-y-1">
<label htmlFor="sendTo" className="block text-sm font-medium text-gray-700 dark:text-white">
{t("phone_number")}
</label>
<div className="mt-1">
<PhoneInput<AddActionFormValues>
control={form.control}
name="sendTo"
placeholder={t("enter_phone_number")}
id="sendTo"
required
/>
{form.formState.errors.sendTo && (
<p className="mt-1 text-sm text-red-500">{form.formState.errors.sendTo.message}</p>
)}
</div>
</div>
)}
<DialogFooter>
<DialogClose asChild>
<Button
color="secondary"
onClick={() => {
setIsOpenDialog(false);
form.unregister("sendTo");
form.unregister("action");
setIsPhoneNumberNeeded(false);
}}>
{t("cancel")}
</Button>
</DialogClose>
<Button type="submit">{t("add")}</Button>
</DialogFooter>
</Form>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,254 @@
import { PlusIcon } from "@heroicons/react/solid";
import { zodResolver } from "@hookform/resolvers/zod";
import { WorkflowTriggerEvents, WorkflowActions, TimeUnit } from "@prisma/client";
import { isValidPhoneNumber } from "libphonenumber-js";
import { useRouter } from "next/router";
import { useState } from "react";
import { useForm, Controller } from "react-hook-form";
import { z } from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { Button } from "@calcom/ui";
import { Dialog, DialogClose, DialogContent, DialogTrigger } from "@calcom/ui/Dialog";
import { Form, TextField } from "@calcom/ui/form/fields";
import { TIME_UNIT, WORKFLOW_ACTIONS, WORKFLOW_TRIGGER_EVENTS } from "@ee/lib/workflows/constants";
import {
getWorkflowActionOptions,
getWorkflowTimeUnitOptions,
getWorkflowTriggerOptions,
} from "@ee/lib/workflows/getOptions";
import { HttpError } from "@lib/core/http/error";
import { trpc } from "@lib/trpc";
import PhoneInput from "@components/ui/form/PhoneInput";
import Select from "@components/ui/form/Select";
type WorkflowFormValues = {
name: string;
trigger: WorkflowTriggerEvents;
action: WorkflowActions;
time?: number;
timeUnit?: TimeUnit;
sendTo?: string;
};
export function NewWorkflowButton() {
const { t } = useLocale();
const router = useRouter();
const [showTimeSection, setShowTimeSection] = useState(false);
const [isPhoneNumberNeeded, setIsPhoneNumberNeeded] = useState(false);
const triggerOptions = getWorkflowTriggerOptions(t);
const actionOptions = getWorkflowActionOptions(t);
const timeUnitOptions = getWorkflowTimeUnitOptions(t);
const formSchema = z.object({
name: z.string().nonempty(),
trigger: z.enum(WORKFLOW_TRIGGER_EVENTS),
action: z.enum(WORKFLOW_ACTIONS),
time: z.number().min(1).optional(),
timeUnit: z.enum(TIME_UNIT).optional(),
sendTo: z
.string()
.refine((val) => isValidPhoneNumber(val))
.optional(),
});
const form = useForm<WorkflowFormValues>({
defaultValues: {
timeUnit: TimeUnit.HOUR,
},
mode: "onSubmit",
resolver: zodResolver(formSchema),
});
const createMutation = trpc.useMutation("viewer.workflows.create", {
onSuccess: async ({ workflow }) => {
await router.replace("/workflows/" + workflow.id);
setIsPhoneNumberNeeded(false);
setShowTimeSection(false);
showToast(t("workflow_created_successfully", { workflowName: workflow.name }), "success");
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
if (err.data?.code === "UNAUTHORIZED") {
const message = `${err.data.code}: You are not able to create this event`;
showToast(message, "error");
}
},
});
return (
<Dialog name="new-workflow">
<DialogTrigger asChild>
<Button StartIcon={PlusIcon}>{t("new_workflow_btn")}</Button>
</DialogTrigger>
<DialogContent>
<div className="mb-4">
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
{t("add_new_workflow")}
</h3>
</div>
<Form
form={form}
handleSubmit={(values) => {
form.clearErrors();
createMutation.mutate(values);
}}>
<>
<div className="mt-9">
<TextField
label={t("workflow_name")}
placeholder={t("custom_workflow")}
{...form.register("name")}
/>
</div>
<div className="mt-5 space-y-1">
<label htmlFor="label" className="mt-5 block text-sm font-medium text-gray-700">
{t("trigger")}:
</label>
<Controller
name="trigger"
control={form.control}
render={() => {
return (
<Select
isSearchable={false}
className="block w-full min-w-0 flex-1 rounded-sm sm:text-sm"
onChange={(val) => {
if (val) {
form.setValue("trigger", val.value);
form.clearErrors("trigger");
if (val.value === WorkflowTriggerEvents.BEFORE_EVENT) {
setShowTimeSection(true);
} else {
setShowTimeSection(false);
form.unregister("time");
form.unregister("timeUnit");
}
}
}}
options={triggerOptions}
/>
);
}}
/>
{form.formState.errors.trigger && (
<p className="mt-1 text-sm text-red-500">{form.formState.errors.trigger.message}</p>
)}
</div>
{showTimeSection && (
<div className="mt-5 mb-4 space-y-1">
<label htmlFor="label" className="mb-2 block text-sm font-medium text-gray-700">
{t("how_long_before")}
</label>
<div className="flex">
<input
type="number"
min="1"
defaultValue={24}
className="mr-5 block w-32 rounded-sm border-gray-300 px-3 py-2 shadow-sm marker:border focus:border-neutral-800 focus:outline-none focus:ring-1 focus:ring-neutral-800 sm:text-sm"
{...form.register("time", { valueAsNumber: true })}
/>
<div className="w-28">
<Controller
name="timeUnit"
control={form.control}
render={() => {
return (
<Select
isSearchable={false}
className="block min-w-0 flex-1 rounded-sm sm:text-sm"
onChange={(val) => {
if (val) {
form.setValue("timeUnit", val.value);
}
}}
defaultValue={timeUnitOptions[1]}
options={timeUnitOptions}
/>
);
}}
/>
</div>
</div>
</div>
)}
<div className="mt-5 space-y-1">
<label htmlFor="label" className="block text-sm font-medium text-gray-700">
{t("action")}:
</label>
<Controller
name="action"
control={form.control}
render={() => {
return (
<Select
isSearchable={false}
className="block w-full min-w-0 flex-1 rounded-sm sm:text-sm"
onChange={(val) => {
if (val) {
form.setValue("action", val.value);
form.clearErrors("action");
if (val.value === WorkflowActions.SMS_NUMBER) {
setIsPhoneNumberNeeded(true);
} else {
setIsPhoneNumberNeeded(false);
form.unregister("sendTo");
}
}
}}
options={actionOptions}
/>
);
}}
/>
{form.formState.errors.action && (
<p className="mt-1 text-sm text-red-500">{form.formState.errors.action.message}</p>
)}
</div>
{isPhoneNumberNeeded && (
<div className="mt-5 space-y-1">
<label htmlFor="sendTo" className="block text-sm font-medium text-gray-700 dark:text-white">
{t("phone_number")}
</label>
<div className="mt-1">
<PhoneInput<WorkflowFormValues>
control={form.control}
name="sendTo"
placeholder={t("enter_phone_number")}
id="sendTo"
required
/>
</div>
{form.formState.errors.sendTo && (
<p className="mt-1 text-sm text-red-500">{form.formState.errors.sendTo.message}</p>
)}
</div>
)}
</>
<div className="mt-8 flex flex-row-reverse gap-x-2">
<Button type="submit">{t("continue")}</Button>
<DialogClose asChild>
<Button
color="secondary"
onClick={() => {
setShowTimeSection(false);
setIsPhoneNumberNeeded(false);
form.clearErrors();
form.setValue("name", "");
}}>
{t("cancel")}
</Button>
</DialogClose>
</div>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,192 @@
import { WorkflowActions, WorkflowTemplates } from "@prisma/client";
import { useRouter } from "next/router";
import { useState, useEffect, Dispatch, SetStateAction } from "react";
import { Controller, UseFormReturn } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import showToast from "@calcom/lib/notification";
import { Button } from "@calcom/ui";
import { Form } from "@calcom/ui/form/fields";
import { AddActionDialog } from "@ee/components/workflows/AddActionDialog";
import WorkflowStepContainer from "@ee/components/workflows/WorkflowStepContainer";
import { Option, FormValues } from "@ee/pages/workflows/[workflow]";
import { trpc } from "@lib/trpc";
import MultiSelectCheckboxes from "@components/ui/form/MultiSelectCheckboxes";
interface Props {
form: UseFormReturn<FormValues, any>;
workflowId: number;
selectedEventTypes: Option[];
setSelectedEventTypes: Dispatch<SetStateAction<Option[]>>;
}
export default function WorkflowDetailsPage(props: Props) {
const { form, workflowId, selectedEventTypes, setSelectedEventTypes } = props;
const { t } = useLocale();
const router = useRouter();
const utils = trpc.useContext();
const [evenTypeOptions, setEventTypeOptions] = useState<Option[]>([]);
const [isAddActionDialogOpen, setIsAddActionDialogOpen] = useState(false);
const [reload, setReload] = useState(false);
const [editCounter, setEditCounter] = useState(0);
const { data, isLoading } = trpc.useQuery(["viewer.eventTypes"]);
useEffect(() => {
if (data) {
let options: Option[] = [];
data.eventTypeGroups.forEach((group) => {
const eventTypeOptions = group.eventTypes.map((eventType) => {
return { value: String(eventType.id), label: eventType.title };
});
options = [...options, ...eventTypeOptions];
});
setEventTypeOptions(options);
}
}, [isLoading]);
const updateMutation = trpc.useMutation("viewer.workflows.update", {
onSuccess: async ({ workflow }) => {
if (workflow) {
await utils.setQueryData(["viewer.workflows.get", { id: +workflow.id }], workflow);
showToast(
t("workflow_updated_successfully", {
workflowName: workflow.name,
}),
"success"
);
}
await router.push("/workflows");
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
},
});
const addAction = (action: WorkflowActions, sendTo?: string) => {
const steps = form.getValues("steps");
const id =
steps && steps.length > 0
? steps.sort((a, b) => {
return a.id - b.id;
})[0].id - 1
: 0;
const step = {
id: id > 0 ? 0 : id, //id of new steps always <= 0
action,
stepNumber:
steps && steps.length > 0
? steps.sort((a, b) => {
return a.stepNumber - b.stepNumber;
})[steps.length - 1].stepNumber + 1
: 1,
sendTo: sendTo || null,
workflowId: workflowId,
reminderBody: null,
emailSubject: null,
template: WorkflowTemplates.REMINDER,
};
steps?.push(step);
form.setValue("steps", steps);
};
return (
<div>
<Form
form={form}
handleSubmit={async (values) => {
let activeOnEventTypeIds: number[] = [];
if (values.activeOn) {
activeOnEventTypeIds = values.activeOn.map((option) => {
return parseInt(option.value, 10);
});
}
updateMutation.mutate({
id: parseInt(router.query.workflow as string, 10),
name: values.name,
activeOn: activeOnEventTypeIds,
steps: values.steps,
trigger: values.trigger,
time: values.time || null,
timeUnit: values.timeUnit || null,
});
}}>
<div className="-mt-7 space-y-1">
<label htmlFor="label" className="blocktext-sm mb-2 font-medium text-gray-700">
{t("active_on")}:
</label>
<Controller
name="activeOn"
control={form.control}
render={() => {
return (
<MultiSelectCheckboxes
options={evenTypeOptions}
isLoading={isLoading}
setSelected={setSelectedEventTypes}
selected={selectedEventTypes}
setValue={(s: Option[]) => {
form.setValue("activeOn", s);
}}
/>
);
}}
/>
</div>
{/* Workflow Trigger Event & Steps */}
<div className="mt-5 px-5 pt-10 pb-5">
{form.getValues("trigger") && (
<div>
<WorkflowStepContainer form={form} setEditCounter={setEditCounter} editCounter={editCounter} />
</div>
)}
{form.getValues("steps") && (
<>
{form.getValues("steps")?.map((step) => {
return (
<WorkflowStepContainer
key={step.id}
form={form}
step={step}
reload={reload}
setReload={setReload}
setEditCounter={setEditCounter}
editCounter={editCounter}
/>
);
})}
</>
)}
<div className="flex justify-center">
<div className="h-10 border-l-2 border-gray-400" />
</div>
<div className="flex justify-center">
<Button type="button" onClick={() => setIsAddActionDialogOpen(true)} color="secondary">
{t("add_action")}
</Button>
</div>
<div className="rtl:space-x-reverse; mt-10 flex justify-end space-x-2">
<Button type="submit" disabled={updateMutation.isLoading || editCounter > 0}>
{t("save")}
</Button>
</div>
</div>
</Form>
<AddActionDialog
isOpenDialog={isAddActionDialogOpen}
setIsOpenDialog={setIsAddActionDialogOpen}
addAction={addAction}
/>
</div>
);
}

View File

@ -0,0 +1,168 @@
import { LightningBoltIcon } from "@heroicons/react/outline";
import { DotsHorizontalIcon, PencilIcon, TrashIcon, LinkIcon } from "@heroicons/react/solid";
import Link from "next/link";
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { EventType, Workflow, WorkflowsOnEventTypes } from "@calcom/prisma/client";
import { Button, Tooltip } from "@calcom/ui";
import { Dialog } from "@calcom/ui/Dialog";
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@calcom/ui/Dropdown";
import EmptyScreen from "@calcom/ui/EmptyScreen";
import { HttpError } from "@lib/core/http/error";
import { trpc } from "@lib/trpc";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
const CreateFirstWorkflowView = () => {
const { t } = useLocale();
return (
<EmptyScreen
Icon={LightningBoltIcon}
headline={t("new_workflow_heading")}
description={t("new_workflow_description")}
/>
);
};
interface Props {
workflows:
| (Workflow & {
activeOn: (WorkflowsOnEventTypes & { eventType: EventType })[];
})[]
| undefined;
}
export default function WorkflowListPage({ workflows }: Props) {
const { t } = useLocale();
const utils = trpc.useContext();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteDialogTypeId, setDeleteDialogTypeId] = useState(0);
const query = trpc.useQuery(["viewer.workflows.list"]);
const deleteMutation = trpc.useMutation("viewer.workflows.delete", {
onSuccess: async () => {
await utils.invalidateQueries(["viewer.workflows.list"]);
showToast(t("workflow_deleted_successfully"), "success");
setDeleteDialogOpen(false);
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
setDeleteDialogOpen(false);
}
},
});
async function deleteWorkflowHandler(id: number) {
const payload = { id };
deleteMutation.mutate(payload);
}
return (
<>
{workflows && workflows.length > 0 ? (
<div className="-mx-4 mb-16 overflow-hidden rounded-sm border border-gray-200 bg-white sm:mx-0">
<ul className="divide-y divide-neutral-200">
{workflows.map((workflow) => (
<li key={workflow.id}>
<div className="first-line:group flex w-full items-center justify-between px-4 py-4 hover:bg-neutral-50 sm:px-6">
<Link href={"/workflows/" + workflow.id}>
<a className="flex-grow cursor-pointer">
<div className="rtl:space-x-reverse">
<div className="max-w-56 truncate text-sm font-medium leading-6 text-neutral-900 md:max-w-max">
{workflow.name}
</div>
<ul className="mt-2 flex flex-wrap text-sm text-neutral-500 sm:flex-nowrap">
<li className="mb-1 mr-4 flex min-w-[265px] items-center truncate whitespace-nowrap">
<span className="mr-1">{t("triggers")}</span>
{workflow.timeUnit && workflow.time && (
<span className="mr-1">
{t(`${workflow.timeUnit.toLowerCase()}`, { count: workflow.time })}
</span>
)}
<span>{t(`${workflow.trigger.toLowerCase()}_trigger`)}</span>
</li>
<li className="mb-1 mr-4 flex min-w-[11rem] items-center whitespace-nowrap">
<LinkIcon className="mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
{workflow.activeOn && workflow.activeOn.length > 0 ? (
<Tooltip
content={workflow.activeOn.map((activeOn, key) => (
<p key={key}>{activeOn.eventType.title}</p>
))}>
<span>{t("active_on_event_types", { count: workflow.activeOn.length })}</span>
</Tooltip>
) : (
<span>{t("no_active_event_types")}</span>
)}
</li>
</ul>
</div>
</a>
</Link>
<div className="mr-5 flex flex-shrink-0">
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
<Dropdown>
<DropdownMenuTrigger className="h-10 w-10 cursor-pointer rounded-sm border border-transparent text-neutral-500 hover:border-gray-300 hover:text-neutral-900 focus:border-gray-300">
<DotsHorizontalIcon className="h-5 w-5 group-hover:text-gray-800" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<Link href={"/workflows/" + workflow.id} passHref={true}>
<Button
type="button"
size="sm"
color="minimal"
className="w-full rounded-none"
StartIcon={PencilIcon}>
{t("edit")}
</Button>
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<Button
onClick={() => {
setDeleteDialogOpen(true);
setDeleteDialogTypeId(workflow.id);
}}
color="warn"
size="sm"
StartIcon={TrashIcon}
className="w-full rounded-none">
{t("delete")}
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
</div>
</div>
</li>
))}
</ul>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<ConfirmationDialogContent
isLoading={deleteMutation.isLoading}
variety="danger"
title={t("delete_workflow")}
confirmBtnText={t("confirm_delete_workflow")}
loadingText={t("confirm_delete_workflow")}
onConfirm={(e) => {
e.preventDefault();
deleteWorkflowHandler(deleteDialogTypeId);
}}>
{t("delete_workflow_description")}
</ConfirmationDialogContent>
</Dialog>
</div>
) : (
<CreateFirstWorkflowView />
)}
</>
);
}

View File

@ -0,0 +1,432 @@
import { DotsHorizontalIcon, TrashIcon } from "@heroicons/react/solid";
import {
TimeUnit,
WorkflowStep,
WorkflowTriggerEvents,
WorkflowActions,
WorkflowTemplates,
} from "@prisma/client";
import { isValidPhoneNumber } from "libphonenumber-js";
import { Dispatch, SetStateAction, useState } from "react";
import { Controller, UseFormReturn } from "react-hook-form";
import PhoneInput from "react-phone-number-input";
import { Button } from "@calcom/ui";
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@calcom/ui/Dropdown";
import Select from "@calcom/ui/form/Select";
import { TextField, TextArea } from "@calcom/ui/form/fields";
import {
getWorkflowActionOptions,
getWorkflowTemplateOptions,
getWorkflowTimeUnitOptions,
getWorkflowTriggerOptions,
} from "@ee/lib/workflows/getOptions";
import { FormValues } from "@ee/pages/workflows/[workflow]";
import classNames from "@lib/classNames";
import { useLocale } from "@lib/hooks/useLocale";
type WorkflowStepProps = {
step?: WorkflowStep;
form: UseFormReturn<FormValues, any>;
reload?: boolean;
setReload?: Dispatch<SetStateAction<boolean>>;
editCounter: number;
setEditCounter: Dispatch<SetStateAction<number>>;
};
export default function WorkflowStepContainer(props: WorkflowStepProps) {
const { t } = useLocale();
const { step, form, reload, setReload, editCounter, setEditCounter } = props;
const [editNumberMode, setEditNumberMode] = useState(
step?.action === WorkflowActions.SMS_NUMBER && !step?.sendTo ? true : false
);
const [editEmailBodyMode, setEditEmailBodyMode] = useState(false);
const [sendTo, setSendTo] = useState(step?.sendTo || "");
const [errorMessageNumber, setErrorMessageNumber] = useState("");
const [errorMessageCustomInput, setErrorMessageCustomInput] = useState("");
const [isPhoneNumberNeeded, setIsPhoneNumberNeeded] = useState(
step?.action === WorkflowActions.SMS_NUMBER ? true : false
);
const [isCustomReminderBodyNeeded, setIsCustomReminderBodyNeeded] = useState(
step?.template === WorkflowTemplates.CUSTOM ? true : false
);
const [isEmailSubjectNeeded, setIsEmailSubjectNeeded] = useState(
step?.action === WorkflowActions.EMAIL_ATTENDEE || step?.action === WorkflowActions.EMAIL_HOST
? true
: false
);
const [showTimeSection, setShowTimeSection] = useState(
form.getValues("trigger") === WorkflowTriggerEvents.BEFORE_EVENT ? true : false
);
const actionOptions = getWorkflowActionOptions(t);
const triggerOptions = getWorkflowTriggerOptions(t);
const timeUnitOptions = getWorkflowTimeUnitOptions(t);
const templateOptions = getWorkflowTemplateOptions(t);
//trigger
if (!step) {
const trigger = form.getValues("trigger");
const timeUnit = form.getValues("timeUnit");
const selectedTrigger = { label: t(`${trigger.toLowerCase()}_trigger`), value: trigger };
const selectedTimeUnit = timeUnit
? { label: t(`${timeUnit.toLowerCase()}_timeUnit`), value: timeUnit }
: undefined;
return (
<>
<div className="flex justify-center">
<div className=" min-w-80 w-[50rem] rounded border-2 border-gray-400 bg-gray-50 px-10 pb-9 pt-5">
<div className="font-bold">{t("triggers")}:</div>
<Controller
name="trigger"
control={form.control}
render={() => {
return (
<Select
isSearchable={false}
className="mt-3 block w-full min-w-0 flex-1 rounded-sm sm:text-sm"
onChange={(val) => {
if (val) {
form.setValue("trigger", val.value);
if (val.value === WorkflowTriggerEvents.BEFORE_EVENT) {
setShowTimeSection(true);
form.setValue("time", 24);
form.setValue("timeUnit", TimeUnit.HOUR);
} else {
setShowTimeSection(false);
form.unregister("time");
form.unregister("timeUnit");
}
}
}}
defaultValue={selectedTrigger}
options={triggerOptions}
/>
);
}}
/>
{showTimeSection && (
<div className="mt-5 space-y-1">
<label htmlFor="label" className="mb-2 block text-sm font-medium text-gray-700">
{t("how_long_before")}
</label>
<div className="flex">
<input
type="number"
min="1"
defaultValue={form.getValues("time") || 24}
className="mr-5 block w-20 rounded-sm border-gray-300 px-3 py-2 text-sm shadow-sm marker:border focus:border-neutral-800 focus:outline-none focus:ring-1 focus:ring-neutral-800"
{...form.register("time", { valueAsNumber: true })}
/>
<div className="w-28">
<Controller
name="timeUnit"
control={form.control}
render={() => {
return (
<Select
isSearchable={false}
className="block min-w-0 flex-1 rounded-sm"
onChange={(val) => {
if (val) {
form.setValue("timeUnit", val.value);
}
}}
defaultValue={selectedTimeUnit || timeUnitOptions[1]}
options={timeUnitOptions}
/>
);
}}
/>
</div>
</div>
</div>
)}
</div>
</div>
</>
);
}
if (step && step.action) {
const selectedAction = { label: t(`${step.action.toLowerCase()}_action`), value: step.action };
const selectedTemplate = { label: t(`${step.template.toLowerCase()}`), value: step.template };
return (
<>
<div className="flex justify-center">
<div className="h-10 border-l-2 border-gray-400" />
</div>
<div className="flex justify-center">
<div className="min-w-80 flex w-[50rem] rounded border-2 border-gray-400 bg-gray-50 pl-10 pb-9 ">
<div className="w-full pt-5">
<div className="font-bold">{t("action")}:</div>
<div>
<Controller
name={`steps.${step.stepNumber - 1}.action`}
control={form.control}
render={() => {
return (
<Select
isSearchable={false}
className="mt-3 block w-full min-w-0 flex-1 rounded-sm"
onChange={(val) => {
if (val) {
if (val.value === WorkflowActions.SMS_NUMBER) {
setIsPhoneNumberNeeded(true);
setEditNumberMode(true);
setEditCounter(editCounter + 1);
} else {
setIsPhoneNumberNeeded(false);
setEditNumberMode(false);
setEditCounter(editCounter - 1);
}
if (
val.value === WorkflowActions.EMAIL_ATTENDEE ||
val.value === WorkflowActions.EMAIL_HOST
) {
setIsEmailSubjectNeeded(true);
if (!form.getValues(`steps.${step.stepNumber - 1}.emailSubject`)) {
setEditEmailBodyMode(true);
setEditCounter(editCounter + 1);
}
} else {
setIsEmailSubjectNeeded(false);
}
form.setValue(`steps.${step.stepNumber - 1}.action`, val.value);
setErrorMessageNumber("");
setErrorMessageCustomInput("");
}
}}
defaultValue={selectedAction}
options={actionOptions}
/>
);
}}
/>
{form.getValues(`steps.${step.stepNumber - 1}.action`) === WorkflowActions.SMS_ATTENDEE && (
<p className="mt-2 ml-1 text-sm text-gray-500">{t("not_triggering_existing_bookings")}</p>
)}
</div>
{isPhoneNumberNeeded && (
<>
<label
htmlFor="sendTo"
className="mt-5 block text-sm font-medium text-gray-700 dark:text-white">
{t("phone_number")}
</label>
<div className="flex space-y-1">
<div className="mt-1 ">
<PhoneInput
value={sendTo}
onChange={(newValue) => {
if (newValue) {
setSendTo(newValue);
setErrorMessageNumber("");
}
}}
placeholder={t("enter_phone_number")}
id="sendTo"
disabled={!editNumberMode}
required
countrySelectProps={{ className: "text-black" }}
numberInputProps={{ className: "border-0 text-sm focus:ring-0 dark:bg-gray-700" }}
className={classNames(
"border-1 focus-within:border-brand block w-full rounded-sm border border-gray-300 py-px pl-3 shadow-sm ring-black focus-within:ring-1 disabled:text-gray-500 disabled:opacity-50 dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 disabled:dark:text-gray-500",
!editNumberMode ? "text-gray-500 dark:text-gray-500" : ""
)}
/>
</div>
{!editNumberMode ? (
<Button
type="button"
color="secondary"
onClick={() => {
setEditNumberMode(true);
setEditCounter(editCounter + 1);
}}>
{t("edit")}
</Button>
) : (
<Button
type="button"
color="primary"
onClick={async () => {
if (sendTo) {
form.setValue(`steps.${step.stepNumber - 1}.sendTo`, sendTo);
if (isValidPhoneNumber(sendTo)) {
setEditNumberMode(false);
setEditCounter(editCounter - 1);
} else {
setErrorMessageNumber(t("invalid_input"));
}
}
}}>
{t("save")}
</Button>
)}
</div>
{errorMessageNumber && <p className="mt-1 text-sm text-red-500">{errorMessageNumber}</p>}
</>
)}
<div className="mt-5">
<label htmlFor="label" className="mt-5 block text-sm font-medium text-gray-700">
{t("choose_template")}
</label>
<Controller
name={`steps.${step.stepNumber - 1}.template`}
control={form.control}
render={() => {
return (
<Select
isSearchable={false}
className="mt-3 block w-full min-w-0 flex-1 rounded-sm sm:text-sm"
onChange={(val) => {
if (val) {
form.setValue(`steps.${step.stepNumber - 1}.template`, val.value);
const isCustomTemplate = val.value === WorkflowTemplates.CUSTOM;
setIsCustomReminderBodyNeeded(isCustomTemplate);
if (isCustomTemplate) {
setEditEmailBodyMode(true);
setEditCounter(editCounter + 1);
} else {
setEditEmailBodyMode(false);
setEditCounter(editCounter - 1);
}
setErrorMessageCustomInput("");
}
}}
defaultValue={selectedTemplate}
options={templateOptions}
/>
);
}}
/>
</div>
{isCustomReminderBodyNeeded && (
<>
{isEmailSubjectNeeded && (
<div className="mt-5 mb-2">
<TextField
label={t("subject")}
type="text"
disabled={!editEmailBodyMode}
className={classNames(
"border-1 focus-within:border-brand block w-full rounded-sm border border-gray-300 px-2 font-sans text-sm shadow-sm ring-black focus-within:ring-1 dark:border-black dark:bg-black dark:text-white",
!editEmailBodyMode ? "text-gray-500 dark:text-gray-500" : ""
)}
{...form.register(`steps.${step.stepNumber - 1}.emailSubject`)}
/>
</div>
)}
<label className="mt-3 mb-1 block text-sm font-medium text-gray-700 dark:text-white">
{isEmailSubjectNeeded ? t("email_body") : t("text_message")}
</label>
<TextArea
className={classNames(
"border-1 focus-within:border-brand mb-2 block w-full rounded-sm border border-gray-300 p-2 text-sm shadow-sm ring-black focus-within:ring-1 dark:border-black dark:bg-black dark:text-white",
!editEmailBodyMode ? "text-gray-500 dark:text-gray-500" : ""
)}
rows={5}
disabled={!editEmailBodyMode}
{...form.register(`steps.${step.stepNumber - 1}.reminderBody`)}
/>
{errorMessageCustomInput && (
<p className="mb-3 text-sm text-red-500">{errorMessageCustomInput}</p>
)}
{!editEmailBodyMode ? (
<Button
type="button"
color="secondary"
onClick={() => {
setEditEmailBodyMode(true);
setEditCounter(editCounter + 1);
}}>
{t("edit")}
</Button>
) : (
<Button
type="button"
color="primary"
onClick={async () => {
const reminderBody = form.getValues(`steps.${step.stepNumber - 1}.reminderBody`);
const emailSubject = form.getValues(`steps.${step.stepNumber - 1}.emailSubject`);
let isEmpty = false;
let errorMessage = "";
if (isEmailSubjectNeeded) {
if (!reminderBody || !emailSubject) {
isEmpty = true;
errorMessage = "Email body or subject is empty";
}
} else if (!reminderBody) {
isEmpty = true;
errorMessage = "Text message is empty";
}
if (!isEmpty) {
setEditEmailBodyMode(false);
setEditCounter(editCounter - 1);
}
setErrorMessageCustomInput(errorMessage);
}}>
{t("save")}
</Button>
)}
</>
)}
</div>
<div>
<Dropdown>
<DropdownMenuTrigger className="h-10 w-10 cursor-pointer rounded-sm border border-transparent text-neutral-500 hover:border-gray-300 hover:text-neutral-900 focus:border-gray-300">
<DotsHorizontalIcon className="h-5 w-5 group-hover:text-gray-800" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<Button
onClick={() => {
const steps = form.getValues("steps");
const updatedSteps = steps
?.filter((currStep) => currStep.id !== step.id)
.map((s) => {
const updatedStep = s;
if (step.stepNumber < updatedStep.stepNumber) {
updatedStep.stepNumber = updatedStep.stepNumber - 1;
}
return updatedStep;
});
form.setValue("steps", updatedSteps);
if (setReload) {
setReload(!reload);
}
}}
color="warn"
size="sm"
StartIcon={TrashIcon}
className="w-full rounded-none">
{t("delete")}
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
</div>
</div>
</>
);
}
return <></>;
}

View File

@ -0,0 +1,21 @@
import { WorkflowTriggerEvents, WorkflowActions, TimeUnit, WorkflowTemplates } from "@prisma/client";
export const WORKFLOW_TRIGGER_EVENTS = [
WorkflowTriggerEvents.BEFORE_EVENT,
WorkflowTriggerEvents.EVENT_CANCELLED,
WorkflowTriggerEvents.NEW_EVENT,
] as ["BEFORE_EVENT", "EVENT_CANCELLED", "NEW_EVENT"];
export const WORKFLOW_ACTIONS = [
WorkflowActions.EMAIL_HOST,
WorkflowActions.EMAIL_ATTENDEE,
WorkflowActions.SMS_ATTENDEE,
WorkflowActions.SMS_NUMBER,
] as ["EMAIL_HOST", "EMAIL_ATTENDEE", "SMS_ATTENDEE", "SMS_NUMBER"];
export const TIME_UNIT = [TimeUnit.DAY, TimeUnit.HOUR, TimeUnit.MINUTE] as ["DAY", "HOUR", "MINUTE"];
export const WORKFLOW_TEMPLATES = [WorkflowTemplates.CUSTOM, WorkflowTemplates.REMINDER] as [
"CUSTOM",
"REMINDER"
];

View File

@ -0,0 +1,32 @@
import { TFunction } from "next-i18next";
import {
TIME_UNIT,
WORKFLOW_ACTIONS,
WORKFLOW_TEMPLATES,
WORKFLOW_TRIGGER_EVENTS,
} from "@ee/lib/workflows/constants";
export function getWorkflowActionOptions(t: TFunction) {
return WORKFLOW_ACTIONS.map((action) => {
return { label: t(`${action.toLowerCase()}_action`), value: action };
});
}
export function getWorkflowTriggerOptions(t: TFunction) {
return WORKFLOW_TRIGGER_EVENTS.map((triggerEvent) => {
return { label: t(`${triggerEvent.toLowerCase()}_trigger`), value: triggerEvent };
});
}
export function getWorkflowTimeUnitOptions(t: TFunction) {
return TIME_UNIT.map((timeUnit) => {
return { label: t(`${timeUnit.toLowerCase()}_timeUnit`), value: timeUnit };
});
}
export function getWorkflowTemplateOptions(t: TFunction) {
return WORKFLOW_TEMPLATES.map((template) => {
return { label: t(`${template.toLowerCase()}`), value: template };
});
}

View File

@ -0,0 +1,145 @@
import {
TimeUnit,
WorkflowTriggerEvents,
WorkflowTemplates,
WorkflowActions,
WorkflowMethods,
} from "@prisma/client";
import client from "@sendgrid/client";
import sgMail from "@sendgrid/mail";
import dayjs from "@calcom/dayjs";
import { sendWorkflowReminderEmail } from "@calcom/emails";
import prisma from "@calcom/prisma";
import { BookingInfo, timeUnitLowerCase } from "@ee/lib/workflows/reminders/smsReminderManager";
import emailReminderTemplate from "@ee/lib/workflows/reminders/templates/emailReminderTemplate";
let sendgridAPIKey, senderEmail: string;
if (process.env.SENDGRID_API_KEY) {
sendgridAPIKey = process.env.SENDGRID_API_KEY as string;
senderEmail = process.env.SENDGRID_EMAIL as string;
sgMail.setApiKey(sendgridAPIKey);
client.setApiKey(sendgridAPIKey);
}
export const scheduleEmailReminder = async (
evt: BookingInfo,
triggerEvent: WorkflowTriggerEvents,
action: WorkflowActions,
timeBefore: {
time: number | null;
timeUnit: TimeUnit | null;
},
sendTo: string,
emailSubject: string,
emailBody: string,
workflowStepId: number,
template: WorkflowTemplates
) => {
const { startTime } = evt;
const uid = evt.uid as string;
const currentDate = dayjs();
const timeUnit: timeUnitLowerCase | undefined =
timeBefore.timeUnit?.toLocaleLowerCase() as timeUnitLowerCase;
const scheduledDate =
timeBefore.time && timeUnit ? dayjs(startTime).subtract(timeBefore.time, timeUnit) : null;
if (!process.env.SENDGRID_API_KEY || !process.env.SENDGRID_EMAIL) return;
const batchIdResponse = await client.request({
url: "/v3/mail/batch",
method: "POST",
});
const name = action === WorkflowActions.EMAIL_HOST ? evt.organizer.name : evt.attendees[0].name;
const attendeeName = action === WorkflowActions.EMAIL_HOST ? evt.attendees[0].name : evt.organizer.name;
switch (template) {
case WorkflowTemplates.REMINDER:
const emailTemplate = emailReminderTemplate(
startTime,
evt.title,
evt.attendees[0].timeZone,
attendeeName,
name
);
emailSubject = emailTemplate.subject;
emailBody = emailTemplate.body;
break;
}
if (
triggerEvent === WorkflowTriggerEvents.NEW_EVENT ||
triggerEvent === WorkflowTriggerEvents.EVENT_CANCELLED
) {
try {
await sendWorkflowReminderEmail(evt, sendTo, emailSubject, emailBody);
} catch (error) {
console.log("Error sending Email");
}
} else if (triggerEvent === WorkflowTriggerEvents.BEFORE_EVENT && scheduledDate) {
// Sendgrid to schedule emails
// Can only schedule at least 60 minutes and at most 72 hours in advance
if (
currentDate.isBefore(scheduledDate.subtract(1, "hour")) &&
!scheduledDate.isAfter(currentDate.add(72, "hour"))
) {
try {
await sgMail.send({
to: sendTo,
from: senderEmail,
subject: emailSubject,
content: [
{
type: "text/html",
value: emailBody,
},
],
batchId: batchIdResponse[1].batch_id,
sendAt: scheduledDate.unix(),
});
await prisma.workflowReminder.create({
data: {
bookingUid: uid,
workflowStepId: workflowStepId,
method: WorkflowMethods.EMAIL,
scheduledDate: scheduledDate.toDate(),
scheduled: true,
referenceId: batchIdResponse[1].batch_id,
},
});
} catch (error) {
console.log(`Error scheduling email with error ${error}`);
}
} else if (scheduledDate.isAfter(currentDate.add(72, "hour"))) {
// Write to DB and send to CRON if scheduled reminder date is past 72 hours
await prisma.workflowReminder.create({
data: {
bookingUid: uid,
workflowStepId: workflowStepId,
method: WorkflowMethods.EMAIL,
scheduledDate: scheduledDate.toDate(),
scheduled: false,
},
});
}
}
};
export const deleteScheduledEmailReminder = async (referenceId: string) => {
try {
await client.request({
url: "/v3/user/scheduled_sends",
method: "POST",
body: {
batch_id: referenceId,
status: "cancel",
},
});
} catch (error) {
console.log(`Error canceling reminder with error ${error}`);
}
};

View File

@ -0,0 +1,129 @@
import {
Workflow,
WorkflowsOnEventTypes,
WorkflowActions,
WorkflowStep,
WorkflowTriggerEvents,
} from "@prisma/client";
import { CalendarEvent } from "@calcom/types/Calendar";
import { scheduleEmailReminder } from "@ee/lib/workflows/reminders/emailReminderManager";
import { scheduleSMSReminder } from "@ee/lib/workflows/reminders/smsReminderManager";
export const scheduleWorkflowReminders = async (
workflows: (WorkflowsOnEventTypes & {
workflow: Workflow & {
steps: WorkflowStep[];
};
})[],
smsReminderNumber: string | null,
evt: CalendarEvent,
needsConfirmation: boolean
) => {
if (workflows.length > 0 && !needsConfirmation) {
workflows.forEach((workflowReference) => {
if (workflowReference.workflow.steps.length > 0) {
const workflow = workflowReference.workflow;
if (
workflow.trigger === WorkflowTriggerEvents.BEFORE_EVENT ||
workflow.trigger === WorkflowTriggerEvents.NEW_EVENT
) {
workflow.steps.forEach(async (step) => {
if (step.action === WorkflowActions.SMS_ATTENDEE || step.action === WorkflowActions.SMS_NUMBER) {
const sendTo = step.action === WorkflowActions.SMS_ATTENDEE ? smsReminderNumber : step.sendTo;
await scheduleSMSReminder(
evt,
sendTo,
workflow.trigger,
step.action,
{
time: workflow.time,
timeUnit: workflow.timeUnit,
},
step.reminderBody || "",
step.id,
step.template
);
} else if (
step.action === WorkflowActions.EMAIL_ATTENDEE ||
step.action === WorkflowActions.EMAIL_HOST
) {
const sendTo =
step.action === WorkflowActions.EMAIL_HOST ? evt.organizer.email : evt.attendees[0].email;
scheduleEmailReminder(
evt,
workflow.trigger,
step.action,
{
time: workflow.time,
timeUnit: workflow.timeUnit,
},
sendTo,
step.emailSubject || "",
step.reminderBody || "",
step.id,
step.template
);
}
});
}
}
});
}
};
export const sendCancelledReminders = async (
workflows: (WorkflowsOnEventTypes & {
workflow: Workflow & {
steps: WorkflowStep[];
};
})[],
smsReminderNumber: string | null,
evt: CalendarEvent
) => {
if (workflows.length > 0) {
workflows
.filter((workflowRef) => workflowRef.workflow.trigger === WorkflowTriggerEvents.EVENT_CANCELLED)
.forEach((workflowRef) => {
const workflow = workflowRef.workflow;
workflow.steps.forEach(async (step) => {
if (step.action === WorkflowActions.SMS_ATTENDEE || step.action === WorkflowActions.SMS_NUMBER) {
const sendTo = step.action === WorkflowActions.SMS_ATTENDEE ? smsReminderNumber : step.sendTo;
await scheduleSMSReminder(
evt,
sendTo,
workflow.trigger,
step.action,
{
time: workflow.time,
timeUnit: workflow.timeUnit,
},
step.reminderBody || "",
step.id,
step.template
);
} else if (
step.action === WorkflowActions.EMAIL_ATTENDEE ||
step.action === WorkflowActions.EMAIL_HOST
) {
const sendTo =
step.action === WorkflowActions.EMAIL_HOST ? evt.organizer.email : evt.attendees[0].email;
scheduleEmailReminder(
evt,
workflow.trigger,
step.action,
{
time: workflow.time,
timeUnit: workflow.timeUnit,
},
sendTo,
step.emailSubject || "",
step.reminderBody || "",
step.id,
step.template
);
}
});
});
}
};

View File

@ -0,0 +1,39 @@
import twilio from "twilio";
let TWILIO_SID, TWILIO_TOKEN, TWILIO_MESSAGING_SID: string, client: twilio.Twilio;
// Only assign the API keys if they exist in .env
if (process.env.TWILIO_SID && process.env.TWILIO_TOKEN && process.env.TWILIO_MESSAGING_SID) {
TWILIO_SID = process.env.TWILIO_SID;
TWILIO_TOKEN = process.env.TWILIO_TOKEN;
TWILIO_MESSAGING_SID = process.env.TWILIO_MESSAGING_SID;
client = twilio(TWILIO_SID, TWILIO_TOKEN);
} else {
console.error("Twilio credentials are missing from the .env file");
}
export const sendSMS = async (phoneNumber: string, body: string) => {
const response = await client.messages.create({
body: body,
messagingServiceSid: TWILIO_MESSAGING_SID,
to: phoneNumber,
});
return response;
};
export const scheduleSMS = async (phoneNumber: string, body: string, scheduledDate: Date) => {
const response = await client.messages.create({
body: body,
messagingServiceSid: TWILIO_MESSAGING_SID,
to: phoneNumber,
scheduleType: "fixed",
sendAt: scheduledDate,
});
return response;
};
export const cancelSMS = async (referenceId: string) => {
await client.messages(referenceId).update({ status: "canceled" });
};

View File

@ -0,0 +1,115 @@
import {
WorkflowTriggerEvents,
TimeUnit,
WorkflowTemplates,
WorkflowActions,
WorkflowMethods,
} from "@prisma/client/";
import dayjs from "@calcom/dayjs";
import prisma from "@calcom/prisma";
import * as twilio from "@ee/lib/workflows/reminders/smsProviders/twilioProvider";
import smsReminderTemplate from "@ee/lib/workflows/reminders/templates/smsReminderTemplate";
export enum timeUnitLowerCase {
DAY = "day",
MINUTE = "minute",
YEAR = "year",
}
export type BookingInfo = {
uid?: string | null;
attendees: { name: string; email: string; timeZone: string }[];
organizer: { name: string; email: string };
startTime: string;
title: string;
};
export const scheduleSMSReminder = async (
evt: BookingInfo,
reminderPhone: string | null,
triggerEvent: WorkflowTriggerEvents,
action: WorkflowActions,
timeBefore: {
time: number | null;
timeUnit: TimeUnit | null;
},
message: string,
workflowStepId: number,
template: WorkflowTemplates
) => {
const { startTime } = evt;
const uid = evt.uid as string;
const currentDate = dayjs();
const timeUnit: timeUnitLowerCase | undefined =
timeBefore.timeUnit?.toLocaleLowerCase() as timeUnitLowerCase;
const scheduledDate =
timeBefore.time && timeUnit ? dayjs(startTime).subtract(timeBefore.time, timeUnit) : null;
const name = action === WorkflowActions.SMS_ATTENDEE ? evt.attendees[0].name : "";
const attendeeName = action === WorkflowActions.SMS_ATTENDEE ? evt.organizer.name : evt.attendees[0].name;
switch (template) {
case WorkflowTemplates.REMINDER:
message =
smsReminderTemplate(evt.startTime, evt.title, evt.attendees[0].timeZone, attendeeName, name) ||
message;
break;
}
if (message.length > 0 && reminderPhone) {
//send SMS when event is booked/cancelled
if (
triggerEvent === WorkflowTriggerEvents.NEW_EVENT ||
triggerEvent === WorkflowTriggerEvents.EVENT_CANCELLED
) {
try {
await twilio.sendSMS(reminderPhone, message);
} catch (error) {
console.log(`Error sending SMS with error ${error}`);
}
} else if (triggerEvent === WorkflowTriggerEvents.BEFORE_EVENT && scheduledDate) {
// Can only schedule at least 60 minutes in advance and at most 7 days in advance
if (
currentDate.isBefore(scheduledDate.subtract(1, "hour")) &&
!scheduledDate.isAfter(currentDate.add(7, "day"))
) {
try {
const scheduledSMS = await twilio.scheduleSMS(reminderPhone, message, scheduledDate.toDate());
await prisma.workflowReminder.create({
data: {
bookingUid: uid,
workflowStepId: workflowStepId,
method: WorkflowMethods.SMS,
scheduledDate: scheduledDate.toDate(),
scheduled: true,
referenceId: scheduledSMS.sid,
},
});
} catch (error) {
console.log(`Error scheduling SMS with error ${error}`);
}
} else if (scheduledDate.isAfter(currentDate.add(7, "day"))) {
// Write to DB and send to CRON if scheduled reminder date is past 7 days
await prisma.workflowReminder.create({
data: {
bookingUid: uid,
workflowStepId: workflowStepId,
method: WorkflowMethods.SMS,
scheduledDate: scheduledDate.toDate(),
scheduled: false,
},
});
}
}
}
};
export const deleteScheduledSMSReminder = async (referenceId: string) => {
try {
await twilio.cancelSMS(referenceId);
} catch (error) {
console.log(`Error canceling reminder with error ${error}`);
}
};

View File

@ -0,0 +1,21 @@
import dayjs from "@calcom/dayjs";
const emailReminderTemplate = (
startTime: string,
eventName: string,
attendeeTimeZone: string,
attendee: string,
name: string
) => {
const templateSubject = `Reminder: ${eventName} at ${dayjs(startTime).format("YYYY MMM D h:mmA")}`;
const templateBody = `Hi ${name},\n\n this is a reminder that your meeting (${eventName}) with ${attendee} is on ${dayjs(
startTime
).format("YYYY MMM D")} at ${dayjs(startTime).format("h:mmA")} ${attendeeTimeZone}.`;
const emailContent = { subject: templateSubject, body: templateBody };
return emailContent;
};
export default emailReminderTemplate;

View File

@ -0,0 +1,28 @@
import dayjs from "@calcom/dayjs";
const smsReminderTemplate = (
startTime: string,
eventName: string,
attendeeTimeZone: string,
attendee: string,
name?: string
) => {
const templateOne = `Hi${name ? ` ${name}` : ``}, this is a reminder that your meeting (${eventName}) with
${attendee} is on ${dayjs(startTime).format("MMM D")} at ${dayjs(startTime).format(
"h:mmA"
)} ${attendeeTimeZone}`;
//Twilio recomments message to be no longer than 320 characters
if (templateOne.length <= 320) return templateOne;
const templateTwo = `Hi, this is a reminder that your meeting with ${attendee} is on ${dayjs(
startTime
).format("MMM D")} at ${dayjs(startTime).format("h:mmA")}`;
//Twilio supports up to 1600 characters
if (templateTwo.length <= 1600) return templateTwo;
return null;
};
export default smsReminderTemplate;

View File

@ -0,0 +1,134 @@
/* Schedule any workflow reminder that falls within 72 hours for email */
import { WorkflowActions, WorkflowTemplates, WorkflowMethods } from "@prisma/client";
import client from "@sendgrid/client";
import sgMail from "@sendgrid/mail";
import type { NextApiRequest, NextApiResponse } from "next";
import dayjs from "@calcom/dayjs";
import { defaultHandler } from "@calcom/lib/server";
import emailReminderTemplate from "@ee/lib/workflows/reminders/templates/emailReminderTemplate";
import prisma from "@lib/prisma";
const sendgridAPIKey = process.env.SENDGRID_API_KEY as string;
const senderEmail = process.env.SENDGRID_EMAIL as string;
sgMail.setApiKey(sendgridAPIKey);
async function handler(req: NextApiRequest, res: NextApiResponse) {
const apiKey = req.headers.authorization || req.query.apiKey;
if (process.env.CRON_API_KEY !== apiKey) {
res.status(401).json({ message: "Not authenticated" });
return;
}
if (!process.env.SENDGRID_API_KEY || !process.env.SENDGRID_EMAIL) {
res.status(405).json({ message: "No SendGrid API key or email" });
return;
}
const batchIdResponse = await client.request({
url: "/v3/mail/batch",
method: "POST",
});
//delete all scheduled email reminders where scheduled is past current date
await prisma.workflowReminder.deleteMany({
where: {
method: WorkflowMethods.EMAIL,
scheduledDate: {
lte: dayjs().toISOString(),
},
},
});
//find all unscheduled Email reminders
const unscheduledReminders = await prisma.workflowReminder.findMany({
where: {
method: WorkflowMethods.EMAIL,
scheduled: false,
},
include: {
workflowStep: true,
booking: {
include: {
eventType: true,
user: true,
attendees: true,
},
},
},
});
if (!unscheduledReminders.length) res.json({ ok: true });
const dateInSeventyTwoHours = dayjs().add(72, "hour");
unscheduledReminders.forEach(async (reminder) => {
if (dayjs(reminder.scheduledDate).isBefore(dateInSeventyTwoHours)) {
try {
const sendTo =
reminder.workflowStep.action === WorkflowActions.EMAIL_HOST
? reminder.booking?.user?.email
: reminder.booking?.attendees[0].email;
let emailTemplate = {
subject: reminder.workflowStep.emailSubject || "",
body: reminder.workflowStep.reminderBody || "",
};
const name =
reminder.workflowStep.action === WorkflowActions.EMAIL_ATTENDEE
? reminder.booking?.attendees[0].name
: reminder.booking?.user?.name;
const attendeeName =
reminder.workflowStep.action === WorkflowActions.EMAIL_ATTENDEE
? reminder.booking?.user?.name
: reminder.booking?.attendees[0].name;
switch (reminder.workflowStep.template) {
case WorkflowTemplates.REMINDER:
emailTemplate = emailReminderTemplate(
reminder.booking?.startTime.toISOString() || "",
reminder.booking?.eventType?.title || "",
reminder.booking?.attendees[0].timeZone || "",
attendeeName || "",
name || ""
);
break;
}
if (emailTemplate.subject.length > 0 && emailTemplate.body.length > 0 && sendTo) {
await sgMail.send({
to: sendTo,
from: senderEmail,
subject: emailTemplate.subject,
content: [
{
type: "text/html",
value: emailTemplate.body,
},
],
batchId: batchIdResponse[1].batch_id,
sendAt: dayjs(reminder.scheduledDate).unix(),
});
}
await prisma.workflowReminder.updateMany({
where: {
id: reminder.id,
},
data: {
scheduled: true,
referenceId: batchIdResponse[1].batch_id,
},
});
} catch (error) {
console.log(`Error scheduling Email with error ${error}`);
}
}
});
}
export default defaultHandler({
POST: Promise.resolve({ default: handler }),
});

View File

@ -0,0 +1,103 @@
/* Schedule any workflow reminder that falls within 7 days for SMS */
import { WorkflowActions, WorkflowTemplates, WorkflowMethods } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import dayjs from "@calcom/dayjs";
import { defaultHandler } from "@calcom/lib/server";
import * as twilio from "@ee/lib/workflows/reminders/smsProviders/twilioProvider";
import smsReminderTemplate from "@ee/lib/workflows/reminders/templates/smsReminderTemplate";
import prisma from "@lib/prisma";
async function handler(req: NextApiRequest, res: NextApiResponse) {
const apiKey = req.headers.authorization || req.query.apiKey;
if (process.env.CRON_API_KEY !== apiKey) {
res.status(401).json({ message: "Not authenticated" });
return;
}
//delete all scheduled sms reminders where scheduled date is past current date
await prisma.workflowReminder.deleteMany({
where: {
method: WorkflowMethods.SMS,
scheduledDate: {
lte: dayjs().toISOString(),
},
},
});
//find all unscheduled SMS reminders
const unscheduledReminders = await prisma.workflowReminder.findMany({
where: {
method: WorkflowMethods.SMS,
scheduled: false,
},
include: {
workflowStep: true,
booking: {
include: {
eventType: true,
user: true,
attendees: true,
},
},
},
});
if (!unscheduledReminders.length) res.json({ ok: true });
const dateInSevenDays = dayjs().add(7, "day");
unscheduledReminders.forEach(async (reminder) => {
if (dayjs(reminder.scheduledDate).isBefore(dateInSevenDays)) {
try {
const sendTo =
reminder.workflowStep.action === WorkflowActions.SMS_NUMBER
? reminder.workflowStep.sendTo
: reminder.booking?.smsReminderNumber;
const userName =
reminder.workflowStep.action === WorkflowActions.SMS_ATTENDEE
? reminder.booking?.attendees[0].name
: "";
const attendeeName =
reminder.workflowStep.action === WorkflowActions.SMS_ATTENDEE
? reminder.booking?.user?.name
: reminder.booking?.attendees[0].name;
let message: string | null = reminder.workflowStep.reminderBody;
switch (reminder.workflowStep.template) {
case WorkflowTemplates.REMINDER:
message = smsReminderTemplate(
reminder.booking?.startTime.toISOString() || "",
reminder.booking?.eventType?.title || "",
reminder.booking?.attendees[0].timeZone || "",
attendeeName || "",
userName
);
break;
}
if (message?.length && message?.length > 0 && sendTo) {
const scheduledSMS = await twilio.scheduleSMS(sendTo, message, reminder.scheduledDate);
await prisma.workflowReminder.updateMany({
where: {
id: reminder.id,
},
data: {
scheduled: true,
referenceId: scheduledSMS.sid,
},
});
}
} catch (error) {
console.log(`Error scheduling SMS with error ${error}`);
}
}
});
}
export default defaultHandler({
POST: Promise.resolve({ default: handler }),
});

View File

@ -0,0 +1,184 @@
import { PencilIcon } from "@heroicons/react/solid";
import { zodResolver } from "@hookform/resolvers/zod";
import { TimeUnit, WorkflowStep, WorkflowTriggerEvents } from "@prisma/client";
import { isValidPhoneNumber } from "libphonenumber-js";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert } from "@calcom/ui/Alert";
import Loader from "@calcom/ui/Loader";
import LicenseRequired from "@ee/components/LicenseRequired";
import WorkflowDetailsPage from "@ee/components/workflows/WorkflowDetailsPage";
import {
TIME_UNIT,
WORKFLOW_ACTIONS,
WORKFLOW_TEMPLATES,
WORKFLOW_TRIGGER_EVENTS,
} from "@ee/lib/workflows/constants";
import useMeQuery from "@lib/hooks/useMeQuery";
import { trpc } from "@lib/trpc";
import Shell from "@components/Shell";
export type Option = {
value: string;
label: string;
};
export type FormValues = {
name: string;
activeOn: Option[];
steps: WorkflowStep[];
trigger: WorkflowTriggerEvents;
time?: number;
timeUnit?: TimeUnit;
};
function WorkflowPage() {
const { t } = useLocale();
const session = useSession();
const router = useRouter();
const me = useMeQuery();
const isFreeUser = me.data?.plan === "FREE";
const [editIcon, setEditIcon] = useState(true);
const [selectedEventTypes, setSelectedEventTypes] = useState<Option[]>([]);
const [isAllDataLoaded, setIsAllDataLoaded] = useState(false);
const formSchema = z.object({
name: z.string(),
activeOn: z.object({ value: z.string(), label: z.string() }).array(),
trigger: z.enum(WORKFLOW_TRIGGER_EVENTS),
time: z.number().gte(0).optional(),
timeUnit: z.enum(TIME_UNIT).optional(),
steps: z
.object({
id: z.number(),
stepNumber: z.number(),
action: z.enum(WORKFLOW_ACTIONS),
workflowId: z.number(),
reminderBody: z.string().optional().nullable(),
emailSubject: z.string().optional().nullable(),
template: z.enum(WORKFLOW_TEMPLATES),
sendTo: z
.string()
.refine((val) => isValidPhoneNumber(val))
.optional()
.nullable(),
})
.array(),
});
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
});
const workflowId = router.query?.workflow as string;
const {
data: workflow,
isLoading,
dataUpdatedAt,
} = trpc.useQuery([
"viewer.workflows.get",
{
id: +workflowId,
},
]);
useEffect(() => {
if (workflow) {
setSelectedEventTypes(
workflow.activeOn.map((active: { eventType: { id: any; title: any } }) => {
return { value: String(active.eventType.id), label: active.eventType.title };
}) || []
);
const activeOn = workflow.activeOn
? workflow.activeOn.map((active) => {
return { value: active.eventType.id.toString(), label: active.eventType.slug };
})
: undefined;
form.setValue("name", workflow.name);
form.setValue("steps", workflow.steps);
form.setValue("trigger", workflow.trigger);
form.setValue("time", workflow.time || undefined);
form.setValue("timeUnit", workflow.timeUnit || undefined);
form.setValue("activeOn", activeOn || []);
setIsAllDataLoaded(true);
}
}, [dataUpdatedAt]);
if (isLoading) {
return <Loader />;
}
return (
<Shell
title="Title"
heading={
session.data?.hasValidLicense && (
<div className="group relative cursor-pointer" onClick={() => setEditIcon(false)}>
{editIcon ? (
<>
<h1
style={{ fontSize: 22, letterSpacing: "-0.0009em" }}
className="inline pl-0 text-gray-900 focus:text-black group-hover:text-gray-500">
{form.getValues("name") && form.getValues("name") !== ""
? form.getValues("name")
: workflow?.name}
</h1>
<PencilIcon className="ml-1 -mt-1 inline h-4 w-4 text-gray-700 group-hover:text-gray-500" />
</>
) : (
<div style={{ marginBottom: -11 }}>
<input
type="text"
autoFocus
style={{ top: -6, fontSize: 22 }}
required
className="relative h-10 w-full cursor-pointer border-none bg-transparent pl-0 text-gray-900 hover:text-gray-700 focus:text-black focus:outline-none focus:ring-0"
placeholder={t("Custom workflow")}
{...form.register("name")}
defaultValue={workflow?.name}
onBlur={() => {
setEditIcon(true);
form.getValues("name") === "" && form.setValue("name", workflow?.name || "");
}}
/>
</div>
)}
</div>
)
}>
<LicenseRequired>
{isFreeUser ? (
<Alert
className="border "
severity="warning"
title="This is a pro feature. Upgrade to pro to automate your event notifications and reminders with workflows."
/>
) : (
<>
{isAllDataLoaded ? (
<WorkflowDetailsPage
form={form}
workflowId={+workflowId}
selectedEventTypes={selectedEventTypes}
setSelectedEventTypes={setSelectedEventTypes}
/>
) : (
<Loader />
)}
</>
)}
</LicenseRequired>
</Shell>
);
}
export default WorkflowPage;

View File

@ -0,0 +1,51 @@
import { useSession } from "next-auth/react";
import { Alert } from "@calcom/ui/Alert";
import LicenseRequired from "@ee/components/LicenseRequired";
import { NewWorkflowButton } from "@ee/components/workflows/NewWorkflowButton";
import WorkflowList from "@ee/components/workflows/WorkflowListPage";
import { useLocale } from "@lib/hooks/useLocale";
import useMeQuery from "@lib/hooks/useMeQuery";
import { trpc } from "@lib/trpc";
import Loader from "@components/Loader";
import Shell from "@components/Shell";
function WorkflowsPage() {
const { t } = useLocale();
const session = useSession();
const me = useMeQuery();
const isFreeUser = me.data?.plan === "FREE";
const { data, isLoading } = trpc.useQuery(["viewer.workflows.list"]);
return (
<Shell
heading={t("workflows")}
subtitle={t("workflows_to_automate_notifications")}
CTA={session.data?.hasValidLicense ? <NewWorkflowButton /> : <></>}>
<LicenseRequired>
{isLoading ? (
<Loader />
) : (
<>
{isFreeUser ? (
<Alert
className="border "
severity="warning"
title="This is a pro feature. Upgrade to pro to automate your event notifications and reminders with workflows."
/>
) : (
<WorkflowList workflows={data?.workflows} />
)}
</>
)}
</LicenseRequired>
</Shell>
);
}
export default WorkflowsPage;

View File

@ -26,6 +26,7 @@ export type BookingCreateBody = {
};
hasHashedBookingLink: boolean;
hashedLink?: string | null;
smsReminderNumber?: string;
};
export type BookingResponse = Booking & {

View File

@ -10,6 +10,7 @@ import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar";
import { refund } from "@ee/lib/stripe/server";
import { scheduleWorkflowReminders } from "@ee/lib/workflows/reminders/reminderScheduler";
import { getSession } from "@lib/auth";
import { HttpError } from "@lib/core/http/error";
@ -107,6 +108,15 @@ async function patchHandler(req: NextApiRequest) {
id: true,
recurringEvent: true,
requiresConfirmation: true,
workflows: {
include: {
workflow: {
include: {
steps: true,
},
},
},
},
},
},
location: true,
@ -118,6 +128,7 @@ async function patchHandler(req: NextApiRequest) {
paid: true,
recurringEventId: true,
status: true,
smsReminderNumber: true,
},
});
@ -264,6 +275,11 @@ async function patchHandler(req: NextApiRequest) {
},
});
}
//Workflows - set reminders for confirmed events
if (booking.eventType?.workflows) {
await scheduleWorkflowReminders(booking.eventType.workflows, booking.smsReminderNumber, evt, false);
}
} else {
evt.rejectionReason = rejectionReason;
if (recurringEventId) {

View File

@ -26,6 +26,7 @@ import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime";
import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar";
import type { EventResult, PartialReference } from "@calcom/types/EventManager";
import { handlePayment } from "@ee/lib/stripe/server";
import { scheduleWorkflowReminders } from "@ee/lib/workflows/reminders/reminderScheduler";
import { HttpError } from "@lib/core/http/error";
import { ensureArray } from "@lib/ensureArray";
@ -178,6 +179,15 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
hideCalendarNotes: true,
seatsPerTimeSlot: true,
recurringEvent: true,
workflows: {
include: {
workflow: {
include: {
steps: true,
},
},
},
},
locations: true,
timeZone: true,
schedule: {
@ -514,6 +524,7 @@ async function handler(req: NextApiRequest) {
status: isConfirmedByDefault ? BookingStatus.ACCEPTED : BookingStatus.PENDING,
location: evt.location,
eventType: eventTypeRel,
smsReminderNumber: reqBody.smsReminderNumber,
attendees: {
createMany: {
data: evt.attendees.map((attendee) => {
@ -860,6 +871,13 @@ async function handler(req: NextApiRequest) {
},
},
});
await scheduleWorkflowReminders(
eventType.workflows,
reqBody.smsReminderNumber as string | null,
evt,
evt.requiresConfirmation || false
);
// booking successful
req.statusCode = 201;
return booking;

View File

@ -1,4 +1,12 @@
import { BookingStatus, Credential, WebhookTriggerEvents } from "@prisma/client";
import {
BookingStatus,
Credential,
WebhookTriggerEvents,
Prisma,
PrismaPromise,
WorkflowMethods,
} from "@prisma/client";
import { WorkflowTriggerEvents, WorkflowActions } from "@prisma/client";
import async from "async";
import { NextApiRequest, NextApiResponse } from "next";
@ -11,6 +19,9 @@ import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { refund } from "@ee/lib/stripe/server";
import { deleteScheduledEmailReminder } from "@ee/lib/workflows/reminders/emailReminderManager";
import { sendCancelledReminders } from "@ee/lib/workflows/reminders/reminderScheduler";
import { deleteScheduledSMSReminder } from "@ee/lib/workflows/reminders/smsReminderManager";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getSession } from "@lib/auth";
@ -61,11 +72,22 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
select: {
recurringEvent: true,
title: true,
workflows: {
include: {
workflow: {
include: {
steps: true,
},
},
},
},
},
},
uid: true,
eventTypeId: true,
destinationCalendar: true,
smsReminderNumber: true,
workflowReminders: true,
},
});
@ -238,9 +260,36 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
});
await Promise.all([apiDeletes, attendeeDeletes, bookingReferenceDeletes]);
//Workflows - delete all reminders for that booking
const remindersToDelete: PrismaPromise<Prisma.BatchPayload>[] = [];
bookingToDelete.workflowReminders.forEach((reminder) => {
if (reminder.scheduled && reminder.referenceId) {
if (reminder.method === WorkflowMethods.EMAIL) {
deleteScheduledEmailReminder(reminder.referenceId);
} else if (reminder.method === WorkflowMethods.SMS) {
deleteScheduledSMSReminder(reminder.referenceId);
}
}
const reminderToDelete = prisma.workflowReminder.deleteMany({
where: {
id: reminder.id,
},
});
remindersToDelete.push(reminderToDelete);
});
await Promise.all([apiDeletes, attendeeDeletes, bookingReferenceDeletes].concat(remindersToDelete));
await sendCancelledEmails(evt);
//Workflows - schedule reminders
if (bookingToDelete.eventType?.workflows) {
await sendCancelledReminders(
bookingToDelete.eventType?.workflows,
bookingToDelete.smsReminderNumber,
evt
);
}
res.status(204).end();
}

View File

@ -54,6 +54,15 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
currency: true,
metadata: true,
seatsPerTimeSlot: true,
workflows: {
include: {
workflow: {
include: {
steps: true,
},
},
},
},
team: {
select: {
slug: true,

View File

@ -0,0 +1 @@
export { default } from "@ee/pages/workflows/[workflow]";

View File

@ -0,0 +1 @@
export { default } from "@ee/pages/workflows/index";

View File

@ -902,6 +902,35 @@
"meeting_url_in_conformation_email": "Meeting url is in the confirmation email",
"url_start_with_https": "URL needs to start with http:// or https://",
"number_provided": "Phone number will be provided",
"before_event_trigger": "before event starts",
"event_cancelled_trigger": "when event is cancelled",
"new_event_trigger": "when new event is booked",
"email_host_action": "send email to host",
"email_attendee_action": "send email to attendee",
"sms_attendee_action": "send SMS to attendee",
"sms_number_action": "send SMS to a specific number",
"workflows": "Workflows",
"new_workflow_btn": "New workflow",
"add_new_workflow": "Add a new workflow",
"trigger": "Trigger",
"triggers": "Triggers",
"action": "Action",
"workflows_to_automate_notifications": "Create workflows to automate notifications and reminders",
"workflow_name": "Workflow name",
"custom_workflow": "Custom workflow",
"workflow_created_successfully": "{{workflowName}} created successfully",
"delete_workflow_description": "Are you sure you want to delete this workflow?",
"delete_workflow": "Delete Workflow",
"confirm_delete_workflow": "Yes, delete workflow",
"workflow_deleted_successfully": "Workflow deleted successfully",
"how_long_before": "How long before event starts?",
"day_timeUnit": "Days",
"hour_timeUnit": "Hours",
"minute_timeUnit": "Minutes",
"new_workflow_heading": "Create your first workflow",
"new_workflow_description": "Workflows enable you to automate sending reminders and notifcations.",
"active_on": "Active on",
"workflow_updated_successfully": "{{workflowName}} workflow updated successfully",
"standard_to_premium_username_description": "This is a standard username and updating will take you to billing to downgrade.",
"current": "Current",
"premium": "premium",
@ -916,8 +945,12 @@
"yes_remove_app": "Yes, remove app",
"are_you_sure_you_want_to_remove_this_app": "Are you sure you want to remove this app?",
"web_conference": "Web conference",
"number_for_sms_reminders": "Phone number (for SMS reminders)",
"requires_confirmation": "Requires confirmation",
"set_whereby_link": "Set Whereby link",
"nr_event_type_one": "{{count}} Event Type",
"nr_event_type_other": "{{count}} Event Types",
"add_action": "Add action",
"set_whereby_link":"Set Whereby link",
"invalid_whereby_link": "Please enter a valid Whereby Link",
"set_around_link": "Set Around.Co link",
"invalid_around_link": "Please enter a valid Around Link",
@ -925,14 +958,31 @@
"invalid_riverside_link": "Please enter a valid Riverside Link",
"add_exchange2013": "Connect Exchange 2013 Server",
"add_exchange2016": "Connect Exchange 2016 Server",
"custom_template": "Custom template",
"email_body": "Body",
"subject": "Subject",
"text_message": "Text message",
"specific_issue": "Have a specific issue",
"browse_our_docs": "browse our docs",
"choose_template": "Choose a template",
"reminder": "Reminder",
"custom": "Custom",
"reminder_email": "Reminder: {{eventType}} with {{name}} at {{date}}",
"not_triggering_existing_bookings": "Won't trigger for already existing bookings as user will be asked for phone number when booking the event.",
"minute_one": "{{count}} minute",
"minute_other": "{{count}} minutes",
"hour_one": "{{count}} hour",
"hour_other": "{{count}} hours",
"invalid_input": "Invalid input",
"broken_video_action": "We could not add the <1>{{location}}</1> meeting link to your scheduled event. Contact your invitees or update your calendar event to add the details. You can either <3> change your location on the event type </3> or try <5>removing and adding the app again.</5>",
"broken_calendar_action": "We could not update your <1>{{calendar}}</1>. <2> Please check your calendar settings or remove and add your calendar again </2>",
"attendee_name": "Attendee's name",
"broken_integration": "Broken integration",
"problem_adding_video_link": "There was a problem adding a video link",
"problem_updating_calendar": "There was a problem updating your calendar",
"active_on_event_types_one": "Active on {{count}} event type",
"active_on_event_types_other": "Active on {{count}} event types",
"no_active_event_types": "No active event types",
"new_seat_subject": "New Attendee {{name}} on {{eventType}} at {{date}}",
"new_seat_title": "Someone has added themselves to an event",
"invalid_number": "Invalid phone number"

View File

@ -35,6 +35,7 @@ import { availabilityRouter } from "@server/routers/viewer/availability";
import { bookingsRouter } from "@server/routers/viewer/bookings";
import { eventTypesRouter } from "@server/routers/viewer/eventTypes";
import { slotsRouter } from "@server/routers/viewer/slots";
import { workflowsRouter } from "@server/routers/viewer/workflows";
import { TRPCError } from "@trpc/server";
import { createProtectedRouter, createRouter } from "../createRouter";
@ -1210,4 +1211,5 @@ export const viewerRouter = createRouter()
.merge("availability.", availabilityRouter)
.merge("teams.", viewerTeamsRouter)
.merge("webhook.", webhookRouter)
.merge("workflows.", workflowsRouter)
.merge("apiKeys.", apiKeysRouter);

View File

@ -0,0 +1,661 @@
import {
Prisma,
PrismaPromise,
WorkflowTemplates,
WorkflowActions,
WorkflowTriggerEvents,
BookingStatus,
WorkflowMethods,
} from "@prisma/client";
import { z } from "zod";
import {
WORKFLOW_TEMPLATES,
WORKFLOW_TRIGGER_EVENTS,
WORKFLOW_ACTIONS,
TIME_UNIT,
} from "@ee/lib/workflows/constants";
import {
deleteScheduledEmailReminder,
scheduleEmailReminder,
} from "@ee/lib/workflows/reminders/emailReminderManager";
import {
deleteScheduledSMSReminder,
scheduleSMSReminder,
} from "@ee/lib/workflows/reminders/smsReminderManager";
import { createProtectedRouter } from "@server/createRouter";
import { TRPCError } from "@trpc/server";
export const workflowsRouter = createProtectedRouter()
.query("list", {
async resolve({ ctx }) {
const workflows = await ctx.prisma.workflow.findMany({
where: {
userId: ctx.user.id,
},
include: {
activeOn: {
include: {
eventType: true,
},
},
},
orderBy: {
id: "asc",
},
});
return { workflows };
},
})
.query("get", {
input: z.object({
id: z.number(),
}),
async resolve({ ctx, input }) {
const workflow = await ctx.prisma.workflow.findFirst({
where: {
AND: [
{
userId: ctx.user.id,
},
{
id: input.id,
},
],
},
select: {
id: true,
name: true,
time: true,
timeUnit: true,
activeOn: {
select: {
eventType: true,
},
},
trigger: true,
steps: {
orderBy: {
stepNumber: "asc",
},
},
},
});
return workflow;
},
})
.mutation("create", {
input: z.object({
name: z.string(),
trigger: z.enum(WORKFLOW_TRIGGER_EVENTS),
action: z.enum(WORKFLOW_ACTIONS),
timeUnit: z.enum(TIME_UNIT).optional(),
time: z.number().optional(),
sendTo: z.string().optional(),
}),
async resolve({ ctx, input }) {
const { name, trigger, action, timeUnit, time, sendTo } = input;
const userId = ctx.user.id;
try {
const workflow = await ctx.prisma.workflow.create({
data: {
name,
trigger,
userId,
timeUnit: time ? timeUnit : undefined,
time,
},
});
await ctx.prisma.workflowStep.create({
data: {
stepNumber: 1,
action,
workflowId: workflow.id,
sendTo,
},
});
return { workflow };
} catch (e) {
throw e;
}
},
})
.mutation("delete", {
input: z.object({
id: z.number(),
}),
async resolve({ ctx, input }) {
const { id } = input;
//delete all scheduled reminders of this workflow
const scheduledReminders = await ctx.prisma.workflowReminder.findMany({
where: {
workflowStep: {
workflowId: id,
},
scheduled: true,
NOT: {
referenceId: null,
},
},
});
scheduledReminders.forEach((reminder) => {
if (reminder.referenceId) {
if (reminder.method === WorkflowMethods.EMAIL) {
deleteScheduledEmailReminder(reminder.referenceId);
} else if (reminder.method === WorkflowMethods.SMS) {
deleteScheduledSMSReminder(reminder.referenceId);
}
}
});
await ctx.prisma.workflow.deleteMany({
where: {
AND: [
{
userId: ctx.user.id,
},
{
id,
},
],
},
});
return {
id,
};
},
})
.mutation("update", {
input: z.object({
id: z.number(),
name: z.string(),
activeOn: z.number().array(),
steps: z
.object({
id: z.number(),
stepNumber: z.number(),
action: z.enum(WORKFLOW_ACTIONS),
workflowId: z.number(),
sendTo: z.string().optional().nullable(),
reminderBody: z.string().optional().nullable(),
emailSubject: z.string().optional().nullable(),
template: z.enum(WORKFLOW_TEMPLATES),
})
.array(),
trigger: z.enum(WORKFLOW_TRIGGER_EVENTS),
time: z.number().nullable(),
timeUnit: z.enum(TIME_UNIT).nullable(),
}),
async resolve({ input, ctx }) {
const { user } = ctx;
const { id, name, activeOn, steps, trigger, time, timeUnit } = input;
const userWorkflow = await ctx.prisma.workflow.findUnique({
where: {
id,
},
select: {
userId: true,
steps: true,
},
});
if (!userWorkflow || userWorkflow.userId !== user.id) throw new TRPCError({ code: "UNAUTHORIZED" });
//remove all scheduled Email and SMS reminders for eventTypes that are not active any more
const oldActiveOnEventTypes = await ctx.prisma.workflowsOnEventTypes.findMany({
where: {
workflowId: id,
},
select: {
eventTypeId: true,
},
});
const removedEventTypes = oldActiveOnEventTypes
.map((eventType) => {
return eventType.eventTypeId;
})
.filter((eventType) => {
if (!activeOn.includes(eventType)) {
return eventType;
}
});
const remindersToDeletePromise: PrismaPromise<
{
id: number;
referenceId: string | null;
method: string;
scheduled: boolean;
}[]
>[] = [];
removedEventTypes.forEach((eventTypeId) => {
const reminderToDelete = ctx.prisma.workflowReminder.findMany({
where: {
booking: {
eventTypeId: eventTypeId,
userId: ctx.user.id,
},
workflowStepId: {
in: userWorkflow.steps.map((step) => {
return step.id;
}),
},
},
select: {
id: true,
referenceId: true,
method: true,
scheduled: true,
},
});
remindersToDeletePromise.push(reminderToDelete);
});
const remindersToDelete = await Promise.all(remindersToDeletePromise);
const deleteReminderPromise: PrismaPromise<Prisma.BatchPayload>[] = [];
remindersToDelete.flat().forEach((reminder) => {
//already scheduled reminders
if (reminder.referenceId) {
if (reminder.method === WorkflowMethods.EMAIL) {
deleteScheduledEmailReminder(reminder.referenceId);
} else if (reminder.method === WorkflowMethods.SMS) {
deleteScheduledSMSReminder(reminder.referenceId);
}
}
const deleteReminder = ctx.prisma.workflowReminder.deleteMany({
where: {
id: reminder.id,
booking: {
userId: ctx.user.id,
},
},
});
deleteReminderPromise.push(deleteReminder);
});
await Promise.all(deleteReminderPromise);
//update active on & reminders for new eventTypes
await ctx.prisma.workflowsOnEventTypes.deleteMany({
where: {
workflowId: id,
},
});
let newEventTypes: number[] = [];
if (activeOn.length) {
if (trigger === WorkflowTriggerEvents.BEFORE_EVENT) {
newEventTypes = activeOn.filter((eventType) => {
if (
!oldActiveOnEventTypes ||
!oldActiveOnEventTypes
.map((oldEventType) => {
return oldEventType.eventTypeId;
})
.includes(eventType)
) {
return eventType;
}
});
}
if (newEventTypes.length > 0) {
//create reminders for all bookings with newEventTypes
const bookingsForReminders = await ctx.prisma.booking.findMany({
where: {
eventTypeId: { in: newEventTypes },
status: BookingStatus.ACCEPTED,
startTime: {
gte: new Date(),
},
},
include: {
attendees: true,
eventType: true,
user: true,
},
});
steps.forEach(async (step) => {
if (step.action !== WorkflowActions.SMS_ATTENDEE) {
//as we do not have attendees phone number (user is notified about that when setting this action)
bookingsForReminders.forEach(async (booking) => {
const bookingInfo = {
uid: booking.uid,
attendees: booking.attendees.map((attendee) => {
return { name: attendee.name, email: attendee.email, timeZone: attendee.timeZone };
}),
organizer: booking.user
? { name: booking.user.name || "", email: booking.user.email }
: { name: "", email: "" },
startTime: booking.startTime.toISOString(),
title: booking.title,
};
if (
step.action === WorkflowActions.EMAIL_HOST ||
step.action === WorkflowActions.EMAIL_ATTENDEE
) {
const sendTo =
step.action === WorkflowActions.EMAIL_HOST
? bookingInfo.organizer?.email
: bookingInfo.attendees[0].email;
await scheduleEmailReminder(
bookingInfo,
WorkflowTriggerEvents.BEFORE_EVENT,
step.action,
{
time,
timeUnit,
},
sendTo,
step.emailSubject || "",
step.reminderBody || "",
step.id,
step.template
);
} else if (step.action === WorkflowActions.SMS_NUMBER) {
await scheduleSMSReminder(
bookingInfo,
step.sendTo || "",
WorkflowTriggerEvents.BEFORE_EVENT,
step.action,
{
time,
timeUnit,
},
step.reminderBody || "",
step.id,
step.template
);
}
});
}
});
}
//create all workflow - eventtypes relationships
activeOn.forEach(async (eventTypeId) => {
await ctx.prisma.workflowsOnEventTypes.createMany({
data: {
workflowId: id,
eventTypeId,
},
});
});
}
userWorkflow.steps.map(async (oldStep) => {
const newStep = steps.filter((s) => s.id === oldStep.id)[0];
const remindersFromStep = await ctx.prisma.workflowReminder.findMany({
where: {
workflowStepId: oldStep.id,
},
include: {
booking: true,
},
});
//step was deleted
if (!newStep) {
//delete already scheduled reminders
if (remindersFromStep.length > 0) {
remindersFromStep.forEach((reminder) => {
if (reminder.referenceId) {
if (reminder.method === WorkflowMethods.EMAIL) {
deleteScheduledEmailReminder(reminder.referenceId);
} else if (reminder.method === WorkflowMethods.SMS) {
deleteScheduledSMSReminder(reminder.referenceId);
}
}
});
}
await ctx.prisma.workflowStep.delete({
where: {
id: oldStep.id,
},
});
//step was edited
} else if (JSON.stringify(oldStep) !== JSON.stringify(newStep)) {
await ctx.prisma.workflowStep.update({
where: {
id: oldStep.id,
},
data: {
action: newStep.action,
sendTo: newStep.action === WorkflowActions.SMS_NUMBER ? newStep.sendTo : null,
stepNumber: newStep.stepNumber,
workflowId: newStep.workflowId,
reminderBody: newStep.template === WorkflowTemplates.CUSTOM ? newStep.reminderBody : null,
emailSubject: newStep.template === WorkflowTemplates.CUSTOM ? newStep.emailSubject : null,
template: newStep.template,
},
});
//cancel all reminders of step and create new ones (not for newEventTypes)
const remindersToUpdate = remindersFromStep.filter((reminder) => {
if (reminder.booking?.eventTypeId && !newEventTypes.includes(reminder.booking?.eventTypeId)) {
return reminder;
}
});
remindersToUpdate.forEach(async (reminder) => {
if (reminder.referenceId) {
if (reminder.method === WorkflowMethods.EMAIL) {
deleteScheduledEmailReminder(reminder.referenceId);
} else if (reminder.method === WorkflowMethods.SMS) {
deleteScheduledSMSReminder(reminder.referenceId);
}
}
await ctx.prisma.workflowReminder.deleteMany({
where: {
id: reminder.id,
},
});
});
const eventTypesToUpdateReminders = activeOn.filter((eventTypeId) => {
if (!newEventTypes.includes(eventTypeId)) {
return eventTypeId;
}
});
if (eventTypesToUpdateReminders && trigger === WorkflowTriggerEvents.BEFORE_EVENT) {
const bookingsOfEventTypes = await ctx.prisma.booking.findMany({
where: {
eventTypeId: {
in: eventTypesToUpdateReminders,
},
status: BookingStatus.ACCEPTED,
startTime: {
gte: new Date(),
},
},
include: {
attendees: true,
eventType: true,
user: true,
},
});
bookingsOfEventTypes.forEach(async (booking) => {
const bookingInfo = {
uid: booking.uid,
attendees: booking.attendees.map((attendee) => {
return { name: attendee.name, email: attendee.email, timeZone: attendee.timeZone };
}),
organizer: booking.user
? { name: booking.user.name || "", email: booking.user.email }
: { name: "", email: "" },
startTime: booking.startTime.toISOString(),
title: booking.title,
};
if (
newStep.action === WorkflowActions.EMAIL_HOST ||
newStep.action === WorkflowActions.EMAIL_ATTENDEE
) {
const sendTo =
newStep.action === WorkflowActions.EMAIL_HOST
? bookingInfo.organizer?.email
: bookingInfo.attendees[0].email;
await scheduleEmailReminder(
bookingInfo,
WorkflowTriggerEvents.BEFORE_EVENT,
newStep.action,
{
time,
timeUnit,
},
sendTo,
newStep.emailSubject || "",
newStep.reminderBody || "",
newStep.id,
newStep.template
);
} else if (newStep.action === WorkflowActions.SMS_NUMBER) {
await scheduleSMSReminder(
bookingInfo,
newStep.sendTo || "",
WorkflowTriggerEvents.BEFORE_EVENT,
newStep.action,
{
time,
timeUnit,
},
newStep.reminderBody || "",
newStep.id || 0,
newStep.template
);
}
});
}
}
});
//added steps
const addedSteps = steps.map((s) => {
if (s.id <= 0) {
const { id, ...stepToAdd } = s;
if (stepToAdd) {
return stepToAdd;
}
}
});
if (addedSteps) {
const eventTypesToCreateReminders = activeOn.map((activeEventType) => {
if (activeEventType && !newEventTypes.includes(activeEventType)) {
return activeEventType;
}
});
addedSteps.forEach(async (step) => {
if (step) {
const createdStep = await ctx.prisma.workflowStep.create({
data: step,
});
if (
trigger === WorkflowTriggerEvents.BEFORE_EVENT &&
eventTypesToCreateReminders &&
step.action !== WorkflowActions.SMS_ATTENDEE
) {
const bookingsForReminders = await ctx.prisma.booking.findMany({
where: {
eventTypeId: { in: eventTypesToCreateReminders as number[] },
status: BookingStatus.ACCEPTED,
startTime: {
gte: new Date(),
},
},
include: {
attendees: true,
eventType: true,
user: true,
},
});
bookingsForReminders.forEach(async (booking) => {
const bookingInfo = {
uid: booking.uid,
attendees: booking.attendees.map((attendee) => {
return { name: attendee.name, email: attendee.email, timeZone: attendee.timeZone };
}),
organizer: booking.user
? { name: booking.user.name || "", email: booking.user.email }
: { name: "", email: "" },
startTime: booking.startTime.toISOString(),
title: booking.title,
};
if (
step.action === WorkflowActions.EMAIL_ATTENDEE ||
step.action === WorkflowActions.EMAIL_HOST
) {
const sendTo =
step.action === WorkflowActions.EMAIL_HOST
? bookingInfo.organizer?.email
: bookingInfo.attendees[0].email;
await scheduleEmailReminder(
bookingInfo,
trigger,
step.action,
{
time,
timeUnit,
},
sendTo,
step.emailSubject || "",
step.reminderBody || "",
createdStep.id,
step.template
);
} else if (step.action === WorkflowActions.SMS_NUMBER && step.sendTo) {
await scheduleSMSReminder(
bookingInfo,
step.sendTo,
WorkflowTriggerEvents.BEFORE_EVENT,
step.action,
{
time,
timeUnit,
},
step.reminderBody || "",
createdStep.id,
step.template
);
}
});
}
}
});
}
//update trigger, name, time, timeUnit
await ctx.prisma.workflow.update({
where: {
id,
},
data: {
name,
trigger,
time,
timeUnit,
},
});
const workflow = await ctx.prisma.workflow.findFirst({
where: {
id,
},
include: {
activeOn: {
select: {
eventType: true,
},
},
steps: true,
},
});
return {
workflow,
};
},
});

View File

@ -9,7 +9,10 @@
"clean": "rm -rf .turbo && rm -rf node_modules"
},
"dependencies": {
"@calcom/lib": "*"
"@calcom/lib": "*",
"@sendgrid/client": "^7.6.2",
"@sendgrid/mail": "^7.6.2",
"twilio": "^3.75.1"
},
"devDependencies": {
"@calcom/tsconfig": "*"

View File

@ -10,6 +10,7 @@ import AttendeeRescheduledEmail from "./templates/attendee-rescheduled-email";
import AttendeeScheduledEmail from "./templates/attendee-scheduled-email";
import BrokenIntegrationEmail from "./templates/broken-integration-email";
import FeedbackEmail, { Feedback } from "./templates/feedback-email";
import WorkflowReminderEmail from "./templates/workflow-reminder-email";
import ForgotPasswordEmail, { PasswordReset } from "./templates/forgot-password-email";
import OrganizerCancelledEmail from "./templates/organizer-cancelled-email";
import OrganizerLocationChangeEmail from "./templates/organizer-location-change-email";
@ -20,6 +21,7 @@ import OrganizerRequestRescheduleEmail from "./templates/organizer-request-resch
import OrganizerRescheduledEmail from "./templates/organizer-rescheduled-email";
import OrganizerScheduledEmail from "./templates/organizer-scheduled-email";
import TeamInviteEmail, { TeamInvite } from "./templates/team-invite-email";
import { BookingInfo } from "@calcom/web/ee/lib/workflows/reminders/smsReminderManager";
export const sendScheduledEmails = async (calEvent: CalendarEvent) => {
const emailsToSend: Promise<unknown>[] = [];
@ -210,7 +212,6 @@ export const sendAwaitingPaymentEmail = async (calEvent: CalendarEvent) => {
});
})
);
await Promise.all(emailsToSend);
};
@ -328,3 +329,14 @@ export const sendBrokenIntegrationEmail = async (evt: CalendarEvent, type: "vide
}
});
};
export const sendWorkflowReminderEmail = async (evt: BookingInfo, sendTo: string, emailSubject: string, emailBody: string) => {
await new Promise((resolve, reject) => {
try {
const workflowReminderEmail = new WorkflowReminderEmail(evt, sendTo, emailSubject, emailBody);
resolve(workflowReminderEmail.sendEmail());
} catch (e) {
reject(console.error("WorkflowReminderEmail.sendEmail failed", e));
}
});
}

View File

@ -0,0 +1,46 @@
import dayjs from "@calcom/dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import BaseEmail from "./_base-email";
import { BookingInfo } from "@calcom/web/ee/lib/workflows/reminders/smsReminderManager";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(localizedFormat);
dayjs.extend(toArray);
export default class WorkflowReminderEmail extends BaseEmail {
sendTo: string;
body: string;
emailSubject: string;
evt: BookingInfo;
constructor(evt: BookingInfo, sendTo: string, emailSubject: string, body: string) {
super();
this.sendTo = sendTo;
this.body = body;
this.evt = evt;
this.emailSubject = emailSubject;
}
protected getNodeMailerPayload(): Record<string, unknown> {
let from ="";
let replyTo ="";
if(this.evt.organizer) {
from = this.evt.organizer.name || "";
replyTo = this.evt.organizer.email;
}
return {
to: `<${this.sendTo}>`,
from: `${from} <${this.getMailerOptions().from}>`,
replyTo: replyTo,
subject: this.emailSubject,
text: this.body,
};
}
}

View File

@ -69,6 +69,7 @@ const commons = {
requiresConfirmation: false,
hidden: false,
userId: 0,
workflows: [],
users: [
{
id: 0,

View File

@ -0,0 +1,86 @@
-- CreateEnum
CREATE TYPE "WorkflowTriggerEvents" AS ENUM ('BEFORE_EVENT', 'EVENT_CANCELLED', 'NEW_EVENT');
-- CreateEnum
CREATE TYPE "WorkflowActions" AS ENUM ('EMAIL_HOST', 'EMAIL_ATTENDEE', 'SMS_ATTENDEE', 'SMS_NUMBER');
-- CreateEnum
CREATE TYPE "TimeUnit" AS ENUM ('day', 'hour', 'minute');
-- CreateEnum
CREATE TYPE "WorkflowTemplates" AS ENUM ('REMINDER', 'CUSTOM');
-- CreateEnum
CREATE TYPE "WorkflowMethods" AS ENUM ('EMAIL', 'SMS');
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "smsReminderNumber" TEXT;
-- CreateTable
CREATE TABLE "WorkflowStep" (
"id" SERIAL NOT NULL,
"stepNumber" INTEGER NOT NULL,
"action" "WorkflowActions" NOT NULL,
"workflowId" INTEGER NOT NULL,
"sendTo" TEXT,
"reminderBody" TEXT,
"emailSubject" TEXT,
"template" "WorkflowTemplates" NOT NULL DEFAULT E'REMINDER',
CONSTRAINT "WorkflowStep_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Workflow" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"trigger" "WorkflowTriggerEvents" NOT NULL,
"time" INTEGER,
"timeUnit" "TimeUnit",
CONSTRAINT "Workflow_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WorkflowsOnEventTypes" (
"id" SERIAL NOT NULL,
"workflowId" INTEGER NOT NULL,
"eventTypeId" INTEGER NOT NULL,
CONSTRAINT "WorkflowsOnEventTypes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WorkflowReminder" (
"id" SERIAL NOT NULL,
"bookingUid" TEXT NOT NULL,
"method" "WorkflowMethods" NOT NULL,
"scheduledDate" TIMESTAMP(3) NOT NULL,
"referenceId" TEXT,
"scheduled" BOOLEAN NOT NULL,
"workflowStepId" INTEGER NOT NULL,
CONSTRAINT "WorkflowReminder_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "WorkflowReminder_referenceId_key" ON "WorkflowReminder"("referenceId");
-- AddForeignKey
ALTER TABLE "WorkflowStep" ADD CONSTRAINT "WorkflowStep_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "Workflow"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Workflow" ADD CONSTRAINT "Workflow_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WorkflowsOnEventTypes" ADD CONSTRAINT "WorkflowsOnEventTypes_eventTypeId_fkey" FOREIGN KEY ("eventTypeId") REFERENCES "EventType"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WorkflowsOnEventTypes" ADD CONSTRAINT "WorkflowsOnEventTypes_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "Workflow"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WorkflowReminder" ADD CONSTRAINT "WorkflowReminder_bookingUid_fkey" FOREIGN KEY ("bookingUid") REFERENCES "Booking"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WorkflowReminder" ADD CONSTRAINT "WorkflowReminder_workflowStepId_fkey" FOREIGN KEY ("workflowStepId") REFERENCES "WorkflowStep"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -30,20 +30,20 @@ enum PeriodType {
}
model EventType {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
/// @zod.nonempty()
title String
/// @zod.custom(imports.eventTypeSlug)
slug String
description String?
position Int @default(0)
position Int @default(0)
/// @zod.custom(imports.eventTypeLocations)
locations Json?
length Int
hidden Boolean @default(false)
users User[] @relation("user_eventtype")
hidden Boolean @default(false)
users User[] @relation("user_eventtype")
userId Int?
team Team? @relation(fields: [teamId], references: [id])
team Team? @relation(fields: [teamId], references: [id])
teamId Int?
hashedLink HashedLink?
bookings Booking[]
@ -53,28 +53,29 @@ model EventType {
eventName String?
customInputs EventTypeCustomInput[]
timeZone String?
periodType PeriodType @default(UNLIMITED)
periodType PeriodType @default(UNLIMITED)
periodStartDate DateTime?
periodEndDate DateTime?
periodDays Int?
periodCountCalendarDays Boolean?
requiresConfirmation Boolean @default(false)
requiresConfirmation Boolean @default(false)
/// @zod.custom(imports.recurringEventType)
recurringEvent Json?
disableGuests Boolean @default(false)
hideCalendarNotes Boolean @default(false)
minimumBookingNotice Int @default(120)
beforeEventBuffer Int @default(0)
afterEventBuffer Int @default(0)
disableGuests Boolean @default(false)
hideCalendarNotes Boolean @default(false)
minimumBookingNotice Int @default(120)
beforeEventBuffer Int @default(0)
afterEventBuffer Int @default(0)
seatsPerTimeSlot Int?
schedulingType SchedulingType?
schedule Schedule? @relation(fields: [scheduleId], references: [id])
schedule Schedule? @relation(fields: [scheduleId], references: [id])
scheduleId Int?
price Int @default(0)
currency String @default("usd")
price Int @default(0)
currency String @default("usd")
slotInterval Int?
metadata Json?
successRedirectUrl String?
workflows WorkflowsOnEventTypes[]
@@unique([userId, slug])
@@unique([teamId, slug])
@ -179,6 +180,7 @@ model User {
apiKeys ApiKey[]
accounts Account[]
sessions Session[]
workflows Workflow[]
Feedback Feedback[]
@@map(name: "users")
@ -290,6 +292,8 @@ model Booking {
rescheduled Boolean?
fromReschedule String?
recurringEventId String?
smsReminderNumber String?
workflowReminders WorkflowReminder[]
}
model Schedule {
@ -492,3 +496,78 @@ model Feedback {
rating String
comment String?
}
enum WorkflowTriggerEvents {
BEFORE_EVENT
EVENT_CANCELLED
NEW_EVENT
}
enum WorkflowActions {
EMAIL_HOST
EMAIL_ATTENDEE
SMS_ATTENDEE
SMS_NUMBER
}
model WorkflowStep {
id Int @id @default(autoincrement())
stepNumber Int
action WorkflowActions
workflowId Int
workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade)
sendTo String?
reminderBody String?
emailSubject String?
template WorkflowTemplates @default(REMINDER)
workflowReminders WorkflowReminder[]
}
model Workflow {
id Int @id @default(autoincrement())
name String
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
activeOn WorkflowsOnEventTypes[]
trigger WorkflowTriggerEvents
time Int?
timeUnit TimeUnit?
steps WorkflowStep[]
}
model WorkflowsOnEventTypes {
id Int @id @default(autoincrement())
workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade)
workflowId Int
eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
eventTypeId Int
}
enum TimeUnit {
DAY @map("day")
HOUR @map("hour")
MINUTE @map("minute")
}
model WorkflowReminder {
id Int @id @default(autoincrement())
bookingUid String
booking Booking? @relation(fields: [bookingUid], references: [uid], onDelete: Cascade)
method WorkflowMethods
scheduledDate DateTime
referenceId String? @unique
scheduled Boolean
workflowStepId Int
workflowStep WorkflowStep @relation(fields: [workflowStepId], references: [id], onDelete: Cascade)
}
enum WorkflowTemplates {
REMINDER
CUSTOM
}
enum WorkflowMethods {
EMAIL
SMS
}

View File

@ -35,6 +35,15 @@ export const bookEventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
disableGuests: true,
userId: true,
seatsPerTimeSlot: true,
workflows: {
include: {
workflow: {
include: {
steps: true,
},
},
},
},
users: {
select: {
id: true,

View File

@ -92,6 +92,7 @@ export const extendedBookingCreateBody = bookingCreateBodySchema.merge(
noEmail: z.boolean().optional(),
recurringCount: z.number().optional(),
rescheduleReason: z.string().optional(),
smsReminderNumber: z.string().optional(),
})
);

View File

@ -34,7 +34,7 @@ export function Label(props: JSX.IntrinsicElements["label"]) {
export function InputLeading(props: JSX.IntrinsicElements["div"]) {
return (
<span className="inline-flex flex-shrink-0 items-center rounded-l-sm border border-r-0 border-gray-300 bg-gray-50 px-3 text-gray-500 sm:text-sm">
<span className="inline-flex items-center flex-shrink-0 px-3 text-gray-500 border border-r-0 border-gray-300 rounded-l-sm bg-gray-50 sm:text-sm">
{props.children}
</span>
);
@ -71,7 +71,7 @@ const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputF
</Label>
)}
{addOnLeading ? (
<div className="mt-1 flex rounded-md shadow-sm">
<div className="flex mt-1 rounded-md shadow-sm">
{addOnLeading}
<Input
id={id}
@ -139,7 +139,7 @@ export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function
ref={ref}
{...props}
className={classNames(
"block w-full rounded-sm border-gray-300 font-mono shadow-sm focus:border-neutral-900 focus:ring-neutral-900 sm:text-sm",
"block w-full rounded-sm border-gray-300 shadow-sm focus:border-neutral-900 focus:ring-neutral-900 sm:text-sm",
props.className
)}
/>

526
yarn.lock
View File

@ -1246,12 +1246,17 @@
resolved "https://registry.yarnpkg.com/@glidejs/glide/-/glide-3.5.2.tgz#7012c5920ecf202bbda44d8526fc979984b6dd54"
integrity sha512-7jGciNJ2bQ4eZLSNlSZ+VAyW63kALf420CvkEpK4lEsUfWJq9odqimci0YCiyNyMUFB+pWHwLYyNc57dijYsCg==
"@headlessui/react@^1.4.1":
version "1.6.6"
resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-1.6.6.tgz#3073c066b85535c9d28783da0a4d9288b5354d0c"
integrity sha512-MFJtmj9Xh/hhBMhLccGbBoSk+sk61BlP6sJe4uQcVMtXZhCgGqd2GyIQzzmsdPdTEWGSF434CBi8mnhR6um46Q==
"@headlessui/react@^1.5.0":
version "1.6.5"
resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-1.6.5.tgz#5587c537de809cf3146eb2ff263e5e940b1bf69c"
integrity sha512-3VkKteDxlxf3fE0KbfO9t60KC1lM7YNpZggLpwzVNg1J/zwL+h+4N7MBlFDVpInZI3rKlZGpNx0PWsG/9c2vQg==
"@heroicons/react@^1.0.6":
"@heroicons/react@^1.0.4", "@heroicons/react@^1.0.6":
version "1.0.6"
resolved "https://registry.yarnpkg.com/@heroicons/react/-/react-1.0.6.tgz#35dd26987228b39ef2316db3b1245c42eb19e324"
integrity sha512-JJCXydOFWMDpCP4q13iEplA503MQO3xLoZiKum+955ZCtHINWnx26CUxVxxFQu/uLb4LW3ge15ZpzIkXKkJ8oQ==
@ -1261,6 +1266,11 @@
resolved "https://registry.yarnpkg.com/@hookform/error-message/-/error-message-2.0.0.tgz#9b1b037fd816ea9b1531c06aa7fab5f5154aa740"
integrity sha512-Y90nHzjgL2MP7GFy75kscdvxrCTjtyxGmOLLxX14nd08OXRIh9lMH/y9Kpdo0p1IPowJBiZMHyueg7p+yrqynQ==
"@hookform/resolvers@^2.8.1":
version "2.9.5"
resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-2.9.5.tgz#adfa2d240a9315f4ab6ff4c63621d22d1b574c07"
integrity sha512-4XABrKdE4GpX6v2RZ+Ij1Wrl9qPEl5ZvVhFxROY+SZhP/6TU8gPw0LO4ZS9/GgIbTx3Pw1U8HmcXVAoGOq+7iA==
"@hookform/resolvers@^2.8.9":
version "2.8.9"
resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-2.8.9.tgz#0177a6b2b5b0dfa7860625f9a1b71803d467e78a"
@ -2147,6 +2157,11 @@
dependencies:
webpack-bundle-analyzer "4.3.0"
"@next/env@12.1.6":
version "12.1.6"
resolved "https://registry.yarnpkg.com/@next/env/-/env-12.1.6.tgz#5f44823a78335355f00f1687cfc4f1dafa3eca08"
integrity sha512-Te/OBDXFSodPU6jlXYPAXpmZr/AkG6DCATAxttQxqOWaq6eDFX25Db3dK0120GZrSZmv4QCe9KsZmJKDbWs4OA==
"@next/env@12.2.0":
version "12.2.0"
resolved "https://registry.yarnpkg.com/@next/env/-/env-12.2.0.tgz#17ce2d9f5532b677829840037e06f208b7eed66b"
@ -2157,6 +2172,11 @@
resolved "https://registry.yarnpkg.com/@next/env/-/env-12.2.1.tgz#083cc88469931fc3dc32bb633623321c29971a09"
integrity sha512-lz3TJKIvbdGRUsUr/+h3vy7XvBNGTGzHwhurk5AtqrABj4Zyo70xbshcI7YQTNUK4x9OA/E+SOcXvVx0DHmFRw==
"@next/env@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/env/-/env-12.2.2.tgz#cc1a0a445bd254499e30f632968c03192455f4cc"
integrity sha512-BqDwE4gDl1F608TpnNxZqrCn6g48MBjvmWFEmeX5wEXDXh3IkAOw6ASKUgjT8H4OUePYFqghDFUss5ZhnbOUjw==
"@next/eslint-plugin-next@12.1.6":
version "12.1.6"
resolved "https://registry.yarnpkg.com/@next/eslint-plugin-next/-/eslint-plugin-next-12.1.6.tgz#dde3f98831f15923b25244588d924c716956292e"
@ -2164,6 +2184,11 @@
dependencies:
glob "7.1.7"
"@next/swc-android-arm-eabi@12.1.6":
version "12.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.1.6.tgz#79a35349b98f2f8c038ab6261aa9cd0d121c03f9"
integrity sha512-BxBr3QAAAXWgk/K7EedvzxJr2dE014mghBSA9iOEAv0bMgF+MRq4PoASjuHi15M2zfowpcRG8XQhMFtxftCleQ==
"@next/swc-android-arm-eabi@12.2.0":
version "12.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.2.0.tgz#f116756e668b267de84b76f068d267a12f18eb22"
@ -2174,6 +2199,16 @@
resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.2.1.tgz#26a4363bd3857b934e7ad63aa1647d83b380ce1f"
integrity sha512-Gk7fvo1McA9gues9hixoeoxKnvvUusW0P+fya4ZAU3us+bQm1EqSoDrnOrUsdsgwIPQ3HobOJPY5C3xvKOl/tA==
"@next/swc-android-arm-eabi@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.2.2.tgz#f6c4111e6371f73af6bf80c9accb3d96850a92cd"
integrity sha512-VHjuCHeq9qCprUZbsRxxM/VqSW8MmsUtqB5nEpGEgUNnQi/BTm/2aK8tl7R4D0twGKRh6g1AAeFuWtXzk9Z/vQ==
"@next/swc-android-arm64@12.1.6":
version "12.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-12.1.6.tgz#ec08ea61794f8752c8ebcacbed0aafc5b9407456"
integrity sha512-EboEk3ROYY7U6WA2RrMt/cXXMokUTXXfnxe2+CU+DOahvbrO8QSWhlBl9I9ZbFzJx28AGB9Yo3oQHCvph/4Lew==
"@next/swc-android-arm64@12.2.0":
version "12.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-12.2.0.tgz#cbd9e329cef386271d4e746c08416b5d69342c24"
@ -2184,6 +2219,16 @@
resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-12.2.1.tgz#28c7e964208e80d4b3ff791f323fbe425eae26fe"
integrity sha512-J+QwWRm2+bOtacZFahoplX3dCYGDpou86VjfcE+M5/E0UCtBmZ6JvItyV4scK8wSKHQQUWq8DmOEm/C0lhsSRQ==
"@next/swc-android-arm64@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-12.2.2.tgz#b69de59c51e631a7600439e7a8993d6e82f3369e"
integrity sha512-v5EYzXUOSv0r9mO/2PX6mOcF53k8ndlu9yeFHVAWW1Dhw2jaJcvTRcCAwYYN8Q3tDg0nH3NbEltJDLKmcJOuVA==
"@next/swc-darwin-arm64@12.1.6":
version "12.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.1.6.tgz#d1053805615fd0706e9b1667893a72271cd87119"
integrity sha512-P0EXU12BMSdNj1F7vdkP/VrYDuCNwBExtRPDYawgSUakzi6qP0iKJpya2BuLvNzXx+XPU49GFuDC5X+SvY0mOw==
"@next/swc-darwin-arm64@12.2.0":
version "12.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.2.0.tgz#3473889157ba70b30ccdd4f59c46232d841744e2"
@ -2194,6 +2239,16 @@
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.2.1.tgz#ae68b105956c985214219d4f676b2e57c882d5ae"
integrity sha512-teSfpKHdHQER4FVVCdvS0fHff35Gh4LB2DZ2eNAateIluP2Gnl+tT881MeM4Knvl2Mvm3Z3vtSJNthVoveJnMA==
"@next/swc-darwin-arm64@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.2.2.tgz#80157c91668eff95b72d052428c353eab0fc4c50"
integrity sha512-JCoGySHKGt+YBk7xRTFGx1QjrnCcwYxIo3yGepcOq64MoiocTM3yllQWeOAJU2/k9MH0+B5E9WUSme4rOCBbpA==
"@next/swc-darwin-x64@12.1.6":
version "12.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-12.1.6.tgz#2d1b926a22f4c5230d5b311f9c56cfdcc406afec"
integrity sha512-9FptMnbgHJK3dRDzfTpexs9S2hGpzOQxSQbe8omz6Pcl7rnEp9x4uSEKY51ho85JCjL4d0tDLBcXEJZKKLzxNg==
"@next/swc-darwin-x64@12.2.0":
version "12.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-12.2.0.tgz#b25198c3ef4c906000af49e4787a757965f760bb"
@ -2204,6 +2259,11 @@
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-12.2.1.tgz#27da7988d01847b642b8d5c274f14bd82439fbb0"
integrity sha512-flA1H+9krrINtdWoXBzeESkdIV34OKX0+Lnqd90J1nsERTXntYy6CNOMxMtv1otAcnFy7EHYJQIL8URuu/2XXg==
"@next/swc-darwin-x64@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-12.2.2.tgz#12be2f58e676fccff3d48a62921b9927ed295133"
integrity sha512-dztDtvfkhUqiqpXvrWVccfGhLe44yQ5tQ7B4tBfnsOR6vxzI9DNPHTlEOgRN9qDqTAcFyPxvg86mn4l8bB9Jcw==
"@next/swc-freebsd-x64@12.2.0":
version "12.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-freebsd-x64/-/swc-freebsd-x64-12.2.0.tgz#78e2213f8b703be0fef23a49507779b4a9842929"
@ -2214,6 +2274,16 @@
resolved "https://registry.yarnpkg.com/@next/swc-freebsd-x64/-/swc-freebsd-x64-12.2.1.tgz#0b4cd5c1707218cac86a7a58e116c74998da6286"
integrity sha512-SkAjp7B7aBxAsRVMZGiAp/qMkh65PLzYuLBTsBSu+4fxFuKF7MAEgaIUhvC8zzD58A+Y9yrY/3813bhtrwkcuA==
"@next/swc-freebsd-x64@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-freebsd-x64/-/swc-freebsd-x64-12.2.2.tgz#de1363431a49059f1efb8c0f86ce6a79c53b3a95"
integrity sha512-JUnXB+2xfxqsAvhFLPJpU1NeyDsvJrKoOjpV7g3Dxbno2Riu4tDKn3kKF886yleAuD/1qNTUCpqubTvbbT2VoA==
"@next/swc-linux-arm-gnueabihf@12.1.6":
version "12.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.1.6.tgz#c021918d2a94a17f823106a5e069335b8a19724f"
integrity sha512-PvfEa1RR55dsik/IDkCKSFkk6ODNGJqPY3ysVUZqmnWMDSuqFtf7BPWHFa/53znpvVB5XaJ5Z1/6aR5CTIqxPw==
"@next/swc-linux-arm-gnueabihf@12.2.0":
version "12.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.2.0.tgz#80a4baf0ba699357e7420e2dea998908dcef5055"
@ -2224,6 +2294,16 @@
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.2.1.tgz#3b93a18f1264a88985bc3a01e0067aa1afe0ab72"
integrity sha512-V7ov2LXrLWuYVH/syzrzpmwWumg5rCh0siwOPNCRzVkrpgP8WoIRNdeZ/NQIj0ng+kq7gDF1jib583Lk0wbDeQ==
"@next/swc-linux-arm-gnueabihf@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.2.2.tgz#d5b8e0d1bb55bbd9db4d2fec018217471dc8b9e6"
integrity sha512-XeYC/qqPLz58R4pjkb+x8sUUxuGLnx9QruC7/IGkK68yW4G17PHwKI/1njFYVfXTXUukpWjcfBuauWwxp9ke7Q==
"@next/swc-linux-arm64-gnu@12.1.6":
version "12.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.1.6.tgz#ac55c07bfabde378dfa0ce2b8fc1c3b2897e81ae"
integrity sha512-53QOvX1jBbC2ctnmWHyRhMajGq7QZfl974WYlwclXarVV418X7ed7o/EzGY+YVAEKzIVaAB9JFFWGXn8WWo0gQ==
"@next/swc-linux-arm64-gnu@12.2.0":
version "12.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.2.0.tgz#134a42ddea804d6bf04761607f774432c3126de6"
@ -2234,6 +2314,16 @@
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.2.1.tgz#9887a772f96680afa440ac3e6f716fd20d7f4178"
integrity sha512-HlnDQD3r4YqCj2gu6uo86oEM0ixBsyKLaPcZcGwWAD5mFG5R4zzTZG7BO2wJkGWmkzijHluE14dlTmfzc8jdEQ==
"@next/swc-linux-arm64-gnu@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.2.2.tgz#3bc75984e1d5ec8f59eb53702cc382d8e1be2061"
integrity sha512-d6jT8xgfKYFkzR7J0OHo2D+kFvY/6W8qEo6/hmdrTt6AKAqxs//rbbcdoyn3YQq1x6FVUUd39zzpezZntg9Naw==
"@next/swc-linux-arm64-musl@12.1.6":
version "12.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.1.6.tgz#e429f826279894be9096be6bec13e75e3d6bd671"
integrity sha512-CMWAkYqfGdQCS+uuMA1A2UhOfcUYeoqnTW7msLr2RyYAys15pD960hlDfq7QAi8BCAKk0sQ2rjsl0iqMyziohQ==
"@next/swc-linux-arm64-musl@12.2.0":
version "12.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.2.0.tgz#c781ac642ad35e0578d8a8d19c638b0f31c1a334"
@ -2244,6 +2334,16 @@
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.2.1.tgz#7ed5981b7afd3d9c4678ff36e1dd7f06a5f0c3d6"
integrity sha512-P8AkWd4RHbuF24ol3jk2akXpntcDI0gv5uD7eMpAOXb8W2A6y/sv0tKNSGUV3efSutOyu23jNn2EiTNxHgU4NQ==
"@next/swc-linux-arm64-musl@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.2.2.tgz#270db73e07a18d999f61e79a917943fa5bc1ef56"
integrity sha512-rIZRFxI9N/502auJT1i7coas0HTHUM+HaXMyJiCpnY8Rimbo0495ir24tzzHo3nQqJwcflcPTwEh/DV17sdv9A==
"@next/swc-linux-x64-gnu@12.1.6":
version "12.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.1.6.tgz#1f276c0784a5ca599bfa34b2fcc0b38f3a738e08"
integrity sha512-AC7jE4Fxpn0s3ujngClIDTiEM/CQiB2N2vkcyWWn6734AmGT03Duq6RYtPMymFobDdAtZGFZd5nR95WjPzbZAQ==
"@next/swc-linux-x64-gnu@12.2.0":
version "12.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.2.0.tgz#0e2235a59429eadd40ac8880aec18acdbc172a31"
@ -2254,6 +2354,16 @@
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.2.1.tgz#0bb3e5162b189cb4d88761ff1781896781c7bd65"
integrity sha512-ZbsM+rIMqK6xi3lovspzPJoIPre3LglKrCXKLkln7rD0uiymzfLhS2VCj8u4qRynz22iAzuI4mJNpZa3AsJFrA==
"@next/swc-linux-x64-gnu@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.2.2.tgz#e6c72fa20478552e898c434f4d4c0c5e89d2ea78"
integrity sha512-ir1vNadlUDj7eQk15AvfhG5BjVizuCHks9uZwBfUgT5jyeDCeRvaDCo1+Q6+0CLOAnYDR/nqSCvBgzG2UdFh9A==
"@next/swc-linux-x64-musl@12.1.6":
version "12.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.1.6.tgz#1d9933dd6ba303dcfd8a2acd6ac7c27ed41e2eea"
integrity sha512-c9Vjmi0EVk0Kou2qbrynskVarnFwfYIi+wKufR9Ad7/IKKuP6aEhOdZiIIdKsYWRtK2IWRF3h3YmdnEa2WLUag==
"@next/swc-linux-x64-musl@12.2.0":
version "12.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.2.0.tgz#b0a10db0d9e16f079429588a58f71fa3c3d46178"
@ -2264,6 +2374,16 @@
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.2.1.tgz#64e983e38a5e86bc613bfc46e0b92a1787ba5392"
integrity sha512-JeATguMe37bviPwkIUjO7T3kcefMBQwJFLhkFTaJYGmPm12EsW1FtKcg87AI87xdGvfrHQKlM3phNaG/dkneTQ==
"@next/swc-linux-x64-musl@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.2.2.tgz#b9ef9efe2c401839cdefa5e70402386aafdce15a"
integrity sha512-bte5n2GzLN3O8JdSFYWZzMgEgDHZmRz5wiispiiDssj4ik3l8E7wq/czNi8RmIF+ioj2sYVokUNa/ekLzrESWw==
"@next/swc-win32-arm64-msvc@12.1.6":
version "12.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.1.6.tgz#2ef9837f12ca652b1783d72ecb86208906042f02"
integrity sha512-3UTOL/5XZSKFelM7qN0it35o3Cegm6LsyuERR3/OoqEExyj3aCk7F025b54/707HTMAnjlvQK3DzLhPu/xxO4g==
"@next/swc-win32-arm64-msvc@12.2.0":
version "12.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.2.0.tgz#3063f850c9db7b774c69e9be74ad59986cf6fc34"
@ -2274,6 +2394,16 @@
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.2.1.tgz#2394b05230f0011a01010524e25d8f4ec71e27e1"
integrity sha512-8dal/MdrVshDKYBtloJw/RhJx140KUoRRYoRfpJ9oAdP8UXBdR0haKfg5EdOy98t8Q76apArxPsK7DfwoR1f3w==
"@next/swc-win32-arm64-msvc@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.2.2.tgz#18fa7ec7248da3a7926a0601d9ececc53ac83157"
integrity sha512-ZUGCmcDmdPVSAlwJ/aD+1F9lYW8vttseiv4n2+VCDv5JloxiX9aY32kYZaJJO7hmTLNrprvXkb4OvNuHdN22Jg==
"@next/swc-win32-ia32-msvc@12.1.6":
version "12.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.1.6.tgz#74003d0aa1c59dfa56cb15481a5c607cbc0027b9"
integrity sha512-8ZWoj6nCq6fI1yCzKq6oK0jE6Mxlz4MrEsRyu0TwDztWQWe7rh4XXGLAa2YVPatYcHhMcUL+fQQbqd1MsgaSDA==
"@next/swc-win32-ia32-msvc@12.2.0":
version "12.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.2.0.tgz#001bbadf3d2cf006c4991f728d1d23e4d5c0e7cc"
@ -2284,6 +2414,16 @@
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.2.1.tgz#90acd18e63e7620992ee3f7d3dec80ccc7120f9e"
integrity sha512-uSAoOBpCp4oxVD9gTY1f27hr9xNLEOCglxZPH1+FonHpM5n9Sp4H01uQHWE/Y26iHmJeUJAWxtRxEYylnO4U9A==
"@next/swc-win32-ia32-msvc@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.2.2.tgz#54936e84f4a219441d051940354da7cd3eafbb4f"
integrity sha512-v7ykeEDbr9eXiblGSZiEYYkWoig6sRhAbLKHUHQtk8vEWWVEqeXFcxmw6LRrKu5rCN1DY357UlYWToCGPQPCRA==
"@next/swc-win32-x64-msvc@12.1.6":
version "12.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.1.6.tgz#a350caf42975e7197b24b495b8d764eec7e6a36e"
integrity sha512-4ZEwiRuZEicXhXqmhw3+de8Z4EpOLQj/gp+D9fFWo6ii6W1kBkNNvvEx4A90ugppu+74pT1lIJnOuz3A9oQeJA==
"@next/swc-win32-x64-msvc@12.2.0":
version "12.2.0"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.2.0.tgz#9f66664f9122ca555b96a5f2fc6e2af677bf801b"
@ -2294,6 +2434,11 @@
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.2.1.tgz#f3b186c8f7278656c7690a64f362d0d5b1d738af"
integrity sha512-gx4aLMAZAVjtShiCrUSszoxnzBWJWf09Lkey6mcc0jFZjbz4xkyDbp53V229DtOYTUL4t0IZJ0I7+ftQ5CYIjg==
"@next/swc-win32-x64-msvc@12.2.2":
version "12.2.2"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.2.2.tgz#7460be700a60d75816f01109400b51fe929d7e89"
integrity sha512-2D2iinWUL6xx8D9LYVZ5qi7FP6uLAoWymt8m8aaG2Ld/Ka8/k723fJfiklfuAcwOxfufPJI+nRbT5VcgHGzHAQ==
"@node-redis/client@^1.0.1":
version "1.0.6"
resolved "https://registry.yarnpkg.com/@node-redis/client/-/client-1.0.6.tgz#de8bfe6cfdc5781f0021ce9d18a11c821c948d9d"
@ -2855,7 +3000,7 @@
"@radix-ui/react-use-callback-ref" "0.1.0"
"@radix-ui/react-use-controllable-state" "0.1.0"
"@radix-ui/react-slider@^0.1.1":
"@radix-ui/react-slider@^0.1.0", "@radix-ui/react-slider@^0.1.1":
version "0.1.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slider/-/react-slider-0.1.4.tgz#a7b7a480ee00158195794b08cd3f1583cf102518"
integrity sha512-0z3bCcdrAi+FIcoLXS6r0ESVWuuyMnUJoCsFm7tC7Rtv95x34YtaI8YfSyQmzuMVS4rTsNtCCTZ/s727uRaVkQ==
@ -3029,6 +3174,29 @@
resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.1.3.tgz#6801033be7ff87a6b7cadaf5b337c9f366a3c4b0"
integrity sha512-WiBSI6JBIhC6LRIsB2Kwh8DsGTlbBU+mLRxJmAe3LjHTdkDpwIbEOZgoXBbZilk/vlfjK8i6nKRAvIRn1XaIMw==
"@sendgrid/client@^7.6.2", "@sendgrid/client@^7.7.0":
version "7.7.0"
resolved "https://registry.yarnpkg.com/@sendgrid/client/-/client-7.7.0.tgz#f8f67abd604205a0d0b1af091b61517ef465fdbf"
integrity sha512-SxH+y8jeAQSnDavrTD0uGDXYIIkFylCo+eDofVmZLQ0f862nnqbC3Vd1ej6b7Le7lboyzQF6F7Fodv02rYspuA==
dependencies:
"@sendgrid/helpers" "^7.7.0"
axios "^0.26.0"
"@sendgrid/helpers@^7.7.0":
version "7.7.0"
resolved "https://registry.yarnpkg.com/@sendgrid/helpers/-/helpers-7.7.0.tgz#93fb4b6e2f0dc65080440d6a784cc93e8e148757"
integrity sha512-3AsAxfN3GDBcXoZ/y1mzAAbKzTtUZ5+ZrHOmWQ279AuaFXUNCh9bPnRpN504bgveTqoW+11IzPg3I0WVgDINpw==
dependencies:
deepmerge "^4.2.2"
"@sendgrid/mail@^7.6.2":
version "7.7.0"
resolved "https://registry.yarnpkg.com/@sendgrid/mail/-/mail-7.7.0.tgz#aba09f5ce2e9d8ceee92284c3ea8b4a90b0e38fe"
integrity sha512-5+nApPE9wINBvHSUxwOxkkQqM/IAAaBYoP9hw7WwgDNQPxraruVqHizeTitVtKGiqWCKm2mnjh4XGN3fvFLqaw==
dependencies:
"@sendgrid/client" "^7.7.0"
"@sendgrid/helpers" "^7.7.0"
"@sentry/browser@6.19.7":
version "6.19.7"
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-6.19.7.tgz#a40b6b72d911b5f1ed70ed3b4e7d4d4e625c0b5f"
@ -3269,6 +3437,11 @@
dependencies:
prop-types "^15.7.2"
"@stripe/stripe-js@^1.17.1":
version "1.32.0"
resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.32.0.tgz#4ecdd298db61ad9b240622eafed58da974bd210e"
integrity sha512-7EvBnbBfS1aynfLRmBFcuumHNGjKxnNkO47rorFBktqDYHwo7Yw6pfDW2iqq0R8r7i7XiJEdWPvvEgQAiDrx3A==
"@stripe/stripe-js@^1.29.0":
version "1.29.0"
resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.29.0.tgz#f41e46aee711d1eabcb3bbc77376016a250ec962"
@ -3402,6 +3575,19 @@
resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.2.tgz#423c77877d0569db20e1fc80885ac4118314010e"
integrity sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==
"@typeform/embed-react@^1.2.4":
version "1.17.0"
resolved "https://registry.yarnpkg.com/@typeform/embed-react/-/embed-react-1.17.0.tgz#f42f2918c8103927238caf930dd6aa5ef42a651c"
integrity sha512-KWLlyISmapKZk5tkOQ3MmEagZ3cOd4y2AOaQbtmwCX565KJX0PbMA4YZbhVoe119LT+K72fU45X+8VjbNCPegQ==
dependencies:
"@typeform/embed" "1.36.0"
fast-deep-equal "^3.1.3"
"@typeform/embed@1.36.0":
version "1.36.0"
resolved "https://registry.yarnpkg.com/@typeform/embed/-/embed-1.36.0.tgz#609351546ac41856584112b0992f00aeb85e8103"
integrity sha512-7T+xbbGw7/o6bOOuMHrD7scwvl+YiJmYIiUP7YOGlyzA5BPSGt3wQx9M+J8D/X12MhgFNX4kgkwhPNyiALXxIg==
"@types/accept-language-parser@1.5.2":
version "1.5.2"
resolved "https://registry.yarnpkg.com/@types/accept-language-parser/-/accept-language-parser-1.5.2.tgz#ea48ed07a3dc9d2ba6666d45c018ad1b5e59d665"
@ -3608,6 +3794,11 @@
dependencies:
"@types/node" "*"
"@types/gtag.js@^0.0.10":
version "0.0.10"
resolved "https://registry.yarnpkg.com/@types/gtag.js/-/gtag.js-0.0.10.tgz#ac90d9b79c00daac447725a4b78ec1c398796760"
integrity sha512-98Hy7woUb3jMAMXkZQwfIOYNyfxmI0+U4m0PpCGdnd/FHk0tDpQFCqgXdNkdEoXsKkcGya/2Gew1cAJjKJspVw==
"@types/har-format@*":
version "1.2.8"
resolved "https://registry.yarnpkg.com/@types/har-format/-/har-format-1.2.8.tgz#e6908b76d4c88be3db642846bb8b455f0bfb1c4e"
@ -3717,6 +3908,13 @@
"@types/node" "*"
"@types/socket.io" "2.1.13"
"@types/micro@7.3.7":
version "7.3.7"
resolved "https://registry.yarnpkg.com/@types/micro/-/micro-7.3.7.tgz#84bef63ef8cc113a70b9a64345ebea2d99946647"
integrity sha512-MFsX7eCj0Tg3TtphOQvANNvNtFpya+s/rYOCdV6o+DFjOQPFi2EVRbBALjbbgZTXUaJP1Q281MJiJOD40d0UxQ==
dependencies:
"@types/node" "*"
"@types/mime-types@^2.1.1":
version "2.1.1"
resolved "https://registry.yarnpkg.com/@types/mime-types/-/mime-types-2.1.1.tgz#d9ba43490fa3a3df958759adf69396c3532cf2c1"
@ -4551,6 +4749,11 @@ arrify@^2.0.0:
resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa"
integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==
asap@^2.0.0:
version "2.0.6"
resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46"
integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==
asn1.js@^5.2.0:
version "5.4.1"
resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.4.1.tgz#11a980b84ebb91781ce35b0fdc2ee294e3783f07"
@ -4645,7 +4848,7 @@ autolinker@^3.11.0:
dependencies:
tslib "^2.3.0"
autoprefixer@^10.4.2, autoprefixer@^10.4.7:
autoprefixer@^10.3.4, autoprefixer@^10.4.2, autoprefixer@^10.4.7:
version "10.4.7"
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.7.tgz#1db8d195f41a52ca5069b7593be167618edbbedf"
integrity sha512-ypHju4Y2Oav95SipEcCcI5J7CGPuvz8oat7sUtYj3ClK44bldfvtvcxK6IEK++7rqB7YchDGzweZIBG+SD0ZAA==
@ -4685,7 +4888,7 @@ axios-retry@^3.2.4:
"@babel/runtime" "^7.15.4"
is-retry-allowed "^2.2.0"
axios@>=0.21.2, axios@^0.26.1:
axios@>=0.21.2, axios@^0.26.0, axios@^0.26.1:
version "0.26.1"
resolved "https://registry.yarnpkg.com/axios/-/axios-0.26.1.tgz#1ede41c51fcf51bbbd6fd43669caaa4f0495aaa9"
integrity sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==
@ -5754,6 +5957,13 @@ co@^4.6.0:
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=
cobe@^0.4.1:
version "0.4.2"
resolved "https://registry.yarnpkg.com/cobe/-/cobe-0.4.2.tgz#c3787f961a30d6adfbaf6cb446c891f45a516bdf"
integrity sha512-EHHCAWUom+dm1p5l/uy8pUiKnxzKyHXVWr4ky3Qk0Fav6Z0iW4iPMf7+nblF6uDYnd9Lk/ghbjDhwPc6b6EteA==
dependencies:
phenomenon "^1.6.0"
code-block-writer@^11.0.0:
version "11.0.0"
resolved "https://registry.yarnpkg.com/code-block-writer/-/code-block-writer-11.0.0.tgz#5956fb186617f6740e2c3257757fea79315dd7d4"
@ -6191,6 +6401,16 @@ dayjs@^1, dayjs@^1.11.2:
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.2.tgz#fa0f5223ef0d6724b3d8327134890cfe3d72fbe5"
integrity sha512-F4LXf1OeU9hrSYRPTTj/6FbO4HTjPKXvEIC1P2kcnFurViINCVk3ZV0xAS3XVx9MkMsXbbqlK6hjseaYbgKEHw==
dayjs@^1.8.29:
version "1.11.3"
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.3.tgz#4754eb694a624057b9ad2224b67b15d552589258"
integrity sha512-xxwlswWOlGhzgQ4TKzASQkUhqERI3egRNqgV4ScR8wlANA/A9tZ7miXa44vTTKEq5l7vWoL5G57bG3zA+Kow0A==
debounce@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5"
integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==
debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.9:
version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
@ -6729,7 +6949,7 @@ enhanced-resolve@^5.7.0:
graceful-fs "^4.2.4"
tapable "^2.2.0"
env-cmd@10.1.0:
env-cmd@10.1.0, env-cmd@^10.1.0:
version "10.1.0"
resolved "https://registry.yarnpkg.com/env-cmd/-/env-cmd-10.1.0.tgz#c7f5d3b550c9519f137fdac4dd8fb6866a8c8c4b"
integrity sha512-mMdWTT9XKN7yNth/6N6g2GuKuJTsKMDHlQFUDacb/heQRRWOTIZ42t1rMHnQu4jYxU1ajdTeJM+9eEETlqToMA==
@ -8121,6 +8341,15 @@ fs-extra@^4.0.2:
jsonfile "^4.0.0"
universalify "^0.1.0"
fs-extra@^8.0.1:
version "8.1.0"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0"
integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==
dependencies:
graceful-fs "^4.2.0"
jsonfile "^4.0.0"
universalify "^0.1.0"
fs-minipass@^1.2.7:
version "1.2.7"
resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.7.tgz#ccff8570841e7fe4265693da88936c55aed7f7c7"
@ -8428,6 +8657,11 @@ goober@^2.1.1:
resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.8.tgz#e592c04d093cb38f77b38cfcb012b7811c85765e"
integrity sha512-S0C85gCzcfFCMSdjD/CxyQMt1rbf2qEg6hmDzxk2FfD7+7Ogk55m8ZFUMtqNaZM4VVX/qaU9AzSORG+Gf4ZpAQ==
goober@^2.1.10:
version "2.1.10"
resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.10.tgz#058def43ba1e3b06f973dbb372a4978aa42f1049"
integrity sha512-7PpuQMH10jaTWm33sQgBQvz45pHR8N4l3Cu3WMGEWmHShAcTuuP7I+5/DwKo39fwti5A80WAjvqgz6SSlgWmGA==
google-auth-library@^7.0.2, google-auth-library@^7.14.0:
version "7.14.1"
resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-7.14.1.tgz#e3483034162f24cc71b95c8a55a210008826213c"
@ -8535,6 +8769,11 @@ graceful-fs@^4.1.10, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.4,
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.9.tgz#041b05df45755e587a24942279b9d113146e1c96"
integrity sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ==
graceful-fs@^4.2.0:
version "4.2.10"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c"
integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==
gradient-string@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/gradient-string/-/gradient-string-2.0.0.tgz#0333846e88e6011bdd12fa73d0fa2a60dfd34f51"
@ -8732,11 +8971,26 @@ hash.js@1.1.7, hash.js@^1.0.0, hash.js@^1.0.3, hash.js@^1.1.7:
inherits "^2.0.3"
minimalistic-assert "^1.0.1"
hast-util-is-element@^2.0.0:
version "2.1.2"
resolved "https://registry.yarnpkg.com/hast-util-is-element/-/hast-util-is-element-2.1.2.tgz#fc0b0dc7cef3895e839b8d66979d57b0338c68f3"
integrity sha512-thjnlGAnwP8ef/GSO1Q8BfVk2gundnc2peGQqEg2kUt/IqesiGg/5mSwN2fE7nLzy61pg88NG6xV+UrGOrx9EA==
dependencies:
"@types/hast" "^2.0.0"
"@types/unist" "^2.0.0"
hast-util-parse-selector@^2.0.0:
version "2.2.5"
resolved "https://registry.yarnpkg.com/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz#d57c23f4da16ae3c63b3b6ca4616683313499c3a"
integrity sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==
hast-util-sanitize@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/hast-util-sanitize/-/hast-util-sanitize-4.0.0.tgz#71a02ca2e50d04b852a5500846418070ca364f60"
integrity sha512-pw56+69jq+QSr/coADNvWTmBPDy+XsmwaF5KnUys4/wM1jt/fZdl7GPxhXXXYdXnz3Gj3qMkbUCH2uKjvX0MgQ==
dependencies:
"@types/hast" "^2.0.0"
hast-util-to-estree@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/hast-util-to-estree/-/hast-util-to-estree-2.0.2.tgz#79c5bf588915610b3f0d47ca83a74dc0269c7dc2"
@ -8757,6 +9011,22 @@ hast-util-to-estree@^2.0.0:
unist-util-position "^4.0.0"
zwitch "^2.0.0"
hast-util-to-html@^8.0.0:
version "8.0.3"
resolved "https://registry.yarnpkg.com/hast-util-to-html/-/hast-util-to-html-8.0.3.tgz#4e37580872e143ea9ce0dba87918b19e4ea997e3"
integrity sha512-/D/E5ymdPYhHpPkuTHOUkSatxr4w1ZKrZsG0Zv/3C2SRVT0JFJG53VS45AMrBtYk0wp5A7ksEhiC8QaOZM95+A==
dependencies:
"@types/hast" "^2.0.0"
ccount "^2.0.0"
comma-separated-tokens "^2.0.0"
hast-util-is-element "^2.0.0"
hast-util-whitespace "^2.0.0"
html-void-elements "^2.0.0"
property-information "^6.0.0"
space-separated-tokens "^2.0.0"
stringify-entities "^4.0.2"
unist-util-is "^5.0.0"
hast-util-whitespace@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/hast-util-whitespace/-/hast-util-whitespace-2.0.0.tgz#4fc1086467cc1ef5ba20673cb6b03cec3a970f1c"
@ -8830,6 +9100,11 @@ html-parse-stringify@^3.0.1:
dependencies:
void-elements "3.1.0"
html-void-elements@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-2.0.1.tgz#29459b8b05c200b6c5ee98743c41b979d577549f"
integrity sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==
http-cache-semantics@3.8.1:
version "3.8.1"
resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-3.8.1.tgz#39b0e16add9b605bf0a9ef3d9daaf4843b4cacd2"
@ -10758,6 +11033,15 @@ jsonfile@^4.0.0:
optionalDependencies:
graceful-fs "^4.1.6"
jsonfile@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-5.0.0.tgz#e6b718f73da420d612823996fdf14a03f6ff6922"
integrity sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==
dependencies:
universalify "^0.1.2"
optionalDependencies:
graceful-fs "^4.1.6"
jsonwebtoken@^8.5.1:
version "8.5.1"
resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d"
@ -11497,6 +11781,21 @@ mdast-util-mdxjs-esm@^1.0.0:
mdast-util-from-markdown "^1.0.0"
mdast-util-to-markdown "^1.0.0"
mdast-util-to-hast@^11.0.0:
version "11.3.0"
resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-11.3.0.tgz#ea9220617a710e80aa5cc3ac7cc9d4bb0440ae7a"
integrity sha512-4o3Cli3hXPmm1LhB+6rqhfsIUBjnKFlIUZvudaermXB+4/KONdd/W4saWWkC+LBLbPMqhFSSTSRgafHsT5fVJw==
dependencies:
"@types/hast" "^2.0.0"
"@types/mdast" "^3.0.0"
"@types/mdurl" "^1.0.0"
mdast-util-definitions "^5.0.0"
mdurl "^1.0.0"
unist-builder "^3.0.0"
unist-util-generated "^2.0.0"
unist-util-position "^4.0.0"
unist-util-visit "^4.0.0"
mdast-util-to-hast@^12.1.0:
version "12.1.1"
resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-12.1.1.tgz#89a2bb405eaf3b05eb8bf45157678f35eef5dbca"
@ -11626,7 +11925,7 @@ methods@^1.1.2, methods@~1.1.2:
resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=
micro@^9.3.4:
micro@9.3.4, micro@^9.3.4:
version "9.3.4"
resolved "https://registry.yarnpkg.com/micro/-/micro-9.3.4.tgz#745a494e53c8916f64fb6a729f8cbf2a506b35ad"
integrity sha512-smz9naZwTG7qaFnEZ2vn248YZq9XR+XoOH3auieZbkhDL4xLOxiE+KqG8qqnBeKfXA9c1uEFGCxPN1D+nT6N7w==
@ -12360,6 +12659,21 @@ next-api-middleware@^1.0.1:
dependencies:
debug "^4.3.2"
next-auth@^4.3.3:
version "4.10.0"
resolved "https://registry.yarnpkg.com/next-auth/-/next-auth-4.10.0.tgz#cffa850cb0f633e6340d34c567634df1d10feabe"
integrity sha512-4CKZbv9VeCaqfDAXyqFThZy05ApbLd0bhXEB+DCq9aD43h6Rkvz0QgM7QOCJXESy0QKJUXHzopkBq+iaGxdc0g==
dependencies:
"@babel/runtime" "^7.16.3"
"@panva/hkdf" "^1.0.1"
cookie "^0.4.1"
jose "^4.3.7"
oauth "^0.9.15"
openid-client "^5.1.0"
preact "^10.6.3"
preact-render-to-string "^5.1.19"
uuid "^8.3.2"
next-auth@^4.9.0:
version "4.9.0"
resolved "https://registry.yarnpkg.com/next-auth/-/next-auth-4.9.0.tgz#0d8cabcb22a976744131a2e68d5f08756f322593"
@ -12405,6 +12719,11 @@ next-mdx-remote@^4.0.3:
vfile "^5.3.0"
vfile-matter "^3.0.1"
next-plausible@^2.1.2:
version "2.2.0"
resolved "https://registry.yarnpkg.com/next-plausible/-/next-plausible-2.2.0.tgz#f825842f97bce0062bdaf897328c4908d7ce0a78"
integrity sha512-pIhs5MikL6ZMJvB7sxkM49xN06W1A6d6RYta5vrqwQmF2/oXoCG+IPoaPzyODZ/vo7f2/NMAOaUm5QM0dKqMdA==
next-seo@^4.26.0:
version "4.29.0"
resolved "https://registry.yarnpkg.com/next-seo/-/next-seo-4.29.0.tgz#d281e95ba47914117cc99e9e468599f0547d9b9b"
@ -12450,6 +12769,29 @@ next-validations@^0.2.0:
resolved "https://registry.yarnpkg.com/next-validations/-/next-validations-0.2.0.tgz#ce3c4bc332b115beda633521fd81e587987864eb"
integrity sha512-QMF2hRNSSbjeBaCYqpt3mEM9CkXXzaMCWCvPyi5/vKTBjbgkiYtaQnUfjj5eH8dX+ZmRrBYGgN1EKqL7ZnI0wQ==
next@12.1.6:
version "12.1.6"
resolved "https://registry.yarnpkg.com/next/-/next-12.1.6.tgz#eb205e64af1998651f96f9df44556d47d8bbc533"
integrity sha512-cebwKxL3/DhNKfg9tPZDQmbRKjueqykHHbgaoG4VBRH3AHQJ2HO0dbKFiS1hPhe1/qgc2d/hFeadsbPicmLD+A==
dependencies:
"@next/env" "12.1.6"
caniuse-lite "^1.0.30001332"
postcss "8.4.5"
styled-jsx "5.0.2"
optionalDependencies:
"@next/swc-android-arm-eabi" "12.1.6"
"@next/swc-android-arm64" "12.1.6"
"@next/swc-darwin-arm64" "12.1.6"
"@next/swc-darwin-x64" "12.1.6"
"@next/swc-linux-arm-gnueabihf" "12.1.6"
"@next/swc-linux-arm64-gnu" "12.1.6"
"@next/swc-linux-arm64-musl" "12.1.6"
"@next/swc-linux-x64-gnu" "12.1.6"
"@next/swc-linux-x64-musl" "12.1.6"
"@next/swc-win32-arm64-msvc" "12.1.6"
"@next/swc-win32-ia32-msvc" "12.1.6"
"@next/swc-win32-x64-msvc" "12.1.6"
next@12.2.0:
version "12.2.0"
resolved "https://registry.yarnpkg.com/next/-/next-12.2.0.tgz#aef47cd96b602bc1307d1dcf9a1ee3e753845544"
@ -12476,6 +12818,32 @@ next@12.2.0:
"@next/swc-win32-ia32-msvc" "12.2.0"
"@next/swc-win32-x64-msvc" "12.2.0"
next@^12.1.6:
version "12.2.2"
resolved "https://registry.yarnpkg.com/next/-/next-12.2.2.tgz#029bf5e4a18a891ca5d05b189b7cd983fd22c072"
integrity sha512-zAYFY45aBry/PlKONqtlloRFqU/We3zWYdn2NoGvDZkoYUYQSJC8WMcalS5C19MxbCZLUVCX7D7a6gTGgl2yLg==
dependencies:
"@next/env" "12.2.2"
"@swc/helpers" "0.4.2"
caniuse-lite "^1.0.30001332"
postcss "8.4.5"
styled-jsx "5.0.2"
use-sync-external-store "1.1.0"
optionalDependencies:
"@next/swc-android-arm-eabi" "12.2.2"
"@next/swc-android-arm64" "12.2.2"
"@next/swc-darwin-arm64" "12.2.2"
"@next/swc-darwin-x64" "12.2.2"
"@next/swc-freebsd-x64" "12.2.2"
"@next/swc-linux-arm-gnueabihf" "12.2.2"
"@next/swc-linux-arm64-gnu" "12.2.2"
"@next/swc-linux-arm64-musl" "12.2.2"
"@next/swc-linux-x64-gnu" "12.2.2"
"@next/swc-linux-x64-musl" "12.2.2"
"@next/swc-win32-arm64-msvc" "12.2.2"
"@next/swc-win32-ia32-msvc" "12.2.2"
"@next/swc-win32-x64-msvc" "12.2.2"
next@^12.2.0:
version "12.2.1"
resolved "https://registry.yarnpkg.com/next/-/next-12.2.1.tgz#b487dc598ef1373a1b1275d68531a7088fe5653d"
@ -13396,6 +13764,11 @@ pgpass@1.x:
dependencies:
split2 "^4.1.0"
phenomenon@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/phenomenon/-/phenomenon-1.6.0.tgz#7b5b7647d0b48152cc0846994da3d92e8f6da677"
integrity sha512-7h9/fjPD3qNlgggzm88cY58l9sudZ6Ey+UmZsizfhtawO6E3srZQXywaNm2lBwT72TbpHYRPy7ytIHeBUD/G0A==
phin@^2.9.1:
version "2.9.3"
resolved "https://registry.yarnpkg.com/phin/-/phin-2.9.3.tgz#f9b6ac10a035636fb65dfc576aaaa17b8743125c"
@ -13494,6 +13867,11 @@ pngjs@^5.0.0:
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb"
integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==
pop-iterate@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/pop-iterate/-/pop-iterate-1.0.1.tgz#ceacfdab4abf353d7a0f2aaa2c1fc7b3f9413ba3"
integrity sha512-HRCx4+KJE30JhX84wBN4+vja9bNfysxg1y28l0DuJmkoaICiv2ZSilKddbS48pq50P8d2erAhqDLbp47yv3MbQ==
posix-character-classes@^0.1.0:
version "0.1.1"
resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"
@ -13552,7 +13930,7 @@ postcss@8.4.5:
picocolors "^1.0.0"
source-map-js "^1.0.1"
postcss@^8.4.13, postcss@^8.4.14, postcss@^8.4.8:
postcss@^8.3.6, postcss@^8.4.13, postcss@^8.4.14, postcss@^8.4.8:
version "8.4.14"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.14.tgz#ee9274d5622b4858c1007a74d76e42e56fd21caf"
integrity sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig==
@ -13823,6 +14201,15 @@ pvutils@latest:
resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.1.3.tgz#f35fc1d27e7cd3dfbd39c0826d173e806a03f5a3"
integrity sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==
q@2.0.x:
version "2.0.3"
resolved "https://registry.yarnpkg.com/q/-/q-2.0.3.tgz#75b8db0255a1a5af82f58c3f3aaa1efec7d0d134"
integrity sha512-gv6vLGcmAOg96/fgo3d9tvA4dJNZL3fMyBqVRrGxQ+Q/o4k9QzbJ3NQF9cOO/71wRodoXhaPgphvMFU68qVAJQ==
dependencies:
asap "^2.0.0"
pop-iterate "^1.0.1"
weak-map "^1.0.5"
qrcode@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.0.tgz#95abb8a91fdafd86f8190f2836abbfc500c72d1b"
@ -13989,6 +14376,13 @@ react-colorful@^5.5.1:
resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.5.1.tgz#29d9c4e496f2ca784dd2bb5053a3a4340cfaf784"
integrity sha512-M1TJH2X3RXEt12sWkpa6hLc/bbYS0H6F4rIqjQZ+RxNBstpY67d9TrFXtqdZwhpmBXcCwEi7stKqFue3ZRkiOg==
react-confetti@^6.0.1:
version "6.1.0"
resolved "https://registry.yarnpkg.com/react-confetti/-/react-confetti-6.1.0.tgz#03dc4340d955acd10b174dbf301f374a06e29ce6"
integrity sha512-7Ypx4vz0+g8ECVxr88W9zhcQpbeujJAVqL14ZnXJ3I23mOI9/oBVTQ3dkJhUmB0D6XOtCZEM6N0Gm9PMngkORw==
dependencies:
tween-functions "^1.2.0"
react-copy-to-clipboard@5.0.4:
version "5.0.4"
resolved "https://registry.yarnpkg.com/react-copy-to-clipboard/-/react-copy-to-clipboard-5.0.4.tgz#42ec519b03eb9413b118af92d1780c403a5f19bf"
@ -14059,6 +14453,11 @@ react-fit@^1.4.0:
prop-types "^15.6.0"
tiny-warning "^1.0.0"
react-hook-form@^7.16.2:
version "7.33.1"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.33.1.tgz#8c4410e3420788d3b804d62cc4c142915c2e46d0"
integrity sha512-ydTfTxEJdvgjCZBj5DDXRc58oTEfnFupEwwTAQ9FSKzykEJkX+3CiAkGtAMiZG7IPWHuzgT6AOBfogiKhUvKgg==
react-hook-form@^7.31.1:
version "7.31.1"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.31.1.tgz#16c357dd366bc226172e6acbb5a1672873bbfb28"
@ -14071,6 +14470,13 @@ react-hot-toast@^2.1.0:
dependencies:
goober "^2.1.1"
react-hot-toast@^2.1.1:
version "2.3.0"
resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.3.0.tgz#70b3d183ac2a4afb6b17cda4a7f4cfe02e730415"
integrity sha512-/RxV+bfjld7tSJR1SCLzMAXgFuNW7fCpK6+vbYqfmbGSWcqTMz2rizrvfWKvtcPH5HK0NqxmBaC5SrAy1F42zA==
dependencies:
goober "^2.1.10"
react-i18next@^11.16.2:
version "11.16.9"
resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-11.16.9.tgz#890cdac0c49120e075d6c520b43dbad3f91bd2df"
@ -14282,6 +14688,15 @@ react-transition-group@^4.3.0:
loose-envify "^1.4.0"
prop-types "^15.6.2"
react-twemoji@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/react-twemoji/-/react-twemoji-0.3.0.tgz#8c8d5aedec8dda5cc0538043639073bcdd44c3a8"
integrity sha512-y2ZQD3KvpZklETxz9c1NycRdUVF5nKsJ0bPNW3SaRJT+ReK36sMcneYwRPfv9EK2p3s9ph/NczDglnB8wbMJ0g==
dependencies:
lodash.isequal "^4.5.0"
prop-types "^15.7.2"
twemoji "^13.0.1"
react-use-intercom@1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/react-use-intercom/-/react-use-intercom-1.5.1.tgz#94567a80ce3b56692962d712a54489c55fb4c54e"
@ -14478,6 +14893,17 @@ remark-gfm@^1.0.0:
mdast-util-gfm "^0.1.0"
micromark-extension-gfm "^0.3.0"
remark-html@^14.0.1:
version "14.0.1"
resolved "https://registry.yarnpkg.com/remark-html/-/remark-html-14.0.1.tgz#2118516604c1a6c2ea9d5914a526942554e04e30"
integrity sha512-a/x5bTlFrkwYkz43zuJIk0m0IuS5Rx8zLztGwdzmAdUj0Hsi4C4nkJ8gTQRNXY/ET/gMrqQORMMI0arRItq/aQ==
dependencies:
"@types/mdast" "^3.0.0"
hast-util-sanitize "^4.0.0"
hast-util-to-html "^8.0.0"
mdast-util-to-hast "^11.0.0"
unified "^10.0.0"
remark-mdx@^2.0.0:
version "2.1.1"
resolved "https://registry.yarnpkg.com/remark-mdx/-/remark-mdx-2.1.1.tgz#14021be9ecbc9ad0310f4240980221328aa7ed55"
@ -14512,6 +14938,15 @@ remark-rehype@^10.0.0:
mdast-util-to-hast "^12.1.0"
unified "^10.0.0"
remark-stringify@^10.0.0:
version "10.0.2"
resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-10.0.2.tgz#50414a6983f5008eb9e72eed05f980582d1f69d7"
integrity sha512-6wV3pvbPvHkbNnWB0wdDvVFHOe1hBRAx1Q/5g/EpH4RppAII6J8Gnwe7VbHuXaoKIF6LAg6ExTel/+kNqSQ7lw==
dependencies:
"@types/mdast" "^3.0.0"
mdast-util-to-markdown "^1.0.0"
unified "^10.0.0"
remark-stringify@^9.0.0:
version "9.0.1"
resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-9.0.1.tgz#576d06e910548b0a7191a71f27b33f1218862894"
@ -14528,6 +14963,16 @@ remark@^13.0.0:
remark-stringify "^9.0.0"
unified "^9.1.0"
remark@^14.0.1:
version "14.0.2"
resolved "https://registry.yarnpkg.com/remark/-/remark-14.0.2.tgz#4a1833f7441a5c29e44b37bb1843fb820797b40f"
integrity sha512-A3ARm2V4BgiRXaUo5K0dRvJ1lbogrbXnhkJRmD0yw092/Yl0kOCZt1k9ZeElEwkZsWGsMumz6qL5MfNJH9nOBA==
dependencies:
"@types/mdast" "^3.0.0"
remark-parse "^10.0.0"
remark-stringify "^10.0.0"
unified "^10.0.0"
remarkable@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/remarkable/-/remarkable-2.0.1.tgz#280ae6627384dfb13d98ee3995627ca550a12f31"
@ -14734,6 +15179,11 @@ rollup@^2.59.0:
optionalDependencies:
fsevents "~2.3.2"
rootpath@^0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/rootpath/-/rootpath-0.1.2.tgz#5b379a87dca906e9b91d690a599439bef267ea6b"
integrity sha512-R3wLbuAYejpxQjL/SjXo1Cjv4wcJECnMRT/FlcCfTwCBhaji9rWaRCoVEQ1SPiTJ4kKK+yh+bZLAV7SCafoDDw==
rrule@^2.6.9:
version "2.6.9"
resolved "https://registry.yarnpkg.com/rrule/-/rrule-2.6.9.tgz#8ee4ee261451e84852741f92ded769245580744a"
@ -14850,6 +15300,11 @@ scheduler@^0.22.0:
dependencies:
loose-envify "^1.1.0"
scmp@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/scmp/-/scmp-2.1.0.tgz#37b8e197c425bdeb570ab91cc356b311a11f9c9a"
integrity sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==
scrypt-js@^3.0.0, scrypt-js@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/scrypt-js/-/scrypt-js-3.0.1.tgz#d314a57c2aef69d1ad98a138a21fe9eafa9ee312"
@ -15504,6 +15959,14 @@ stringify-entities@^4.0.0:
character-entities-html4 "^2.0.0"
character-entities-legacy "^3.0.0"
stringify-entities@^4.0.2:
version "4.0.3"
resolved "https://registry.yarnpkg.com/stringify-entities/-/stringify-entities-4.0.3.tgz#cfabd7039d22ad30f3cc435b0ca2c1574fc88ef8"
integrity sha512-BP9nNHMhhfcMbiuQKCqMjhDP5yBCAxsPu4pHFFzJ6Alo9dZgY4VLDPutXqIjpRiMoKdp7Av85Gr73Q5uH9k7+g==
dependencies:
character-entities-html4 "^2.0.0"
character-entities-legacy "^3.0.0"
stringify-object@^3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/stringify-object/-/stringify-object-3.3.0.tgz#703065aefca19300d3ce88af4f5b3956d7556629"
@ -16370,11 +16833,48 @@ turbo@^1.3.1:
turbo-windows-64 "1.3.1"
turbo-windows-arm64 "1.3.1"
tween-functions@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/tween-functions/-/tween-functions-1.2.0.tgz#1ae3a50e7c60bb3def774eac707acbca73bbc3ff"
integrity sha512-PZBtLYcCLtEcjL14Fzb1gSxPBeL7nWvGhO5ZFPGqziCcr8uvHp0NDmdjBchp6KHL+tExcg0m3NISmKxhU394dA==
tweetnacl@^0.14.3, tweetnacl@~0.14.0:
version "0.14.5"
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=
twemoji-parser@13.1.0:
version "13.1.0"
resolved "https://registry.yarnpkg.com/twemoji-parser/-/twemoji-parser-13.1.0.tgz#65e7e449c59258791b22ac0b37077349127e3ea4"
integrity sha512-AQOzLJpYlpWMy8n+0ATyKKZzWlZBJN+G0C+5lhX7Ftc2PeEVdUU/7ns2Pn2vVje26AIZ/OHwFoUbdv6YYD/wGg==
twemoji@^13.0.1:
version "13.1.1"
resolved "https://registry.yarnpkg.com/twemoji/-/twemoji-13.1.1.tgz#6e31409908bb5383cdb1d09c9c8e7856aa6e2e3b"
integrity sha512-IIIoq+n1lk1M1+evBKZD3DO0ud02fDQ4ssbgAv8rp3YBWUeNmskjlisFUPPDacQ50XS3bhrd4Kq9Q2gqhxb0dg==
dependencies:
fs-extra "^8.0.1"
jsonfile "^5.0.0"
twemoji-parser "13.1.0"
universalify "^0.1.2"
twilio@^3.75.1:
version "3.78.0"
resolved "https://registry.yarnpkg.com/twilio/-/twilio-3.78.0.tgz#d03913d13dd9b74fc39fada686e001b9bdef6235"
integrity sha512-XowaxcOeLVNnvxx3t81seLZ/hE/N4z6yt9Vg+KtGhj81gf2ghUa57cr5QtqsI06qnknsGcId5I9X4mRoxZ4nMg==
dependencies:
axios "^0.26.1"
dayjs "^1.8.29"
https-proxy-agent "^5.0.0"
jsonwebtoken "^8.5.1"
lodash "^4.17.21"
q "2.0.x"
qs "^6.9.4"
rootpath "^0.1.2"
scmp "^2.1.0"
url-parse "^1.5.9"
xmlbuilder "^13.0.2"
type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
@ -16733,7 +17233,7 @@ url-parse-lax@^3.0.0:
dependencies:
prepend-http "^2.0.0"
url-parse@^1.4.3, url-parse@^1.5.8:
url-parse@^1.4.3, url-parse@^1.5.8, url-parse@^1.5.9:
version "1.5.10"
resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1"
integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==
@ -17010,6 +17510,11 @@ wcwidth@^1.0.1:
dependencies:
defaults "^1.0.3"
weak-map@^1.0.5:
version "1.0.8"
resolved "https://registry.yarnpkg.com/weak-map/-/weak-map-1.0.8.tgz#394c18a9e8262e790544ed8b55c6a4ddad1cb1a3"
integrity sha512-lNR9aAefbGPpHO7AEnY0hCFjz1eTkWCXYvkTRrTHs9qv8zJp+SkVYpzfLIFXQQiG3tVvbNFQgVg2bQS8YGgxyw==
web-streams-polyfill@4.0.0-beta.1:
version "4.0.0-beta.1"
resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.1.tgz#3b19b9817374b7cee06d374ba7eeb3aeb80e8c95"
@ -17575,6 +18080,11 @@ xmlbuilder@15.1.1:
resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz#9dcdce49eea66d8d10b42cae94a79c3c8d0c2ec5"
integrity sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==
xmlbuilder@^13.0.2:
version "13.0.2"
resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-13.0.2.tgz#02ae33614b6a047d1c32b5389c1fdacb2bce47a7"
integrity sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==
xmlbuilder@~11.0.0:
version "11.0.1"
resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3"