Merge branch 'main' into monorepo/app-store

This commit is contained in:
zomars 2022-03-07 17:30:12 -07:00
commit 26e5904d00
91 changed files with 2721 additions and 1668 deletions

View File

@ -8,7 +8,7 @@ Contributions are what make the open source community such an amazing place to b
The development branch is `main`. This is the branch that all pull
requests should be made against. The changes on the `main`
branch are tagged into a release biweekly.
branch are tagged into a release monthly.
To develop locally:
@ -51,7 +51,7 @@ Please be sure that you can make a full production build before pushing code.
## Testing
More info on how to add new tests comming soon.
More info on how to add new tests coming soon.
### Running tests

View File

@ -0,0 +1,83 @@
---
title: Contributing
---
# Contributing
Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are **greatly appreciated**.
- Before jumping into a PR be sure to search [existing PRs](https://github.com/calcom/cal.com/pulls) or [issues](https://github.com/calcom/cal.com/issues) for an open or closed item that relates to your submission.
## Developing
The development branch is `main`. This is the branch that all pull
requests should be made against. The changes on the `main`
branch are tagged into a release monthly.
To develop locally:
1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your
own GitHub account and then
[clone](https://help.github.com/articles/cloning-a-repository/) it to your local device.
2. Create a new branch:
```sh
git checkout -b MY_BRANCH_NAME
```
3. Install yarn:
```sh
npm install -g yarn
```
4. Install the dependencies with:
```sh
yarn
```
5. Start developing and watch for code changes:
```sh
yarn dev
```
## Building
You can build the project with:
```bash
yarn build
```
Please be sure that you can make a full production build before pushing code.
## Testing
More info on how to add new tests coming soon.
### Running tests
This will run and test all flows in multiple Chromium windows to verify that no critical flow breaks:
```sh
yarn test-e2e
```
## Linting
To check the formatting of your code:
```sh
yarn lint
```
If you get errors, be sure to fix them before comitting.
## Making a Pull Request
- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating you PR.
- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. Se more about [Linking a pull request to an issue
](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
- Be sure to fill the PR Template accordingly.

View File

@ -11,5 +11,6 @@
"import": "Import",
"billing": "Billing",
"developer": "Developer",
"contributing": "Contributing",
"faq": "FAQs"
}

View File

@ -1,6 +1,6 @@
export default {
github: 'https://github.com/calcom/docs',
docsRepositoryBase: 'https://github.com/calcom/docs/blob/master',
github: 'https://github.com/calcom/cal.com',
docsRepositoryBase: 'https://github.com/calcom/cal.com/blob/main/apps/docs/pages',
titleSuffix: ' | Cal.com',
logo: (
<h4 className="m-0">

View File

@ -1 +0,0 @@
export * from "@calcom/prisma/client";

View File

@ -18,7 +18,7 @@ export default function AddToHomescreen() {
<div className="rounded-lg p-2 shadow-lg sm:p-3" style={{ background: "#2F333D" }}>
<div className="flex flex-wrap items-center justify-between">
<div className="flex w-0 flex-1 items-center">
<span className="bg-brand text-brandcontrast flex rounded-lg bg-opacity-30 p-2">
<span className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast flex rounded-lg bg-opacity-30 p-2">
<svg
className="h-7 w-7 fill-current text-indigo-500"
xmlns="http://www.w3.org/2000/svg"

View File

@ -2,6 +2,7 @@ import { useEffect } from "react";
const brandColor = "#292929";
const brandTextColor = "#ffffff";
const darkBrandColor = "#fafafa";
export function colorNameToHex(color: string) {
const colors = {
@ -174,8 +175,8 @@ function hexToRGB(hex: string) {
return [parseInt(color.slice(0, 2), 16), parseInt(color.slice(2, 4), 16), parseInt(color.slice(4, 6), 16)];
}
function getContrastingTextColor(bgColor: string | null): string {
bgColor = bgColor == "" || bgColor == null ? brandColor : bgColor;
function getContrastingTextColor(bgColor: string | null, dark: boolean): string {
bgColor = bgColor == "" || bgColor == null ? (dark ? darkBrandColor : brandColor) : bgColor;
const rgb = hexToRGB(bgColor);
const whiteContrastRatio = computeContrastRatio(rgb, [255, 255, 255]);
const blackContrastRatio = computeContrastRatio(rgb, [41, 41, 41]); //#292929
@ -191,18 +192,38 @@ export function isValidHexCode(val: string | null) {
return false;
}
export function fallBackHex(val: string | null): string {
export function fallBackHex(val: string | null, dark: boolean): string {
if (val) if (colorNameToHex(val)) return colorNameToHex(val) as string;
return brandColor;
return dark ? darkBrandColor : brandColor;
}
const BrandColor = ({ val = brandColor }: { val: string | undefined | null }) => {
const BrandColor = ({
lightVal = brandColor,
darkVal = darkBrandColor,
}: {
lightVal: string | undefined | null;
darkVal: string | undefined | null;
}) => {
// ensure acceptable hex-code
val = isValidHexCode(val) ? (val?.indexOf("#") === 0 ? val : "#" + val) : fallBackHex(val);
lightVal = isValidHexCode(lightVal)
? lightVal?.indexOf("#") === 0
? lightVal
: "#" + lightVal
: fallBackHex(lightVal, false);
darkVal = isValidHexCode(darkVal)
? darkVal?.indexOf("#") === 0
? darkVal
: "#" + darkVal
: fallBackHex(darkVal, true);
useEffect(() => {
document.documentElement.style.setProperty("--brand-color", val);
document.documentElement.style.setProperty("--brand-text-color", getContrastingTextColor(val));
}, [val]);
document.documentElement.style.setProperty("--brand-color", lightVal);
document.documentElement.style.setProperty("--brand-text-color", getContrastingTextColor(lightVal, true));
document.documentElement.style.setProperty("--brand-color-dark-mode", darkVal);
document.documentElement.style.setProperty(
"--brand-text-color-dark-mode",
getContrastingTextColor(darkVal, true)
);
}, [lightVal, darkVal]);
return null;
};

View File

@ -1,11 +1,55 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import React, { ReactNode } from "react";
import { useRouter } from "next/router";
import React, { ReactNode, useState } from "react";
export type DialogProps = React.ComponentProps<typeof DialogPrimitive["Root"]>;
export type DialogProps = React.ComponentProps<typeof DialogPrimitive["Root"]> & {
name?: string;
clearQueryParamsOnClose?: string[];
};
export function Dialog(props: DialogProps) {
const { children, ...other } = props;
const router = useRouter();
const { children, name, ...dialogProps } = props;
// only used if name is set
const [open, setOpen] = useState(!!dialogProps.open);
if (name) {
const clearQueryParamsOnClose = ["dialog", ...(props.clearQueryParamsOnClose || [])];
dialogProps.onOpenChange = (open) => {
if (props.onOpenChange) {
props.onOpenChange(open);
}
// toggles "dialog" query param
if (open) {
router.query["dialog"] = name;
} else {
clearQueryParamsOnClose.forEach((queryParam) => {
delete router.query[queryParam];
});
}
router.push(
{
pathname: router.pathname,
query: {
...router.query,
},
},
undefined,
{ shallow: true }
);
setOpen(open);
};
// handles initial state
if (!open && router.query["dialog"] === name) {
setOpen(true);
}
// allow overriding
if (!("open" in dialogProps)) {
dialogProps.open = open;
}
}
return (
<DialogPrimitive.Root {...other}>
<DialogPrimitive.Root {...dialogProps}>
<DialogPrimitive.Overlay className="fixed inset-0 z-40 bg-gray-500 bg-opacity-75 transition-opacity" />
{children}
</DialogPrimitive.Root>

View File

@ -1,7 +1,7 @@
export default function Loader() {
return (
<div className="loader border-brand dark:border-white">
<span className="loader-inner bg-brand dark:bg-white"></span>
<div className="loader border-brand dark:border-darkmodebrand">
<span className="loader-inner bg-brand dark:bg-darkmodebrand"></span>
</div>
);
}

View File

@ -210,7 +210,7 @@ export default function Shell(props: {
}
return (
<>
<CustomBranding val={user?.brandColor} />
<CustomBranding lightVal={user?.brandColor} darkVal={user?.darkBrandColor} />
<HeadSeo
title={pageTitle ?? "Cal.com"}
description={props.subtitle ? props.subtitle?.toString() : ""}

View File

@ -14,6 +14,8 @@ import Loader from "@components/Loader";
type AvailableTimesProps = {
timeFormat: string;
minimumBookingNotice: number;
beforeBufferTime: number;
afterBufferTime: number;
eventTypeId: number;
eventLength: number;
slotInterval: number | null;
@ -33,6 +35,8 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
timeFormat,
users,
schedulingType,
beforeBufferTime,
afterBufferTime,
}) => {
const { t, i18n } = useLocale();
const router = useRouter();
@ -45,6 +49,8 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
schedulingType,
users,
minimumBookingNotice,
beforeBufferTime,
afterBufferTime,
eventTypeId,
});
@ -95,7 +101,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
<Link href={bookingUrl}>
<a
className={classNames(
"text-primary-500 hover:bg-brand hover:text-brandcontrast dark:hover:bg-brand dark:hover:text-brandcontrast mb-2 block rounded-sm border bg-white py-4 font-medium hover:text-white dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 dark:hover:border-black",
"text-primary-500 hover:bg-brand hover:text-brandcontrast dark:hover:bg-darkmodebrand dark:hover:text-darkmodebrandcontrast mb-2 block rounded-sm border bg-white py-4 font-medium hover:text-white dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 dark:hover:border-black",
brand === "#fff" || brand === "#ffffff" ? "border-brandcontrast" : "border-brand"
)}
data-testid="time">

View File

@ -170,7 +170,9 @@ function BookingListItem(booking: BookingItem) {
<td className="whitespace-nowrap py-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4">
{isUpcoming && !isCancelled ? (
<>
{!booking.confirmed && !booking.rejected && <TableActions actions={pendingActions} />}
{!booking.confirmed && !booking.rejected && user!.id === booking.user!.id && (
<TableActions actions={pendingActions} />
)}
{booking.confirmed && !booking.rejected && <TableActions actions={bookedActions} />}
{!booking.confirmed && booking.rejected && (
<div className="text-sm text-gray-500">{t("rejected")}</div>

View File

@ -278,7 +278,7 @@ function DatePicker({
"hover:border-brand hover:border dark:hover:border-white",
day.disabled ? "cursor-default font-light text-gray-400 hover:border-0" : "font-medium",
date && date.isSame(browsingDate.date(day.date), "day")
? "bg-brand text-brandcontrast"
? "bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast"
: !day.disabled
? " bg-gray-100 dark:bg-gray-600 dark:text-white"
: ""

View File

@ -46,7 +46,9 @@ const TimeOptions: FC<Props> = ({ onToggle24hClock, onSelectTimeZone }) => {
checked={is24hClock}
onChange={handle24hClockToggle}
className={classNames(
is24hClock ? "bg-brand text-brandcontrast" : "bg-gray-200 dark:bg-gray-600",
is24hClock
? "bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast"
: "bg-gray-200 dark:bg-gray-600",
"relative inline-flex h-5 w-8 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2"
)}>
<span className="sr-only">{t("use_setting")}</span>

View File

@ -1,5 +1,12 @@
// Get router variables
import { ChevronDownIcon, ChevronUpIcon, ClockIcon, CreditCardIcon, GlobeIcon } from "@heroicons/react/solid";
import {
ArrowLeftIcon,
ChevronDownIcon,
ChevronUpIcon,
ClockIcon,
CreditCardIcon,
GlobeIcon,
} from "@heroicons/react/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useContracts } from "contexts/contractsContext";
import dayjs, { Dayjs } from "dayjs";
@ -11,6 +18,7 @@ import { FormattedNumber, IntlProvider } from "react-intl";
import { asStringOrNull } from "@lib/asStringOrNull";
import { timeZone } from "@lib/clock";
import { BASE_URL } from "@lib/config/constants";
import { useLocale } from "@lib/hooks/useLocale";
import useTheme from "@lib/hooks/useTheme";
import { isBrandingHidden } from "@lib/isBrandingHidden";
@ -33,7 +41,7 @@ dayjs.extend(customParseFormat);
type Props = AvailabilityTeamPageProps | AvailabilityPageProps;
const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
const AvailabilityPage = ({ profile, eventType, workingHours, previousPage }: Props) => {
const router = useRouter();
const { rescheduleUid } = router.query;
const { isReady, Theme } = useTheme(profile.theme);
@ -110,7 +118,7 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
username={profile.slug || undefined}
// avatar={profile.image || undefined}
/>
<CustomBranding val={profile.brandColor} />
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
<div>
<main
className={
@ -168,7 +176,7 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
<div className="px-4 sm:flex sm:p-4 sm:py-5">
<div
className={
"hidden pr-8 sm:border-r sm:dark:border-gray-800 md:block " +
"hidden pr-8 sm:border-r sm:dark:border-gray-800 md:flex md:flex-col " +
(selectedDate ? "sm:w-1/3" : "sm:w-1/2")
}>
<AvatarGroup
@ -212,6 +220,15 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
<TimezoneDropdown />
<p className="mt-3 mb-8 text-gray-600 dark:text-gray-200">{eventType.description}</p>
{previousPage === `${BASE_URL}/${profile.slug}` && (
<div className="flex h-full flex-col justify-end">
<ArrowLeftIcon
className="h-4 w-4 text-black transition-opacity hover:cursor-pointer dark:text-white"
onClick={() => router.back()}
/>
<p className="sr-only">Go Back</p>
</div>
)}
</div>
<DatePicker
date={selectedDate}
@ -241,6 +258,8 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
date={selectedDate}
users={eventType.users}
schedulingType={eventType.schedulingType ?? null}
beforeBufferTime={eventType.beforeEventBuffer}
afterBufferTime={eventType.afterEventBuffer}
/>
)}
</div>

View File

@ -85,9 +85,6 @@ const BookingPage = (props: BookingPageProps) => {
if (!location) {
return;
}
if (location === "integrations:jitsi") {
return "https://meet.jit.si/cal/" + uuidv4();
}
if (location.includes("integration")) {
return t("web_conferencing_details_to_follow");
}
@ -254,7 +251,9 @@ const BookingPage = (props: BookingPageProps) => {
language: i18n.language,
rescheduleUid,
user: router.query.user,
location: getLocationValue(booking.locationType ? booking : { locationType: selectedLocation }),
location: getLocationValue(
booking.locationType ? booking : { ...booking, locationType: selectedLocation }
),
metadata,
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
label: props.eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
@ -281,8 +280,8 @@ const BookingPage = (props: BookingPageProps) => {
</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<CustomBranding val={props.profile.brandColor} />
<main className=" mx-auto my-0 max-w-3xl rounded-sm sm:my-24 sm:border sm:dark:border-gray-600">
<CustomBranding lightVal={props.profile.brandColor} darkVal={props.profile.darkBrandColor} />
<main className="mx-auto my-0 max-w-3xl rounded-sm sm:my-24 sm:border sm:dark:border-gray-600">
{isReady && (
<div className="overflow-hidden border border-gray-200 bg-white dark:border-0 dark:bg-neutral-900 sm:rounded-sm">
<div className="px-4 py-5 sm:flex sm:p-4">
@ -345,7 +344,7 @@ const BookingPage = (props: BookingPageProps) => {
name="name"
id="name"
required
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white sm:text-sm"
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white dark:selection:bg-green-500 sm:text-sm"
placeholder={t("example_name")}
/>
</div>
@ -360,7 +359,7 @@ const BookingPage = (props: BookingPageProps) => {
<EmailInput
{...bookingForm.register("email")}
required
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white sm:text-sm"
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white dark:selection:bg-green-500 sm:text-sm"
placeholder="you@example.com"
/>
</div>
@ -394,8 +393,14 @@ const BookingPage = (props: BookingPageProps) => {
{t("phone_number")}
</label>
<div className="mt-1">
{/* @ts-ignore */}
<PhoneInput name="phone" placeholder={t("enter_phone_number")} id="phone" required />
<PhoneInput
// @ts-expect-error
control={bookingForm.control}
name="phone"
placeholder={t("enter_phone_number")}
id="phone"
required
/>
</div>
</div>
)}
@ -417,7 +422,7 @@ const BookingPage = (props: BookingPageProps) => {
})}
id={"custom_" + input.id}
rows={3}
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white sm:text-sm"
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white dark:selection:bg-green-500 sm:text-sm"
placeholder={input.placeholder}
/>
)}
@ -428,7 +433,7 @@ const BookingPage = (props: BookingPageProps) => {
required: input.required,
})}
id={"custom_" + input.id}
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white sm:text-sm"
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white dark:selection:bg-green-500 sm:text-sm"
placeholder={input.placeholder}
/>
)}
@ -439,7 +444,7 @@ const BookingPage = (props: BookingPageProps) => {
required: input.required,
})}
id={"custom_" + input.id}
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white sm:text-sm"
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white dark:selection:bg-green-500 sm:text-sm"
placeholder=""
/>
)}
@ -521,7 +526,7 @@ const BookingPage = (props: BookingPageProps) => {
{...bookingForm.register("notes")}
id="notes"
rows={3}
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white sm:text-sm"
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white dark:selection:bg-green-500 sm:text-sm"
placeholder={t("share_additional_notes")}
/>
</div>
@ -535,7 +540,9 @@ const BookingPage = (props: BookingPageProps) => {
</div>
</Form>
{mutation.isError && (
<div className="mt-2 border-l-4 border-yellow-400 bg-yellow-50 p-4">
<div
data-testid="booking-fail"
className="mt-2 border-l-4 border-yellow-400 bg-yellow-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />

View File

@ -10,7 +10,6 @@ import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype";
import { HttpError } from "@lib/core/http/error";
import { useLocale } from "@lib/hooks/useLocale";
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
import showToast from "@lib/notification";
import { slugify } from "@lib/slugify";
import { trpc } from "@lib/trpc";
@ -49,7 +48,6 @@ interface Props {
export default function CreateEventTypeButton(props: Props) {
const { t } = useLocale();
const router = useRouter();
const modalOpen = useToggleQuery("new");
// URL encoded params
const teamId: number | undefined =
@ -95,44 +93,33 @@ export default function CreateEventTypeButton(props: Props) {
// inject selection data into url for correct router history
const openModal = (option: EventTypeParent) => {
// setTimeout fixes a bug where the url query params are removed immediately after opening the modal
setTimeout(() => {
router.push(
{
pathname: router.pathname,
query: {
...router.query,
new: "1",
eventPage: option.slug,
teamId: option.teamId || undefined,
},
},
undefined,
{ shallow: true }
);
});
};
// remove url params after close modal to reset state
const closeModal = () => {
router.replace({
pathname: router.pathname,
query: { id: router.query.id || undefined },
});
const query = {
...router.query,
dialog: "new-eventtype",
eventPage: option.slug,
teamId: option.teamId,
};
if (!option.teamId) {
delete query.teamId;
}
router.push(
{
pathname: router.pathname,
query,
},
undefined,
{ shallow: true }
);
};
return (
<Dialog
open={modalOpen.isOn}
onOpenChange={(isOpen) => {
if (!isOpen) closeModal();
}}>
<Dialog name="new-eventtype" clearQueryParamsOnClose={["eventPage", "teamId"]}>
{!hasTeams || props.isIndividualTeam ? (
<Button
onClick={() => openModal(props.options[0])}
data-testid="new-event-type"
StartIcon={PlusIcon}
{...(props.canAddEvents ? { href: modalOpen.hrefOn } : { disabled: true })}>
disabled={!props.canAddEvents}>
{t("new_event_type_btn")}
</Button>
) : (

View File

@ -4,7 +4,7 @@ import React from "react";
const TwoFactorModalHeader = ({ title, description }: { title: string; description: string }) => {
return (
<div className="mb-4 sm:flex sm:items-start">
<div className="bg-brand text-brandcontrast mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
<div className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
<ShieldCheckIcon className="h-6 w-6 text-black" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">

View File

@ -64,7 +64,7 @@ export default function MemberInvitationModal(props: { team: TeamWithMembers | n
<div className="inline-block transform rounded-lg bg-white px-4 pt-5 pb-4 text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6 sm:align-middle">
<div className="mb-4 sm:flex sm:items-start">
<div className="bg-brand text-brandcontrast mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
<div className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
<UserIcon className="text-brandcontrast h-6 w-6" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">

View File

@ -1,7 +1,6 @@
import { User } from "@prisma/client";
import classNames from "@lib/classNames";
import { defaultAvatarSrc } from "@lib/profile";
export type AvatarProps = {
user: Pick<User, "name" | "username" | "avatar"> & { emailMd5?: string };
@ -11,6 +10,11 @@ export type AvatarProps = {
alt: string;
};
// defaultAvatarSrc from profile.tsx can't be used as it imports crypto
function defaultAvatarSrc({ md5 }) {
return `https://www.gravatar.com/avatar/${md5}?s=160&d=identicon&r=PG`;
}
// An SSR Supported version of Avatar component.
// FIXME: title support is missing
export function AvatarSSR(props: AvatarProps) {

View File

@ -33,7 +33,7 @@ export const WeekdaySelect = (props: WeekdaySelectProps) => {
toggleDay(idx);
}}
className={`
bg-brand text-brandcontrast
bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast
h-10 w-10 rounded px-3 py-1 focus:outline-none
${activeDays[idx + 1] ? "rounded-r-none" : ""}
${activeDays[idx - 1] ? "rounded-l-none" : ""}

View File

@ -62,7 +62,9 @@ export type ColorPickerProps = {
};
const ColorPicker = (props: ColorPickerProps) => {
const init = !isValidHexCode(props.defaultValue) ? fallBackHex(props.defaultValue) : props.defaultValue;
const init = !isValidHexCode(props.defaultValue)
? fallBackHex(props.defaultValue, false)
: props.defaultValue;
const [color, setColor] = useState(init);
const [isOpen, toggle] = useState(false);
const popover = useRef() as React.MutableRefObject<HTMLInputElement>;

View File

@ -1,18 +1,24 @@
import React from "react";
import BasePhoneInput, { Props as PhoneInputProps } from "react-phone-number-input";
import { Control } from "react-hook-form";
import BasePhoneInput, { Props } from "react-phone-number-input/react-hook-form";
import "react-phone-number-input/style.css";
import classNames from "@lib/classNames";
import { Optional } from "@lib/types/utils";
export const PhoneInput = (
props: Optional<PhoneInputProps<React.InputHTMLAttributes<HTMLInputElement>>, "onChange">
) => (
type PhoneInputProps = {
value: string;
id: string;
placeholder: string;
required: boolean;
};
export const PhoneInput = ({ control, name, ...rest }: Props<PhoneInputProps>) => (
<BasePhoneInput
{...props}
{...rest}
name={name}
control={control}
className={classNames(
"border-1 focus-within:border-brand block w-full rounded-sm border border-gray-300 py-px px-3 shadow-sm ring-black focus-within:ring-1 dark:border-black dark:bg-black dark:text-white",
props.className
"border-1 focus-within:border-brand block w-full rounded-sm border border-gray-300 py-px px-3 shadow-sm ring-black focus-within:ring-1 dark:border-black dark:bg-black dark:text-white"
)}
onChange={() => {
/* DO NOT REMOVE: Callback required by PhoneInput, comment added to satisfy eslint:no-empty-function */

View File

@ -0,0 +1,162 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useLocale } from "@lib/hooks/useLocale";
import showToast from "@lib/notification";
import { trpc } from "@lib/trpc";
import { WEBHOOK_TRIGGER_EVENTS } from "@lib/webhooks/constants";
import customTemplate, { hasTemplateIntegration } from "@lib/webhooks/integrationTemplate";
import { DialogFooter } from "@components/Dialog";
import { FieldsetLegend, Form, InputGroupBox, TextArea, TextField } from "@components/form/fields";
import Button from "@components/ui/Button";
import Switch from "@components/ui/Switch";
import { TWebhook } from "@components/webhook/WebhookListItem";
import WebhookTestDisclosure from "@components/webhook/WebhookTestDisclosure";
export default function WebhookDialogForm(props: {
eventTypeId?: number;
defaultValues?: TWebhook;
handleClose: () => void;
}) {
const { t } = useLocale();
const utils = trpc.useContext();
const handleSubscriberUrlChange = (e) => {
form.setValue("subscriberUrl", e.target.value);
if (hasTemplateIntegration({ url: e.target.value })) {
setUseCustomPayloadTemplate(true);
form.setValue("payloadTemplate", customTemplate({ url: e.target.value }));
}
};
const {
defaultValues = {
id: "",
eventTriggers: WEBHOOK_TRIGGER_EVENTS,
subscriberUrl: "",
active: true,
payloadTemplate: null,
} as Omit<TWebhook, "userId" | "createdAt" | "eventTypeId">,
} = props;
const [useCustomPayloadTemplate, setUseCustomPayloadTemplate] = useState(!!defaultValues.payloadTemplate);
const form = useForm({
defaultValues,
});
return (
<Form
data-testid="WebhookDialogForm"
form={form}
handleSubmit={async (event) => {
const e = { ...event, eventTypeId: props.eventTypeId };
if (!useCustomPayloadTemplate && event.payloadTemplate) {
event.payloadTemplate = null;
}
if (event.id) {
await utils.client.mutation("viewer.webhook.edit", e);
await utils.invalidateQueries(["viewer.webhook.list"]);
showToast(t("webhook_updated_successfully"), "success");
} else {
await utils.client.mutation("viewer.webhook.create", e);
await utils.invalidateQueries(["viewer.webhook.list"]);
showToast(t("webhook_created_successfully"), "success");
}
props.handleClose();
}}
className="space-y-4">
<input type="hidden" {...form.register("id")} />
<fieldset className="space-y-2">
<InputGroupBox className="border-0 bg-gray-50">
<Controller
control={form.control}
name="active"
render={({ field }) => (
<Switch
label={field.value ? t("webhook_enabled") : t("webhook_disabled")}
defaultChecked={field.value}
onCheckedChange={(isChecked) => {
form.setValue("active", isChecked);
}}
/>
)}
/>
</InputGroupBox>
</fieldset>
<TextField
label={t("subscriber_url")}
{...form.register("subscriberUrl")}
required
type="url"
onChange={handleSubscriberUrlChange}
/>
<fieldset className="space-y-2">
<FieldsetLegend>{t("event_triggers")}</FieldsetLegend>
<InputGroupBox className="border-0 bg-gray-50">
{WEBHOOK_TRIGGER_EVENTS.map((key) => (
<Controller
key={key}
control={form.control}
name="eventTriggers"
render={({ field }) => (
<Switch
label={t(key.toLowerCase())}
defaultChecked={field.value.includes(key)}
onCheckedChange={(isChecked) => {
const value = field.value;
const newValue = isChecked ? [...value, key] : value.filter((v) => v !== key);
form.setValue("eventTriggers", newValue, {
shouldDirty: true,
});
}}
/>
)}
/>
))}
</InputGroupBox>
</fieldset>
<fieldset className="space-y-2">
<FieldsetLegend>{t("payload_template")}</FieldsetLegend>
<div className="space-x-3 text-sm rtl:space-x-reverse">
<label>
<input
className="text-neutral-900 focus:ring-neutral-500"
type="radio"
name="useCustomPayloadTemplate"
onChange={(value) => setUseCustomPayloadTemplate(!value.target.checked)}
defaultChecked={!useCustomPayloadTemplate}
/>{" "}
Default
</label>
<label>
<input
className="text-neutral-900 focus:ring-neutral-500"
onChange={(value) => setUseCustomPayloadTemplate(value.target.checked)}
name="useCustomPayloadTemplate"
type="radio"
defaultChecked={useCustomPayloadTemplate}
/>{" "}
Custom
</label>
</div>
{useCustomPayloadTemplate && (
<TextArea
{...form.register("payloadTemplate")}
defaultValue={useCustomPayloadTemplate && (defaultValues.payloadTemplate || "")}
rows={3}
/>
)}
</fieldset>
<WebhookTestDisclosure />
<DialogFooter>
<Button type="button" color="secondary" onClick={props.handleClose} tabIndex={-1}>
{t("cancel")}
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
{t("save")}
</Button>
</DialogFooter>
</Form>
);
}

View File

@ -0,0 +1,103 @@
import classNames from "classnames";
import Image from "next/image";
import { useState } from "react";
import { QueryCell } from "@lib/QueryCell";
import { useLocale } from "@lib/hooks/useLocale";
import { trpc } from "@lib/trpc";
import { Dialog, DialogContent } from "@components/Dialog";
import { List, ListItem, ListItemText, ListItemTitle } from "@components/List";
import { ShellSubHeading } from "@components/Shell";
import Button from "@components/ui/Button";
import WebhookDialogForm from "@components/webhook/WebhookDialogForm";
import WebhookListItem, { TWebhook } from "@components/webhook/WebhookListItem";
export type WebhookListContainerType = {
title: string;
subtitle: string;
eventTypeId?: number;
};
export default function WebhookListContainer(props: WebhookListContainerType) {
const { t } = useLocale();
const query = props.eventTypeId
? trpc.useQuery(["viewer.webhook.list", { eventTypeId: props.eventTypeId }], {
suspense: true,
})
: trpc.useQuery(["viewer.webhook.list"], {
suspense: true,
});
const [newWebhookModal, setNewWebhookModal] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [editing, setEditing] = useState<TWebhook | null>(null);
return (
<QueryCell
query={query}
success={({ data }) => (
<>
<ShellSubHeading className="mt-10" title={props.title} subtitle={props.subtitle} />
<List>
<ListItem className={classNames("flex-col")}>
<div
className={classNames("flex w-full flex-1 items-center space-x-2 p-3 rtl:space-x-reverse")}>
<Image width={40} height={40} src="/integrations/webhooks.svg" alt="Webhooks" />
<div className="flex-grow truncate pl-2">
<ListItemTitle component="h3">Webhooks</ListItemTitle>
<ListItemText component="p">{t("automation")}</ListItemText>
</div>
<div>
<Button
color="secondary"
onClick={() => setNewWebhookModal(true)}
data-testid="new_webhook">
{t("new_webhook")}
</Button>
</div>
</div>
</ListItem>
</List>
{data.length ? (
<List>
{data.map((item) => (
<WebhookListItem
key={item.id}
webhook={item}
onEditWebhook={() => {
setEditing(item);
setEditModalOpen(true);
}}
/>
))}
</List>
) : null}
{/* New webhook dialog */}
<Dialog open={newWebhookModal} onOpenChange={(isOpen) => !isOpen && setNewWebhookModal(false)}>
<DialogContent>
<WebhookDialogForm
eventTypeId={props.eventTypeId}
handleClose={() => setNewWebhookModal(false)}
/>
</DialogContent>
</Dialog>
{/* Edit webhook dialog */}
<Dialog open={editModalOpen} onOpenChange={(isOpen) => !isOpen && setEditModalOpen(false)}>
<DialogContent>
{editing && (
<WebhookDialogForm
key={editing.id}
eventTypeId={props.eventTypeId || undefined}
handleClose={() => setEditModalOpen(false)}
defaultValues={editing}
/>
)}
</DialogContent>
</Dialog>
</>
)}
/>
);
}

View File

@ -0,0 +1,93 @@
import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@lib/hooks/useLocale";
import { inferQueryOutput, trpc } from "@lib/trpc";
import { Dialog, DialogTrigger } from "@components/Dialog";
import { ListItem } from "@components/List";
import { Tooltip } from "@components/Tooltip";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import Button from "@components/ui/Button";
export type TWebhook = inferQueryOutput<"viewer.webhook.list">[number];
export default function WebhookListItem(props: { webhook: TWebhook; onEditWebhook: () => void }) {
const { t } = useLocale();
const utils = trpc.useContext();
const deleteWebhook = trpc.useMutation("viewer.webhook.delete", {
async onSuccess() {
await utils.invalidateQueries(["viewer.webhook.list"]);
},
});
return (
<ListItem className="-mt-px flex w-full p-4">
<div className="flex w-full justify-between">
<div className="flex max-w-full flex-col truncate">
<div className="flex space-y-1">
<span
className={classNames(
"truncate text-sm",
props.webhook.active ? "text-neutral-700" : "text-neutral-200"
)}>
{props.webhook.subscriberUrl}
</span>
</div>
<div className="mt-2 flex">
<span className="flex flex-col space-x-2 space-y-1 text-xs sm:flex-row sm:space-y-0 sm:rtl:space-x-reverse">
{props.webhook.eventTriggers.map((eventTrigger, ind) => (
<span
key={ind}
className={classNames(
"w-max rounded-sm px-1 text-xs ",
props.webhook.active ? "bg-blue-100 text-blue-700" : "bg-blue-50 text-blue-200"
)}>
{t(`${eventTrigger.toLowerCase()}`)}
</span>
))}
</span>
</div>
</div>
<div className="flex">
<Tooltip content={t("edit_webhook")}>
<Button
onClick={() => props.onEditWebhook()}
color="minimal"
size="icon"
StartIcon={PencilAltIcon}
className="ml-4 w-full self-center p-2"></Button>
</Tooltip>
<Dialog>
<Tooltip content={t("delete_webhook")}>
<DialogTrigger asChild>
<Button
onClick={(e) => {
e.stopPropagation();
}}
color="minimal"
size="icon"
StartIcon={TrashIcon}
className="ml-2 w-full self-center p-2"></Button>
</DialogTrigger>
</Tooltip>
<ConfirmationDialogContent
variety="danger"
title={t("delete_webhook")}
confirmBtnText={t("confirm_delete_webhook")}
cancelBtnText={t("cancel")}
onConfirm={() =>
deleteWebhook.mutate({
id: props.webhook.id,
eventTypeId: props.webhook.eventTypeId || undefined,
})
}>
{t("delete_webhook_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</div>
</div>
</ListItem>
);
}

View File

@ -0,0 +1,64 @@
import { ChevronRightIcon, SwitchHorizontalIcon } from "@heroicons/react/solid";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
import { useState } from "react";
import { useWatch } from "react-hook-form";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@lib/hooks/useLocale";
import showToast from "@lib/notification";
import { trpc } from "@lib/trpc";
import { InputGroupBox } from "@components/form/fields";
import Button from "@components/ui/Button";
export default function WebhookTestDisclosure() {
const subscriberUrl: string = useWatch({ name: "subscriberUrl" });
const payloadTemplate = useWatch({ name: "payloadTemplate" }) || null;
const { t } = useLocale();
const [open, setOpen] = useState(false);
const mutation = trpc.useMutation("viewer.webhook.testTrigger", {
onError(err) {
showToast(err.message, "error");
},
});
return (
<Collapsible open={open} onOpenChange={() => setOpen(!open)}>
<CollapsibleTrigger type="button" className={"flex w-full cursor-pointer"}>
<ChevronRightIcon className={`${open ? "rotate-90 transform" : ""} h-5 w-5 text-neutral-500`} />
<span className="text-sm font-medium text-gray-700">{t("webhook_test")}</span>
</CollapsibleTrigger>
<CollapsibleContent>
<InputGroupBox className="space-y-0 border-0 px-0">
<div className="flex justify-between bg-gray-50 p-2">
<h3 className="self-center text-gray-700">{t("webhook_response")}</h3>
<Button
StartIcon={SwitchHorizontalIcon}
type="button"
color="minimal"
disabled={mutation.isLoading}
onClick={() => mutation.mutate({ url: subscriberUrl, type: "PING", payloadTemplate })}>
{t("ping_test")}
</Button>
</div>
<div className="border-8 border-gray-50 p-2 text-gray-500">
{!mutation.data && <em>{t("no_data_yet")}</em>}
{mutation.status === "success" && (
<>
<div
className={classNames(
"ml-auto w-max px-2 py-1 text-xs",
mutation.data.ok ? "bg-green-50 text-green-500" : "bg-red-50 text-red-500"
)}>
{mutation.data.ok ? t("success") : t("failed")}
</div>
<pre className="overflow-x-auto">{JSON.stringify(mutation.data, null, 4)}</pre>
</>
)}
</div>
</InputGroupBox>
</CollapsibleContent>
</Collapsible>
);
}

View File

@ -13,9 +13,11 @@ const TrialBanner = () => {
if (!user || user.plan !== "TRIAL") return null;
const trialDaysLeft = dayjs(user.createdDate)
.add(TRIAL_LIMIT_DAYS + 1, "day")
.diff(dayjs(), "day");
const trialDaysLeft = user.trialEndsAt
? dayjs(user.trialEndsAt).add(1, "day").diff(dayjs(), "day")
: dayjs(user.createdDate)
.add(TRIAL_LIMIT_DAYS + 1, "day")
.diff(dayjs(), "day");
return (
<div

View File

@ -59,7 +59,7 @@ export default function TeamAvailabilityTimes(props: Props) {
{times.map((time) => (
<div key={time.format()} className="flex flex-row items-center">
<a
className="min-w-48 text-primary-500 border-brand hover:bg-brand hover:text-brandcontrast mb-2 mr-3 block flex-grow rounded-sm border bg-white py-2 text-center font-medium dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 dark:hover:border-black dark:hover:bg-black dark:hover:text-white"
className="min-w-48 border-brand text-primary-500 hover:bg-brand hover:text-brandcontrast dark:hover:bg-darkmodebrand dark:hover:text-darkmodebrandcontrast dark:text-neutral-200 mb-2 mr-3 block flex-grow rounded-sm border bg-white py-2 text-center font-medium dark:border-transparent dark:bg-gray-600 dark:hover:border-black dark:hover:bg-black dark:hover:text-white"
data-testid="time">
{time.format("HH:mm")}
</a>

View File

@ -1,11 +1,12 @@
import { Prisma } from "@prisma/client";
import stripe from "@ee/lib/stripe/server";
import prisma from "@calcom/prisma";
import { HttpError as HttpCode } from "@lib/core/http/error";
import { prisma } from "@lib/prisma";
export async function getStripeCustomerFromUser(userId: number) {
import stripe from "./server";
export async function getStripeCustomerIdFromUserId(userId: number) {
// Get user
const user = await prisma.user.findUnique({
where: {
@ -33,7 +34,8 @@ const userType = Prisma.validator<Prisma.UserArgs>()({
});
type UserType = Prisma.UserGetPayload<typeof userType>;
export async function getStripeCustomerId(user: UserType): Promise<string | null> {
/** This will retrieve the customer ID from Stripe or create it if it doesn't exists yet. */
export async function getStripeCustomerId(user: UserType): Promise<string> {
let customerId: string | null = null;
if (user?.metadata && typeof user.metadata === "object" && "stripeCustomerId" in user.metadata) {

View File

@ -0,0 +1,90 @@
#!/usr/bin/env ts-node
// To run this script: `yarn downgrade 2>&1 | tee result.log`
import { MembershipRole, Prisma, UserPlan } from "@prisma/client";
import dayjs from "dayjs";
import prisma from "@calcom/prisma";
import { TRIAL_LIMIT_DAYS } from "@lib/config/constants";
import { getStripeCustomerIdFromUserId } from "./customer";
import stripe from "./server";
import { getPremiumPlanPrice, getProPlanPrice } from "./team-billing";
export async function downgradeIllegalProUsers() {
const usersDowngraded: string[] = [];
const illegalProUsers = await prisma.membership.findMany({
where: {
role: {
not: MembershipRole.OWNER,
},
user: {
plan: {
not: UserPlan.PRO,
},
},
},
include: {
user: true,
},
});
const downgrade = async (member: typeof illegalProUsers[number]) => {
console.log(`Downgrading: ${member.user.email}`);
await prisma.user.update({
where: { id: member.user.id },
data: {
plan: UserPlan.TRIAL,
trialEndsAt: dayjs().add(TRIAL_LIMIT_DAYS, "day").toDate(),
},
});
console.log(`Downgraded: ${member.user.email}`);
usersDowngraded.push(member.user.username || `${member.user.id}`);
};
for (const member of illegalProUsers) {
const metadata = (member.user.metadata as Prisma.JsonObject) ?? {};
// if their pro is already sponsored by a team, do not downgrade
if (metadata.proPaidForTeamId !== undefined) continue;
const stripeCustomerId = await getStripeCustomerIdFromUserId(member.user.id);
if (!stripeCustomerId) {
await downgrade(member);
continue;
}
const customer = await stripe.customers.retrieve(stripeCustomerId, {
expand: ["subscriptions.data.plan"],
});
if (!customer || customer.deleted) {
await downgrade(member);
continue;
}
const subscription = customer.subscriptions?.data[0];
if (!subscription) {
await downgrade(member);
continue;
}
const hasProPlan = !!subscription.items.data.find(
(item) => item.plan.id === getProPlanPrice() || item.plan.id === getPremiumPlanPrice()
);
// if they're pro, do not downgrade
if (hasProPlan) continue;
await downgrade(member);
}
return {
usersDowngraded,
usersDowngradedAmount: usersDowngraded.length,
};
}
downgradeIllegalProUsers()
.then(({ usersDowngraded, usersDowngradedAmount }) => {
console.log(`Downgraded ${usersDowngradedAmount} illegal pro users`);
console.table(usersDowngraded);
})
.catch((e) => {
console.error(e);
process.exit(1);
});

View File

@ -2,11 +2,11 @@ import { PaymentType, Prisma } from "@prisma/client";
import Stripe from "stripe";
import { v4 as uuidv4 } from "uuid";
import prisma from "@calcom/prisma";
import type { CalendarEvent } from "@calcom/types/CalendarEvent";
import { sendAwaitingPaymentEmail, sendOrganizerPaymentRefundFailedEmail } from "@lib/emails/email-manager";
import { getErrorFromUnknown } from "@lib/errors";
import prisma from "@lib/prisma";
import { createPaymentLink } from "./client";

View File

@ -1,17 +1,17 @@
import { MembershipRole, Prisma, UserPlan } from "@prisma/client";
import Stripe from "stripe";
import { getStripeCustomerFromUser } from "@ee/lib/stripe/customer";
import prisma from "@calcom/prisma";
import { getStripeCustomerIdFromUserId } from "@ee/lib/stripe/customer";
import { HOSTED_CAL_FEATURES } from "@lib/config/constants";
import { HttpError } from "@lib/core/http/error";
import prisma from "@lib/prisma";
import stripe from "./server";
// get team owner's Pro Plan subscription from Cal userId
export async function getProPlanSubscription(userId: number) {
const stripeCustomerId = await getStripeCustomerFromUser(userId);
const stripeCustomerId = await getStripeCustomerIdFromUserId(userId);
if (!stripeCustomerId) return null;
const customer = await stripe.customers.retrieve(stripeCustomerId, {
@ -82,11 +82,17 @@ export async function upgradeTeam(userId: number, teamId: number) {
const { membersMissingSeats, ownerIsMissingSeat } = await getMembersMissingSeats(teamId);
if (!subscription) {
const customer = await getStripeCustomerFromUser(userId);
if (!customer) throw new HttpError({ statusCode: 400, message: "User has no Stripe customer" });
let customerId = await getStripeCustomerIdFromUserId(userId);
if (!customerId) {
// create stripe customer if it doesn't already exist
const res = await stripe.customers.create({
email: ownerUser.user.email,
});
customerId = res.id;
}
// create a checkout session with the quantity of missing seats
const session = await createCheckoutSession(
customer,
customerId,
membersMissingSeats.length,
teamId,
ownerIsMissingSeat
@ -257,19 +263,20 @@ export async function ensureSubscriptionQuantityCorrectness(userId: number, team
}
}
const isProductionSite =
process.env.NEXT_PUBLIC_BASE_URL === "https://app.cal.com" && process.env.VERCEL_ENV === "production";
// TODO: these should be moved to env vars
export function getPerSeatProPlanPrice(): string {
return process.env.NODE_ENV === "production"
? "price_1KHkoeH8UDiwIftkkUbiggsM"
: "price_1KLD4GH8UDiwIftkWQfsh1Vh";
return isProductionSite ? "price_1KHkoeH8UDiwIftkkUbiggsM" : "price_1KLD4GH8UDiwIftkWQfsh1Vh";
}
export function getProPlanPrice(): string {
return process.env.NODE_ENV === "production"
? "price_1KHkoeH8UDiwIftkkUbiggsM"
: "price_1JZ0J3H8UDiwIftk0YIHYKr8";
return isProductionSite ? "price_1KHkoeH8UDiwIftkkUbiggsM" : "price_1JZ0J3H8UDiwIftk0YIHYKr8";
}
export function getPremiumPlanPrice(): string {
return process.env.NODE_ENV === "production"
? "price_1Jv3CMH8UDiwIftkFgyXbcHN"
: "price_1Jv3CMH8UDiwIftkFgyXbcHN";
return isProductionSite ? "price_1Jv3CMH8UDiwIftkFgyXbcHN" : "price_1Jv3CMH8UDiwIftkFgyXbcHN";
}
export function getProPlanProduct(): string {
return isProductionSite ? "prod_JVxwoOF5odFiZ8" : "prod_KDRBg0E4HyVZee";
}

View File

@ -1,6 +1,6 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getStripeCustomerFromUser } from "@ee/lib/stripe/customer";
import { getStripeCustomerIdFromUserId } from "@ee/lib/stripe/customer";
import stripe from "@ee/lib/stripe/server";
import { getSession } from "@lib/auth";
@ -15,7 +15,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return;
}
const customerId = await getStripeCustomerFromUser(session.user.id);
const customerId = await getStripeCustomerIdFromUserId(session.user.id);
if (!customerId) {
res.status(500).json({ message: "Missing customer id" });

View File

@ -1,8 +1,10 @@
import type { CalendarEvent } from "@calcom/types/CalendarEvent";
import type { Person } from "@lib/apps/calendar/types/CalendarTypes";
import AttendeeAwaitingPaymentEmail from "@lib/emails/templates/attendee-awaiting-payment-email";
import AttendeeCancelledEmail from "@lib/emails/templates/attendee-cancelled-email";
import AttendeeDeclinedEmail from "@lib/emails/templates/attendee-declined-email";
import AttendeeRequestEmail from "@lib/emails/templates/attendee-request-email";
import AttendeeRescheduledEmail from "@lib/emails/templates/attendee-rescheduled-email";
import AttendeeScheduledEmail from "@lib/emails/templates/attendee-scheduled-email";
import ForgotPasswordEmail, { PasswordReset } from "@lib/emails/templates/forgot-password-email";
@ -85,6 +87,17 @@ export const sendOrganizerRequestEmail = async (calEvent: CalendarEvent) => {
});
};
export const sendAttendeeRequestEmail = async (calEvent: CalendarEvent, attendee: Person) => {
await new Promise((resolve, reject) => {
try {
const attendeeRequestEmail = new AttendeeRequestEmail(calEvent, attendee);
resolve(attendeeRequestEmail.sendEmail());
} catch (e) {
reject(console.error("AttendRequestEmail.sendEmail failed", e));
}
});
};
export const sendDeclinedEmails = async (calEvent: CalendarEvent) => {
const emailsToSend: Promise<unknown>[] = [];

View File

@ -0,0 +1,134 @@
import dayjs from "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 AttendeeScheduledEmail from "./attendee-scheduled-email";
import {
emailHead,
emailSchedulingBodyHeader,
emailBodyLogo,
emailScheduledBodyHeaderContent,
emailSchedulingBodyDivider,
} from "./common";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(localizedFormat);
dayjs.extend(toArray);
export default class AttendeeRequestEmail extends AttendeeScheduledEmail {
protected getNodeMailerPayload(): Record<string, unknown> {
const toAddresses = [this.calEvent.attendees[0].email];
if (this.calEvent.team) {
this.calEvent.team.members.forEach((member) => {
const memberAttendee = this.calEvent.attendees.find((attendee) => attendee.name === member);
if (memberAttendee) {
toAddresses.push(memberAttendee.email);
}
});
}
return {
from: `Cal.com <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
subject: `${this.calEvent.organizer.language.translate("booking_submitted_subject", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
date: `${this.getInviteeStart().format("h:mma")} - ${this.getInviteeEnd().format(
"h:mma"
)}, ${this.calEvent.organizer.language.translate(
this.getInviteeStart().format("dddd").toLowerCase()
)}, ${this.calEvent.organizer.language.translate(
this.getInviteeStart().format("MMMM").toLowerCase()
)} ${this.getInviteeStart().format("D")}, ${this.getInviteeStart().format("YYYY")}`,
})}`,
html: this.getHtmlBody(),
text: this.getTextBody(),
};
}
protected getTextBody(): string {
return `
${this.calEvent.attendees[0].language.translate("booking_submitted", {
name: this.calEvent.attendees[0].name,
})}
${this.calEvent.attendees[0].language.translate("user_needs_to_confirm_or_reject_booking", {
user: this.calEvent.attendees[0].name,
})}
${this.getWhat()}
${this.getWhen()}
${this.getLocation()}
${this.getAdditionalNotes()}
`.replace(/(<([^>]+)>)/gi, "");
}
protected getHtmlBody(): string {
const headerContent = this.calEvent.attendees[0].language.translate("booking_submitted_subject", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
date: `${this.getInviteeStart().format("h:mma")} - ${this.getInviteeEnd().format(
"h:mma"
)}, ${this.calEvent.attendees[0].language.translate(
this.getInviteeStart().format("dddd").toLowerCase()
)}, ${this.calEvent.attendees[0].language.translate(
this.getInviteeStart().format("MMMM").toLowerCase()
)} ${this.getInviteeStart().format("D")}, ${this.getInviteeStart().format("YYYY")}`,
});
return `
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
${emailHead(headerContent)}
<body style="word-spacing:normal;background-color:#F5F5F5;">
<div style="background-color:#F5F5F5;">
${emailSchedulingBodyHeader("calendarCircle")}
${emailScheduledBodyHeaderContent(
this.calEvent.organizer.language.translate("booking_submitted"),
this.calEvent.organizer.language.translate("user_needs_to_confirm_or_reject_booking", {
user: this.calEvent.attendees[0].name,
})
)}
${emailSchedulingBodyDivider()}
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;line-height:1;text-align:left;color:#3E3E3E;">
${this.getWhat()}
${this.getWhen()}
${this.getWho()}
${this.getLocation()}
${this.getAdditionalNotes()}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
${emailSchedulingBodyDivider()}
${emailBodyLogo()}
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>
`;
}
}

View File

@ -5,6 +5,7 @@ import { v5 as uuidv5 } from "uuid";
import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
import { FAKE_HUDDLE_CREDENTIAL } from "@calcom/app-store/huddle01video/lib/VideoApiAdapter";
import { FAKE_JITSI_CREDENTIAL } from "@calcom/app-store/jitsivideo/lib/VideoApiAdapter";
import type { CalendarEvent } from "@calcom/types/CalendarEvent";
import type { PartialReference } from "@calcom/types/EventManager";
import type { VideoCallData } from "@calcom/types/VideoApiAdapter";
@ -53,8 +54,14 @@ export const isTandem = (location: string): boolean => {
return location === "integrations:tandem";
};
export const isJitsi = (location: string): boolean => {
return location === "integrations:jitsi";
};
export const isDedicatedIntegration = (location: string): boolean => {
return isZoom(location) || isDaily(location) || isHuddle01(location) || isTandem(location);
return (
isZoom(location) || isDaily(location) || isHuddle01(location) || isTandem(location) || isJitsi(location)
);
};
export const getLocationRequestFromIntegration = (location: string) => {
@ -117,6 +124,7 @@ export default class EventManager {
this.videoCredentials.push(FAKE_DAILY_CREDENTIAL);
}
this.videoCredentials.push(FAKE_HUDDLE_CREDENTIAL);
this.videoCredentials.push(FAKE_JITSI_CREDENTIAL);
}
/**

View File

@ -30,10 +30,21 @@ type UseSlotsProps = {
date: Dayjs;
users: { username: string | null }[];
schedulingType: SchedulingType | null;
beforeBufferTime?: number;
afterBufferTime?: number;
};
export const useSlots = (props: UseSlotsProps) => {
const { slotInterval, eventLength, minimumBookingNotice = 0, date, users, eventTypeId } = props;
const {
slotInterval,
eventLength,
minimumBookingNotice = 0,
beforeBufferTime = 0,
afterBufferTime = 0,
date,
users,
eventTypeId,
} = props;
const [slots, setSlots] = useState<Slot[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<Error | null>(null);
@ -124,6 +135,29 @@ export const useSlots = (props: UseSlotsProps) => {
// Check if startTime is between slot
else if (startTime.isBetween(times[i], times[i].add(eventLength, "minutes"))) {
times.splice(i, 1);
}
// Check if time is between afterBufferTime and beforeBufferTime
else if (
times[i].isBetween(
startTime.subtract(beforeBufferTime, "minutes"),
endTime.add(afterBufferTime, "minutes")
)
) {
times.splice(i, 1);
}
// considering preceding event's after buffer time
else if (
i > 0 &&
times[i - 1]
.add(eventLength + afterBufferTime, "minutes")
.isBetween(
startTime.subtract(beforeBufferTime, "minutes"),
endTime.add(afterBufferTime, "minutes"),
null,
"[)"
)
) {
times.splice(i, 1);
} else {
return true;
}

View File

@ -1,20 +1 @@
import { PrismaClient } from "@prisma/client";
import { IS_PRODUCTION } from "@lib/config/constants";
declare global {
// eslint-disable-next-line no-var
var prisma: PrismaClient | undefined;
}
export const prisma =
globalThis.prisma ||
new PrismaClient({
// log: ["query", "error", "warn"],
});
if (!IS_PRODUCTION) {
globalThis.prisma = prisma;
}
export default prisma;
export { default } from "@calcom/prisma";

View File

@ -14,6 +14,8 @@ export type AdvancedOptions = {
requiresConfirmation?: boolean;
disableGuests?: boolean;
minimumBookingNotice?: number;
beforeBufferTime?: number;
afterBufferTime?: number;
slotInterval?: number | null;
price?: number;
currency?: string;

View File

@ -0,0 +1,28 @@
const supportedWebhookIntegrationList = ["https://discord.com/api/webhooks/"];
type WebhookIntegrationProps = {
url: string;
};
export const hasTemplateIntegration = (props: WebhookIntegrationProps) => {
const ind = supportedWebhookIntegrationList.findIndex((integration) => {
return props.url.includes(integration);
});
return ind > -1 ? true : false;
};
const customTemplate = (props: WebhookIntegrationProps) => {
const ind = supportedWebhookIntegrationList.findIndex((integration) => {
return props.url.includes(integration);
});
return integrationTemplate(supportedWebhookIntegrationList[ind]) || "";
};
const integrationTemplate = (webhookIntegration: string) => {
switch (webhookIntegration) {
case "https://discord.com/api/webhooks/":
return '{"content": "A new event has been scheduled","embeds": [{"color": 2697513,"fields": [{"name": "What","value": "{{title}} ({{type}})"},{"name": "When","value": "Start: {{startTime}} \\n End: {{endTime}} \\n Timezone: ({{organizer.timeZone}})"},{"name": "Who","value": "Organizer: {{organizer.name}} ({{organizer.email}}) \\n Booker: {{attendees.0.name}} ({{attendees.0.email}})" },{"name":"Description", "value":": {{description}}"},{"name":"Where","value":": {{location}} "}]}]}';
}
};
export default customTemplate;

View File

@ -2,13 +2,27 @@ import { WebhookTriggerEvents } from "@prisma/client";
import prisma from "@lib/prisma";
const getSubscribers = async (userId: number, triggerEvent: WebhookTriggerEvents) => {
export type GetSubscriberOptions = {
userId: number;
eventTypeId: number;
triggerEvent: WebhookTriggerEvents;
};
const getSubscribers = async (options: GetSubscriberOptions) => {
const { userId, eventTypeId } = options;
const allWebhooks = await prisma.webhook.findMany({
where: {
userId: userId,
OR: [
{
userId,
},
{
eventTypeId,
},
],
AND: {
eventTriggers: {
has: triggerEvent,
has: options.triggerEvent,
},
active: {
equals: true,

View File

@ -1,4 +1,4 @@
const withTM = require("@vercel/edge-functions-ui/transpile")([
const withTM = require("next-transpile-modules")([
"@calcom/app-store",
"@calcom/lib",
"@calcom/prisma",

View File

@ -18,7 +18,8 @@
"start": "next start",
"lint": "next lint",
"lint:fix": "next lint . --ext .ts,.js,.tsx,.jsx --fix",
"check-changed-files": "ts-node scripts/ts-check-changed-files.ts"
"check-changed-files": "ts-node scripts/ts-check-changed-files.ts",
"downgrade": "ts-node ee/lib/stripe/downgrade.ts"
},
"engines": {
"node": ">=14.x",
@ -140,7 +141,7 @@
"eslint": "^8.9.0",
"tailwindcss": "^3.0.0",
"ts-jest": "^26.0.0",
"ts-node": "^10.2.1",
"ts-node": "^10.6.0",
"typescript": "^4.5.3"
}
}

View File

@ -1,6 +1,5 @@
import { ArrowRightIcon } from "@heroicons/react/outline";
import { BadgeCheckIcon } from "@heroicons/react/solid";
import crypto from "crypto";
import { GetServerSidePropsContext } from "next";
import dynamic from "next/dynamic";
import Link from "next/link";
@ -66,7 +65,9 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
style={{ display: "flex" }}
className="group hover:border-brand relative rounded-sm border border-neutral-200 bg-white hover:bg-gray-50 dark:border-0 dark:bg-neutral-900 dark:hover:border-neutral-600">
<ArrowRightIcon className="absolute right-3 top-3 h-4 w-4 text-black opacity-0 transition-opacity group-hover:opacity-100 dark:text-white" />
{/* Don't prefetch till the time we drop the amount of javascript in [user][type] page which is impacting score for [user] page */}
<Link
prefetch={false}
href={{
pathname: `/${user.username}/${type.slug}`,
query,
@ -121,9 +122,10 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const ssr = await ssrInit(context);
const crypto = require("crypto");
const username = (context.query.user as string).toLowerCase();
const dataFetchStart = Date.now();
const user = await prisma.user.findUnique({
where: {
username: username.toLowerCase(),
@ -205,7 +207,10 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
},
take: user.plan === "FREE" ? 1 : undefined,
});
const dataFetchEnd = Date.now();
if (context.query.log === "1") {
context.res.setHeader("X-Data-Fetch-Time", `${dataFetchEnd - dataFetchStart}ms`);
}
const eventTypesRaw = eventTypesWithHidden.filter((evt) => !evt.hidden);
const eventTypes = eventTypesRaw.map((eventType) => ({

View File

@ -44,6 +44,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
periodCountCalendarDays: true,
schedulingType: true,
minimumBookingNotice: true,
beforeEventBuffer: true,
afterEventBuffer: true,
timeZone: true,
metadata: true,
slotInterval: true,
@ -77,6 +79,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
availability: true,
hideBranding: true,
brandColor: true,
darkBrandColor: true,
theme: true,
plan: true,
eventTypes: {
@ -190,11 +193,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
theme: user.theme,
weekStart: user.weekStart,
brandColor: user.brandColor,
darkBrandColor: user.darkBrandColor,
},
date: dateParam,
eventType: eventTypeObject,
workingHours,
trpcState: ssr.dehydrate(),
previousPage: context.req.headers.referer ?? null,
},
};
};

View File

@ -26,7 +26,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const ssr = await ssrInit(context);
const user = await prisma.user.findUnique({
where: {
username: asStringOrThrow(context.query.user),
username: asStringOrThrow(context.query.user).toLowerCase(),
},
select: {
id: true,
@ -37,6 +37,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
avatar: true,
theme: true,
brandColor: true,
darkBrandColor: true,
},
});
@ -140,6 +141,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
image: user.avatar,
theme: user.theme,
brandColor: user.brandColor,
darkBrandColor: user.darkBrandColor,
},
eventType: eventTypeObject,
booking,

View File

@ -1,4 +1,4 @@
import { Credential, Prisma, SchedulingType } from "@prisma/client";
import { Credential, Prisma, SchedulingType, WebhookTriggerEvents } from "@prisma/client";
import async from "async";
import dayjs from "dayjs";
import dayjsBusinessTime from "dayjs-business-time";
@ -17,14 +17,15 @@ import { AdditionInformation } from "@lib/apps/calendar/interfaces/Calendar";
import { getBusyCalendarTimes } from "@lib/apps/calendar/managers/CalendarManager";
import { BufferedBusyTime } from "@lib/apps/office365_calendar/types/Office365Calendar";
import {
sendScheduledEmails,
sendRescheduledEmails,
sendAttendeeRequestEmail,
sendOrganizerRequestEmail,
sendRescheduledEmails,
sendScheduledEmails,
} from "@lib/emails/email-manager";
import { ensureArray } from "@lib/ensureArray";
import { getErrorFromUnknown } from "@lib/errors";
import { getEventName } from "@lib/event";
import EventManager, { EventResult } from "@lib/events/EventManager";
import EventManager from "@lib/events/EventManager";
import logger from "@lib/logger";
import notEmpty from "@lib/notEmpty";
import prisma from "@lib/prisma";
@ -410,27 +411,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
}
type Booking = Prisma.PromiseReturnType<typeof createBooking>;
let booking: Booking | null = null;
try {
booking = await createBooking();
evt.uid = booking.uid;
} catch (_err) {
const err = getErrorFromUnknown(_err);
log.error(`Booking ${eventTypeId} failed`, "Error when saving booking to db", err.message);
if (err.code === "P2002") {
res.status(409).json({ message: "booking.conflict" });
return;
}
res.status(500).end();
return;
}
let results: EventResult[] = [];
let referencesToCreate: PartialReference[] = [];
let user: User | null = null;
/** Let's start cheking for availability */
/** Let's start checking for availability */
for (const currentUser of users) {
if (!currentUser) {
console.error(`currentUser not found`);
@ -445,68 +430,94 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
const credentials = currentUser.credentials;
const calendarBusyTimes: EventBusyDate[] = await prisma.booking
.findMany({
where: {
userId: currentUser.id,
eventTypeId: eventTypeId,
},
})
.then((bookings) => bookings.map((booking) => ({ end: booking.endTime, start: booking.startTime })));
if (credentials) {
const calendarBusyTimes = await getBusyCalendarTimes(
credentials,
reqBody.start,
reqBody.end,
selectedCalendars
await getBusyCalendarTimes(credentials, reqBody.start, reqBody.end, selectedCalendars).then(
(busyTimes) => calendarBusyTimes.push(...busyTimes)
);
const videoBusyTimes = (await getBusyVideoTimes(credentials)).filter(notEmpty);
calendarBusyTimes.push(...videoBusyTimes);
console.log("calendarBusyTimes==>>>", calendarBusyTimes);
const bufferedBusyTimes: BufferedBusyTimes = calendarBusyTimes.map((a) => ({
start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toString(),
end: dayjs(a.end).add(currentUser.bufferTime, "minute").toString(),
}));
let isAvailableToBeBooked = true;
try {
isAvailableToBeBooked = isAvailable(bufferedBusyTimes, reqBody.start, eventType.length);
} catch {
log.debug({
message: "Unable set isAvailableToBeBooked. Using true. ",
});
}
if (!isAvailableToBeBooked) {
const error = {
errorCode: "BookingUserUnAvailable",
message: `${currentUser.name} is unavailable at this time.`,
};
log.debug(`Booking ${currentUser.name} failed`, error);
}
let timeOutOfBounds = false;
try {
timeOutOfBounds = isOutOfBounds(reqBody.start, {
periodType: eventType.periodType,
periodDays: eventType.periodDays,
periodEndDate: eventType.periodEndDate,
periodStartDate: eventType.periodStartDate,
periodCountCalendarDays: eventType.periodCountCalendarDays,
timeZone: currentUser.timeZone,
});
} catch {
log.debug({
message: "Unable set timeOutOfBounds. Using false. ",
});
}
if (timeOutOfBounds) {
const error = {
errorCode: "BookingUserUnAvailable",
message: `${currentUser.name} is unavailable at this time.`,
};
log.debug(`Booking ${currentUser.name} failed`, error);
res.status(400).json(error);
}
}
console.log("calendarBusyTimes==>>>", calendarBusyTimes);
const bufferedBusyTimes: BufferedBusyTimes = calendarBusyTimes.map((a) => ({
start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toString(),
end: dayjs(a.end).add(currentUser.bufferTime, "minute").toString(),
}));
let isAvailableToBeBooked = true;
try {
isAvailableToBeBooked = isAvailable(bufferedBusyTimes, reqBody.start, eventType.length);
} catch {
log.debug({
message: "Unable set isAvailableToBeBooked. Using true. ",
});
}
if (!isAvailableToBeBooked) {
const error = {
errorCode: "BookingUserUnAvailable",
message: `${currentUser.name} is unavailable at this time.`,
};
log.debug(`Booking ${currentUser.name} failed`, error);
res.status(409).json(error);
return;
}
let timeOutOfBounds = false;
try {
timeOutOfBounds = isOutOfBounds(reqBody.start, {
periodType: eventType.periodType,
periodDays: eventType.periodDays,
periodEndDate: eventType.periodEndDate,
periodStartDate: eventType.periodStartDate,
periodCountCalendarDays: eventType.periodCountCalendarDays,
timeZone: currentUser.timeZone,
});
} catch {
log.debug({
message: "Unable set timeOutOfBounds. Using false. ",
});
}
if (timeOutOfBounds) {
const error = {
errorCode: "BookingUserUnAvailable",
message: `${currentUser.name} is unavailable at this time.`,
};
log.debug(`Booking ${currentUser.name} failed`, error);
res.status(400).json(error);
return;
}
}
type Booking = Prisma.PromiseReturnType<typeof createBooking>;
let booking: Booking | null = null;
try {
booking = await createBooking();
evt.uid = booking.uid;
} catch (_err) {
const err = getErrorFromUnknown(_err);
log.error(`Booking ${eventTypeId} failed`, "Error when saving booking to db", err.message);
if (err.code === "P2002") {
res.status(409).json({ message: "booking.conflict" });
return;
}
res.status(500).end();
return;
}
if (!user) throw Error("Can't continue, user not found.");
@ -577,6 +588,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (eventType.requiresConfirmation && !rescheduleUid) {
await sendOrganizerRequestEmail(evt);
await sendAttendeeRequestEmail(evt, attendeesList[0]);
}
if (typeof eventType.price === "number" && eventType.price > 0) {
@ -596,9 +608,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
log.debug(`Booking ${user.username} completed`);
const eventTrigger = rescheduleUid ? "BOOKING_RESCHEDULED" : "BOOKING_CREATED";
const eventTrigger: WebhookTriggerEvents = rescheduleUid ? "BOOKING_RESCHEDULED" : "BOOKING_CREATED";
const subscriberOptions = {
userId: user.id,
eventTypeId,
triggerEvent: eventTrigger,
};
// Send Webhook call if hooked to BOOKING_CREATED & BOOKING_RESCHEDULED
const subscribers = await getSubscribers(user.id, eventTrigger);
const subscribers = await getSubscribers(subscriberOptions);
console.log("evt:", {
...evt,
metadata: reqBody.metadata,

View File

@ -1,4 +1,4 @@
import { BookingStatus } from "@prisma/client";
import { BookingStatus, WebhookTriggerEvents } from "@prisma/client";
import async from "async";
import dayjs from "dayjs";
import { NextApiRequest, NextApiResponse } from "next";
@ -130,9 +130,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
};
// Hook up the webhook logic here
const eventTrigger = "BOOKING_CANCELLED";
const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED";
// Send Webhook call if hooked to BOOKING.CANCELLED
const subscribers = await getSubscribers(bookingToDelete.userId, eventTrigger);
const subscriberOptions = {
userId: bookingToDelete.userId,
eventTypeId: bookingToDelete.eventTypeId as number,
triggerEvent: eventTrigger,
};
const subscribers = await getSubscribers(subscriberOptions);
const promises = subscribers.map((sub) =>
sendPayload(eventTrigger, new Date().toISOString(), sub.subscriberUrl, evt, sub.payloadTemplate).catch(
(e) => {

View File

@ -27,9 +27,24 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
where: {
plan: "TRIAL",
createdDate: {
lt: dayjs().subtract(TRIAL_LIMIT_DAYS, "day").toDate(),
},
OR: [
/**
* If the user doesn't have a trial end date,
* use the default 14 day trial from creation.
*/
{
createdDate: {
lt: dayjs().subtract(TRIAL_LIMIT_DAYS, "day").toDate(),
},
trialEndsAt: null,
},
/** If it does, then honor the trial end date. */
{
trialEndsAt: {
lt: dayjs().toDate(),
},
},
],
},
});
res.json({ ok: true });

View File

@ -1,6 +1,8 @@
import { Prisma } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import { getStripeCustomerId } from "@ee/lib/stripe/customer";
import { getSession } from "@lib/auth";
import { WEBSITE_URL } from "@lib/config/constants";
import { HttpError as HttpCode } from "@lib/core/http/error";
@ -27,6 +29,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
});
const stripeCustomerId = await getStripeCustomerId(user);
try {
const response = await fetch(`${WEBSITE_URL}/api/upgrade`, {
method: "POST",
@ -35,7 +39,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
"Content-Type": "application/json",
},
body: JSON.stringify({
stripeCustomerId: (user.metadata as Prisma.JsonObject)?.stripeCustomerId,
stripeCustomerId,
email: user.email,
fromApp: true,
}),

View File

@ -1,396 +1,27 @@
import { ChevronRightIcon, PencilAltIcon, SwitchHorizontalIcon, TrashIcon } from "@heroicons/react/outline";
import { ClipboardIcon } from "@heroicons/react/solid";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
import Image from "next/image";
import { useEffect, useState } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
import React, { useEffect, useState } from "react";
import { JSONObject } from "superjson/dist/types";
import { QueryCell } from "@lib/QueryCell";
import { CalendarListContainer } from "@lib/apps/calendar/components/CalendarListContainer";
import classNames from "@lib/classNames";
import { HttpError } from "@lib/core/http/error";
import { useLocale } from "@lib/hooks/useLocale";
import showToast from "@lib/notification";
import { inferQueryOutput, trpc } from "@lib/trpc";
import { WEBHOOK_TRIGGER_EVENTS } from "@lib/webhooks/constants";
import { trpc } from "@lib/trpc";
import { ClientSuspense } from "@components/ClientSuspense";
import { Dialog, DialogContent, DialogFooter, DialogTrigger } from "@components/Dialog";
import { List, ListItem, ListItemText, ListItemTitle } from "@components/List";
import Loader from "@components/Loader";
import Shell, { ShellSubHeading } from "@components/Shell";
import { Tooltip } from "@components/Tooltip";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import { FieldsetLegend, Form, InputGroupBox, TextArea, TextField } from "@components/form/fields";
import ConnectIntegration from "@components/integrations/ConnectIntegrations";
import DisconnectIntegration from "@components/integrations/DisconnectIntegration";
import IntegrationListItem from "@components/integrations/IntegrationListItem";
import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections";
import { Alert } from "@components/ui/Alert";
import Button from "@components/ui/Button";
import Switch from "@components/ui/Switch";
import { CalendarListContainer } from "../../lib/apps/calendar/components/CalendarListContainer";
type TWebhook = inferQueryOutput<"viewer.webhook.list">[number];
function WebhookListItem(props: { webhook: TWebhook; onEditWebhook: () => void }) {
const { t } = useLocale();
const utils = trpc.useContext();
const deleteWebhook = trpc.useMutation("viewer.webhook.delete", {
async onSuccess() {
await utils.invalidateQueries(["viewer.webhook.list"]);
},
});
return (
<ListItem className="-mt-px flex w-full p-4">
<div className="flex w-full justify-between">
<div className="flex max-w-full flex-col truncate">
<div className="flex space-y-1">
<span
className={classNames(
"truncate text-sm",
props.webhook.active ? "text-neutral-700" : "text-neutral-200"
)}>
{props.webhook.subscriberUrl}
</span>
</div>
<div className="mt-2 flex">
<span className="flex flex-col space-x-2 space-y-1 text-xs sm:flex-row sm:space-y-0 sm:rtl:space-x-reverse">
{props.webhook.eventTriggers.map((eventTrigger, ind) => (
<span
key={ind}
className={classNames(
"w-max rounded-sm px-1 text-xs ",
props.webhook.active ? "bg-blue-100 text-blue-700" : "bg-blue-50 text-blue-200"
)}>
{t(`${eventTrigger.toLowerCase()}`)}
</span>
))}
</span>
</div>
</div>
<div className="flex">
<Tooltip content={t("edit_webhook")}>
<Button
onClick={() => props.onEditWebhook()}
color="minimal"
size="icon"
StartIcon={PencilAltIcon}
className="ml-4 w-full self-center p-2"></Button>
</Tooltip>
<Dialog>
<Tooltip content={t("delete_webhook")}>
<DialogTrigger asChild>
<Button
onClick={(e) => {
e.stopPropagation();
}}
color="minimal"
size="icon"
StartIcon={TrashIcon}
className="ml-2 w-full self-center p-2"></Button>
</DialogTrigger>
</Tooltip>
<ConfirmationDialogContent
variety="danger"
title={t("delete_webhook")}
confirmBtnText={t("confirm_delete_webhook")}
cancelBtnText={t("cancel")}
onConfirm={() => deleteWebhook.mutate({ id: props.webhook.id })}>
{t("delete_webhook_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</div>
</div>
</ListItem>
);
}
function WebhookTestDisclosure() {
const subscriberUrl: string = useWatch({ name: "subscriberUrl" });
const payloadTemplate = useWatch({ name: "payloadTemplate" }) || null;
const { t } = useLocale();
const [open, setOpen] = useState(false);
const mutation = trpc.useMutation("viewer.webhook.testTrigger", {
onError(err) {
showToast(err.message, "error");
},
});
return (
<Collapsible open={open} onOpenChange={() => setOpen(!open)}>
<CollapsibleTrigger type="button" className={"flex w-full cursor-pointer"}>
<ChevronRightIcon className={`${open ? "rotate-90 transform" : ""} text-neutral-500 h-5 w-5`} />
<span className="text-sm font-medium text-gray-700">{t("webhook_test")}</span>
</CollapsibleTrigger>
<CollapsibleContent>
<InputGroupBox className="space-y-0 border-0 px-0">
<div className="flex justify-between bg-gray-50 p-2">
<h3 className="self-center text-gray-700">{t("webhook_response")}</h3>
<Button
StartIcon={SwitchHorizontalIcon}
type="button"
color="minimal"
disabled={mutation.isLoading}
onClick={() => mutation.mutate({ url: subscriberUrl, type: "PING", payloadTemplate })}>
{t("ping_test")}
</Button>
</div>
<div className="border-8 border-gray-50 p-2 text-gray-500">
{!mutation.data && <em>{t("no_data_yet")}</em>}
{mutation.status === "success" && (
<>
<div
className={classNames(
"ml-auto w-max px-2 py-1 text-xs",
mutation.data.ok ? "bg-green-50 text-green-500" : "bg-red-50 text-red-500"
)}>
{mutation.data.ok ? t("success") : t("failed")}
</div>
<pre className="overflow-x-auto">{JSON.stringify(mutation.data, null, 4)}</pre>
</>
)}
</div>
</InputGroupBox>
</CollapsibleContent>
</Collapsible>
);
}
function WebhookDialogForm(props: {
//
defaultValues?: TWebhook;
handleClose: () => void;
}) {
const { t } = useLocale();
const utils = trpc.useContext();
const supportedWebhookIntegrationList = ["https://discord.com/api/webhooks/"];
const handleSubscriberUrlChange = (e) => {
form.setValue("subscriberUrl", e.target.value);
const ind = supportedWebhookIntegrationList.findIndex((integration) => {
return e.target.value.includes(integration);
});
if (ind > -1) updateCustomTemplate(supportedWebhookIntegrationList[ind]);
};
const updateCustomTemplate = (webhookIntegration) => {
setUseCustomPayloadTemplate(true);
switch (webhookIntegration) {
case "https://discord.com/api/webhooks/":
form.setValue(
"payloadTemplate",
'{"content": "A new event has been scheduled","embeds": [{"color": 2697513,"fields": [{"name": "What","value": "{{title}} ({{type}})"},{"name": "When","value": "Start: {{startTime}} \\n End: {{endTime}} \\n Timezone: ({{organizer.timeZone}})"},{"name": "Who","value": "Organizer: {{organizer.name}} ({{organizer.email}}) \\n Booker: {{attendees.0.name}} ({{attendees.0.email}})" },{"name":"Description", "value":": {{description}}"},{"name":"Where","value":": {{location}} "}]}]}'
);
}
};
const {
defaultValues = {
id: "",
eventTriggers: WEBHOOK_TRIGGER_EVENTS,
subscriberUrl: "",
active: true,
payloadTemplate: null,
} as Omit<TWebhook, "userId" | "createdAt">,
} = props;
const [useCustomPayloadTemplate, setUseCustomPayloadTemplate] = useState(!!defaultValues.payloadTemplate);
const form = useForm({
defaultValues,
});
return (
<Form
data-testid="WebhookDialogForm"
form={form}
handleSubmit={async (event) => {
if (!useCustomPayloadTemplate && event.payloadTemplate) {
event.payloadTemplate = null;
}
if (event.id) {
await utils.client.mutation("viewer.webhook.edit", event);
await utils.invalidateQueries(["viewer.webhook.list"]);
showToast(t("webhook_updated_successfully"), "success");
} else {
await utils.client.mutation("viewer.webhook.create", event);
await utils.invalidateQueries(["viewer.webhook.list"]);
showToast(t("webhook_created_successfully"), "success");
}
props.handleClose();
}}
className="space-y-4">
<input type="hidden" {...form.register("id")} />
<fieldset className="space-y-2">
<InputGroupBox className="border-0 bg-gray-50">
<Controller
control={form.control}
name="active"
render={({ field }) => (
<Switch
label={field.value ? t("webhook_enabled") : t("webhook_disabled")}
defaultChecked={field.value}
onCheckedChange={(isChecked) => {
form.setValue("active", isChecked);
}}
/>
)}
/>
</InputGroupBox>
</fieldset>
<TextField
label={t("subscriber_url")}
{...form.register("subscriberUrl")}
required
type="url"
onChange={handleSubscriberUrlChange}
/>
<fieldset className="space-y-2">
<FieldsetLegend>{t("event_triggers")}</FieldsetLegend>
<InputGroupBox className="border-0 bg-gray-50">
{WEBHOOK_TRIGGER_EVENTS.map((key) => (
<Controller
key={key}
control={form.control}
name="eventTriggers"
render={({ field }) => (
<Switch
label={t(key.toLowerCase())}
defaultChecked={field.value.includes(key)}
onCheckedChange={(isChecked) => {
const value = field.value;
const newValue = isChecked ? [...value, key] : value.filter((v) => v !== key);
form.setValue("eventTriggers", newValue, {
shouldDirty: true,
});
}}
/>
)}
/>
))}
</InputGroupBox>
</fieldset>
<fieldset className="space-y-2">
<FieldsetLegend>{t("payload_template")}</FieldsetLegend>
<div className="space-x-3 text-sm rtl:space-x-reverse">
<label>
<input
className="text-neutral-900 focus:ring-neutral-500"
type="radio"
name="useCustomPayloadTemplate"
onChange={(value) => setUseCustomPayloadTemplate(!value.target.checked)}
defaultChecked={!useCustomPayloadTemplate}
/>{" "}
Default
</label>
<label>
<input
className="text-neutral-900 focus:ring-neutral-500"
onChange={(value) => setUseCustomPayloadTemplate(value.target.checked)}
name="useCustomPayloadTemplate"
type="radio"
defaultChecked={useCustomPayloadTemplate}
/>{" "}
Custom
</label>
</div>
{useCustomPayloadTemplate && (
<TextArea
{...form.register("payloadTemplate")}
defaultValue={useCustomPayloadTemplate && (defaultValues.payloadTemplate || "")}
rows={3}
/>
)}
</fieldset>
<WebhookTestDisclosure />
<DialogFooter>
<Button type="button" color="secondary" onClick={props.handleClose} tabIndex={-1}>
{t("cancel")}
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
{t("save")}
</Button>
</DialogFooter>
</Form>
);
}
function WebhookListContainer() {
const { t } = useLocale();
const query = trpc.useQuery(["viewer.webhook.list"], { suspense: true });
const [newWebhookModal, setNewWebhookModal] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [editing, setEditing] = useState<TWebhook | null>(null);
return (
<QueryCell
query={query}
success={({ data }) => (
<>
<ShellSubHeading className="mt-10" title={t("Webhooks")} subtitle={t("receive_cal_meeting_data")} />
<List>
<ListItem className={classNames("flex-col")}>
<div
className={classNames("flex w-full flex-1 items-center space-x-2 p-3 rtl:space-x-reverse")}>
<Image width={40} height={40} src="/apps/webhooks.svg" alt="Webhooks" />
<div className="flex-grow truncate pl-2">
<ListItemTitle component="h3">Webhooks</ListItemTitle>
<ListItemText component="p">{t("automation")}</ListItemText>
</div>
<div>
<Button
color="secondary"
onClick={() => setNewWebhookModal(true)}
data-testid="new_webhook">
{t("new_webhook")}
</Button>
</div>
</div>
</ListItem>
</List>
{data.length ? (
<List>
{data.map((item) => (
<WebhookListItem
key={item.id}
webhook={item}
onEditWebhook={() => {
setEditing(item);
setEditModalOpen(true);
}}
/>
))}
</List>
) : null}
{/* New webhook dialog */}
<Dialog open={newWebhookModal} onOpenChange={(isOpen) => !isOpen && setNewWebhookModal(false)}>
<DialogContent>
<WebhookDialogForm handleClose={() => setNewWebhookModal(false)} />
</DialogContent>
</Dialog>
{/* Edit webhook dialog */}
<Dialog open={editModalOpen} onOpenChange={(isOpen) => !isOpen && setEditModalOpen(false)}>
<DialogContent>
{editing && (
<WebhookDialogForm
key={editing.id}
handleClose={() => setEditModalOpen(false)}
defaultValues={editing}
/>
)}
</DialogContent>
</Dialog>
</>
)}
/>
);
}
import WebhookListContainer from "@components/webhook/WebhookListContainer";
function IframeEmbedContainer() {
const { t } = useLocale();
@ -659,7 +290,7 @@ export default function IntegrationsPage() {
<ClientSuspense fallback={<Loader />}>
<IntegrationsContainer />
<CalendarListContainer />
<WebhookListContainer />
<WebhookListContainer title={t("webhooks")} subtitle={t("receive_cal_meeting_data")} />
<IframeEmbedContainer />
<Web3Container />
</ClientSuspense>

View File

@ -64,8 +64,8 @@ const AvailabilityView = ({ user }: { user: User }) => {
/>
<small className="block text-neutral-400">{t("hover_over_bold_times_tip")}</small>
<div className="mt-4 space-y-4">
<div className="bg-brand overflow-hidden rounded-sm">
<div className="text-brandcontrast px-4 py-2 sm:px-6">
<div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-sm">
<div className="text-brandcontrast dark:text-darkmodebrandcontrast px-4 py-2 sm:px-6">
{t("your_day_starts_at")} {convertMinsToHrsMins(user.startTime)}
</div>
</div>
@ -94,8 +94,8 @@ const AvailabilityView = ({ user }: { user: User }) => {
</div>
)}
<div className="bg-brand overflow-hidden rounded-sm">
<div className="text-brandcontrast px-4 py-2 sm:px-6">
<div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-sm">
<div className="text-brandcontrast dark:text-darkmodebrandcontrast px-4 py-2 sm:px-6">
{t("your_day_ends_at")} {convertMinsToHrsMins(user.endTime)}
</div>
</div>

View File

@ -35,7 +35,7 @@ export default function Type(props: inferSSRProps<typeof getServerSideProps>) {
title={`${t("cancel")} ${props.booking && props.booking.title} | ${props.profile?.name}`}
description={`${t("cancel")} ${props.booking && props.booking.title} | ${props.profile?.name}`}
/>
<CustomBranding val={props.profile?.brandColor} />
<CustomBranding lightVal={props.profile?.brandColor} darkVal={props.profile?.darkBrandColor} />
<main className="mx-auto my-24 max-w-3xl">
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
@ -179,6 +179,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
username: true,
name: true,
brandColor: true,
darkBrandColor: true,
},
},
eventType: {
@ -210,6 +211,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
name: booking.eventType?.team?.name || booking.user?.name || null,
slug: booking.eventType?.team?.slug || booking.user?.username || null,
brandColor: booking.user?.brandColor || null,
darkBrandColor: booking.user?.darkBrandColor || null,
};
return {

File diff suppressed because it is too large Load Diff

View File

@ -176,6 +176,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
const [hasErrors, setHasErrors] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [brandColor, setBrandColor] = useState(props.user.brandColor);
const [darkBrandColor, setDarkBrandColor] = useState(props.user.darkBrandColor);
useEffect(() => {
if (!props.user.theme) return;
@ -194,6 +195,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
const enteredDescription = descriptionRef.current.value;
const enteredAvatar = avatarRef.current.value;
const enteredBrandColor = brandColor;
const enteredDarkBrandColor = darkBrandColor;
const enteredTimeZone = typeof selectedTimeZone === "string" ? selectedTimeZone : selectedTimeZone.value;
const enteredWeekStartDay = selectedWeekStartDay.value;
const enteredHideBranding = hideBrandingRef.current.checked;
@ -213,6 +215,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
hideBranding: enteredHideBranding,
theme: asStringOrNull(selectedTheme?.value),
brandColor: enteredBrandColor,
darkBrandColor: enteredDarkBrandColor,
locale: enteredLanguage,
timeFormat: enteredTimeFormat,
});
@ -424,11 +427,19 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
</div>
</div>
</div>
<div>
<label htmlFor="brandColor" className="block text-sm font-medium text-gray-700">
{t("brand_color")}
</label>
<ColorPicker defaultValue={props.user.brandColor} onChange={setBrandColor} />
<div className="block rtl:space-x-reverse sm:flex sm:space-x-2">
<div className="mb-6 w-full sm:w-1/2">
<label htmlFor="brandColor" className="block text-sm font-medium text-gray-700">
{t("light_brand_color")}
</label>
<ColorPicker defaultValue={props.user.brandColor} onChange={setBrandColor} />
</div>
<div className="mb-6 w-full sm:w-1/2">
<label htmlFor="darkBrandColor" className="block text-sm font-medium text-gray-700">
{t("dark_brand_color")}
</label>
<ColorPicker defaultValue={props.user.darkBrandColor} onChange={setDarkBrandColor} />
</div>
<hr className="mt-6" />
</div>
<div>
@ -524,6 +535,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
theme: true,
plan: true,
brandColor: true,
darkBrandColor: true,
metadata: true,
timeFormat: true,
},

View File

@ -96,7 +96,7 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
title={needsConfirmation ? t("booking_submitted") : t("booking_confirmed")}
description={needsConfirmation ? t("booking_submitted") : t("booking_confirmed")}
/>
<CustomBranding val={props.profile.brandColor} />
<CustomBranding lightVal={props.profile.brandColor} darkVal={props.profile.darkBrandColor} />
<main className="mx-auto max-w-3xl py-24">
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
@ -320,6 +320,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
plan: true,
theme: true,
brandColor: true,
darkBrandColor: true,
},
},
team: {
@ -348,6 +349,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
plan: true,
theme: true,
brandColor: true,
darkBrandColor: true,
},
});
if (user) {
@ -365,6 +367,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
name: eventType.team?.name || eventType.users[0]?.name || null,
theme: (!eventType.team?.name && eventType.users[0]?.theme) || null,
brandColor: eventType.team ? null : eventType.users[0].brandColor,
darkBrandColor: eventType.team ? null : eventType.users[0].darkBrandColor,
};
return {

View File

@ -48,6 +48,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
hideBranding: true,
plan: true,
brandColor: true,
darkBrandColor: true,
},
},
title: true,
@ -61,6 +62,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
periodDays: true,
periodCountCalendarDays: true,
minimumBookingNotice: true,
beforeEventBuffer: true,
afterEventBuffer: true,
price: true,
currency: true,
timeZone: true,
@ -103,10 +106,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
theme: null,
weekStart: "Sunday",
brandColor: "" /* TODO: Add a way to set a brand color for Teams */,
darkBrandColor: "" /* TODO: Add a way to set a brand color for Teams */,
},
date: dateParam,
eventType: eventTypeObject,
workingHours,
previousPage: context.req.headers.referer ?? null,
},
};
};

View File

@ -102,6 +102,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
image: eventTypeObject.team?.logo || null,
theme: null /* Teams don't have a theme, and `BookingPage` uses it */,
brandColor: null /* Teams don't have a brandColor, and `BookingPage` uses it */,
darkBrandColor: null /* Teams don't have a darkBrandColor, and `BookingPage` uses it */,
},
eventType: eventTypeObject,
booking,

View File

@ -1,16 +1,80 @@
import { expect, test } from "@playwright/test";
import prisma from "@lib/prisma";
import { todo } from "./lib/testUtils";
const deleteBookingsByEmail = async (email: string) =>
prisma.booking.deleteMany({
where: {
user: {
email,
},
},
});
test.describe("free user", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/free");
});
test.afterEach(async () => {
// delete test bookings
await deleteBookingsByEmail("free@example.com");
});
test("only one visible event", async ({ page }) => {
await expect(page.locator(`[href="/free/30min"]`)).toBeVisible();
await expect(page.locator(`[href="/free/60min"]`)).not.toBeVisible();
});
test("cannot book same slot multiple times", async ({ page }) => {
// Click first event type
await page.click('[data-testid="event-type-link"]');
// Click [data-testid="incrementMonth"]
await page.click('[data-testid="incrementMonth"]');
// Click [data-testid="day"]
await page.click('[data-testid="day"][data-disabled="false"]');
// Click [data-testid="time"]
await page.click('[data-testid="time"]');
// Navigate to book page
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/book");
},
});
// save booking url
const bookingUrl: string = page.url();
const bookTimeSlot = async () => {
// --- fill form
await page.fill('[name="name"]', "Test Testson");
await page.fill('[name="email"]', "test@example.com");
await page.press('[name="email"]', "Enter");
};
// book same time spot twice
await bookTimeSlot();
// Make sure we're navigated to the success page
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/success");
},
});
// return to same time spot booking page
await page.goto(bookingUrl);
// book same time spot again
await bookTimeSlot();
// check for error message
await expect(page.locator("[data-testid=booking-fail]")).toBeVisible();
});
todo("`/free/30min` is bookable");
todo("`/free/60min` is not bookable");
@ -21,6 +85,11 @@ test.describe("pro user", () => {
await page.goto("/pro");
});
test.afterEach(async () => {
// delete test bookings
await deleteBookingsByEmail("pro@example.com");
});
test("pro user's page has at least 2 visible events", async ({ page }) => {
const $eventTypes = await page.$$("[data-testid=event-types] > *");
expect($eventTypes.length).toBeGreaterThanOrEqual(2);
@ -31,6 +100,11 @@ test.describe("pro user", () => {
await page.click('[data-testid="event-type-link"]');
// Click [data-testid="incrementMonth"]
await page.click('[data-testid="incrementMonth"]');
// @TODO: Find a better way to make test wait for full month change render to end
// so it can click up on the right day, also when resolve remove other todos
// Waiting for full month increment
await page.waitForTimeout(400);
// Click [data-testid="day"]
await page.click('[data-testid="day"][data-disabled="false"]');
// Click [data-testid="time"]

View File

@ -39,6 +39,11 @@ test.describe.serial("Stripe integration", () => {
await page.goto("/pro/paid");
// Click [data-testid="incrementMonth"]
await page.click('[data-testid="incrementMonth"]');
// @TODO: Find a better way to make test wait for full month change render to end
// so it can click up on the right day, also when resolve remove other todos
// Waiting for full month increment
await page.waitForTimeout(400);
// Click [data-testid="day"]
await page.click('[data-testid="day"][data-disabled="false"]');
// Click [data-testid="time"]

View File

@ -39,6 +39,11 @@ test.describe("integrations", () => {
// --- Book the first available day next month in the pro user's "30min"-event
await page.goto(`/pro/30min`);
await page.click('[data-testid="incrementMonth"]');
// @TODO: Find a better way to make test wait for full month change render to end
// so it can click up on the right day, also when resolve remove other todos
// Waiting for full month increment
await page.waitForTimeout(400);
await page.click('[data-testid="day"][data-disabled="false"]');
await page.click('[data-testid="time"]');

View File

@ -4,13 +4,13 @@
"day_plural": "{{count}} أيام",
"upgrade_now": "تحديث الآن",
"accept_invitation": "اقبل الدعوة",
"calcom_explained": "Cal.com هو بديل Calendly مفتوح المصدر الذي يتيح لك التحكم في بياناتك وسير عملك ومظهرك.",
"calcom_explained": "Cal.com هو بديل Calendly مفتوح المصدر الذي يتيح لك التحكم في بياناتك وسير عملك والمظهر على التطبيق أو موقع الويب.",
"have_any_questions": "هل لديك أسئلة؟ نحن هنا للمساعدة.",
"reset_password_subject": "Cal.com: إرشادات إعادة تعيين كلمة المرور",
"event_declined_subject": "تم الرفض: {{eventType}} مع {{name}} في {{date}}",
"event_cancelled_subject": "تم الإلغاء: {{eventType}} مع {{name}} في {{date}}",
"event_request_declined": "تم رفض طلب الحدث الخاص بك",
"event_request_cancelled": "تم إلغاء الحدث المجدول",
"event_request_cancelled": "تم إلغاء حدثك المُجَدْوَل",
"organizer": "المنظِّم",
"need_to_reschedule_or_cancel": "هل تحتاج إلى إعادة جدولة أو إلغاء؟",
"cancellation_reason": "سبب الإلغاء",
@ -21,12 +21,12 @@
"rejection_confirmation": "رفض الحجز",
"manage_this_event": "قم بإدارة هذا الحدث",
"your_event_has_been_scheduled": "تم جدولة الحدث الخاص بك",
"accept_our_license": "اقبل ترخيصنا من خلال تغيير المتغير .env <1>NEXT_PUBLIC_LICENSE_CONSENT</1> إلى '{{agree}}'.",
"remove_banner_instructions": "لإزالة هذا الشعار، يُرجى فتح ملف .env وتغيير متغير <1>NEXT_PUBLIC_LICENSE_CONSENT</1> إلى '{{agree}}'.",
"accept_our_license": "اقبل ترخيصنا من خلال تغيير متغير .env المسمى <1>NEXT_PUBLIC_LICENSE_CONSENT</1> إلى '{{agree}}'.",
"remove_banner_instructions": "لإزالة هذا الشعار، يُرجى فتح ملف .env وتغيير المتغير <1>NEXT_PUBLIC_LICENSE_CONSENT</1> إلى '{{agree}}'.",
"error_message": "كانت رسالة الخطأ: '{{errorMessage}}'",
"refund_failed_subject": "فشل الاسترداد: {{name}} - {{date}} - {{eventType}}",
"refund_failed": "فشل استرداد مقابل الحدث {{eventType}} مع {{userName}} في {{date}}.",
"check_with_provider_and_user": "يُرجى التحقق مع مزود الدفع و{{user}} بشأن كيفية التعامل مع هذا.",
"check_with_provider_and_user": "يُرجى التحقق من مزود الدفع لديك و{{user}} بشأن كيفية التعامل مع الأمر.",
"a_refund_failed": "فشل الاسترداد",
"awaiting_payment_subject": "في انتظار الدفع: {{eventType}} مع {{name}} في {{date}}",
"meeting_awaiting_payment": "اجتماعك في انتظار الدفع",
@ -36,24 +36,24 @@
"refunded": "تم الاسترداد",
"pay_later_instructions": "لقد تلقيت أيضا رسالة بريد إلكتروني مع هذا الرابط، إذا كنت ترغب في الدفع لاحقاً.",
"payment": "الدفعات",
"missing_card_fields": "حقول البطاقة المفقودة",
"missing_card_fields": "بعض خانات البطاقة مفقودة",
"pay_now": "ادفع الآن",
"codebase_has_to_stay_opensource": "يجب أن يظل مصدر البرنامج مفتوح المصدر، سواء تم تعديله أم لا",
"cannot_repackage_codebase": "لا يمكنك إعادة حزم مصدر البيانات أو بيعه",
"acquire_license": "احصل على ترخيص تجاري لإزالة هذه الشروط عن طريق البريد الإلكتروني",
"codebase_has_to_stay_opensource": "يجب أن يظل مصدر البرنامج مفتوحًا، سواء تم تعديله أم لا",
"cannot_repackage_codebase": "لا يمكنك بيع مصدر البرنامج أو تغييره للتصرف فيه (repackage)",
"acquire_license": "احصل عن طريق البريد الإلكتروني على ترخيص تجاري لإزالة هذه الشروط",
"terms_summary": "ملخص الشروط",
"open_env": "افتح .env ووافق على الترخيص",
"env_changed": "لقد قمت بتغيير .env",
"accept_license": "اقبل الترخيص",
"still_waiting_for_approval": "لا يزال يوجد حدث في انتظار الموافقة",
"event_is_still_waiting": "لا يزال طلب الحدث في انتظار الموافقة: {{attendeeName}} - {{date}} - {{eventType}}",
"still_waiting_for_approval": "يوجد حدث لا يزال في انتظار الموافقة",
"event_is_still_waiting": "لا يزال طلب الحدث في الانتظار: {{attendeeName}} - {{date}} - {{eventType}}",
"no_more_results": "لا يوجد مزيد من النتائج",
"load_more_results": "تحميل المزيد من النتائج",
"integration_meeting_id": "مُعرّف اجتماع {{integrationName}}: {{meetingId}}",
"confirmed_event_type_subject": "تم التأكيد: {{eventType}} مع {{name}} في {{date}}",
"new_event_request": "طلب الحدث الجديد: {{attendeeName}} - {{date}} - {{eventType}}",
"confirm_or_reject_request": "تأكيد الطلب أو رفضه",
"check_bookings_page_to_confirm_or_reject": "تحقق من صفحة الحجوزات لتأكيد الحجز أو رفضه.",
"confirm_or_reject_request": "يمكنك تأكيد الطلب أو رفضه",
"check_bookings_page_to_confirm_or_reject": "تحقق من صفحة عمليات الحجز لديك لتأكيد الحجز أو رفضه.",
"event_awaiting_approval": "الحدث في انتظار موافقتك",
"someone_requested_an_event": "طلب شخص ما جدولة حدث في تقويمك.",
"someone_requested_password_reset": "طلب شخص ما رابطا لتغيير كلمة المرور الخاصة بك.",
@ -61,18 +61,18 @@
"event_awaiting_approval_subject": "في انتظار الموافقة: {{eventType}} مع {{name}} في {{date}}",
"event_still_awaiting_approval": "الحدث لا يزال في انتظار موافقتك",
"your_meeting_has_been_booked": "لقد تم حجز الاجتماع الخاص بك",
"event_type_has_been_rescheduled_on_time_date": "تمت إعادة جدولة {{eventType}} مع {{name}} إلى {{time}} ({{timeZone}}) في {{date}}.",
"event_type_has_been_rescheduled_on_time_date": "تمت إعادة جدولة {{eventType}} لديك مع {{name}} إلى {{time}} ({{timeZone}}) في {{date}}.",
"event_has_been_rescheduled": "تم التحديث - تم إعادة جدولة الحدث الخاص بك",
"hi_user_name": "مرحبا {{name}}",
"ics_event_title": "{{eventType}} مع {{name}}",
"new_event_subject": "حدث جديد: {{attendeeName}} - {{date}} - {{eventType}}",
"join_by_entrypoint": "انضمام بواسطة {{entryPoint}}",
"join_by_entrypoint": "الانضمام بواسطة {{entryPoint}}",
"notes": "ملاحظات",
"manage_my_bookings": "إدارة الحجوزات الخاصة بي",
"need_to_make_a_change": "هل تحتاج لإجراء تغيير؟",
"new_event_scheduled": "تم جدولة حدث جديد.",
"invitee_email": "البريد الإلكتروني للشخص المدعو",
"invitee_timezone": "المنطقة الزمنية للشخص المدعو",
"invitee_email": "البريد الإلكتروني للمدعو",
"invitee_timezone": "المنطقة الزمنية للمدعو",
"event_type": "نوع الحدث",
"enter_meeting": "ادخل الاجتماع",
"video_call_provider": "مزود مكالمة الفيديو",
@ -90,17 +90,17 @@
"you_have_been_invited": "تم دعوتك للانضمام إلى الفريق {{teamName}}",
"user_invited_you": "قام {{user}} بدعوتك للانضمام إلى فريق {{team}} على Cal.com",
"hidden_team_member_title": "أنت في الوضع الخفي في هذا الفريق",
"hidden_team_member_message": "لم يتم الدفع مقابل مقعدك، قم إما بالترقية إلى Pro أو بالسماح لمالك الفريق بمعرفة أنه بإمكانه الدفع مقابل مقعدك.",
"hidden_team_owner_message": "تحتاج إلى حساب pro لاستخدام الفرق، أنت في الوضع الخفي حتى تقوم بالترقية.",
"hidden_team_member_message": "لم يتم الدفع مقابل مقعدك. يمكنك الترقية إلى Pro أو إعلام مالك الفريق أنه يستطيع الدفع مقابل مقعدك.",
"hidden_team_owner_message": "تحتاج إلى حساب Pro لاستخدام خدمة الفرق، وأنت حاليًا في الوضع الخفي حتى تقوم بالترقية.",
"link_expires": "ملاحظة: تنتهي الصلاحية في غضون {{expiresIn}} من الساعات.",
"upgrade_to_per_seat": "الترقية إلى كل مقعد",
"team_upgrade_seats_details": "من بين {{memberCount}} من الأعضاء في فريقك، لم يتم الدفع مقابل {{unpaidCount}} مقعد (مقاعد). عندما يكون السعر ${{seatPrice}} شهريًا لكل مقعد، تبلغ التكلفة الإجمالية المقدرة لعضويتك ${{totalCost}} شهريًا.",
"upgrade_to_per_seat": "الترقية إلى \"كل مقعد بشكل منفرد\"",
"team_upgrade_seats_details": "من بين {{memberCount}} من الأعضاء في فريقك، لم يتم الدفع مقابل {{unpaidCount}} من المقاعد. بما أن سعر المقعد ${{seatPrice}} شهريًا، تبلغ التكلفة الإجمالية المقدرة لعضويتك ${{totalCost}} شهريًا.",
"team_upgraded_successfully": "تمت ترقية فريقك بنجاح!",
"use_link_to_reset_password": "استخدم الرابط أدناه لإعادة تعيين كلمة المرور",
"hey_there": "مرحبًا،",
"forgot_your_password_calcom": "نسيت كلمة المرور الخاصة بك؟ - Cal.com",
"event_type_title": "{{eventTypeTitle}} | نوع الحدث",
"delete_webhook_confirmation_message": "هل تريد بالتأكيد حذف الويبهوك هذا؟ لن تتلقى بيانات اجتماع Cal.com بعد الآن على عنوان URL محدد، في الوقت الفعلي، عندما تتم جدولة حدث أو إلغاؤه.",
"delete_webhook_confirmation_message": "هل أنت متأكد أنك تريد حذف الويب هوك هذا؟ لن تتلقى بعد الآن بيانات اجتماعات Cal.com على عنوان URL محدد، وفي الوقت الفعلي، عندما تتم جدولة حدث أو يتم إلغاؤه.",
"confirm_delete_webhook": "نعم، قم بحذف الويبهوك",
"edit_webhook": "تعديل الويبهوك",
"delete_webhook": "حذف الويب هوك",
@ -113,10 +113,10 @@
"webhook_created_successfully": "تم إنشاء الويبهوك بنجاح!",
"webhook_updated_successfully": "تم تحديث الويبهوك بنجاح!",
"webhook_removed_successfully": "تمت إزالة الويبهوك بنجاح!",
"payload_template": "قالب الحمولة",
"payload_template": "قالب البيانات",
"dismiss": "تجاهل",
"no_data_yet": "لا توجد بيانات حتى الآن",
"ping_test": "اختبار الاتصال",
"no_data_yet": "لا بيانات حتى الآن",
"ping_test": "اختبار الاتصال (ping)",
"add_to_homescreen": "أضف هذا التطبيق إلى الشاشة الرئيسية للوصول بصورة أسرع وتحسين التجربة.",
"upcoming": "القادم",
"past": "السابق",
@ -130,13 +130,13 @@
"sign_out": "تسجيل الخروج",
"add_another": "أضف واحدًا آخر",
"until": "حتى",
"powered_by": "التشغيل بواسطة",
"powered_by": "مدعوم بواسطة",
"unavailable": "غير متاح",
"set_work_schedule": "ضع جدول عملك",
"change_bookings_availability": "قم بتغيير الوقت الذي تكون فيه متاحًا للحجوزات",
"set_work_schedule": "حدد جدول أعمالك",
"change_bookings_availability": "تغيير الوقت الذي تكون فيه متاحًا لعمليات الحجز",
"select": "حدد...",
"2fa_confirm_current_password": "قم بتأكيد كلمة المرور الحالية للبدء.",
"2fa_scan_image_or_use_code": "امسح الصورة أدناه ضوئيًا باستخدام تطبيق المصادقة على هاتفك أو أدخل رمز النص يدويًا بدلاً من ذلك.",
"2fa_scan_image_or_use_code": "امسح الصورة أدناه ضوئيًا باستخدام تطبيق المصادقة على هاتفك، أو أدخل الرمز النصي يدويًا.",
"text": "النص",
"multiline_text": "النص متعدد السطور",
"number": "الرقم",
@ -144,25 +144,25 @@
"is_required": "مطلوب",
"required": "مطلوب",
"input_type": "نوع الإدخال",
"rejected": "تم الرفض",
"rejected": "مرفوض",
"unconfirmed": "غير مؤكد",
"guests": "الضيوف",
"guest": "الضيف",
"web_conferencing_details_to_follow": "تفاصيل مؤتمرات الويب للمتابعة.",
"web_conferencing_details_to_follow": "تفاصيل مؤتمرات الويب القادمة.",
"the_username": "اسم المستخدم",
"username": "اسم المستخدم",
"is_still_available": "لا يزال متاحًا.",
"documentation": "المستندات",
"documentation_description": "تعرف على كيفية دمج أدواتنا مع تطبيقك",
"api_reference": "مرجع واجهة برمجة التطبيقات",
"api_reference_description": "مرجع واجهة برمجة تطبيقات كامل لمكتبتنا",
"api_reference": "مرجع واجهة برمجة التطبيقات (API)",
"api_reference_description": "مرجع كامل لواجهة برمجة التطبيقات (API) لمكتبتنا",
"blog": "المدونة",
"blog_description": "اقرأ أحدث أخبارنا ومقالاتنا",
"join_our_community": "انضم إلى مجتمعنا",
"join_our_slack": "انضم إلى فترة السماح لدينا",
"claim_username_and_schedule_events": "طالب باسم المستخدم وجدولة الأحداث",
"join_our_slack": "انضم إلى Slack الخاص بنا",
"claim_username_and_schedule_events": "احصل على اسم المستخدم وقم بجدولة الأحداث",
"popular_pages": "الصفحات الشائعة",
"register_now": "تسجيل الآن",
"register_now": "التسجيل الآن",
"register": "تسجيل",
"page_doesnt_exist": "هذه الصفحة غير موجودة.",
"check_spelling_mistakes_or_go_back": "تحقق من الأخطاء الإملائية أو ارجع إلى الصفحة السابقة.",
@ -171,23 +171,23 @@
"15min_meeting": "اجتماع لمدة 15 دقيقة",
"30min_meeting": "اجتماع لمدة 30 دقيقة",
"secret_meeting": "اجتماع سري",
"login_instead": "تسجيل الدخول بدلاً من ذلك",
"login_instead": "تسجيل الدخول بدلًا من ذلك",
"already_have_an_account": "هل لديك حساب بالفعل؟",
"create_account": "إنشاء حساب",
"confirm_password": "تأكيد كلمة المرور",
"create_your_account": "إنشاء حساب",
"create_your_account": "إنشاء حسابك",
"sign_up": "تسجيل الاشتراك",
"youve_been_logged_out": "لقد قمت بتسجيل الخروج",
"hope_to_see_you_soon": "نأمل أن نراك قريبًا مجددًا!",
"logged_out": "تم تسجيل الخروج",
"please_try_again_and_contact_us": "يُرجى المحاولة مجددًا والاتصال بنا إذا استمرت المشكلة.",
"incorrect_2fa_code": "الرمز المكون من عاملين غير صحيح.",
"incorrect_2fa_code": "رمز المصادقة من عاملين غير صحيح.",
"no_account_exists": "لا يوجد حساب مطابق لعنوان البريد الإلكتروني هذا.",
"2fa_enabled_instructions": "تم تمكين المصادقة من عاملين. يُرجى إدخال الرمز المكون من ستة أرقام من تطبيق المصادقة لديك.",
"2fa_enter_six_digit_code": "أدخل الرمز المكون من ستة أرقام من تطبيق المصادقة أدناه.",
"2fa_enter_six_digit_code": "أدخل أدناه الرمز المكون من ستة أرقام من تطبيق المصادقة لديك.",
"create_an_account": "إنشاء حساب",
"dont_have_an_account": "أليس لديك حساب؟",
"2fa_code": "الرمز المكون من عاملين",
"2fa_code": "رمز المصادقة من عاملين",
"sign_in_account": "تسجيل الدخول إلى حسابك",
"sign_in": "تسجيل الدخول",
"go_back_login": "العودة إلى صفحة تسجيل الدخول",
@ -198,22 +198,22 @@
"done": "تم",
"check_email_reset_password": "تحقق من البريد الإلكتروني. لقد أرسلنا رابطًا لإعادة تعيين كلمة المرور.",
"finish": "إنهاء",
"few_sentences_about_yourself": "اكتب بضع جمل عن نفسك. سيظهر هذا على صفحة عنوان url الشخصية لديك.",
"few_sentences_about_yourself": "اكتب بضع جمل عن نفسك. سيظهر هذا على صفحة عنوان URL الشخصية لديك.",
"nearly_there": "على وشك الانتهاء",
"nearly_there_instructions": "آخر شيء، تساعدك كتابة وصف موجز عنك ووضع صورة على الحصول على الحجوزات وإعلام الأشخاص بمَن سيحجزون معه حقًا.",
"set_availability_instructions": "حدد نطاقات الوقت عندما تكون متاحًا على أساس متكرر. يمكنك إنشاء المزيد منها لاحقًا وتعيينها لتقويمات مختلفة.",
"nearly_there_instructions": "آخر شيء: تساعدك كتابة وصف موجز عنك ووضع صورة في الحصول على عمليات الحجز وإعلام الأشخاص بمَن سيحجزون معه حقًا.",
"set_availability_instructions": "حدد الفترات الزمنية التي تكون متاحًا فيها بشكل متكرر. يمكنك لاحقًا تحديد المزيد منها وربطها مع تقاويم مختلفة.",
"set_availability": "تحديد الوقت الذي تكون فيه متاحًا",
"continue_without_calendar": "المتابعة من دون تقويم",
"connect_your_calendar": "توصيل التقويم لديك",
"connect_your_calendar_instructions": "قم بتوصيل التقويم لديك للتحقق تلقائيًا من الأوقات المشغولة والأحداث الجديدة أثناء جدولتها.",
"connect_your_calendar": "ربط التقويم لديك",
"connect_your_calendar_instructions": "اربط التقويم لديك للتحقق تلقائيًا من الأوقات المشغولة والأحداث الجديدة أثناء جدولتها.",
"set_up_later": "الإعداد لاحقًا",
"current_time": "الوقت الحالي",
"welcome": "مرحبًا",
"welcome_to_calcom": "مرحبًا بك في Cal.com",
"welcome_instructions": "أخبرنا باسمك وبالمنطقة الزمنية التي توجد فيها. ستتمكن من تحرير هذا لاحقًا.",
"welcome_instructions": "أخبرنا باسمك وبالمنطقة الزمنية التي توجد فيها. ستتمكن لاحقًا من تعديل هذا.",
"connect_caldav": "الاتصال بخادم CalDav",
"credentials_stored_and_encrypted": "سيتم تخزين بيانات الاعتماد الخاصة بك وتشفيرها.",
"connect": "اتصال",
"credentials_stored_and_encrypted": "سيجري تشفير بياناتك وتخزينها.",
"connect": "الاتصال",
"try_for_free": "جرّبه مجانًا",
"create_booking_link_with_calcom": "أنشئ رابط الحجز الخاص بك باستخدام Cal.com",
"who": "مَن",
@ -222,7 +222,7 @@
"where": "أين",
"add_to_calendar": "إضافة إلى التقويم",
"other": "آخر",
"emailed_you_and_attendees": "لقد أرسلنا إليك وإلى الحضور الآخرين دعوة تتضمن كل التفاصيل عبر البريد الإلكتروني.",
"emailed_you_and_attendees": "لقد أرسلنا إليك وإلى الحضور الآخرين دعوة للتقويم عبر البريد الإلكتروني تتضمن كل التفاصيل.",
"emailed_you_and_any_other_attendees": "تم إرسال هذه المعلومات إليك وإلى الحضور الآخرين عبر البريد الإلكتروني.",
"needs_to_be_confirmed_or_rejected": "لا يزال الحجز الخاص بك يحتاج إلى التأكيد أو الرفض.",
"user_needs_to_confirm_or_reject_booking": "لا يزال {{user}} يحتاج إلى تأكيد الحجز أو رفضه.",
@ -234,9 +234,9 @@
"reset_password": "إعادة تعيين كلمة المرور",
"change_your_password": "تغيير كلمة المرور",
"try_again": "حاول مجددًا",
"request_is_expired": "انتهت صلاحية هذا الطلب.",
"request_is_expired": "انتهت صلاحية ذلك الطلب.",
"reset_instructions": "أدخل عنوان البريد الإلكتروني المرتبط بحسابك وسنرسل إليك رابطًا لإعادة تعيين كلمة المرور.",
"request_is_expired_instructions": "انتهت صلاحية هذا الطلب. قم بالعودة وأدخل البريد الإلكتروني المرتبط بحسابك وسنرسل إليك رابطًا آخر لإعادة تعيين كلمة المرور.",
"request_is_expired_instructions": "انتهت صلاحية ذلك الطلب. قم بالعودة وأدخل البريد الإلكتروني المرتبط بحسابك وسنرسل إليك رابطًا آخر لإعادة تعيين كلمة المرور.",
"whoops": "عذرًا",
"login": "تسجيل الدخول",
"success": "تم بنجاح",
@ -251,48 +251,48 @@
"friday_time_error": "وقت غير صالح يوم الجمعة",
"saturday_time_error": "وقت غير صالح يوم السبت",
"error_end_time_before_start_time": "لا يمكن أن يكون وقت الانتهاء قبل وقت البدء",
"error_end_time_next_day": "لا يمكن أن يكون وقت الانتهاء أكبر من 24 ساعة",
"back_to_bookings": "العودة إلى الحجوزات",
"error_end_time_next_day": "لا يمكن أن يكون وقت الانتهاء أطول من 24 ساعة",
"back_to_bookings": "العودة إلى عمليات الحجز",
"free_to_pick_another_event_type": "لا تتردد في اختيار حدث آخر في أي وقت.",
"cancelled": "تم الإلغاء",
"cancellation_successful": "تم الإلغاء بنجاح",
"really_cancel_booking": "هل تريد حقًا إلغاء الحجز؟",
"cannot_cancel_booking": "لا يمكنك إلغاء هذا الحجز",
"reschedule_instead": "بدلاً من ذلك، يمكنك أيضًا إعادة جدولته.",
"reschedule_instead": "بدلًا من ذلك، يمكنك إعادة جدولته.",
"event_is_in_the_past": "الحدث في الماضي",
"error_with_status_code_occured": "حدث خطأ في رمز الحالة {{status}}.",
"error_with_status_code_occured": "حدث خطأ برمز الحالة {{status}}.",
"booking_already_cancelled": "تم إلغاء هذا الحجز بالفعل",
"go_back_home": "العودة إلى الشاشة الرئيسية",
"or_go_back_home": "أو العودة إلى الشاشة الرئيسية",
"no_availability": "غير متاح",
"no_meeting_found": "لم يتم العثور على اجتماعات",
"no_meeting_found_description": "هذا الاجتماع غير موجود. اتصل بمالك الاجتماع للحصول على الرابط المُحدَّث.",
"no_status_bookings_yet": "لا توجد حجوزات {{status}} حتى الآن",
"no_status_bookings_yet_description": "ليست لديك حجوزات {{status}}. {{description}}",
"no_meeting_found_description": "هذا الاجتماع غير موجود. اتصل بصاحب الاجتماع للحصول على الرابط المُحدَّث.",
"no_status_bookings_yet": "لا توجد عمليات حجز في حالة {{status}} حتى الآن",
"no_status_bookings_yet_description": "ليست لديك عمليات حجز في حالة {{status}}. {{description}}",
"event_between_users": "{{eventName}} بين {{host}} و{{attendeeName}}",
"bookings": "الحجوزات",
"bookings_description": "اطلع على الأحداث القادمة والسابقة المحجوزة من خلال روابط نوع الحدث.",
"bookings": "عمليات الحجز",
"bookings_description": "اطلع على الأحداث القادمة والسابقة المحجوزة لديك من خلال روابط أنواع الحدث.",
"upcoming_bookings": "بمجرد أن يحجز شخص ما موعدًا معك، سيظهر هنا.",
"past_bookings": "ستظهر حجوزاتك السابقة هنا.",
"cancelled_bookings": "ستظهر حجوزاتك التي تم إلغاؤها هنا.",
"past_bookings": "ستظهر هنا عمليات حجزك السابقة.",
"cancelled_bookings": "ستظهر هنا عمليات حجزك التي تم إلغاؤها.",
"on": "في",
"and": "و",
"calendar_shows_busy_between": "يظهر التقويم أنك مشغول بين",
"troubleshoot": "استكشاف الأخطاء وإصلاحها",
"troubleshoot_description": "فهم سبب توفر أوقات معينة وحظر أوقات أخرى.",
"overview_of_day": "إليك نظرة عامة على يومك في",
"hover_over_bold_times_tip": "نصيحة: قم بالمرور فوق الأوقات المكتوبة بخط غامق للحصول على طابع زمني كامل",
"hover_over_bold_times_tip": "نصيحة: قم بالمرور فوق الأوقات المكتوبة بخط عريض للحصول على الزمن بالكامل",
"start_time": "وقت البدء",
"end_time": "وقت الانتهاء",
"buffer": "المخزن المؤقت",
"buffer": "الفترة بين الحدثين",
"your_day_starts_at": "يبدأ يومك في",
"your_day_ends_at": "ينتهي يومك في",
"launch_troubleshooter": "تشغيل مستكشف الأخطاء وإصلاحها",
"troubleshoot_availability": "استكشف الأخطاء المتعلقة بأوقات إتاحتك وأصلحها لمعرفة السبب وراء ظهور أوقاتك كما هي.",
"troubleshoot_availability": "استكشف الأخطاء المتعلقة بأوقاتك المتاحة لمعرفة السبب وراء ظهور أوقاتك بشكل خطأ.",
"change_available_times": "تغيير الأوقات المتاحة",
"change_your_available_times": "قم بتغيير الأوقات المتاحة",
"change_start_end": "قم بتغيير أوقات البدء والانتهاء في يومك",
"change_start_end_buffer": "قم بتعيين وقت البدء ووقت الانتهاء ليومك والحد الأدنى للوقت الاحتياطي بين اجتماعاتك.",
"change_start_end_buffer": "قم بتعيين وقت البدء ووقت الانتهاء ليومك والحد الأدنى للفترة بين اجتماعاتك.",
"current_start_date": "حاليًا، تم تعيين وقت بدء يومك في",
"start_end_changed_successfully": "تم تغيير أوقات البدء والانتهاء في يومك بنجاح.",
"and_end_at": "وينتهي في",
@ -300,48 +300,48 @@
"dark": "داكن",
"automatically_adjust_theme": "ضبط السمة تلقائيًا استنادًا إلى تفضيلات المدعوين",
"email": "البريد الإلكتروني",
"email_placeholder": "jdoe@example.com",
"email_placeholder": "name@example.com",
"full_name": "الاسم بالكامل",
"browse_api_documentation": "استعراض مستندات واجهة برمجة التطبيقات",
"leverage_our_api": "استفد من واجهة برمجة التطبيقات لدينا للتحكم والتخصيص بالكامل.",
"create_webhook": "إنشاء ويبهوك",
"browse_api_documentation": "استعراض مستندات واجهة برمجة التطبيقات (API) لدينا",
"leverage_our_api": "استفد من واجهة برمجة التطبيقات (API) لدينا من أجل قدرة كاملة على التحكم والتخصيص.",
"create_webhook": "إنشاء الويب هوك",
"booking_cancelled": "تم إلغاء الحجز",
"booking_rescheduled": "تمت إعادة جدولة الحجز",
"booking_created": "تم إنشاء الحجز",
"event_triggers": "مشغلات الحدث",
"subscriber_url": "عنوان Url للمشترك",
"create_new_webhook": "إنشاء ويبهوك جديد",
"create_new_webhook_to_account": "إنشاء ويبهوك جديد لحسابك",
"new_webhook": "ويبهوك جديد",
"receive_cal_meeting_data": "تلقَّ بيانات اجتماع Cal على عنوان URL محدد، في الوقت الفعلي، عندما تتم إعادة جدولة حدث ما أو يتم إلغاؤه.",
"subscriber_url": "عنوان URL للمشترك",
"create_new_webhook": "إنشاء ويب هوك جديد",
"create_new_webhook_to_account": "إنشاء ويب هوك جديد لحسابك",
"new_webhook": "ويب هوك جديد",
"receive_cal_meeting_data": "تلقَّ بيانات اجتماعات Cal على عنوان URL محدد، وفي الوقت الفعلي، عندما تتم جدولة حدث ما أو يتم إلغاؤه.",
"responsive_fullscreen_iframe": "إطار iframe سريع الاستجابة بكامل الشاشة",
"loading": "جارٍ التحميل...",
"loading": "يجري التحميل...",
"standard_iframe": "إطار iframe القياسي",
"iframe_embed": "تضمين إطار iframe",
"embed_calcom": "أسهل طريقة لتضمين Cal.com على موقع الويب لديك.",
"integrate_using_embed_or_webhooks": "تكامل مع موقع الويب لديك باستخدام خيارات التضمين لدينا أو احصل على معلومات الحجز في الوقت الفعلي باستخدام روابط ويبهوك المخصصة.",
"integrate_using_embed_or_webhooks": "حقق التكامل مع موقع الويب لديك باستخدام خيارات التضمين لدينا، أو احصل على معلومات الحجز في الوقت الفعلي باستخدام إجراءات الويب هوك المخصصة.",
"schedule_a_meeting": "جدولة اجتماع",
"view_and_manage_billing_details": "عرض تفاصيل الفوترة وإدارتها",
"view_and_edit_billing_details": "عرض تفاصيل الفوترة وتحريرها، بالإضافة إلى إلغاء الاشتراك.",
"view_and_edit_billing_details": "اعرض تفاصيل الفوترة وعدّلها، بالإضافة إلى إمكانية إلغاء الاشتراك.",
"go_to_billing_portal": "انتقل إلى بوابة الفوترة",
"need_anything_else": "هل تحتاج إلى أي شيء آخر؟",
"further_billing_help": "إذا كنت تحتاج إلى أي مساعدة إضافية في الفوترة، فإن فريق الدعم لدينا في انتظارك لتقديم المساعدة.",
"further_billing_help": "إذا كنت تحتاج إلى أي مساعدة إضافية بخصوص الفوترة، فإن فريق الدعم لدينا في انتظارك لتقديم المساعدة.",
"contact_our_support_team": "الاتصال بفريق الدعم لدينا",
"uh_oh": "عذرًا!",
"no_event_types_have_been_setup": "لم يقم هذا المستخدم بإعداد أي أنواع للحدث حتى الآن.",
"edit_logo": حرير الشعار",
"edit_logo": عديل الشعار",
"upload_a_logo": "تحميل شعار",
"remove_logo": "إزالة الشعار",
"enable": "تمكين",
"code": "الرمز",
"code_is_incorrect": "الرمز غير صحيح.",
"add_an_extra_layer_of_security": "أضف طبقة أمان إضافية إلى حسابك في حال سرقة كلمة المرور الخاصة بك.",
"2fa": "المصادقة المكونة من عاملين",
"enable_2fa": "تمكين المصادقة المكونة من عاملين",
"disable_2fa": "تعطيل المصادقة المكونة من عاملين",
"disable_2fa_recommendation": "إذا كنت بحاجة إلى تعطيل المصادقة المكونة من عاملين، فنوصيك بإعادة تمكينها في أقرب وقت ممكن.",
"error_disabling_2fa": "خطأ في تعطيل المصادقة المكونة من عاملين",
"error_enabling_2fa": "خطأ في إعداد المصادقة المكونة من عاملين",
"add_an_extra_layer_of_security": "أضف إلى حسابك طبقة أمان إضافية للاحتياط من سرقة كلمة المرور الخاصة بك.",
"2fa": "المصادقة من عاملين",
"enable_2fa": "تمكين المصادقة من عاملين",
"disable_2fa": "تعطيل المصادقة من عاملين",
"disable_2fa_recommendation": "إذا كنت بحاجة إلى تعطيل المصادقة من عاملين، فنوصيك بإعادة تمكينها في أقرب وقت ممكن.",
"error_disabling_2fa": "خطأ في تعطيل المصادقة من عاملين",
"error_enabling_2fa": "خطأ في إعداد المصادقة من عاملين",
"security": "الأمان",
"manage_account_security": "إدارة أمان حسابك.",
"password": "كلمة المرور",
@ -349,9 +349,9 @@
"password_has_been_changed": "تم تغيير كلمة المرور بنجاح.",
"error_changing_password": "خطأ في تغيير كلمة المرور",
"something_went_wrong": "حدث خطأ ما.",
"something_doesnt_look_right": "هل ثمة شيء لا يبدو صحيحًا؟",
"something_doesnt_look_right": "هل يوجد شيء ما لا يبدو صحيحًا؟",
"please_try_again": "يُرجى المحاولة مجددًا.",
"super_secure_new_password": "كلمة المرور الجديدة ذات الأمان الممتاز",
"super_secure_new_password": "كلمة المرور الجديدة الآمنة كليًا",
"new_password": "كلمة المرور الجديدة",
"your_old_password": "كلمة المرور القديمة",
"current_password": "كلمة المرور الحالية",
@ -359,15 +359,15 @@
"new_password_matches_old_password": "تتطابق كلمة المرور الجديدة مع كلمة المرور القديمة. يُرجى اختيار كلمة مرور مختلفة.",
"current_incorrect_password": "كلمة المرور الحالية غير صحيحة",
"incorrect_password": "كلمة المرور غير صحيحة.",
"1_on_1": "فردي",
"1_on_1": "اجتماع 1 مع 1",
"24_h": "24 ساعة",
"use_setting": "استخدام الإعدادات",
"use_setting": "استخدام الإعداد",
"am_pm": "صباحًا/مساءً",
"time_options": "خيارات الوقت",
"january": "يناير",
"february": "فبراير",
"march": "مارس",
"april": "إبريل",
"april": "أبريل",
"may": "مايو",
"june": "يونيو",
"july": "يوليو",
@ -385,7 +385,7 @@
"sunday": "الأحد",
"all_booked_today": "تم حجز الكل اليوم.",
"slots_load_fail": "تعذر تحميل الفترات الزمنية المتاحة.",
"additional_guests": "مزيد من الضيوف الإضافيين",
"additional_guests": "إضافة مزيد من الضيوف",
"your_name": "اسمك",
"email_address": "عنوان البريد الإلكتروني",
"location": "الموقع",
@ -402,14 +402,14 @@
"phone_number": "رقم الهاتف",
"enter_phone_number": "أدخل رقم الهاتف",
"reschedule": "إعادة الجدولة",
"book_a_team_member": "حجز عضو في الفريق بدلاً من ذلك",
"book_a_team_member": "حجز عضو في الفريق بدلًا من ذلك",
"or": "أو",
"go_back": "العودة",
"email_or_username": "البريد الإلكتروني أو اسم المستخدم",
"send_invite_email": "إرسال دعوة عبر البريد الإلكتروني",
"role": "الدور",
"edit_role": حرير الدور",
"edit_team": حرير الفريق",
"edit_role": عديل الدور",
"edit_team": عديل الفريق",
"reject": "رفض",
"accept": "قبول",
"leave": "خروج",
@ -424,24 +424,24 @@
"members": "الأعضاء",
"member": "العضو",
"owner": "المالك",
"admin": "المسؤول",
"admin": "المشرف",
"new_member": "العضو الجديد",
"invite": "دعوة",
"invite_new_member": "دعوة عضو جديد",
"invite_new_team_member": "دعوة شخص ما إلى فريقك.",
"change_member_role": "تغيير دور العضو في الفريق",
"disable_cal_branding": "تعطيل علامة Cal.com التجارية",
"disable_cal_branding_description": "إخفاء كل علامات Cal.com التجارية من الصفحات العامة.",
"disable_cal_branding": "تعطيل علامات Cal.com التجارية",
"disable_cal_branding_description": "إخفاء كل علامات Cal.com التجارية من الصفحات العامة لديك.",
"danger_zone": "منطقة الخطر",
"back": "عودة",
"cancel": "إلغاء",
"continue": "تابع",
"continue": "متابعة",
"confirm": "تأكيد",
"disband_team": "حل الفريق",
"disband_team_confirmation_message": "هل تريد بالتأكيد حل هذا الفريق؟ لن يتمكن أي شخص قمت بمشاركة رابط هذا الفريق معه من الحجز باستخدامه بعد الآن.",
"disband_team": "تفكيك الفريق",
"disband_team_confirmation_message": "هل تريد بالتأكيد تفكيك هذا الفريق؟ أي شخص شاركت رابط هذا الفريق معه لن يستطيع بعد الآن الحجز باستخدامه.",
"remove_member_confirmation_message": "هل تريد بالتأكيد إزالة هذا العضو من الفريق؟",
"confirm_disband_team": "نعم، حل الفريق",
"confirm_remove_member": "نعم، إزالة العضو",
"confirm_disband_team": "نعم، أريد تفكيك الفريق",
"confirm_remove_member": "نعم، أريد إزالة العضو",
"remove_member": "إزالة العضو",
"manage_your_team": "إدارة الفريق",
"no_teams": "ليس لديك أي فرق حتى الآن.",
@ -450,14 +450,14 @@
"delete": "حذف",
"update": "تحديث",
"save": "حفظ",
"pending": "معلّق",
"pending": "قيد الانتظار",
"open_options": "فتح الخيارات",
"copy_link": "نسخ الرابط إلى الحدث",
"share": "مشاركة",
"share_event": "هل تمانع في حجز cal أو إرسال الرابط إليّ؟",
"share_event": "أيمكنك حجز تقويمي أو إرسال رابطك إلي؟",
"copy_link_team": "نسخ الرابط إلى الفريق",
"leave_team": "الخروج من الفريق",
"confirm_leave_team": "نعم، الخروج من الفريق",
"confirm_leave_team": "نعم، أريد الخروج من الفريق",
"leave_team_confirmation_message": "هل تريد بالتأكيد الخروج من هذا الفريق؟ لن تتمكن بعد الآن من الحجز باستخدامه.",
"user_from_team": "{{user}} من {{team}}",
"preview": "معاينة",
@ -473,126 +473,126 @@
"duration": "المدة",
"minutes": "الدقائق",
"round_robin": "الترتيب الدوري",
"round_robin_description": "انتقل عبر الاجتماعات بين أعضاء الفريق المتعددين.",
"url": "عنوان URL",
"round_robin_description": "نقل الاجتماعات بشكل دوري بين أعضاء الفريق المتعددين.",
"url": "URL",
"hidden": "مخفي",
"readonly": "للقراءة فقط",
"plan_description": "أنت حاليًا في خطة {{plan}}.",
"plan_upgrade_invitation": "قم بترقية حسابك إلى خطة pro لفتح كل الميزات التي نقدمها.",
"plan_upgrade": "تحتاج إلى ترقية خطتك للحصول على أكثر من نوع حدث واحد نشط.",
"plan_description": "أنت حاليًا على خطة {{plan}}.",
"plan_upgrade_invitation": "قم بترقية حسابك إلى خطة Pro لفتح كل الميزات التي نقدمها.",
"plan_upgrade": "تحتاج إلى ترقية خطتك للحصول على أكثر من نوع حدث نشط.",
"plan_upgrade_teams": "تحتاج إلى ترقية خطتك لإنشاء فريق.",
"plan_upgrade_instructions": "يمكنك <1>الترقية هنا</1>.",
"event_types_page_title": "أنواع الحدث",
"event_types_page_subtitle": "قم بإنشاء أحداث لمشاركتها حتى يتسنى للأشخاص الحجز في التقويم الخاص بك.",
"new_event_type_btn": "نوع حدث جديد",
"new_event_type_heading": "إنشاء نوع الحدث الأول لديك",
"new_event_type_description": "تتيح لك أنواع الأحداث مشاركة الروابط التي تعرض الأوقات المتاحة في تقويمك وتسمح للأشخاص بإجراء حجوزات معك.",
"new_event_type_description": "تتيح لك أنواع الأحداث مشاركة الروابط التي تعرض الأوقات المتاحة في تقويمك وتسمح للأشخاص بالحجز معك.",
"new_event_title": "إضافة نوع حدث جديد",
"new_event_subtitle": "قم بإنشاء نوع حدث يندرج تحت اسمك أو أحد الفرق.",
"new_team_event": "إضافة نوع حدث لفريق جديد",
"new_event_description": "قم بإنشاء نوع حدث جديد للأشخاص لحجز أوقات معهم.",
"new_team_event": "إضافة نوع حدث جديد لفريق",
"new_event_description": "قم بإنشاء نوع حدث جديد ليحجز الأشخاص من خلاله.",
"event_type_created_successfully": "تم إنشاء نوع الحدث {{eventTypeTitle}} بنجاح",
"event_type_updated_successfully": "تم تحديث نوع الحدث {{eventTypeTitle}} بنجاح",
"event_type_deleted_successfully": "تم حذف نوع الحدث بنجاح",
"web3_metamask_added": "تمت إضافة Metamask بنجاح",
"web3_metamask_disconnected": "تم فصل Metamask بنجاح",
"hours": "ساعات",
"hours": "الساعات",
"your_email": "بريدك الإلكتروني",
"change_avatar": "تغيير الصورة الرمزية",
"language": "اللغة",
"timezone": "المنطقة الزمنية",
"first_day_of_week": "أول يوم في الأسبوع",
"single_theme": "موضوع واحد",
"single_theme": "سمة واحدة",
"brand_color": "لون العلامة التجارية",
"file_not_named": "لم تتم تسمية الملف [idOrSlug]/[user]",
"file_not_named": "لم تتم تسمية الملف [user]/[idOrSlug]",
"create_team": "إنشاء فريق",
"name": "الاسم",
"create_new_team_description": "قم بإنشاء فريق جديد للتعاون مع المستخدمين.",
"create_new_team": "إنشاء فريق جديد",
"open_invitations": "فتح الدعوات",
"open_invitations": "الدعوات المفتوحة",
"new_team": "فريق جديد",
"create_first_team_and_invite_others": "قم بإنشاء فريقك الأول ودعوة المستخدمين الآخرين للعمل معك.",
"create_team_to_get_started": "إنشاء فريق للبدء",
"create_team_to_get_started": "قم بإنشاء فريق للبدء",
"teams": "الفرق",
"team_billing": "الفوترة الخاصة بالفريق",
"upgrade_to_flexible_pro_title": "لقد قمنا بتغيير الفوترة الخاصة بالفرق",
"upgrade_to_flexible_pro_message": "ثمة أعضاء في فريق لا يملكون مقعدًا. قم بترقية خطتك الاحترافية لتوفير المقاعد المفقودة.",
"changed_team_billing_info": "بدءًا من يناير 2022، سنفرض رسومًا على كل مقعد لأعضاء الفريق. يتمتع أعضاء فريقك الذين لديهم خطة Pro مجانًا الآن بفترة تجريبية مدتها 14 يومًا. بمجرد انتهاء الفترة التجريبية الخاصة بهم، سيتم إخفاء هؤلاء الأعضاء من فريقك ما لم تقم بالترقية الآن.",
"create_manage_teams_collaborative": "قم بإنشاء الفرق وإدارتها لاستخدام الميزات المساعدة.",
"upgrade_to_flexible_pro_message": "يوجد أعضاء في فريقك من دون مقاعد. قم بترقية خطة Pro خاصتك لتوفير المقاعد الناقصة.",
"changed_team_billing_info": "بدءًا من يناير 2022، أصبحنا نفرض الرسوم على مقاعد أعضاء الفريق بشكل منفرد. حاليًا، أعضاء فريقك الذين كانت لديهم خطة Pro مجانية أصبحوا في فترة تجريبية مدتها 14 يومًا. وبمجرد انتهاء الفترة التجريبية الخاصة بهم، سيجري إخفاء هؤلاء الأعضاء من فريقك ما لم تقم بالترقية الآن.",
"create_manage_teams_collaborative": "قم بإنشاء الفرق وإدارتها لاستخدام الميزات التعاونية.",
"only_available_on_pro_plan": "لا تتوفر هذه الميزة إلا في خطة Pro",
"remove_cal_branding_description": "لإزالة العلامة التجارية لخدمة Cal من صفحات الحجوزات، تجب ترقية حسابك إلى حساب Pro.",
"edit_profile_info_description": "قم بتعديل معلومات ملفك الشخصي التي يتم عرضها على رابط الجدولة.",
"remove_cal_branding_description": "لإزالة علامة Cal التجارية من صفحات عمليات الحجز، تحتاج إلى ترقية حسابك إلى حساب Pro.",
"edit_profile_info_description": "قم بتعديل معلومات ملفك الشخصي المعروضة على رابط الجدولة.",
"change_email_tip": "قد تحتاج إلى تسجيل الخروج والعودة مجددًا لرؤية التغييرات التي تم تنفيذها.",
"little_something_about": "نبذة عن نفسك.",
"profile_updated_successfully": "تم تحديث الملف الشخصي بنجاح",
"your_user_profile_updated_successfully": "تم تحديث الملف الشخصي للمستخدم الخاص بك بنجاح.",
"your_user_profile_updated_successfully": "تم تحديث ملفك الشخصي بنجاح.",
"user_cannot_found_db": "يبدو أن المستخدم قد سجل الدخول ولكن لا يمكن العثور عليه في قاعدة البيانات",
"embed_and_webhooks": "التضمين والإخطارات على الويب",
"embed_and_webhooks": "التضمين والويب هوك",
"enabled": "تم التمكين",
"disabled": "تم التعطيل",
"disable": "تعطيل",
"billing": "الفوترة",
"manage_your_billing_info": "قم بإدارة معلومات الفوترة لديك وإلغاء اشتراكك.",
"availability": "التوفر",
"availability_updated_successfully": "تم تحديث التوفر بنجاح",
"configure_availability": "قم بتكوين الأوقات التي تتوفر فيها خدماتك للحجز.",
"change_weekly_schedule": "قم بتغيير جدولك الأسبوعي",
"availability": "الأوقات المتاحة",
"availability_updated_successfully": "تم تحديث الأوقات المتاحة بنجاح",
"configure_availability": "اضبط الأوقات التي تكون متاحًا فيها للحجز.",
"change_weekly_schedule": "تغيير جدولك الأسبوعي",
"logo": "الشعار",
"error": "خطأ",
"team_logo": "شعار الفريق",
"add_location": "إضافة موقع",
"attendees": "الحاضرون",
"add_attendees": "إضافة الحاضرين",
"attendees": "الحضور",
"add_attendees": "إضافة الحضور",
"show_advanced_settings": "عرض الإعدادات المتقدمة",
"event_name": "اسم الحدث",
"event_name_tooltip": "الاسم الذي سيظهر في التقويمات",
"event_name_tooltip": "الاسم الذي سيظهر في التقاويم",
"meeting_with_user": "الاجتماع مع {USER}",
"additional_inputs": "إدخالات إضافية",
"label": "التسمية",
"additional_inputs": "مدخلات إضافية",
"label": "العلامة",
"placeholder": "العنصر النائب",
"type": "النوع",
"edit": "تعديل",
"add_input": "إضافة إدخال",
"opt_in_booking": "حجز الاشتراك",
"opt_in_booking_description": "يجب التأكيد على الحجز يدويًا قبل دفعه إلى التكاملات وإرسال رسالة تأكيد عبر البريد الإلكتروني.",
"add_input": "إضافة مُدخَل",
"opt_in_booking": "حجز يتطلب موافقة",
"opt_in_booking_description": "يجب تأكيد الحجز يدويًا قبل نقله إلى التكاملات وإرسال رسالة تأكيد عبر البريد الإلكتروني.",
"disable_guests": "تعطيل خاصية الضيوف",
"disable_guests_description": "قم بتعطيل إضافة مزيد من الضيوف أثناء الحجز.",
"invitees_can_schedule": "يمكن جدولة أوقات المدعوين",
"date_range": "النطاق الزمني",
"invitees_can_schedule": "يمكن للمدعوين الجدولة",
"date_range": "الفترة الزمنية",
"calendar_days": "أيام التقويم",
"business_days": "أيام العمل",
"set_address_place": "تعيين عنوان أو مكان",
"cal_invitee_phone_number_scheduling": "سيطلب Cal من المدعو لديك إدخال رقم الهاتف قبل الجدولة.",
"cal_provide_google_meet_location": "سيوفر Cal موقعًا لخدمة Google Meet.",
"cal_provide_google_meet_location": "سيوفر Cal رابطًا لـ Google Meet.",
"cal_provide_zoom_meeting_url": "سيوفر Cal رابطًا للاجتماع عبر Zoom.",
"cal_provide_tandem_meeting_url": "سيوفر Cal رابطًا للاجتماع عبر Tandem.",
"cal_provide_video_meeting_url": "سيوفر Cal رابطًا للاجتماع عبر الفيديو على Daily.",
"cal_provide_jitsi_meeting_url": "سنقوم بإنشاء رابط إلى Jitsi Meet من أجلك.",
"cal_provide_jitsi_meeting_url": "سنوفر لك رابطًا للاجتماع عبر Jitsi Meet.",
"cal_provide_huddle01_meeting_url": "سيوفر Cal رابطًا للاجتماع عبر الفيديو على Huddle01 web3.",
"require_payment": "يلزم الدفع",
"commission_per_transaction": "عمولة لكل معاملة",
"commission_per_transaction": "عمولة كل معاملة",
"event_type_updated_successfully_description": "تم تحديث نوع الحدث لديك بنجاح.",
"hide_event_type": "إخفاء نوع الحدث",
"edit_location": "تعديل الموقع",
"into_the_future": "في المستقبل",
"within_date_range": "ضمن نطاق زمني",
"within_date_range": "خلال فترة زمنية",
"indefinitely_into_future": "إلى أجل غير مسمى في المستقبل",
"this_input_will_shown_booking_this_event": "سيتم عرض هذا الإدخال عند حجز هذا الحدث",
"add_new_custom_input_field": "إضافة مجال إدخال مخصص جديد",
"quick_chat": "أداة الدردشة السريعة",
"add_new_team_event_type": "إضافة نوع حدث لفريق جديد",
"this_input_will_shown_booking_this_event": "سيُعرَض هذا المُدخَل عند حجز هذا الحدث",
"add_new_custom_input_field": "إضافة خانة إدخال مخصص جديدة",
"quick_chat": "الدردشة السريعة",
"add_new_team_event_type": "إضافة نوع حدث جديد لفريق",
"add_new_event_type": "إضافة نوع حدث جديد",
"new_event_type_to_book_description": "قم بإنشاء نوع حدث جديد للأشخاص لحجز أوقات معهم.",
"new_event_type_to_book_description": "قم بإنشاء نوع حدث جديد ليحجز الأشخاص من خلاله.",
"length": "الطول",
"minimum_booking_notice": "الحد الأدنى من إشعار الحجز",
"slot_interval": "فواصل الفترات الزمنية",
"minimum_booking_notice": "الحد الأدنى من الوقت للحجز",
"slot_interval": "الفترات الزمنية بين عمليات الحجز",
"slot_interval_default": "استخدام طول الحدث (الوضع الافتراضي)",
"delete_event_type_description": "هل تريد بالتأكيد حذف هذا النوع من الأحداث؟ لن يتمكن أي شخص قمت بمشاركة هذا الرابط معه من الحجز باستخدامه بعد الآن.",
"delete_event_type_description": "هل تريد بالتأكيد حذف نوع الحدث هذا؟ أي شخص شاركت هذا الرابط معه لن يستطيع بعد الآن الحجز باستخدامه.",
"delete_event_type": "حذف نوع الحدث",
"confirm_delete_event_type": "نعم، احذف نوع الحدث",
"delete_account": "حذف الحساب",
"confirm_delete_account": "نعم، احذف الحساب",
"delete_account_confirmation_message": "هل تريد بالتأكيد حذف حساب Cal.com؟ لن يتمكن أي شخص قمت بمشاركة رابط حسابك معه من الحجز باستخدامه وسيتم فقدان أي تفضيلات قمت بحفظها.",
"delete_account_confirmation_message": "هل تريد بالتأكيد حذف حسابك على Cal.com؟ أي شخص شاركت رابط حسابك معه لن يستطيع بعد الآن الحجز باستخدامه، وستفقد أي تفضيلات حفظتها.",
"integrations": "التكاملات",
"settings": "الإعدادات",
"event_type_moved_successfully": "تم نقل نوع الحدث بنجاح",
@ -601,61 +601,61 @@
"installed": "تم التثبيت",
"disconnect": "فصل",
"embed_your_calendar": "تضمين تقويمك في صفحة الويب لديك",
"connect_your_favourite_apps": "قم بتوصيل تطبيقاتك المفضلة.",
"automation": "الأتمتة",
"configure_how_your_event_types_interact": "قم بتكوين كيفية تفاعل نوع الأحداث مع تقويماتك.",
"connect_your_favourite_apps": "الربط مع تطبيقاتك المفضلة.",
"automation": "التشغيل التلقائي",
"configure_how_your_event_types_interact": "اضبط كيفية تفاعل أنواع الأحداث لديك مع تقاويمك.",
"select_destination_calendar": "إنشاء أحداث في",
"connect_an_additional_calendar": "توصيل تقويم إضافي",
"conferencing": "المؤتمرات",
"connect_an_additional_calendar": "ربط تقويم إضافي",
"conferencing": "المؤتمرات عبر الفيديو",
"calendar": "التقويم",
"not_installed": "لم يتم التثبيت",
"error_password_mismatch": "كلمات المرور غير متطابقة.",
"error_required_field": "هذا الحقل مطلوب.",
"status": "الحالة",
"team_view_user_availability": "عرض توفر المستخدم",
"team_view_user_availability_disabled": "يحتاج المستخدم إلى قبول الدعوة لعرض التوفر",
"set_as_away": عيين حالتك على بالخارج",
"set_as_free": "تعطيل حالة بالخارج",
"user_away": "هذا المستخدم في حالة بالخارج حاليًا.",
"user_away_description": "الشخص الذي تحاول حجز خدماته قام بتعيين حالته على بالخارج، ومن ثم لا يقبل حجوزات جديدة.",
"meet_people_with_the_same_tokens": "الاجتماع مع الأشخاص باستخدام الرموز المميزة نفسها",
"only_book_people_and_allow": "ما عليك سوى إجراء حجز والسماح بالحجوزات التي تتم من قِبل الأشخاص الذين يشاركون الرموز المميزة نفسها أو DAOs أو NFTs.",
"saml_config_deleted_successfully": "تم حذف عملية تكوين SAML بنجاح",
"account_created_with_identity_provider": "تم إنشاء حسابك باستخدام موفر هوية.",
"account_managed_by_identity_provider": تم إدارة حسابك بواسطة {{provider}}",
"account_managed_by_identity_provider_description": "لتغيير بريدك الإلكتروني وكلمة المرور وتمكين المصادقة ثنائية العوامل والمزيد، يُرجى زيارة إعدادات حساب {{provider}} لديك.",
"signin_with_google": "تسجيل الدخول إلى Google",
"signin_with_saml": "تسجيل الدخول إلى SAML",
"team_view_user_availability": "عرض أوقات المستخدم المتاحة",
"team_view_user_availability_disabled": "يحتاج المستخدم إلى قبول الدعوة لعرض الأوقات المتاحة",
"set_as_away": غيير حالتك إلى \"ليس موجودًا\"",
"set_as_free": "تعطيل حالة \"ليس موجودًا\"",
"user_away": "هذا المستخدم ليس موجودًا حاليًا.",
"user_away_description": "حالة الشخص الذي تحاول حجز خدماته \"ليس موجودًا\"، ولذا فهو لا يقبل عمليات حجز جديدة.",
"meet_people_with_the_same_tokens": "الاجتماع مع أشخاص بنفس ال tokens",
"only_book_people_and_allow": "ما عليك سوى إجراء حجز والسماح بعمليات الحجز من الأشخاص الذين يتشاركون نفس ال tokens أو DAOs أو NFTs.",
"saml_config_deleted_successfully": "تم حذف تكوين SAML بنجاح",
"account_created_with_identity_provider": "تم إنشاء حسابك باستخدام مزود هوية.",
"account_managed_by_identity_provider": جري إدارة حسابك بواسطة {{provider}}",
"account_managed_by_identity_provider_description": "لتغيير بريدك الإلكتروني وكلمة المرور وتمكين المصادقة من عاملين والمزيد، يُرجى زيارة إعدادات حساب {{provider}} لديك.",
"signin_with_google": "تسجيل الدخول عبر Google",
"signin_with_saml": "تسجيل الدخول عبر SAML",
"saml_configuration": "تكوين SAML",
"delete_saml_configuration": "حذف تكوين SAML",
"delete_saml_configuration_confirmation_message": "هل تريد بالتأكيد حذف تكوين SAML؟ لن يتمكن أعضاء فريقك الذين يستخدمون تسجيل الدخول إلى SAML من الوصول إلى Cal.com بعد الآن.",
"delete_saml_configuration_confirmation_message": "هل تريد بالتأكيد حذف تكوين SAML؟ لن يتمكن بعد الآن أعضاء فريقك الذين يستخدمون تسجيل الدخول عبر SAML من الوصول إلى Cal.com.",
"confirm_delete_saml_configuration": "نعم، احذف تكوين SAML",
"saml_not_configured_yet": "لم يتم تكوين SAML حتى الآن",
"saml_configuration_description": "يُرجى لصق بيانات تعريف SAML المقدمة من موفر الهوية في مربع النص أدناه لتحديث تكوين SAML.",
"saml_configuration_placeholder": "يُرجى لصق بيانات تعريف SAML المقدمة من موفر الهوية هنا",
"saml_configuration_description": "يُرجى لصق بيانات تعريف SAML المقدمة من مزود الهوية في مربع النص أدناه لتحديث تكوين SAML.",
"saml_configuration_placeholder": "يُرجى لصق بيانات تعريف SAML المقدمة من مزود الهوية هنا",
"saml_configuration_update_failed": "فشل تحديث تكوين SAML",
"saml_configuration_delete_failed": "فشل حذف تكوين SAML",
"saml_email_required": "يُرجى إدخال بريد إلكتروني حتى نتمكن من العثور على موفر هوية SAML لديك",
"you_will_need_to_generate": "ستحتاج إلى إنشاء رمز مميز للوصول من خلال أداة الجدولة القديمة.",
"saml_email_required": "يُرجى إدخال بريد إلكتروني حتى نتمكن من العثور على مزود هوية SAML الخاص بك",
"you_will_need_to_generate": "ستحتاج إلى إنشاء access token من أداة الجدولة القديمة لديك.",
"import": "استيراد",
"import_from": "استيراد من",
"access_token": "الرمز المميز الخاص بالوصول",
"import_from": "الاستيراد من",
"access_token": "Access token",
"visit_roadmap": "المخطط",
"remove": "إزالة",
"add": "إضافة",
"verify_wallet": "التحقق من المحفظة",
"verify_wallet": "تأكيد المحفظة",
"connect_metamask": "توصيل Metamask",
"create_events_on": "إنشاء أحداث في:",
"missing_license": "الترخيص مفقود",
"signup_requires": "يلزم تقديم ترخيص تجاري",
"signup_requires_description": "لا تقدم شركة Cal.com, Inc. حاليًا إصدارًا مجانيًا مفتوح المصدر لصفحة التسجيل. للحصول على حق الوصول الكامل إلى مكونات الاشتراك، يجب أن تحصل على ترخيص تجاري. للاستخدام الشخصي، نوصي باستخدام منصة Prisma Data أو أي واجهة أخرى من واجهات Postgres لإنشاء حسابات.",
"signup_requires": "يلزم ترخيص تجاري",
"signup_requires_description": "لا تقدم شركة Cal.com, Inc. حاليًا إصدارًا مجانيًا مفتوح المصدر لصفحة التسجيل. للحصول على حق الوصول الكامل إلى مكونات التسجيل، يجب أن تحصل على ترخيص تجاري. للاستخدام الشخصي، نوصي باستخدام منصة Prisma Data أو أي واجهة أخرى من واجهات Postgres لإنشاء الحسابات.",
"next_steps": "الخطوات التالية",
"acquire_commercial_license": "تتطلب ترخيصًا تجاريًا",
"the_infrastructure_plan": "تعتمد خطة البنية التحتية على الاستخدام وتتضمن خصومات مناسبة لبدء التشغيل.",
"acquire_commercial_license": "الحصول على ترخيص تجاري",
"the_infrastructure_plan": "تعتمد خطة البنية التحتية على الاستخدام وتتضمن خصومات مناسبة للأعمال التجارية حديثة الإنشاء.",
"prisma_studio_tip": "إنشاء حساب عبر Prisma Studio",
"prisma_studio_tip_description": "تعرّف على كيفية إعداد مستخدمك الأول",
"contact_sales": "تواصل مع قسم المبيعات",
"error_404": "خطأ 404",
"requires_ownership_of_a_token": "يتطلب امتلاك رمز مميز يعود إلى العنوان التالي:",
"example_name": "أسامة منصور"
"requires_ownership_of_a_token": "يتطلب امتلاك token يخص العنوان التالي:",
"example_name": "فلان الفلاني"
}

View File

@ -284,6 +284,10 @@
"hover_over_bold_times_tip": "Tip: Najeďte myší na vytučněné časy a zobrazte si úplnou informaci",
"start_time": "Čas začátku",
"end_time": "Čas konce",
"buffer_time": "Mezičas",
"before_event": "Před událostí",
"after_event": "Po události",
"event_buffer_default": "Žádný mezičas",
"buffer": "Rozestup",
"your_day_starts_at": "Váš den začíná v",
"your_day_ends_at": "Váš den končí v",
@ -311,9 +315,12 @@
"event_triggers": "Eventy na základě akce",
"subscriber_url": "URL pro odběry",
"create_new_webhook": "Vytvořit nový webhook",
"webhooks": "Webhooky",
"team_webhooks": "Týmové Webhooky",
"create_new_webhook_to_account": "Vytvořit nový webhook pro váš účet",
"new_webhook": "Nový webhook",
"receive_cal_meeting_data": "Přijímat v reálném čase data schůzky na zadanou adresu URL, když je událost naplánována nebo zrušena.",
"receive_cal_event_meeting_data": "Přijmout real-time data o Cal události na určené URL adrese, jakmile je událost naplánována nebo zrušena.",
"responsive_fullscreen_iframe": "Responzivní iframe na celou obrazovku",
"loading": "Načítám...",
"standard_iframe": "Klasický iframe",
@ -456,6 +463,7 @@
"open_options": "Otevřít možnosti",
"copy_link": "Kopírovat odkaz na událost",
"share": "Sdílet",
"share_event": "Chcete si rezervovat čas nebo poslat váš odkaz?",
"copy_link_team": "Kopírovat odkaz na tým",
"leave_team": "Opustit tým",
"confirm_leave_team": "Ano, opustit tým",

View File

@ -314,6 +314,7 @@
"create_new_webhook_to_account": "Erstellen Sie einen neuen Webhook in Ihrem Account",
"new_webhook": "Neuer Webhook",
"receive_cal_meeting_data": "Erhalte Cal Buchungsdaten unter einer bestimmten URL in Echtzeit, wenn ein Ereignis geplant oder abgebrochen wird.",
"receive_cal_event_meeting_data": "Erhalte Cal Buchungsdaten unter einer bestimmten URL in Echtzeit, wenn ein Ereignis geplant oder abgebrochen wird.",
"responsive_fullscreen_iframe": "Responsives Vollbild-iframe",
"loading": "Wird geladen...",
"standard_iframe": "Standard iframe",
@ -402,6 +403,7 @@
"phone_number": "Telefonnummer",
"enter_phone_number": "Telefonnummer eingeben",
"reschedule": "Neuplanen",
"reschedule_this": "Stattdessen neu planen",
"book_a_team_member": "Teammitglied stattdessen buchen",
"or": "ODER",
"go_back": "Zurück",
@ -435,6 +437,7 @@
"danger_zone": "Achtung",
"back": "Zurück",
"cancel": "Abbrechen",
"cancel_event": "Diesen Termin stornieren",
"continue": "Weiter",
"confirm": "Bestätigen",
"disband_team": "Team auflösen",
@ -453,6 +456,8 @@
"pending": "Ausstehend",
"open_options": "Optionen öffnen",
"copy_link": "Link kopieren",
"share": "Teilen",
"share_event": "Können Sie einen Termin in meinem Kalender reservieren oder mir Ihren Kalender zukommen lassen?",
"copy_link_team": "Link zum Team kopieren",
"leave_team": "Team verlassen",
"confirm_leave_team": "Ja, Team verlassen",
@ -460,6 +465,7 @@
"user_from_team": "{{user}} von {{team}}",
"preview": "Vorschau",
"link_copied": "Link kopiert!",
"link_shared": "Link geteilt!",
"title": "Titel",
"description": "Beschreibung",
"quick_video_meeting": "Ein schnelles Video-Meeting.",
@ -654,5 +660,8 @@
"contact_sales": "Vertrieb kontaktieren",
"error_404": "Fehler 404",
"requires_ownership_of_a_token": "Erfordert Besitz eines Tokens, das zu der folgenden Adresse gehört:",
"example_name": "Max Mustermann"
"example_name": "Max Mustermann",
"time_format": "Zeitformat",
"12_hour": "12 Stunden",
"24_hour": "24 Stunden"
}

View File

@ -60,6 +60,7 @@
"password_reset_instructions": "If you didn't request this, you can safely ignore this email and your password will not be changed.",
"event_awaiting_approval_subject": "Awaiting Approval: {{eventType}} with {{name}} at {{date}}",
"event_still_awaiting_approval": "An event is still waiting for your approval",
"booking_submitted_subject": "Booking Submitted: {{eventType}} with {{name}} at {{date}}",
"your_meeting_has_been_booked": "Your meeting has been booked",
"event_type_has_been_rescheduled_on_time_date": "Your {{eventType}} with {{name}} has been rescheduled to {{time}} ({{timeZone}}) on {{date}}.",
"event_has_been_rescheduled": "Updated - Your event has been rescheduled",
@ -94,7 +95,7 @@
"hidden_team_owner_message": "You need a pro account to use teams, you are hidden until you upgrade.",
"link_expires": "p.s. It expires in {{expiresIn}} hours.",
"upgrade_to_per_seat": "Upgrade to Per-Seat",
"team_upgrade_seats_details": "Of the {{memberCount}} members in your team, {{unpaidCount}} seat(s) are unpaid. At ${{seatPrice}}/m per seat the estimated total cost of your membership is ${{totalCost}}/m.",
"team_upgrade_seats_details": "Of the {{memberCount}} members in your team, {{unpaidCount}} seat(s) are unpaid. At ${{seatPrice}}/month per seat the estimated total cost of your membership is ${{totalCost}}/month.",
"team_upgraded_successfully": "Your team was upgraded successfully!",
"use_link_to_reset_password": "Use the link below to reset your password",
"hey_there": "Hey there,",
@ -284,6 +285,10 @@
"hover_over_bold_times_tip": "Tip: Hover over the bold times for a full timestamp",
"start_time": "Start time",
"end_time": "End time",
"buffer_time": "Buffer time",
"before_event": "Before event",
"after_event": "After event",
"event_buffer_default": "No buffer time",
"buffer": "Buffer",
"your_day_starts_at": "Your day starts at",
"your_day_ends_at": "Your day ends at",
@ -311,9 +316,12 @@
"event_triggers": "Event Triggers",
"subscriber_url": "Subscriber Url",
"create_new_webhook": "Create a new webhook",
"webhooks": "Webhooks",
"team_webhooks": "Team Webhooks",
"create_new_webhook_to_account": "Create a new webhook to your account",
"new_webhook": "New Webhook",
"receive_cal_meeting_data": "Receive Cal meeting data at a specified URL, in real-time, when an event is scheduled or cancelled.",
"receive_cal_event_meeting_data": "Receive Cal meeting data at a specified URL, in real-time, when this event is scheduled or cancelled.",
"responsive_fullscreen_iframe": "Responsive full screen iframe",
"loading": "Loading...",
"standard_iframe": "Standard iframe",
@ -506,6 +514,8 @@
"first_day_of_week": "First Day of Week",
"single_theme": "Single Theme",
"brand_color": "Brand Color",
"light_brand_color": "Brand Color (Light Theme)",
"dark_brand_color": "Brand Color (Dark Theme)",
"file_not_named": "File is not named [idOrSlug]/[user]",
"create_team": "Create Team",
"name": "Name",
@ -519,7 +529,7 @@
"team_billing": "Team Billing",
"upgrade_to_flexible_pro_title": "We've changed billing for teams",
"upgrade_to_flexible_pro_message": "There are members in your team without a seat. Upgrade your pro plan to cover missing seats.",
"changed_team_billing_info": "As of January 2022 we charge on a per-seat basis for team members. Members of your team who had Pro for free are now on a 14 day trial. Once their trial expires these members will be hidden from your team unless you upgrade now.",
"changed_team_billing_info": "As of January 2022 we charge on a per-seat basis for team members. Members of your team who had PRO for free are now on a 14 day trial. Once their trial expires these members will be hidden from your team unless you upgrade now.",
"create_manage_teams_collaborative": "Create and manage teams to use collaborative features.",
"only_available_on_pro_plan": "This feature is only available in Pro plan",
"remove_cal_branding_description": "In order to remove the Cal branding from your booking pages, you need to upgrade to a Pro account.",

View File

@ -284,6 +284,10 @@
"hover_over_bold_times_tip": "Astuce : Survolez les heures en gras pour obtenir un horodatage complet",
"start_time": "Début",
"end_time": "Fin",
"buffer_time": "Période tampon",
"before_event": "Avant l'événement",
"after_event": "Après l'événement",
"event_buffer_default": "Pas de période tampon",
"buffer": "Intervalle",
"your_day_starts_at": "Votre journée commence à",
"your_day_ends_at": "Votre journée se termine à",
@ -311,9 +315,12 @@
"event_triggers": "Déclencheurs d'événements",
"subscriber_url": "URL de l'abonné",
"create_new_webhook": "Créez un nouveau webhook",
"webhooks": "Webhooks",
"team_webhooks": "Webhooks déquipe",
"create_new_webhook_to_account": "Créez un nouveau webhook sur votre compte",
"new_webhook": "Nouveau Webhook",
"receive_cal_meeting_data": "Recevoir les données de la réunion Cal à une URL spécifiée, en temps réel, lorsqu'un événement est programmé ou annulé.",
"receive_cal_event_meeting_data": "Recevoir les données de la réunion Cal à une URL spécifiée, en temps réel, lorsque cet événement est programmé ou annulé.",
"responsive_fullscreen_iframe": "iframe plein écran réactif",
"loading": "Chargement...",
"standard_iframe": "iframe standard",

View File

@ -284,6 +284,10 @@
"hover_over_bold_times_tip": "Dica: passe o rato sobre os tempos ousados para um horário completo",
"start_time": "Hora de início",
"end_time": "Hora de fim",
"buffer_time": "Tempo de buffer",
"before_event": "Antes do evento",
"after_event": "Depois do evento",
"event_buffer_default": "Nenhum tempo de buffer",
"buffer": "Intervalo",
"your_day_starts_at": "O seu dia começa às",
"your_day_ends_at": "O seu dia termina às",
@ -311,9 +315,12 @@
"event_triggers": "Causadores de eventos",
"subscriber_url": "URL do assinante",
"create_new_webhook": "Criar um novo webhook",
"webhooks": "Webhooks",
"team_webhooks": "Webhooks da equipa",
"create_new_webhook_to_account": "Criar um webhook na sua conta",
"new_webhook": "Novo Webhook",
"receive_cal_meeting_data": "Receba dados da reunião Cal em uma, URL especificada, em tempo real, quando um evento for programado ou cancelado.",
"receive_cal_event_meeting_data": "Receba dados da reunião Cal num URL especificado, em tempo real, assim que o evento for agendado ou cancelado.",
"responsive_fullscreen_iframe": "Iframe responsivo de ecrã inteiro",
"loading": "A carregar...",
"standard_iframe": "Iframe padrão",

View File

@ -0,0 +1 @@
{}

View File

@ -45,6 +45,7 @@ async function getUserFromSession({
twoFactorEnabled: true,
identityProvider: true,
brandColor: true,
darkBrandColor: true,
plan: true,
away: true,
credentials: {
@ -67,6 +68,7 @@ async function getUserFromSession({
destinationCalendar: true,
locale: true,
timeFormat: true,
trialEndsAt: true,
},
});

View File

@ -78,10 +78,12 @@ const loggedInViewerRouter = createProtectedRouter()
timeFormat: user.timeFormat,
avatar: user.avatar,
createdDate: user.createdDate,
trialEndsAt: user.trialEndsAt,
completedOnboarding: user.completedOnboarding,
twoFactorEnabled: user.twoFactorEnabled,
identityProvider: user.identityProvider,
brandColor: user.brandColor,
darkBrandColor: user.darkBrandColor,
plan: user.plan,
away: user.away,
};
@ -385,6 +387,11 @@ const loggedInViewerRouter = createProtectedRouter()
},
status: true,
paid: true,
user: {
select: {
id: true,
},
},
},
orderBy,
take: take + 1,
@ -610,6 +617,7 @@ const loggedInViewerRouter = createProtectedRouter()
weekStart: z.string().optional(),
hideBranding: z.boolean().optional(),
brandColor: z.string().optional(),
darkBrandColor: z.string().optional(),
theme: z.string().optional().nullable(),
completedOnboarding: z.boolean().optional(),
locale: z.string().optional(),

View File

@ -10,7 +10,19 @@ import { getTranslation } from "@server/lib/i18n";
export const webhookRouter = createProtectedRouter()
.query("list", {
async resolve({ ctx }) {
input: z
.object({
eventTypeId: z.number().optional(),
})
.optional(),
async resolve({ ctx, input }) {
if (input?.eventTypeId) {
return await ctx.prisma.webhook.findMany({
where: {
eventTypeId: input.eventTypeId,
},
});
}
return await ctx.prisma.webhook.findMany({
where: {
userId: ctx.user.id,
@ -24,8 +36,17 @@ export const webhookRouter = createProtectedRouter()
eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array(),
active: z.boolean(),
payloadTemplate: z.string().nullable(),
eventTypeId: z.number().optional(),
}),
async resolve({ ctx, input }) {
if (input.eventTypeId) {
return await ctx.prisma.webhook.create({
data: {
id: v4(),
...input,
},
});
}
return await ctx.prisma.webhook.create({
data: {
id: v4(),
@ -42,17 +63,26 @@ export const webhookRouter = createProtectedRouter()
eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array().optional(),
active: z.boolean().optional(),
payloadTemplate: z.string().nullable(),
eventTypeId: z.number().optional(),
}),
async resolve({ ctx, input }) {
const { id, ...data } = input;
const webhook = await ctx.prisma.webhook.findFirst({
where: {
userId: ctx.user.id,
id,
},
});
const webhook = input.eventTypeId
? await ctx.prisma.webhook.findFirst({
where: {
eventTypeId: input.eventTypeId,
id,
},
})
: await ctx.prisma.webhook.findFirst({
where: {
userId: ctx.user.id,
id,
},
});
if (!webhook) {
// user does not own this webhook
// team event doesn't own this webhook
return null;
}
return await ctx.prisma.webhook.update({
@ -66,23 +96,36 @@ export const webhookRouter = createProtectedRouter()
.mutation("delete", {
input: z.object({
id: z.string(),
eventTypeId: z.number().optional(),
}),
async resolve({ ctx, input }) {
const { id } = input;
await ctx.prisma.user.update({
where: {
id: ctx.user.id,
},
data: {
webhooks: {
delete: {
id,
input.eventTypeId
? await ctx.prisma.eventType.update({
where: {
id: input.eventTypeId,
},
},
},
});
data: {
webhooks: {
delete: {
id,
},
},
},
})
: await ctx.prisma.user.update({
where: {
id: ctx.user.id,
},
data: {
webhooks: {
delete: {
id,
},
},
},
});
return {
id,
};

View File

@ -5,6 +5,8 @@
:root {
--brand-color: #292929;
--brand-text-color: #ffffff;
--brand-color-dark-mode: #fafafa;
--brand-text-color-dark-mode: #292929;
}
/* PhoneInput dark-mode overrides (it would add a lot of boilerplate to do this in JavaScript) */

View File

@ -14,6 +14,8 @@ module.exports = {
/* your primary brand color */
brand: "var(--brand-color)",
brandcontrast: "var(--brand-text-color)",
darkmodebrand: "var(--brand-color-dark-mode)",
darkmodebrandcontrast: "var(--brand-text-color-dark-mode)",
black: "#111111",
gray: {
50: "#F8F8F8",

View File

@ -7,7 +7,8 @@
"@lib/*": ["lib/*"],
"@server/*": ["server/*"],
"@ee/*": ["ee/*"],
"@apps/*": ["lib/apps/*"]
"@apps/*": ["lib/apps/*"],
"@prisma/client/*": ["@calcom/prisma/client/*"]
},
"typeRoots": ["./types", "@calcom/types"],
"types": ["@types/jest"]

@ -1 +1 @@
Subproject commit aac908d6405603d2ef0554e5c43a55d4b7eb025c
Subproject commit e2b55ccca5dbc40f8212426ad720074b61aad92a

View File

@ -27,7 +27,7 @@
"start": "turbo run start --scope=\"@calcom/web\"",
"test": "turbo run test",
"test-playwright": "yarn playwright test",
"test-e2e": "turbo run test-e2e",
"test-e2e": "turbo run test-e2e --concurrency=1",
"type-check": "turbo run type-check"
},
"devDependencies": {

View File

@ -1,8 +1,17 @@
import { Credential } from "@prisma/client";
import { v4 as uuidv4 } from "uuid";
import { randomString } from "@calcom/lib/random";
import type { PartialReference } from "@calcom/types/EventManager";
import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter";
export const FAKE_JITSI_CREDENTIAL: Credential = {
id: +new Date().getTime(),
type: "jitsi_video",
key: { apikey: randomString(12) },
userId: +new Date().getTime(),
};
const JitsiVideoApiAdapter = (): VideoApiAdapter => {
return {
getAvailability: () => {

View File

@ -1,7 +1,7 @@
import { PrismaClient } from "@prisma/client";
declare global {
var prisma: PrismaClient;
var prisma: PrismaClient | undefined;
}
export const prisma =

View File

@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "Webhook" ADD COLUMN "eventTypeId" INTEGER,
ALTER COLUMN "userId" DROP NOT NULL;
-- AddForeignKey
ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_eventTypeId_fkey" FOREIGN KEY ("eventTypeId") REFERENCES "EventType"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "EventType" ADD COLUMN "afterEventBuffer" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "beforeEventBuffer" INTEGER NOT NULL DEFAULT 0;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "darkBrandColor" TEXT NOT NULL DEFAULT E'#fafafa';

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "trialEndsAt" TIMESTAMP(3);

View File

@ -15,20 +15,26 @@
"db-up": "docker-compose up -d",
"deploy": "run-s build db-deploy",
"dx": "yarn db-setup",
"generate-schemas": "prisma generate"
"generate-schemas": "prisma generate && prisma format",
"postinstall": "yarn generate-schemas"
},
"devDependencies": {
"npm-run-all": "^4.1.5",
"prisma": "3.9.2",
"ts-node": "^10.2.1",
"prisma": "3.10.0",
"ts-node": "^10.6.0",
"zod-prisma": "^0.5.4"
},
"dependencies": {
"@calcom/lib": "*",
"@prisma/client": "3.9.2"
"@prisma/client": "3.10.0"
},
"main": "index.ts",
"types": "index.d.ts",
"files": [
"client",
"zod",
"zod-utils.ts"
],
"prisma": {
"seed": "ts-node ./seed.ts"
}

View File

@ -46,6 +46,7 @@ model EventType {
teamId Int?
bookings Booking[]
availability Availability[]
webhooks Webhook[]
destinationCalendar DestinationCalendar?
eventName String?
customInputs EventTypeCustomInput[]
@ -58,6 +59,8 @@ model EventType {
requiresConfirmation Boolean @default(false)
disableGuests Boolean @default(false)
minimumBookingNotice Int @default(120)
beforeEventBuffer Int @default(0)
afterEventBuffer Int @default(0)
schedulingType SchedulingType?
Schedule Schedule[]
price Int @default(0)
@ -120,6 +123,7 @@ model User {
hideBranding Boolean @default(false)
theme String?
createdDate DateTime @default(now()) @map(name: "created")
trialEndsAt DateTime?
eventTypes EventType[] @relation("user_eventtype")
credentials Credential[]
teams Membership[]
@ -138,6 +142,7 @@ model User {
Schedule Schedule[]
webhooks Webhook[]
brandColor String @default("#292929")
darkBrandColor String @default("#fafafa")
// the location where the events will end up
destinationCalendar DestinationCalendar?
away Boolean @default(false)
@ -345,11 +350,13 @@ enum WebhookTriggerEvents {
model Webhook {
id String @id @unique
userId Int
userId Int?
eventTypeId Int?
subscriberUrl String
payloadTemplate String?
createdAt DateTime @default(now())
active Boolean @default(true)
eventTriggers WebhookTriggerEvents[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
eventType EventType? @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
}

View File

@ -0,0 +1,30 @@
import * as z from "zod"
import * as imports from "../zod-utils"
import { WebhookTriggerEvents } from "@prisma/client"
import { CompleteUser, UserModel, CompleteEventType, EventTypeModel } from "./index"
export const _WebhookModel = z.object({
id: z.string(),
userId: z.number().int().nullish(),
eventTypeId: z.number().int().nullish(),
subscriberUrl: z.string(),
payloadTemplate: z.string().nullish(),
createdAt: z.date(),
active: z.boolean(),
eventTriggers: z.nativeEnum(WebhookTriggerEvents).array(),
})
export interface CompleteWebhook extends z.infer<typeof _WebhookModel> {
user?: CompleteUser | null
eventType?: CompleteEventType | null
}
/**
* WebhookModel contains all relations on your model in addition to the scalars
*
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const WebhookModel: z.ZodSchema<CompleteWebhook> = z.lazy(() => _WebhookModel.extend({
user: UserModel.nullish(),
eventType: EventTypeModel.nullish(),
}))

View File

@ -18,6 +18,9 @@
},
"exclude": ["node_modules"],
"ts-node": {
"files": true,
"require": ["tsconfig-paths/register"],
"experimentalResolverFeatures": true,
"compilerOptions": {
"module": "CommonJS",
"types": ["node"]

View File

@ -7,5 +7,8 @@
"base.json",
"nextjs.json",
"react-library.json"
]
],
"devDependencies": {
"tsconfig-paths": "^3.12.0"
}
}

View File

@ -65,7 +65,7 @@ export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonPr
color === "primary" &&
(disabled
? "border border-transparent bg-gray-400 text-white"
: "border border-transparent dark:text-brandcontrast text-brandcontrast bg-brand dark:bg-brand hover:bg-opacity-90 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-neutral-900"),
: "border border-transparent dark:text-darkmodebrandcontrast text-brandcontrast bg-brand dark:bg-darkmodebrand hover:bg-opacity-90 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-neutral-900"),
color === "secondary" &&
(disabled
? "border border-gray-200 text-gray-400 bg-white"

View File

@ -7,7 +7,9 @@
"dependsOn": ["$DATABASE_URL"],
"outputs": ["zod/**"]
},
"@calcom/prisma#db-deploy": {},
"@calcom/prisma#db-deploy": {
"dependsOn": ["$DATABASE_URL"]
},
"@calcom/prisma#db-reset": {
"cache": false
},
@ -18,7 +20,7 @@
"@calcom/web#build": {
"dependsOn": [
"^build",
"@calcom/prisma#build",
"@calcom/prisma#db-deploy",
"$BASE_URL",
"$CALENDSO_ENCRYPTION_KEY",
"$CRON_API_KEY",
@ -53,6 +55,7 @@
"$TANDEM_BASE_URL",
"$TANDEM_CLIENT_ID",
"$TANDEM_CLIENT_SECRET",
"$WEBSITE_BASE_URL",
"$ZOOM_CLIENT_ID",
"$ZOOM_CLIENT_SECRET"
],
@ -62,6 +65,10 @@
"dependsOn": ["@calcom/prisma#dx"]
},
"@calcom/web#start": {},
"@calcom/website#build": {
"dependsOn": ["$WEBSITE_BASE_URL"],
"outputs": [".next/**"]
},
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]

View File

@ -1017,7 +1017,12 @@
resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-1.4.3.tgz#f77c6bb5cb4a614a5d730fb880cab502d48abf37"
integrity sha512-n2IQkaaw0aAAlQS5MEXsM4uRK+w18CrM72EqnGRl/UBOQeQajad8oiKXR9Nk15jOzTFQjpxzrZMf1NxHidFBiw==
"@heroicons/react@^1.0.4", "@heroicons/react@^1.0.5":
"@heroicons/react@^1.0.4":
version "1.0.6"
resolved "https://registry.yarnpkg.com/@heroicons/react/-/react-1.0.6.tgz#35dd26987228b39ef2316db3b1245c42eb19e324"
integrity sha512-JJCXydOFWMDpCP4q13iEplA503MQO3xLoZiKum+955ZCtHINWnx26CUxVxxFQu/uLb4LW3ge15ZpzIkXKkJ8oQ==
"@heroicons/react@^1.0.5":
version "1.0.5"
resolved "https://registry.yarnpkg.com/@heroicons/react/-/react-1.0.5.tgz#2fe4df9d33eb6ce6d5178a0f862e97b61c01e27d"
integrity sha512-UDMyLM2KavIu2vlWfMspapw9yii7aoLwzI2Hudx4fyoPwfKfxU8r3cL8dEBXOjcLG0/oOONZzbT14M1HoNtEcg==
@ -2015,12 +2020,12 @@
resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.21.tgz#5de5a2385a35309427f6011992b544514d559aa1"
integrity sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==
"@prisma/client@3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.9.2.tgz#ad17dcfb702842573fe6ec3b7dc4615eff8d8fc6"
integrity sha512-VlEIYVMyfFZHbVBOlunPl47gmP/Z0zzPjPj8I7uKEIaABqrUy50ru3XS0aZd8GFvevVwt7p91xxkUjNjrWhKAQ==
"@prisma/client@3.10.0":
version "3.10.0"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.10.0.tgz#4782fe6f1b0e43c2a11a75ad4bb1098599d1dfb1"
integrity sha512-6P4sV7WFuODSfSoSEzCH1qfmWMrCUBk1LIIuTbQf6m1LI/IOpLN4lnqGDmgiBGprEzuWobnGLfe9YsXLn0inrg==
dependencies:
"@prisma/engines-version" "3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009"
"@prisma/engines-version" "3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86"
"@prisma/debug@3.8.1":
version "3.8.1"
@ -2031,15 +2036,15 @@
ms "2.1.3"
strip-ansi "6.0.1"
"@prisma/engines-version@3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009":
version "3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009"
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009.tgz#ea03ffa723382a526dc6625ce6eae9b6ad984400"
integrity sha512-5Dh+qTDhpPR66w6NNAnPs+/W/Qt4r1DSd+qhfPFcDThUK4uxoZKGlPb2IYQn5LL+18aIGnmteDf7BnVMmvBNSQ==
"@prisma/engines-version@3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86":
version "3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86"
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86.tgz#82750856fa637dd89b8f095d2dcc6ac0631231c6"
integrity sha512-cVYs5gyQH/qyut24hUvDznCfPrWiNMKNfPb9WmEoiU6ihlkscIbCfkmuKTtspVLWRdl0LqjYEC7vfnPv17HWhw==
"@prisma/engines@3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009":
version "3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009.tgz#e5c345cdedb7be83d11c1e0c5ab61d866b411256"
integrity sha512-qM+uJbkelB21bnK44gYE049YTHIjHysOuj0mj5U2gDGyNLfmiazlggzFPCgEjgme4U5YB2tYs6Z5Hq08Kl8pjA==
"@prisma/engines@3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86":
version "3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86.tgz#2964113729a78b8b21e186b5592affd1fde73c16"
integrity sha512-LjRssaWu9w2SrXitofnutRIyURI7l0veQYIALz7uY4shygM9nMcK3omXcObRm7TAcw3Z+9ytfK1B+ySOsOesxQ==
"@prisma/generator-helper@~3.8.1":
version "3.8.1"
@ -2595,9 +2600,9 @@
integrity sha512-fm8TR8r4LwbXgBIYdPmeMjJJkxxFC66tvoliNnmXOpUgZSgQKoNPW3ON0ZphZIiif1oqWNhAaSrr7tOvGu+AFg==
"@stripe/stripe-js@^1.17.1":
version "1.23.0"
resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.23.0.tgz#62eed14e83c63c3e8c27f14f6b1e6feb8496c867"
integrity sha512-+7w4rVs71Fk8/8uzyzQB5GotHSH9mjOjxM3EYDq/3MR3I2ewELHtvWVMOqfS/9WSKCaKv7h7eFLsMZGpK5jApQ==
version "1.24.0"
resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.24.0.tgz#d23977f364565981f8ab30b1b540e367f72abc5c"
integrity sha512-8CEILOpzoRhGwvgcf6y+BlPyEq1ZqxAv3gsX7LvokFYvbcyH72GRcHQMGXuZS3s7HqfYQuTSFrvZNL/qdkgA9Q==
"@szmarczak/http-timer@^1.1.2":
version "1.1.2"
@ -11544,9 +11549,9 @@ postcss@8.4.5:
source-map-js "^1.0.1"
postcss@^8.1.6, postcss@^8.3.5, postcss@^8.3.6:
version "8.4.7"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.7.tgz#f99862069ec4541de386bf57f5660a6c7a0875a8"
integrity sha512-L9Ye3r6hkkCeOETQX6iOaWZgjp3LL6Lpqm6EtgbKrgqGGteRMNb9vzBfRL96YOSu8o7x3MfIH9Mo5cPJFGrW6A==
version "8.4.8"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.8.tgz#dad963a76e82c081a0657d3a2f3602ce10c2e032"
integrity sha512-2tXEqGxrjvAO6U+CJzDL2Fk2kPHTv1jQsYkSoMeOis2SsYaXRO2COxTdQp99cYvif9JTXaAk9lYGc3VhJt7JPQ==
dependencies:
nanoid "^3.3.1"
picocolors "^1.0.0"
@ -11671,12 +11676,12 @@ prism-react-renderer@^1.1.1:
resolved "https://registry.yarnpkg.com/prism-react-renderer/-/prism-react-renderer-1.3.1.tgz#88fc9d0df6bed06ca2b9097421349f8c2f24e30d"
integrity sha512-xUeDMEz074d0zc5y6rxiMp/dlC7C+5IDDlaEUlcBOFE2wddz7hz5PNupb087mPwTt7T9BrFmewObfCBuf/LKwQ==
prisma@3.9.2:
version "3.9.2"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.9.2.tgz#cc2da4e8db91231dea7465adf9db6e19f11032a9"
integrity sha512-i9eK6cexV74OgeWaH3+e6S07kvC9jEZTl6BqtBH398nlCU0tck7mE9dicY6YQd+euvMjjCtY89q4NgmaPnUsSg==
prisma@3.10.0:
version "3.10.0"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.10.0.tgz#872d87afbeb1cbcaa77c3d6a63c125e0d704b04d"
integrity sha512-dAld12vtwdz9Rz01nOjmnXe+vHana5PSog8t0XGgLemKsUVsaupYpr74AHaS3s78SaTS5s2HOghnJF+jn91ZrA==
dependencies:
"@prisma/engines" "3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009"
"@prisma/engines" "3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86"
process-es6@^0.11.2:
version "0.11.6"
@ -13924,10 +13929,10 @@ ts-morph@^13.0.2:
"@ts-morph/common" "~0.12.3"
code-block-writer "^11.0.0"
ts-node@^10.2.1:
version "10.5.0"
resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.5.0.tgz#618bef5854c1fbbedf5e31465cbb224a1d524ef9"
integrity sha512-6kEJKwVxAJ35W4akuiysfKwKmjkbYxwQMTBaAxo9KKAx/Yd26mPUyhGz3ji+EsJoAgrLqVsYHNuuYwQe22lbtw==
ts-node@^10.6.0:
version "10.6.0"
resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.6.0.tgz#c3f4195d5173ce3affdc8f2fd2e9a7ac8de5376a"
integrity sha512-CJen6+dfOXolxudBQXnVjRVvYTmTWbyz7cn+xq2XTsvnaXbHqr4gXSCNbS2Jj8yTZMuGwUoBESLaOkLascVVvg==
dependencies:
"@cspotcode/source-map-support" "0.7.0"
"@tsconfig/node10" "^1.0.7"
@ -15424,9 +15429,9 @@ zod@^3.8.2:
integrity sha512-daZ80A81I3/9lIydI44motWe6n59kRBfNzTuS2bfzVh1nAXi667TOTWWtatxyG+fwgNUiagSj/CWZwRRbevJIg==
zod@^3.9.5:
version "3.12.0"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.12.0.tgz#84ba9f6bdb7835e2483982d5f52cfffcb6a00346"
integrity sha512-w+mmntgEL4hDDL5NLFdN6Fq2DSzxfmlSoJqiYE1/CApO8EkOCxvJvRYEVf8Vr/lRs3i6gqoiyFM6KRcWqqdBzQ==
version "3.13.4"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.13.4.tgz#5d6fe03ef4824a637d7ef50b5441cf6ab3acede0"
integrity sha512-LZRucWt4j/ru5azOkJxCfpR87IyFDn8h2UODdqvXzZLb3K7bb9chUrUIGTy3BPsr8XnbQYfQ5Md5Hu2OYIo1mg==
zwitch@^1.0.0:
version "1.0.5"