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 The development branch is `main`. This is the branch that all pull
requests should be made against. The changes on the `main` 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: To develop locally:
@ -51,7 +51,7 @@ Please be sure that you can make a full production build before pushing code.
## Testing ## Testing
More info on how to add new tests comming soon. More info on how to add new tests coming soon.
### Running tests ### 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", "import": "Import",
"billing": "Billing", "billing": "Billing",
"developer": "Developer", "developer": "Developer",
"contributing": "Contributing",
"faq": "FAQs" "faq": "FAQs"
} }

View File

@ -1,6 +1,6 @@
export default { export default {
github: 'https://github.com/calcom/docs', github: 'https://github.com/calcom/cal.com',
docsRepositoryBase: 'https://github.com/calcom/docs/blob/master', docsRepositoryBase: 'https://github.com/calcom/cal.com/blob/main/apps/docs/pages',
titleSuffix: ' | Cal.com', titleSuffix: ' | Cal.com',
logo: ( logo: (
<h4 className="m-0"> <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="rounded-lg p-2 shadow-lg sm:p-3" style={{ background: "#2F333D" }}>
<div className="flex flex-wrap items-center justify-between"> <div className="flex flex-wrap items-center justify-between">
<div className="flex w-0 flex-1 items-center"> <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 <svg
className="h-7 w-7 fill-current text-indigo-500" className="h-7 w-7 fill-current text-indigo-500"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"

View File

@ -2,6 +2,7 @@ import { useEffect } from "react";
const brandColor = "#292929"; const brandColor = "#292929";
const brandTextColor = "#ffffff"; const brandTextColor = "#ffffff";
const darkBrandColor = "#fafafa";
export function colorNameToHex(color: string) { export function colorNameToHex(color: string) {
const colors = { 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)]; 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 { function getContrastingTextColor(bgColor: string | null, dark: boolean): string {
bgColor = bgColor == "" || bgColor == null ? brandColor : bgColor; bgColor = bgColor == "" || bgColor == null ? (dark ? darkBrandColor : brandColor) : bgColor;
const rgb = hexToRGB(bgColor); const rgb = hexToRGB(bgColor);
const whiteContrastRatio = computeContrastRatio(rgb, [255, 255, 255]); const whiteContrastRatio = computeContrastRatio(rgb, [255, 255, 255]);
const blackContrastRatio = computeContrastRatio(rgb, [41, 41, 41]); //#292929 const blackContrastRatio = computeContrastRatio(rgb, [41, 41, 41]); //#292929
@ -191,18 +192,38 @@ export function isValidHexCode(val: string | null) {
return false; 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; 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 // 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(() => { useEffect(() => {
document.documentElement.style.setProperty("--brand-color", val); document.documentElement.style.setProperty("--brand-color", lightVal);
document.documentElement.style.setProperty("--brand-text-color", getContrastingTextColor(val)); document.documentElement.style.setProperty("--brand-text-color", getContrastingTextColor(lightVal, true));
}, [val]); 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; return null;
}; };

View File

@ -1,11 +1,55 @@
import * as DialogPrimitive from "@radix-ui/react-dialog"; 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) { 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 ( return (
<DialogPrimitive.Root {...other}> <DialogPrimitive.Root {...dialogProps}>
<DialogPrimitive.Overlay className="fixed inset-0 z-40 bg-gray-500 bg-opacity-75 transition-opacity" /> <DialogPrimitive.Overlay className="fixed inset-0 z-40 bg-gray-500 bg-opacity-75 transition-opacity" />
{children} {children}
</DialogPrimitive.Root> </DialogPrimitive.Root>

View File

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

View File

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

View File

@ -14,6 +14,8 @@ import Loader from "@components/Loader";
type AvailableTimesProps = { type AvailableTimesProps = {
timeFormat: string; timeFormat: string;
minimumBookingNotice: number; minimumBookingNotice: number;
beforeBufferTime: number;
afterBufferTime: number;
eventTypeId: number; eventTypeId: number;
eventLength: number; eventLength: number;
slotInterval: number | null; slotInterval: number | null;
@ -33,6 +35,8 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
timeFormat, timeFormat,
users, users,
schedulingType, schedulingType,
beforeBufferTime,
afterBufferTime,
}) => { }) => {
const { t, i18n } = useLocale(); const { t, i18n } = useLocale();
const router = useRouter(); const router = useRouter();
@ -45,6 +49,8 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
schedulingType, schedulingType,
users, users,
minimumBookingNotice, minimumBookingNotice,
beforeBufferTime,
afterBufferTime,
eventTypeId, eventTypeId,
}); });
@ -95,7 +101,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
<Link href={bookingUrl}> <Link href={bookingUrl}>
<a <a
className={classNames( 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" brand === "#fff" || brand === "#ffffff" ? "border-brandcontrast" : "border-brand"
)} )}
data-testid="time"> 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"> <td className="whitespace-nowrap py-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4">
{isUpcoming && !isCancelled ? ( {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 && <TableActions actions={bookedActions} />}
{!booking.confirmed && booking.rejected && ( {!booking.confirmed && booking.rejected && (
<div className="text-sm text-gray-500">{t("rejected")}</div> <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", "hover:border-brand hover:border dark:hover:border-white",
day.disabled ? "cursor-default font-light text-gray-400 hover:border-0" : "font-medium", day.disabled ? "cursor-default font-light text-gray-400 hover:border-0" : "font-medium",
date && date.isSame(browsingDate.date(day.date), "day") date && date.isSame(browsingDate.date(day.date), "day")
? "bg-brand text-brandcontrast" ? "bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast"
: !day.disabled : !day.disabled
? " bg-gray-100 dark:bg-gray-600 dark:text-white" ? " bg-gray-100 dark:bg-gray-600 dark:text-white"
: "" : ""

View File

@ -46,7 +46,9 @@ const TimeOptions: FC<Props> = ({ onToggle24hClock, onSelectTimeZone }) => {
checked={is24hClock} checked={is24hClock}
onChange={handle24hClockToggle} onChange={handle24hClockToggle}
className={classNames( 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" "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> <span className="sr-only">{t("use_setting")}</span>

View File

@ -1,5 +1,12 @@
// Get router variables // 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 * as Collapsible from "@radix-ui/react-collapsible";
import { useContracts } from "contexts/contractsContext"; import { useContracts } from "contexts/contractsContext";
import dayjs, { Dayjs } from "dayjs"; import dayjs, { Dayjs } from "dayjs";
@ -11,6 +18,7 @@ import { FormattedNumber, IntlProvider } from "react-intl";
import { asStringOrNull } from "@lib/asStringOrNull"; import { asStringOrNull } from "@lib/asStringOrNull";
import { timeZone } from "@lib/clock"; import { timeZone } from "@lib/clock";
import { BASE_URL } from "@lib/config/constants";
import { useLocale } from "@lib/hooks/useLocale"; import { useLocale } from "@lib/hooks/useLocale";
import useTheme from "@lib/hooks/useTheme"; import useTheme from "@lib/hooks/useTheme";
import { isBrandingHidden } from "@lib/isBrandingHidden"; import { isBrandingHidden } from "@lib/isBrandingHidden";
@ -33,7 +41,7 @@ dayjs.extend(customParseFormat);
type Props = AvailabilityTeamPageProps | AvailabilityPageProps; type Props = AvailabilityTeamPageProps | AvailabilityPageProps;
const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => { const AvailabilityPage = ({ profile, eventType, workingHours, previousPage }: Props) => {
const router = useRouter(); const router = useRouter();
const { rescheduleUid } = router.query; const { rescheduleUid } = router.query;
const { isReady, Theme } = useTheme(profile.theme); const { isReady, Theme } = useTheme(profile.theme);
@ -110,7 +118,7 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
username={profile.slug || undefined} username={profile.slug || undefined}
// avatar={profile.image || undefined} // avatar={profile.image || undefined}
/> />
<CustomBranding val={profile.brandColor} /> <CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
<div> <div>
<main <main
className={ 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="px-4 sm:flex sm:p-4 sm:py-5">
<div <div
className={ 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") (selectedDate ? "sm:w-1/3" : "sm:w-1/2")
}> }>
<AvatarGroup <AvatarGroup
@ -212,6 +220,15 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
<TimezoneDropdown /> <TimezoneDropdown />
<p className="mt-3 mb-8 text-gray-600 dark:text-gray-200">{eventType.description}</p> <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> </div>
<DatePicker <DatePicker
date={selectedDate} date={selectedDate}
@ -241,6 +258,8 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
date={selectedDate} date={selectedDate}
users={eventType.users} users={eventType.users}
schedulingType={eventType.schedulingType ?? null} schedulingType={eventType.schedulingType ?? null}
beforeBufferTime={eventType.beforeEventBuffer}
afterBufferTime={eventType.afterEventBuffer}
/> />
)} )}
</div> </div>

View File

@ -85,9 +85,6 @@ const BookingPage = (props: BookingPageProps) => {
if (!location) { if (!location) {
return; return;
} }
if (location === "integrations:jitsi") {
return "https://meet.jit.si/cal/" + uuidv4();
}
if (location.includes("integration")) { if (location.includes("integration")) {
return t("web_conferencing_details_to_follow"); return t("web_conferencing_details_to_follow");
} }
@ -254,7 +251,9 @@ const BookingPage = (props: BookingPageProps) => {
language: i18n.language, language: i18n.language,
rescheduleUid, rescheduleUid,
user: router.query.user, user: router.query.user,
location: getLocationValue(booking.locationType ? booking : { locationType: selectedLocation }), location: getLocationValue(
booking.locationType ? booking : { ...booking, locationType: selectedLocation }
),
metadata, metadata,
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({ customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
label: props.eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label, label: props.eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
@ -281,8 +280,8 @@ const BookingPage = (props: BookingPageProps) => {
</title> </title>
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<CustomBranding val={props.profile.brandColor} /> <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"> <main className="mx-auto my-0 max-w-3xl rounded-sm sm:my-24 sm:border sm:dark:border-gray-600">
{isReady && ( {isReady && (
<div className="overflow-hidden border border-gray-200 bg-white dark:border-0 dark:bg-neutral-900 sm:rounded-sm"> <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"> <div className="px-4 py-5 sm:flex sm:p-4">
@ -345,7 +344,7 @@ const BookingPage = (props: BookingPageProps) => {
name="name" name="name"
id="name" id="name"
required 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")} placeholder={t("example_name")}
/> />
</div> </div>
@ -360,7 +359,7 @@ const BookingPage = (props: BookingPageProps) => {
<EmailInput <EmailInput
{...bookingForm.register("email")} {...bookingForm.register("email")}
required 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" placeholder="you@example.com"
/> />
</div> </div>
@ -394,8 +393,14 @@ const BookingPage = (props: BookingPageProps) => {
{t("phone_number")} {t("phone_number")}
</label> </label>
<div className="mt-1"> <div className="mt-1">
{/* @ts-ignore */} <PhoneInput
<PhoneInput name="phone" placeholder={t("enter_phone_number")} id="phone" required /> // @ts-expect-error
control={bookingForm.control}
name="phone"
placeholder={t("enter_phone_number")}
id="phone"
required
/>
</div> </div>
</div> </div>
)} )}
@ -417,7 +422,7 @@ const BookingPage = (props: BookingPageProps) => {
})} })}
id={"custom_" + input.id} id={"custom_" + input.id}
rows={3} 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} placeholder={input.placeholder}
/> />
)} )}
@ -428,7 +433,7 @@ const BookingPage = (props: BookingPageProps) => {
required: input.required, required: input.required,
})} })}
id={"custom_" + input.id} 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} placeholder={input.placeholder}
/> />
)} )}
@ -439,7 +444,7 @@ const BookingPage = (props: BookingPageProps) => {
required: input.required, required: input.required,
})} })}
id={"custom_" + input.id} 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="" placeholder=""
/> />
)} )}
@ -521,7 +526,7 @@ const BookingPage = (props: BookingPageProps) => {
{...bookingForm.register("notes")} {...bookingForm.register("notes")}
id="notes" id="notes"
rows={3} 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")} placeholder={t("share_additional_notes")}
/> />
</div> </div>
@ -535,7 +540,9 @@ const BookingPage = (props: BookingPageProps) => {
</div> </div>
</Form> </Form>
{mutation.isError && ( {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">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" /> <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 { HttpError } from "@lib/core/http/error";
import { useLocale } from "@lib/hooks/useLocale"; import { useLocale } from "@lib/hooks/useLocale";
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
import showToast from "@lib/notification"; import showToast from "@lib/notification";
import { slugify } from "@lib/slugify"; import { slugify } from "@lib/slugify";
import { trpc } from "@lib/trpc"; import { trpc } from "@lib/trpc";
@ -49,7 +48,6 @@ interface Props {
export default function CreateEventTypeButton(props: Props) { export default function CreateEventTypeButton(props: Props) {
const { t } = useLocale(); const { t } = useLocale();
const router = useRouter(); const router = useRouter();
const modalOpen = useToggleQuery("new");
// URL encoded params // URL encoded params
const teamId: number | undefined = const teamId: number | undefined =
@ -95,44 +93,33 @@ export default function CreateEventTypeButton(props: Props) {
// inject selection data into url for correct router history // inject selection data into url for correct router history
const openModal = (option: EventTypeParent) => { const openModal = (option: EventTypeParent) => {
// setTimeout fixes a bug where the url query params are removed immediately after opening the modal const query = {
setTimeout(() => { ...router.query,
dialog: "new-eventtype",
eventPage: option.slug,
teamId: option.teamId,
};
if (!option.teamId) {
delete query.teamId;
}
router.push( router.push(
{ {
pathname: router.pathname, pathname: router.pathname,
query: { query,
...router.query,
new: "1",
eventPage: option.slug,
teamId: option.teamId || undefined,
},
}, },
undefined, undefined,
{ shallow: true } { shallow: true }
); );
});
};
// remove url params after close modal to reset state
const closeModal = () => {
router.replace({
pathname: router.pathname,
query: { id: router.query.id || undefined },
});
}; };
return ( return (
<Dialog <Dialog name="new-eventtype" clearQueryParamsOnClose={["eventPage", "teamId"]}>
open={modalOpen.isOn}
onOpenChange={(isOpen) => {
if (!isOpen) closeModal();
}}>
{!hasTeams || props.isIndividualTeam ? ( {!hasTeams || props.isIndividualTeam ? (
<Button <Button
onClick={() => openModal(props.options[0])} onClick={() => openModal(props.options[0])}
data-testid="new-event-type" data-testid="new-event-type"
StartIcon={PlusIcon} StartIcon={PlusIcon}
{...(props.canAddEvents ? { href: modalOpen.hrefOn } : { disabled: true })}> disabled={!props.canAddEvents}>
{t("new_event_type_btn")} {t("new_event_type_btn")}
</Button> </Button>
) : ( ) : (

View File

@ -4,7 +4,7 @@ import React from "react";
const TwoFactorModalHeader = ({ title, description }: { title: string; description: string }) => { const TwoFactorModalHeader = ({ title, description }: { title: string; description: string }) => {
return ( return (
<div className="mb-4 sm:flex sm:items-start"> <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" /> <ShieldCheckIcon className="h-6 w-6 text-black" />
</div> </div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> <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="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="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" /> <UserIcon className="text-brandcontrast h-6 w-6" />
</div> </div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> <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 { User } from "@prisma/client";
import classNames from "@lib/classNames"; import classNames from "@lib/classNames";
import { defaultAvatarSrc } from "@lib/profile";
export type AvatarProps = { export type AvatarProps = {
user: Pick<User, "name" | "username" | "avatar"> & { emailMd5?: string }; user: Pick<User, "name" | "username" | "avatar"> & { emailMd5?: string };
@ -11,6 +10,11 @@ export type AvatarProps = {
alt: string; 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. // An SSR Supported version of Avatar component.
// FIXME: title support is missing // FIXME: title support is missing
export function AvatarSSR(props: AvatarProps) { export function AvatarSSR(props: AvatarProps) {

View File

@ -33,7 +33,7 @@ export const WeekdaySelect = (props: WeekdaySelectProps) => {
toggleDay(idx); toggleDay(idx);
}} }}
className={` 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 h-10 w-10 rounded px-3 py-1 focus:outline-none
${activeDays[idx + 1] ? "rounded-r-none" : ""} ${activeDays[idx + 1] ? "rounded-r-none" : ""}
${activeDays[idx - 1] ? "rounded-l-none" : ""} ${activeDays[idx - 1] ? "rounded-l-none" : ""}

View File

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

View File

@ -1,18 +1,24 @@
import React from "react"; 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 "react-phone-number-input/style.css";
import classNames from "@lib/classNames"; import classNames from "@lib/classNames";
import { Optional } from "@lib/types/utils";
export const PhoneInput = ( type PhoneInputProps = {
props: Optional<PhoneInputProps<React.InputHTMLAttributes<HTMLInputElement>>, "onChange"> value: string;
) => ( id: string;
placeholder: string;
required: boolean;
};
export const PhoneInput = ({ control, name, ...rest }: Props<PhoneInputProps>) => (
<BasePhoneInput <BasePhoneInput
{...props} {...rest}
name={name}
control={control}
className={classNames( 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", "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
)} )}
onChange={() => { onChange={() => {
/* DO NOT REMOVE: Callback required by PhoneInput, comment added to satisfy eslint:no-empty-function */ /* 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,7 +13,9 @@ const TrialBanner = () => {
if (!user || user.plan !== "TRIAL") return null; if (!user || user.plan !== "TRIAL") return null;
const trialDaysLeft = dayjs(user.createdDate) const trialDaysLeft = user.trialEndsAt
? dayjs(user.trialEndsAt).add(1, "day").diff(dayjs(), "day")
: dayjs(user.createdDate)
.add(TRIAL_LIMIT_DAYS + 1, "day") .add(TRIAL_LIMIT_DAYS + 1, "day")
.diff(dayjs(), "day"); .diff(dayjs(), "day");

View File

@ -59,7 +59,7 @@ export default function TeamAvailabilityTimes(props: Props) {
{times.map((time) => ( {times.map((time) => (
<div key={time.format()} className="flex flex-row items-center"> <div key={time.format()} className="flex flex-row items-center">
<a <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"> data-testid="time">
{time.format("HH:mm")} {time.format("HH:mm")}
</a> </a>

View File

@ -1,11 +1,12 @@
import { Prisma } from "@prisma/client"; 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 { 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 // Get user
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { where: {
@ -33,7 +34,8 @@ const userType = Prisma.validator<Prisma.UserArgs>()({
}); });
type UserType = Prisma.UserGetPayload<typeof userType>; 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; let customerId: string | null = null;
if (user?.metadata && typeof user.metadata === "object" && "stripeCustomerId" in user.metadata) { 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 Stripe from "stripe";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import prisma from "@calcom/prisma";
import type { CalendarEvent } from "@calcom/types/CalendarEvent"; import type { CalendarEvent } from "@calcom/types/CalendarEvent";
import { sendAwaitingPaymentEmail, sendOrganizerPaymentRefundFailedEmail } from "@lib/emails/email-manager"; import { sendAwaitingPaymentEmail, sendOrganizerPaymentRefundFailedEmail } from "@lib/emails/email-manager";
import { getErrorFromUnknown } from "@lib/errors"; import { getErrorFromUnknown } from "@lib/errors";
import prisma from "@lib/prisma";
import { createPaymentLink } from "./client"; import { createPaymentLink } from "./client";

View File

@ -1,17 +1,17 @@
import { MembershipRole, Prisma, UserPlan } from "@prisma/client"; import { MembershipRole, Prisma, UserPlan } from "@prisma/client";
import Stripe from "stripe"; 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 { HOSTED_CAL_FEATURES } from "@lib/config/constants";
import { HttpError } from "@lib/core/http/error"; import { HttpError } from "@lib/core/http/error";
import prisma from "@lib/prisma";
import stripe from "./server"; import stripe from "./server";
// get team owner's Pro Plan subscription from Cal userId // get team owner's Pro Plan subscription from Cal userId
export async function getProPlanSubscription(userId: number) { export async function getProPlanSubscription(userId: number) {
const stripeCustomerId = await getStripeCustomerFromUser(userId); const stripeCustomerId = await getStripeCustomerIdFromUserId(userId);
if (!stripeCustomerId) return null; if (!stripeCustomerId) return null;
const customer = await stripe.customers.retrieve(stripeCustomerId, { 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); const { membersMissingSeats, ownerIsMissingSeat } = await getMembersMissingSeats(teamId);
if (!subscription) { if (!subscription) {
const customer = await getStripeCustomerFromUser(userId); let customerId = await getStripeCustomerIdFromUserId(userId);
if (!customer) throw new HttpError({ statusCode: 400, message: "User has no Stripe customer" }); 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 // create a checkout session with the quantity of missing seats
const session = await createCheckoutSession( const session = await createCheckoutSession(
customer, customerId,
membersMissingSeats.length, membersMissingSeats.length,
teamId, teamId,
ownerIsMissingSeat 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 // TODO: these should be moved to env vars
export function getPerSeatProPlanPrice(): string { export function getPerSeatProPlanPrice(): string {
return process.env.NODE_ENV === "production" return isProductionSite ? "price_1KHkoeH8UDiwIftkkUbiggsM" : "price_1KLD4GH8UDiwIftkWQfsh1Vh";
? "price_1KHkoeH8UDiwIftkkUbiggsM"
: "price_1KLD4GH8UDiwIftkWQfsh1Vh";
} }
export function getProPlanPrice(): string { export function getProPlanPrice(): string {
return process.env.NODE_ENV === "production" return isProductionSite ? "price_1KHkoeH8UDiwIftkkUbiggsM" : "price_1JZ0J3H8UDiwIftk0YIHYKr8";
? "price_1KHkoeH8UDiwIftkkUbiggsM"
: "price_1JZ0J3H8UDiwIftk0YIHYKr8";
} }
export function getPremiumPlanPrice(): string { export function getPremiumPlanPrice(): string {
return process.env.NODE_ENV === "production" return isProductionSite ? "price_1Jv3CMH8UDiwIftkFgyXbcHN" : "price_1Jv3CMH8UDiwIftkFgyXbcHN";
? "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 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 stripe from "@ee/lib/stripe/server";
import { getSession } from "@lib/auth"; import { getSession } from "@lib/auth";
@ -15,7 +15,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return; return;
} }
const customerId = await getStripeCustomerFromUser(session.user.id); const customerId = await getStripeCustomerIdFromUserId(session.user.id);
if (!customerId) { if (!customerId) {
res.status(500).json({ message: "Missing customer id" }); res.status(500).json({ message: "Missing customer id" });

View File

@ -1,8 +1,10 @@
import type { CalendarEvent } from "@calcom/types/CalendarEvent"; 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 AttendeeAwaitingPaymentEmail from "@lib/emails/templates/attendee-awaiting-payment-email";
import AttendeeCancelledEmail from "@lib/emails/templates/attendee-cancelled-email"; import AttendeeCancelledEmail from "@lib/emails/templates/attendee-cancelled-email";
import AttendeeDeclinedEmail from "@lib/emails/templates/attendee-declined-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 AttendeeRescheduledEmail from "@lib/emails/templates/attendee-rescheduled-email";
import AttendeeScheduledEmail from "@lib/emails/templates/attendee-scheduled-email"; import AttendeeScheduledEmail from "@lib/emails/templates/attendee-scheduled-email";
import ForgotPasswordEmail, { PasswordReset } from "@lib/emails/templates/forgot-password-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) => { export const sendDeclinedEmails = async (calEvent: CalendarEvent) => {
const emailsToSend: Promise<unknown>[] = []; 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_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
import { FAKE_HUDDLE_CREDENTIAL } from "@calcom/app-store/huddle01video/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 { CalendarEvent } from "@calcom/types/CalendarEvent";
import type { PartialReference } from "@calcom/types/EventManager"; import type { PartialReference } from "@calcom/types/EventManager";
import type { VideoCallData } from "@calcom/types/VideoApiAdapter"; import type { VideoCallData } from "@calcom/types/VideoApiAdapter";
@ -53,8 +54,14 @@ export const isTandem = (location: string): boolean => {
return location === "integrations:tandem"; return location === "integrations:tandem";
}; };
export const isJitsi = (location: string): boolean => {
return location === "integrations:jitsi";
};
export const isDedicatedIntegration = (location: string): boolean => { 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) => { export const getLocationRequestFromIntegration = (location: string) => {
@ -117,6 +124,7 @@ export default class EventManager {
this.videoCredentials.push(FAKE_DAILY_CREDENTIAL); this.videoCredentials.push(FAKE_DAILY_CREDENTIAL);
} }
this.videoCredentials.push(FAKE_HUDDLE_CREDENTIAL); this.videoCredentials.push(FAKE_HUDDLE_CREDENTIAL);
this.videoCredentials.push(FAKE_JITSI_CREDENTIAL);
} }
/** /**

View File

@ -30,10 +30,21 @@ type UseSlotsProps = {
date: Dayjs; date: Dayjs;
users: { username: string | null }[]; users: { username: string | null }[];
schedulingType: SchedulingType | null; schedulingType: SchedulingType | null;
beforeBufferTime?: number;
afterBufferTime?: number;
}; };
export const useSlots = (props: UseSlotsProps) => { 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 [slots, setSlots] = useState<Slot[]>([]);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
@ -124,6 +135,29 @@ export const useSlots = (props: UseSlotsProps) => {
// Check if startTime is between slot // Check if startTime is between slot
else if (startTime.isBetween(times[i], times[i].add(eventLength, "minutes"))) { else if (startTime.isBetween(times[i], times[i].add(eventLength, "minutes"))) {
times.splice(i, 1); 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 { } else {
return true; return true;
} }

View File

@ -1,20 +1 @@
import { PrismaClient } from "@prisma/client"; export { default } from "@calcom/prisma";
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;

View File

@ -14,6 +14,8 @@ export type AdvancedOptions = {
requiresConfirmation?: boolean; requiresConfirmation?: boolean;
disableGuests?: boolean; disableGuests?: boolean;
minimumBookingNotice?: number; minimumBookingNotice?: number;
beforeBufferTime?: number;
afterBufferTime?: number;
slotInterval?: number | null; slotInterval?: number | null;
price?: number; price?: number;
currency?: string; 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"; 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({ const allWebhooks = await prisma.webhook.findMany({
where: { where: {
userId: userId, OR: [
{
userId,
},
{
eventTypeId,
},
],
AND: { AND: {
eventTriggers: { eventTriggers: {
has: triggerEvent, has: options.triggerEvent,
}, },
active: { active: {
equals: true, 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/app-store",
"@calcom/lib", "@calcom/lib",
"@calcom/prisma", "@calcom/prisma",

View File

@ -18,7 +18,8 @@
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"lint:fix": "next lint . --ext .ts,.js,.tsx,.jsx --fix", "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": { "engines": {
"node": ">=14.x", "node": ">=14.x",
@ -140,7 +141,7 @@
"eslint": "^8.9.0", "eslint": "^8.9.0",
"tailwindcss": "^3.0.0", "tailwindcss": "^3.0.0",
"ts-jest": "^26.0.0", "ts-jest": "^26.0.0",
"ts-node": "^10.2.1", "ts-node": "^10.6.0",
"typescript": "^4.5.3" "typescript": "^4.5.3"
} }
} }

View File

@ -1,6 +1,5 @@
import { ArrowRightIcon } from "@heroicons/react/outline"; import { ArrowRightIcon } from "@heroicons/react/outline";
import { BadgeCheckIcon } from "@heroicons/react/solid"; import { BadgeCheckIcon } from "@heroicons/react/solid";
import crypto from "crypto";
import { GetServerSidePropsContext } from "next"; import { GetServerSidePropsContext } from "next";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import Link from "next/link"; import Link from "next/link";
@ -66,7 +65,9 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
style={{ display: "flex" }} 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"> 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" /> <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 <Link
prefetch={false}
href={{ href={{
pathname: `/${user.username}/${type.slug}`, pathname: `/${user.username}/${type.slug}`,
query, query,
@ -121,9 +122,10 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
export const getServerSideProps = async (context: GetServerSidePropsContext) => { export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const ssr = await ssrInit(context); const ssr = await ssrInit(context);
const crypto = require("crypto");
const username = (context.query.user as string).toLowerCase(); const username = (context.query.user as string).toLowerCase();
const dataFetchStart = Date.now();
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { where: {
username: username.toLowerCase(), username: username.toLowerCase(),
@ -205,7 +207,10 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
}, },
take: user.plan === "FREE" ? 1 : undefined, 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 eventTypesRaw = eventTypesWithHidden.filter((evt) => !evt.hidden);
const eventTypes = eventTypesRaw.map((eventType) => ({ const eventTypes = eventTypesRaw.map((eventType) => ({

View File

@ -44,6 +44,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
periodCountCalendarDays: true, periodCountCalendarDays: true,
schedulingType: true, schedulingType: true,
minimumBookingNotice: true, minimumBookingNotice: true,
beforeEventBuffer: true,
afterEventBuffer: true,
timeZone: true, timeZone: true,
metadata: true, metadata: true,
slotInterval: true, slotInterval: true,
@ -77,6 +79,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
availability: true, availability: true,
hideBranding: true, hideBranding: true,
brandColor: true, brandColor: true,
darkBrandColor: true,
theme: true, theme: true,
plan: true, plan: true,
eventTypes: { eventTypes: {
@ -190,11 +193,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
theme: user.theme, theme: user.theme,
weekStart: user.weekStart, weekStart: user.weekStart,
brandColor: user.brandColor, brandColor: user.brandColor,
darkBrandColor: user.darkBrandColor,
}, },
date: dateParam, date: dateParam,
eventType: eventTypeObject, eventType: eventTypeObject,
workingHours, workingHours,
trpcState: ssr.dehydrate(), 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 ssr = await ssrInit(context);
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { where: {
username: asStringOrThrow(context.query.user), username: asStringOrThrow(context.query.user).toLowerCase(),
}, },
select: { select: {
id: true, id: true,
@ -37,6 +37,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
avatar: true, avatar: true,
theme: true, theme: true,
brandColor: true, brandColor: true,
darkBrandColor: true,
}, },
}); });
@ -140,6 +141,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
image: user.avatar, image: user.avatar,
theme: user.theme, theme: user.theme,
brandColor: user.brandColor, brandColor: user.brandColor,
darkBrandColor: user.darkBrandColor,
}, },
eventType: eventTypeObject, eventType: eventTypeObject,
booking, 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 async from "async";
import dayjs from "dayjs"; import dayjs from "dayjs";
import dayjsBusinessTime from "dayjs-business-time"; 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 { getBusyCalendarTimes } from "@lib/apps/calendar/managers/CalendarManager";
import { BufferedBusyTime } from "@lib/apps/office365_calendar/types/Office365Calendar"; import { BufferedBusyTime } from "@lib/apps/office365_calendar/types/Office365Calendar";
import { import {
sendScheduledEmails, sendAttendeeRequestEmail,
sendRescheduledEmails,
sendOrganizerRequestEmail, sendOrganizerRequestEmail,
sendRescheduledEmails,
sendScheduledEmails,
} from "@lib/emails/email-manager"; } from "@lib/emails/email-manager";
import { ensureArray } from "@lib/ensureArray"; import { ensureArray } from "@lib/ensureArray";
import { getErrorFromUnknown } from "@lib/errors"; import { getErrorFromUnknown } from "@lib/errors";
import { getEventName } from "@lib/event"; import { getEventName } from "@lib/event";
import EventManager, { EventResult } from "@lib/events/EventManager"; import EventManager from "@lib/events/EventManager";
import logger from "@lib/logger"; import logger from "@lib/logger";
import notEmpty from "@lib/notEmpty"; import notEmpty from "@lib/notEmpty";
import prisma from "@lib/prisma"; 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 results: EventResult[] = [];
let referencesToCreate: PartialReference[] = []; let referencesToCreate: PartialReference[] = [];
let user: User | null = null; let user: User | null = null;
/** Let's start cheking for availability */ /** Let's start checking for availability */
for (const currentUser of users) { for (const currentUser of users) {
if (!currentUser) { if (!currentUser) {
console.error(`currentUser not found`); console.error(`currentUser not found`);
@ -445,16 +430,24 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}); });
const credentials = currentUser.credentials; 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) { if (credentials) {
const calendarBusyTimes = await getBusyCalendarTimes( await getBusyCalendarTimes(credentials, reqBody.start, reqBody.end, selectedCalendars).then(
credentials, (busyTimes) => calendarBusyTimes.push(...busyTimes)
reqBody.start,
reqBody.end,
selectedCalendars
); );
const videoBusyTimes = (await getBusyVideoTimes(credentials)).filter(notEmpty); const videoBusyTimes = (await getBusyVideoTimes(credentials)).filter(notEmpty);
calendarBusyTimes.push(...videoBusyTimes); calendarBusyTimes.push(...videoBusyTimes);
}
console.log("calendarBusyTimes==>>>", calendarBusyTimes); console.log("calendarBusyTimes==>>>", calendarBusyTimes);
const bufferedBusyTimes: BufferedBusyTimes = calendarBusyTimes.map((a) => ({ const bufferedBusyTimes: BufferedBusyTimes = calendarBusyTimes.map((a) => ({
@ -478,6 +471,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}; };
log.debug(`Booking ${currentUser.name} failed`, error); log.debug(`Booking ${currentUser.name} failed`, error);
res.status(409).json(error);
return;
} }
let timeOutOfBounds = false; let timeOutOfBounds = false;
@ -505,8 +500,24 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
log.debug(`Booking ${currentUser.name} failed`, error); log.debug(`Booking ${currentUser.name} failed`, error);
res.status(400).json(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."); 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) { if (eventType.requiresConfirmation && !rescheduleUid) {
await sendOrganizerRequestEmail(evt); await sendOrganizerRequestEmail(evt);
await sendAttendeeRequestEmail(evt, attendeesList[0]);
} }
if (typeof eventType.price === "number" && eventType.price > 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`); 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 // 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:", { console.log("evt:", {
...evt, ...evt,
metadata: reqBody.metadata, 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 async from "async";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { NextApiRequest, NextApiResponse } from "next"; import { NextApiRequest, NextApiResponse } from "next";
@ -130,9 +130,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}; };
// Hook up the webhook logic here // Hook up the webhook logic here
const eventTrigger = "BOOKING_CANCELLED"; const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED";
// Send Webhook call if hooked to 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) => const promises = subscribers.map((sub) =>
sendPayload(eventTrigger, new Date().toISOString(), sub.subscriberUrl, evt, sub.payloadTemplate).catch( sendPayload(eventTrigger, new Date().toISOString(), sub.subscriberUrl, evt, sub.payloadTemplate).catch(
(e) => { (e) => {

View File

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

View File

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

View File

@ -1,396 +1,27 @@
import { ChevronRightIcon, PencilAltIcon, SwitchHorizontalIcon, TrashIcon } from "@heroicons/react/outline";
import { ClipboardIcon } from "@heroicons/react/solid"; import { ClipboardIcon } from "@heroicons/react/solid";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
import { JSONObject } from "superjson/dist/types"; import { JSONObject } from "superjson/dist/types";
import { QueryCell } from "@lib/QueryCell"; import { QueryCell } from "@lib/QueryCell";
import { CalendarListContainer } from "@lib/apps/calendar/components/CalendarListContainer";
import classNames from "@lib/classNames"; import classNames from "@lib/classNames";
import { HttpError } from "@lib/core/http/error"; import { HttpError } from "@lib/core/http/error";
import { useLocale } from "@lib/hooks/useLocale"; import { useLocale } from "@lib/hooks/useLocale";
import showToast from "@lib/notification"; import showToast from "@lib/notification";
import { inferQueryOutput, trpc } from "@lib/trpc"; import { trpc } from "@lib/trpc";
import { WEBHOOK_TRIGGER_EVENTS } from "@lib/webhooks/constants";
import { ClientSuspense } from "@components/ClientSuspense"; import { ClientSuspense } from "@components/ClientSuspense";
import { Dialog, DialogContent, DialogFooter, DialogTrigger } from "@components/Dialog";
import { List, ListItem, ListItemText, ListItemTitle } from "@components/List"; import { List, ListItem, ListItemText, ListItemTitle } from "@components/List";
import Loader from "@components/Loader"; import Loader from "@components/Loader";
import Shell, { ShellSubHeading } from "@components/Shell"; 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 ConnectIntegration from "@components/integrations/ConnectIntegrations";
import DisconnectIntegration from "@components/integrations/DisconnectIntegration"; import DisconnectIntegration from "@components/integrations/DisconnectIntegration";
import IntegrationListItem from "@components/integrations/IntegrationListItem"; import IntegrationListItem from "@components/integrations/IntegrationListItem";
import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections"; import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections";
import { Alert } from "@components/ui/Alert"; import { Alert } from "@components/ui/Alert";
import Button from "@components/ui/Button"; import Button from "@components/ui/Button";
import Switch from "@components/ui/Switch"; import WebhookListContainer from "@components/webhook/WebhookListContainer";
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>
</>
)}
/>
);
}
function IframeEmbedContainer() { function IframeEmbedContainer() {
const { t } = useLocale(); const { t } = useLocale();
@ -659,7 +290,7 @@ export default function IntegrationsPage() {
<ClientSuspense fallback={<Loader />}> <ClientSuspense fallback={<Loader />}>
<IntegrationsContainer /> <IntegrationsContainer />
<CalendarListContainer /> <CalendarListContainer />
<WebhookListContainer /> <WebhookListContainer title={t("webhooks")} subtitle={t("receive_cal_meeting_data")} />
<IframeEmbedContainer /> <IframeEmbedContainer />
<Web3Container /> <Web3Container />
</ClientSuspense> </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> <small className="block text-neutral-400">{t("hover_over_bold_times_tip")}</small>
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
<div className="bg-brand overflow-hidden rounded-sm"> <div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-sm">
<div className="text-brandcontrast px-4 py-2 sm:px-6"> <div className="text-brandcontrast dark:text-darkmodebrandcontrast px-4 py-2 sm:px-6">
{t("your_day_starts_at")} {convertMinsToHrsMins(user.startTime)} {t("your_day_starts_at")} {convertMinsToHrsMins(user.startTime)}
</div> </div>
</div> </div>
@ -94,8 +94,8 @@ const AvailabilityView = ({ user }: { user: User }) => {
</div> </div>
)} )}
<div className="bg-brand overflow-hidden rounded-sm"> <div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-sm">
<div className="text-brandcontrast px-4 py-2 sm:px-6"> <div className="text-brandcontrast dark:text-darkmodebrandcontrast px-4 py-2 sm:px-6">
{t("your_day_ends_at")} {convertMinsToHrsMins(user.endTime)} {t("your_day_ends_at")} {convertMinsToHrsMins(user.endTime)}
</div> </div>
</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}`} title={`${t("cancel")} ${props.booking && props.booking.title} | ${props.profile?.name}`}
description={`${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"> <main className="mx-auto my-24 max-w-3xl">
<div className="fixed inset-0 z-50 overflow-y-auto"> <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"> <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, username: true,
name: true, name: true,
brandColor: true, brandColor: true,
darkBrandColor: true,
}, },
}, },
eventType: { eventType: {
@ -210,6 +211,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
name: booking.eventType?.team?.name || booking.user?.name || null, name: booking.eventType?.team?.name || booking.user?.name || null,
slug: booking.eventType?.team?.slug || booking.user?.username || null, slug: booking.eventType?.team?.slug || booking.user?.username || null,
brandColor: booking.user?.brandColor || null, brandColor: booking.user?.brandColor || null,
darkBrandColor: booking.user?.darkBrandColor || null,
}; };
return { return {

View File

@ -12,6 +12,7 @@ import {
UserAddIcon, UserAddIcon,
UsersIcon, UsersIcon,
} from "@heroicons/react/solid"; } from "@heroicons/react/solid";
import { MembershipRole } from "@prisma/client";
import { Availability, EventTypeCustomInput, PeriodType, Prisma, SchedulingType } from "@prisma/client"; import { Availability, EventTypeCustomInput, PeriodType, Prisma, SchedulingType } from "@prisma/client";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
import * as RadioGroup from "@radix-ui/react-radio-group"; import * as RadioGroup from "@radix-ui/react-radio-group";
@ -28,11 +29,11 @@ import { JSONObject } from "superjson/dist/types";
import { StripeData } from "@ee/lib/stripe/server"; import { StripeData } from "@ee/lib/stripe/server";
import getApps, { getLocationOptions, hasIntegration } from "@lib/apps/utils/AppUtils";
import { asStringOrThrow, asStringOrUndefined } from "@lib/asStringOrNull"; import { asStringOrThrow, asStringOrUndefined } from "@lib/asStringOrNull";
import { getSession } from "@lib/auth"; import { getSession } from "@lib/auth";
import { HttpError } from "@lib/core/http/error"; import { HttpError } from "@lib/core/http/error";
import { useLocale } from "@lib/hooks/useLocale"; import { useLocale } from "@lib/hooks/useLocale";
import getIntegrations, { hasIntegration } from "@lib/integrations/getIntegrations";
import { LocationType } from "@lib/location"; import { LocationType } from "@lib/location";
import showToast from "@lib/notification"; import showToast from "@lib/notification";
import prisma from "@lib/prisma"; import prisma from "@lib/prisma";
@ -40,8 +41,10 @@ import { slugify } from "@lib/slugify";
import { trpc } from "@lib/trpc"; import { trpc } from "@lib/trpc";
import { inferSSRProps } from "@lib/types/inferSSRProps"; import { inferSSRProps } from "@lib/types/inferSSRProps";
import { ClientSuspense } from "@components/ClientSuspense";
import DestinationCalendarSelector from "@components/DestinationCalendarSelector"; import DestinationCalendarSelector from "@components/DestinationCalendarSelector";
import { Dialog, DialogContent, DialogTrigger } from "@components/Dialog"; import { Dialog, DialogContent, DialogTrigger } from "@components/Dialog";
import Loader from "@components/Loader";
import Shell from "@components/Shell"; import Shell from "@components/Shell";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent"; import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import { Form } from "@components/form/fields"; import { Form } from "@components/form/fields";
@ -55,6 +58,7 @@ import CheckedSelect from "@components/ui/form/CheckedSelect";
import { DateRangePicker } from "@components/ui/form/DateRangePicker"; import { DateRangePicker } from "@components/ui/form/DateRangePicker";
import MinutesField from "@components/ui/form/MinutesField"; import MinutesField from "@components/ui/form/MinutesField";
import * as RadioArea from "@components/ui/form/radio-area"; import * as RadioArea from "@components/ui/form/radio-area";
import WebhookListContainer from "@components/webhook/WebhookListContainer";
import bloxyApi from "../../web3/dummyResps/bloxyApi"; import bloxyApi from "../../web3/dummyResps/bloxyApi";
@ -108,17 +112,19 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
prefix: t("indefinitely_into_future"), prefix: t("indefinitely_into_future"),
}, },
]; ];
const { const { eventType, locationOptions, availability, team, teamMembers, hasPaymentIntegration, currency } =
eventType, props;
locationOptions: untraslatedLocationOptions,
availability, /** Appending default locations */
team,
teamMembers, const defaultLocations = [
hasPaymentIntegration, { value: LocationType.InPerson, label: t("in_person_meeting") },
currency, { value: LocationType.Jitsi, label: "Jitsi Meet" },
} = props; { value: LocationType.Phone, label: t("phone_call") },
];
addDefaultLocationOptions(defaultLocations, locationOptions);
const locationOptions = untraslatedLocationOptions.map((l) => ({ ...l, label: t(l.label) }));
const router = useRouter(); const router = useRouter();
const updateMutation = trpc.useMutation("viewer.eventTypes.update", { const updateMutation = trpc.useMutation("viewer.eventTypes.update", {
@ -358,6 +364,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
periodCountCalendarDays: "1" | "0"; periodCountCalendarDays: "1" | "0";
periodDates: { startDate: Date; endDate: Date }; periodDates: { startDate: Date; endDate: Date };
minimumBookingNotice: number; minimumBookingNotice: number;
beforeBufferTime: number;
afterBufferTime: number;
slotInterval: number | null; slotInterval: number | null;
destinationCalendar: { destinationCalendar: {
integration: string; integration: string;
@ -402,7 +410,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
{formMethods.getValues("locations").map((location) => ( {formMethods.getValues("locations").map((location) => (
<li <li
key={location.type} key={location.type}
className="border-neutral-300 mb-2 rounded-sm border py-1.5 px-2 shadow-sm"> className="mb-2 rounded-sm border border-neutral-300 py-1.5 px-2 shadow-sm">
<div className="flex justify-between"> <div className="flex justify-between">
{location.type === LocationType.InPerson && ( {location.type === LocationType.InPerson && (
<div className="flex flex-grow items-center"> <div className="flex flex-grow items-center">
@ -480,7 +488,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
</div> </div>
)} )}
{location.type === LocationType.Daily && ( {location.type === LocationType.Daily && (
<div className="flex flex-grow"> <div className="flex flex-grow items-center">
<svg <svg
id="svg" id="svg"
version="1.1" version="1.1"
@ -621,10 +629,10 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<li> <li>
<button <button
type="button" type="button"
className="flex rounded-sm px-3 py-2 hover:bg-gray-100" className="flex rounded-sm py-2 hover:bg-gray-100"
onClick={() => setShowLocationModal(true)}> onClick={() => setShowLocationModal(true)}>
<PlusIcon className="text-neutral-900 mt-0.5 h-4 w-4" /> <PlusIcon className="mt-0.5 h-4 w-4 text-neutral-900" />
<span className="text-neutral-700 ml-1 text-sm font-medium">{t("add_location")}</span> <span className="ml-1 text-sm font-medium text-neutral-700">{t("add_location")}</span>
</button> </button>
</li> </li>
)} )}
@ -634,6 +642,9 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
); );
}; };
const membership = team?.members.find((membership) => membership.user.id === props.session.user.id);
const isAdmin = membership?.role === MembershipRole.OWNER || membership?.role === MembershipRole.ADMIN;
return ( return (
<div> <div>
<Shell <Shell
@ -657,7 +668,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
autoFocus autoFocus
style={{ top: -6, fontSize: 22 }} style={{ top: -6, fontSize: 22 }}
required required
className="focus:outline-none relative h-10 w-full cursor-pointer border-none bg-transparent pl-0 text-gray-900 hover:text-gray-700 focus:text-black focus:ring-0" className="relative h-10 w-full cursor-pointer border-none bg-transparent pl-0 text-gray-900 hover:text-gray-700 focus:text-black focus:outline-none focus:ring-0"
placeholder={t("quick_chat")} placeholder={t("quick_chat")}
{...formMethods.register("title")} {...formMethods.register("title")}
defaultValue={eventType.title} defaultValue={eventType.title}
@ -667,14 +678,21 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
</div> </div>
} }
subtitle={eventType.description || ""}> subtitle={eventType.description || ""}>
<ClientSuspense fallback={<Loader />}>
<div className="mx-auto block sm:flex md:max-w-5xl"> <div className="mx-auto block sm:flex md:max-w-5xl">
<div className="w-full ltr:mr-2 rtl:ml-2 sm:w-9/12"> <div className="w-full ltr:mr-2 rtl:ml-2 sm:w-9/12">
<div className="border-neutral-200 -mx-4 rounded-sm border bg-white p-4 py-6 sm:mx-0 sm:px-8"> <div className="-mx-4 rounded-sm border border-neutral-200 bg-white p-4 py-6 sm:mx-0 sm:px-8">
<Form <Form
form={formMethods} form={formMethods}
handleSubmit={async (values) => { handleSubmit={async (values) => {
const { periodDates, periodCountCalendarDays, smartContractAddress, ...input } = values; const {
periodDates,
periodCountCalendarDays,
smartContractAddress,
beforeBufferTime,
afterBufferTime,
...input
} = values;
updateMutation.mutate({ updateMutation.mutate({
...input, ...input,
availability: availabilityState, availability: availabilityState,
@ -682,6 +700,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
periodEndDate: periodDates.endDate, periodEndDate: periodDates.endDate,
periodCountCalendarDays: periodCountCalendarDays === "1", periodCountCalendarDays: periodCountCalendarDays === "1",
id: eventType.id, id: eventType.id,
beforeEventBuffer: beforeBufferTime,
afterEventBuffer: afterBufferTime,
metadata: smartContractAddress metadata: smartContractAddress
? { ? {
smartContractAddress, smartContractAddress,
@ -693,8 +713,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="space-y-3"> <div className="space-y-3">
<div className="block items-center sm:flex"> <div className="block items-center sm:flex">
<div className="min-w-48 mb-4 sm:mb-0"> <div className="min-w-48 mb-4 sm:mb-0">
<label htmlFor="slug" className="text-neutral-700 flex text-sm font-medium"> <label htmlFor="slug" className="flex text-sm font-medium text-neutral-700">
<LinkIcon className="text-neutral-500 mt-0.5 h-4 w-4 ltr:mr-2 rtl:ml-2" /> <LinkIcon className="mt-0.5 h-4 w-4 text-neutral-500 ltr:mr-2 rtl:ml-2" />
{t("url")} {t("url")}
</label> </label>
</div> </div>
@ -724,7 +744,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<MinutesField <MinutesField
label={ label={
<> <>
<ClockIcon className="text-neutral-500 mt-0.5 h-4 w-4 ltr:mr-2 rtl:ml-2" />{" "} <ClockIcon className="h-4 w-4 text-neutral-500 ltr:mr-2 rtl:ml-2" />{" "}
{t("duration")} {t("duration")}
</> </>
} }
@ -744,8 +764,10 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="space-y-3"> <div className="space-y-3">
<div className="block sm:flex"> <div className="block sm:flex">
<div className="min-w-48 sm:mb-0"> <div className="min-w-48 sm:mb-0">
<label htmlFor="location" className="text-neutral-700 mt-2.5 flex text-sm font-medium"> <label
<LocationMarkerIcon className="text-neutral-500 mt-0.5 h-4 w-4 ltr:mr-2 rtl:ml-2" /> htmlFor="location"
className="mt-2.5 flex text-sm font-medium text-neutral-700">
<LocationMarkerIcon className="mt-0.5 mb-4 h-4 w-4 text-neutral-500 ltr:mr-2 rtl:ml-2" />
{t("location")} {t("location")}
</label> </label>
</div> </div>
@ -761,8 +783,10 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="space-y-3"> <div className="space-y-3">
<div className="block sm:flex"> <div className="block sm:flex">
<div className="min-w-48 mb-4 mt-2.5 sm:mb-0"> <div className="min-w-48 mb-4 mt-2.5 sm:mb-0">
<label htmlFor="description" className="text-neutral-700 mt-0 flex text-sm font-medium"> <label
<DocumentIcon className="text-neutral-500 mt-0.5 h-4 w-4 ltr:mr-2 rtl:ml-2" /> htmlFor="description"
className="mt-0 flex text-sm font-medium text-neutral-700">
<DocumentIcon className="mt-0.5 h-4 w-4 text-neutral-500 ltr:mr-2 rtl:ml-2" />
{t("description")} {t("description")}
</label> </label>
</div> </div>
@ -783,8 +807,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="min-w-48 mb-4 sm:mb-0"> <div className="min-w-48 mb-4 sm:mb-0">
<label <label
htmlFor="schedulingType" htmlFor="schedulingType"
className="text-neutral-700 mt-2 flex text-sm font-medium"> className="mt-2 flex text-sm font-medium text-neutral-700">
<UsersIcon className="text-neutral-500 h-5 w-5 ltr:mr-2 rtl:ml-2" />{" "} <UsersIcon className="h-5 w-5 text-neutral-500 ltr:mr-2 rtl:ml-2" />{" "}
{t("scheduling_type")} {t("scheduling_type")}
</label> </label>
</div> </div>
@ -807,8 +831,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="block sm:flex"> <div className="block sm:flex">
<div className="min-w-48 mb-4 sm:mb-0"> <div className="min-w-48 mb-4 sm:mb-0">
<label htmlFor="users" className="text-neutral-700 flex text-sm font-medium"> <label htmlFor="users" className="flex text-sm font-medium text-neutral-700">
<UserAddIcon className="text-neutral-500 h-5 w-5 ltr:mr-2 rtl:ml-2" />{" "} <UserAddIcon className="h-5 w-5 text-neutral-500 ltr:mr-2 rtl:ml-2" />{" "}
{t("attendees")} {t("attendees")}
</label> </label>
</div> </div>
@ -844,9 +868,9 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<ChevronRightIcon <ChevronRightIcon
className={`${ className={`${
advancedSettingsVisible ? "rotate-90 transform" : "" advancedSettingsVisible ? "rotate-90 transform" : ""
} text-neutral-500 ml-auto h-5 w-5`} } ml-auto h-5 w-5 text-neutral-500`}
/> />
<span className="text-neutral-700 text-sm font-medium"> <span className="text-sm font-medium text-neutral-700">
{t("show_advanced_settings")} {t("show_advanced_settings")}
</span> </span>
</CollapsibleTrigger> </CollapsibleTrigger>
@ -861,7 +885,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="min-w-48 mb-4 sm:mb-0"> <div className="min-w-48 mb-4 sm:mb-0">
<label <label
htmlFor="createEventsOn" htmlFor="createEventsOn"
className="text-neutral-700 flex text-sm font-medium"> className="flex text-sm font-medium text-neutral-700">
{t("create_events_on")} {t("create_events_on")}
</label> </label>
</div> </div>
@ -885,7 +909,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
)} )}
<div className="block items-center sm:flex"> <div className="block items-center sm:flex">
<div className="min-w-48 mb-4 sm:mb-0"> <div className="min-w-48 mb-4 sm:mb-0">
<label htmlFor="eventName" className="text-neutral-700 flex text-sm font-medium"> <label htmlFor="eventName" className="flex text-sm font-medium text-neutral-700">
{t("event_name")} <InfoBadge content={t("event_name_tooltip")} /> {t("event_name")} <InfoBadge content={t("event_name_tooltip")} />
</label> </label>
</div> </div>
@ -906,7 +930,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="min-w-48 mb-4 sm:mb-0"> <div className="min-w-48 mb-4 sm:mb-0">
<label <label
htmlFor="smartContractAddress" htmlFor="smartContractAddress"
className="text-neutral-700 flex text-sm font-medium"> className="flex text-sm font-medium text-neutral-700">
{t("Smart Contract Address")} {t("Smart Contract Address")}
</label> </label>
</div> </div>
@ -929,7 +953,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="min-w-48 mb-4 sm:mb-0"> <div className="min-w-48 mb-4 sm:mb-0">
<label <label
htmlFor="additionalFields" htmlFor="additionalFields"
className="flexflex text-neutral-700 mt-2 text-sm font-medium"> className="flexflex mt-2 text-sm font-medium text-neutral-700">
{t("additional_inputs")} {t("additional_inputs")}
</label> </label>
</div> </div>
@ -1035,7 +1059,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
)} )}
/> />
<hr className="border-neutral-200 my-2" /> <hr className="my-2 border-neutral-200" />
<Controller <Controller
name="minimumBookingNotice" name="minimumBookingNotice"
control={formMethods.control} control={formMethods.control}
@ -1056,7 +1080,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="block items-center sm:flex"> <div className="block items-center sm:flex">
<div className="min-w-48 mb-4 sm:mb-0"> <div className="min-w-48 mb-4 sm:mb-0">
<label htmlFor="eventName" className="text-neutral-700 flex text-sm font-medium"> <label htmlFor="eventName" className="flex text-sm font-medium text-neutral-700">
{t("slot_interval")} {t("slot_interval")}
</label> </label>
</div> </div>
@ -1105,7 +1129,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="min-w-48 mb-4 sm:mb-0"> <div className="min-w-48 mb-4 sm:mb-0">
<label <label
htmlFor="inviteesCanSchedule" htmlFor="inviteesCanSchedule"
className="text-neutral-700 mt-2.5 flex text-sm font-medium"> className="mt-2.5 flex text-sm font-medium text-neutral-700">
{t("invitees_can_schedule")} {t("invitees_can_schedule")}
</label> </label>
</div> </div>
@ -1125,7 +1149,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<RadioGroup.Item <RadioGroup.Item
id={period.type} id={period.type}
value={period.type} value={period.type}
className="focus:outline-none flex h-4 w-4 cursor-pointer items-center rounded-full border border-black bg-white focus:border-2 ltr:mr-2 rtl:ml-2"> className="flex h-4 w-4 cursor-pointer items-center rounded-full border border-black bg-white focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
<RadioGroup.Indicator className="relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full after:bg-black" /> <RadioGroup.Indicator className="relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full after:bg-black" />
</RadioGroup.Item> </RadioGroup.Item>
{period.prefix ? <span>{period.prefix}&nbsp;</span> : null} {period.prefix ? <span>{period.prefix}&nbsp;</span> : null}
@ -1140,7 +1164,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
/> />
<select <select
id="" id=""
className="focus:border-primary-500 focus:ring-primary-500 focus:outline-none block w-full rounded-sm border-gray-300 py-2 pl-3 pr-10 text-base sm:text-sm" className="focus:border-primary-500 focus:ring-primary-500 block w-full rounded-sm border-gray-300 py-2 pl-3 pr-10 text-base focus:outline-none sm:text-sm"
{...formMethods.register("periodCountCalendarDays")} {...formMethods.register("periodCountCalendarDays")}
defaultValue={eventType.periodCountCalendarDays ? "1" : "0"}> defaultValue={eventType.periodCountCalendarDays ? "1" : "0"}>
<option value="1">{t("calendar_days")}</option> <option value="1">{t("calendar_days")}</option>
@ -1176,12 +1200,105 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
/> />
</div> </div>
</div> </div>
<hr className="border-neutral-200" /> <hr className="border-neutral-200" />
<div className="block sm:flex"> <div className="block sm:flex">
<div className="min-w-48 mb-4 sm:mb-0"> <div className="min-w-48 mb-4 sm:mb-0">
<label htmlFor="availability" className="text-neutral-700 flex text-sm font-medium"> <label
htmlFor="bufferTime"
className="mt-2.5 flex text-sm font-medium text-neutral-700">
{t("buffer_time")}
</label>
</div>
<div className="w-full">
<div className="inline-flex w-full space-x-2">
<div className="w-full">
<label
htmlFor="beforeBufferTime"
className="mb-2 flex text-sm font-medium text-neutral-700">
{t("before_event")}
</label>
<Controller
name="beforeBufferTime"
control={formMethods.control}
defaultValue={eventType.beforeEventBuffer || 0}
render={({ field: { onChange, value } }) => {
const beforeBufferOptions = [
{
label: t("event_buffer_default"),
value: 0,
},
...[5, 10, 15, 20, 30, 45, 60].map((minutes) => ({
label: minutes + " " + t("minutes"),
value: minutes,
})),
];
return (
<Select
isSearchable={false}
classNamePrefix="react-select"
className="react-select-container focus:border-primary-500 focus:ring-primary-500 block w-full min-w-0 flex-1 rounded-sm border border-gray-300 sm:text-sm"
onChange={(val) => {
if (val) onChange(val.value);
}}
defaultValue={
beforeBufferOptions.find((option) => option.value === value) ||
beforeBufferOptions[0]
}
options={beforeBufferOptions}
/>
);
}}
/>
</div>
<div className="w-full">
<label
htmlFor="afterBufferTime"
className="mb-2 flex text-sm font-medium text-neutral-700">
{t("after_event")}
</label>
<Controller
name="afterBufferTime"
control={formMethods.control}
defaultValue={eventType.afterEventBuffer || 0}
render={({ field: { onChange, value } }) => {
const afterBufferOptions = [
{
label: t("event_buffer_default"),
value: 0,
},
...[5, 10, 15, 20, 30, 45, 60].map((minutes) => ({
label: minutes + " " + t("minutes"),
value: minutes,
})),
];
return (
<Select
isSearchable={false}
classNamePrefix="react-select"
className="react-select-container focus:border-primary-500 focus:ring-primary-500 block w-full min-w-0 flex-1 rounded-sm border border-gray-300 sm:text-sm"
onChange={(val) => {
if (val) onChange(val.value);
}}
defaultValue={
afterBufferOptions.find((option) => option.value === value) ||
afterBufferOptions[0]
}
options={afterBufferOptions}
/>
);
}}
/>
</div>
</div>
</div>
</div>
<hr className="border-neutral-200" />
<div className="block sm:flex">
<div className="min-w-48 mb-4 sm:mb-0">
<label
htmlFor="availability"
className="flex text-sm font-medium text-neutral-700">
{t("availability")} {t("availability")}
</label> </label>
</div> </div>
@ -1224,7 +1341,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="min-w-48 mb-4 sm:mb-0"> <div className="min-w-48 mb-4 sm:mb-0">
<label <label
htmlFor="payment" htmlFor="payment"
className="text-neutral-700 mt-2 flex text-sm font-medium"> className="mt-2 flex text-sm font-medium text-neutral-700">
{t("payment")} {t("payment")}
</label> </label>
</div> </div>
@ -1349,8 +1466,11 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
href={permalink} href={permalink}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="text-md text-neutral-700 inline-flex items-center rounded-sm px-2 py-1 text-sm font-medium hover:bg-gray-200 hover:text-gray-900"> className="text-md inline-flex items-center rounded-sm px-2 py-1 text-sm font-medium text-neutral-700 hover:bg-gray-200 hover:text-gray-900">
<ExternalLinkIcon className="text-neutral-500 h-4 w-4 ltr:mr-2 rtl:ml-2" aria-hidden="true" /> <ExternalLinkIcon
className="h-4 w-4 text-neutral-500 ltr:mr-2 rtl:ml-2"
aria-hidden="true"
/>
{t("preview")} {t("preview")}
</a> </a>
<button <button
@ -1360,12 +1480,12 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
}} }}
type="button" type="button"
className="text-md flex items-center rounded-sm px-2 py-1 text-sm font-medium text-gray-700 hover:bg-gray-200 hover:text-gray-900"> className="text-md flex items-center rounded-sm px-2 py-1 text-sm font-medium text-gray-700 hover:bg-gray-200 hover:text-gray-900">
<LinkIcon className="text-neutral-500 h-4 w-4 ltr:mr-2 rtl:ml-2" /> <LinkIcon className="h-4 w-4 text-neutral-500 ltr:mr-2 rtl:ml-2" />
{t("copy_link")} {t("copy_link")}
</button> </button>
<Dialog> <Dialog>
<DialogTrigger className="text-md text-neutral-700 flex items-center rounded-sm px-2 py-1 text-sm font-medium hover:bg-gray-200 hover:text-gray-900"> <DialogTrigger className="text-md flex items-center rounded-sm px-2 py-1 text-sm font-medium text-neutral-700 hover:bg-gray-200 hover:text-gray-900">
<TrashIcon className="text-neutral-500 h-4 w-4 ltr:mr-2 rtl:ml-2" /> <TrashIcon className="h-4 w-4 text-neutral-500 ltr:mr-2 rtl:ml-2" />
{t("delete")} {t("delete")}
</DialogTrigger> </DialogTrigger>
<ConfirmationDialogContent <ConfirmationDialogContent
@ -1514,6 +1634,14 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
</Dialog> </Dialog>
)} )}
/> />
{isAdmin && (
<WebhookListContainer
title={t("team_webhooks")}
subtitle={t("receive_cal_event_meeting_data")}
eventTypeId={props.eventType.id}
/>
)}
</ClientSuspense>
</Shell> </Shell>
</div> </div>
); );
@ -1593,6 +1721,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
requiresConfirmation: true, requiresConfirmation: true,
disableGuests: true, disableGuests: true,
minimumBookingNotice: true, minimumBookingNotice: true,
beforeEventBuffer: true,
afterEventBuffer: true,
slotInterval: true, slotInterval: true,
team: { team: {
select: { select: {
@ -1602,6 +1732,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
accepted: true, accepted: true,
}, },
select: { select: {
role: true,
user: { user: {
select: userSelect, select: userSelect,
}, },
@ -1662,9 +1793,17 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
eventType.users.push(fallbackUser); eventType.users.push(fallbackUser);
} }
const integrations = getApps(credentials); const integrations = getIntegrations(credentials);
const locationOptions = getLocationOptions(integrations);
const locationOptions: OptionTypeBase[] = [];
if (hasIntegration(integrations, "zoom_video")) {
locationOptions.push({
value: LocationType.Zoom,
label: "Zoom Video",
disabled: true,
});
}
const hasPaymentIntegration = hasIntegration(integrations, "stripe_payment"); const hasPaymentIntegration = hasIntegration(integrations, "stripe_payment");
if (hasIntegration(integrations, "google_calendar")) { if (hasIntegration(integrations, "google_calendar")) {
locationOptions.push({ locationOptions.push({
@ -1672,6 +1811,24 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
label: "Google Meet", label: "Google Meet",
}); });
} }
if (hasIntegration(integrations, "daily_video")) {
locationOptions.push({
value: LocationType.Daily,
label: "Daily.co Video",
});
}
if (hasIntegration(integrations, "jitsi_video")) {
locationOptions.push({
value: LocationType.Jitsi,
label: "Jitsi Meet",
});
}
if (hasIntegration(integrations, "huddle01_video")) {
locationOptions.push({
value: LocationType.Huddle01,
label: "Huddle01 Video",
});
}
if (hasIntegration(integrations, "tandem_video")) { if (hasIntegration(integrations, "tandem_video")) {
locationOptions.push({ value: LocationType.Tandem, label: "Tandem Video" }); locationOptions.push({ value: LocationType.Tandem, label: "Tandem Video" });
} }

View File

@ -176,6 +176,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
const [hasErrors, setHasErrors] = useState(false); const [hasErrors, setHasErrors] = useState(false);
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const [brandColor, setBrandColor] = useState(props.user.brandColor); const [brandColor, setBrandColor] = useState(props.user.brandColor);
const [darkBrandColor, setDarkBrandColor] = useState(props.user.darkBrandColor);
useEffect(() => { useEffect(() => {
if (!props.user.theme) return; if (!props.user.theme) return;
@ -194,6 +195,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
const enteredDescription = descriptionRef.current.value; const enteredDescription = descriptionRef.current.value;
const enteredAvatar = avatarRef.current.value; const enteredAvatar = avatarRef.current.value;
const enteredBrandColor = brandColor; const enteredBrandColor = brandColor;
const enteredDarkBrandColor = darkBrandColor;
const enteredTimeZone = typeof selectedTimeZone === "string" ? selectedTimeZone : selectedTimeZone.value; const enteredTimeZone = typeof selectedTimeZone === "string" ? selectedTimeZone : selectedTimeZone.value;
const enteredWeekStartDay = selectedWeekStartDay.value; const enteredWeekStartDay = selectedWeekStartDay.value;
const enteredHideBranding = hideBrandingRef.current.checked; const enteredHideBranding = hideBrandingRef.current.checked;
@ -213,6 +215,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
hideBranding: enteredHideBranding, hideBranding: enteredHideBranding,
theme: asStringOrNull(selectedTheme?.value), theme: asStringOrNull(selectedTheme?.value),
brandColor: enteredBrandColor, brandColor: enteredBrandColor,
darkBrandColor: enteredDarkBrandColor,
locale: enteredLanguage, locale: enteredLanguage,
timeFormat: enteredTimeFormat, timeFormat: enteredTimeFormat,
}); });
@ -424,11 +427,19 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
</div> </div>
</div> </div>
</div> </div>
<div> <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"> <label htmlFor="brandColor" className="block text-sm font-medium text-gray-700">
{t("brand_color")} {t("light_brand_color")}
</label> </label>
<ColorPicker defaultValue={props.user.brandColor} onChange={setBrandColor} /> <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" /> <hr className="mt-6" />
</div> </div>
<div> <div>
@ -524,6 +535,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
theme: true, theme: true,
plan: true, plan: true,
brandColor: true, brandColor: true,
darkBrandColor: true,
metadata: true, metadata: true,
timeFormat: 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")} title={needsConfirmation ? t("booking_submitted") : t("booking_confirmed")}
description={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"> <main className="mx-auto max-w-3xl py-24">
<div className="fixed inset-0 z-50 overflow-y-auto"> <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"> <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, plan: true,
theme: true, theme: true,
brandColor: true, brandColor: true,
darkBrandColor: true,
}, },
}, },
team: { team: {
@ -348,6 +349,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
plan: true, plan: true,
theme: true, theme: true,
brandColor: true, brandColor: true,
darkBrandColor: true,
}, },
}); });
if (user) { if (user) {
@ -365,6 +367,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
name: eventType.team?.name || eventType.users[0]?.name || null, name: eventType.team?.name || eventType.users[0]?.name || null,
theme: (!eventType.team?.name && eventType.users[0]?.theme) || null, theme: (!eventType.team?.name && eventType.users[0]?.theme) || null,
brandColor: eventType.team ? null : eventType.users[0].brandColor, brandColor: eventType.team ? null : eventType.users[0].brandColor,
darkBrandColor: eventType.team ? null : eventType.users[0].darkBrandColor,
}; };
return { return {

View File

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

View File

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

View File

@ -1,16 +1,80 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import prisma from "@lib/prisma";
import { todo } from "./lib/testUtils"; import { todo } from "./lib/testUtils";
const deleteBookingsByEmail = async (email: string) =>
prisma.booking.deleteMany({
where: {
user: {
email,
},
},
});
test.describe("free user", () => { test.describe("free user", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto("/free"); await page.goto("/free");
}); });
test.afterEach(async () => {
// delete test bookings
await deleteBookingsByEmail("free@example.com");
});
test("only one visible event", async ({ page }) => { test("only one visible event", async ({ page }) => {
await expect(page.locator(`[href="/free/30min"]`)).toBeVisible(); await expect(page.locator(`[href="/free/30min"]`)).toBeVisible();
await expect(page.locator(`[href="/free/60min"]`)).not.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/30min` is bookable");
todo("`/free/60min` is not bookable"); todo("`/free/60min` is not bookable");
@ -21,6 +85,11 @@ test.describe("pro user", () => {
await page.goto("/pro"); 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 }) => { test("pro user's page has at least 2 visible events", async ({ page }) => {
const $eventTypes = await page.$$("[data-testid=event-types] > *"); const $eventTypes = await page.$$("[data-testid=event-types] > *");
expect($eventTypes.length).toBeGreaterThanOrEqual(2); expect($eventTypes.length).toBeGreaterThanOrEqual(2);
@ -31,6 +100,11 @@ test.describe("pro user", () => {
await page.click('[data-testid="event-type-link"]'); await page.click('[data-testid="event-type-link"]');
// Click [data-testid="incrementMonth"] // Click [data-testid="incrementMonth"]
await page.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"] // Click [data-testid="day"]
await page.click('[data-testid="day"][data-disabled="false"]'); await page.click('[data-testid="day"][data-disabled="false"]');
// Click [data-testid="time"] // Click [data-testid="time"]

View File

@ -39,6 +39,11 @@ test.describe.serial("Stripe integration", () => {
await page.goto("/pro/paid"); await page.goto("/pro/paid");
// Click [data-testid="incrementMonth"] // Click [data-testid="incrementMonth"]
await page.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"] // Click [data-testid="day"]
await page.click('[data-testid="day"][data-disabled="false"]'); await page.click('[data-testid="day"][data-disabled="false"]');
// Click [data-testid="time"] // 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 // --- Book the first available day next month in the pro user's "30min"-event
await page.goto(`/pro/30min`); await page.goto(`/pro/30min`);
await page.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);
await page.click('[data-testid="day"][data-disabled="false"]'); await page.click('[data-testid="day"][data-disabled="false"]');
await page.click('[data-testid="time"]'); await page.click('[data-testid="time"]');

View File

@ -4,13 +4,13 @@
"day_plural": "{{count}} أيام", "day_plural": "{{count}} أيام",
"upgrade_now": "تحديث الآن", "upgrade_now": "تحديث الآن",
"accept_invitation": "اقبل الدعوة", "accept_invitation": "اقبل الدعوة",
"calcom_explained": "Cal.com هو بديل Calendly مفتوح المصدر الذي يتيح لك التحكم في بياناتك وسير عملك ومظهرك.", "calcom_explained": "Cal.com هو بديل Calendly مفتوح المصدر الذي يتيح لك التحكم في بياناتك وسير عملك والمظهر على التطبيق أو موقع الويب.",
"have_any_questions": "هل لديك أسئلة؟ نحن هنا للمساعدة.", "have_any_questions": "هل لديك أسئلة؟ نحن هنا للمساعدة.",
"reset_password_subject": "Cal.com: إرشادات إعادة تعيين كلمة المرور", "reset_password_subject": "Cal.com: إرشادات إعادة تعيين كلمة المرور",
"event_declined_subject": "تم الرفض: {{eventType}} مع {{name}} في {{date}}", "event_declined_subject": "تم الرفض: {{eventType}} مع {{name}} في {{date}}",
"event_cancelled_subject": "تم الإلغاء: {{eventType}} مع {{name}} في {{date}}", "event_cancelled_subject": "تم الإلغاء: {{eventType}} مع {{name}} في {{date}}",
"event_request_declined": "تم رفض طلب الحدث الخاص بك", "event_request_declined": "تم رفض طلب الحدث الخاص بك",
"event_request_cancelled": "تم إلغاء الحدث المجدول", "event_request_cancelled": "تم إلغاء حدثك المُجَدْوَل",
"organizer": "المنظِّم", "organizer": "المنظِّم",
"need_to_reschedule_or_cancel": "هل تحتاج إلى إعادة جدولة أو إلغاء؟", "need_to_reschedule_or_cancel": "هل تحتاج إلى إعادة جدولة أو إلغاء؟",
"cancellation_reason": "سبب الإلغاء", "cancellation_reason": "سبب الإلغاء",
@ -21,12 +21,12 @@
"rejection_confirmation": "رفض الحجز", "rejection_confirmation": "رفض الحجز",
"manage_this_event": "قم بإدارة هذا الحدث", "manage_this_event": "قم بإدارة هذا الحدث",
"your_event_has_been_scheduled": "تم جدولة الحدث الخاص بك", "your_event_has_been_scheduled": "تم جدولة الحدث الخاص بك",
"accept_our_license": "اقبل ترخيصنا من خلال تغيير المتغير .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}}'.", "remove_banner_instructions": "لإزالة هذا الشعار، يُرجى فتح ملف .env وتغيير المتغير <1>NEXT_PUBLIC_LICENSE_CONSENT</1> إلى '{{agree}}'.",
"error_message": "كانت رسالة الخطأ: '{{errorMessage}}'", "error_message": "كانت رسالة الخطأ: '{{errorMessage}}'",
"refund_failed_subject": "فشل الاسترداد: {{name}} - {{date}} - {{eventType}}", "refund_failed_subject": "فشل الاسترداد: {{name}} - {{date}} - {{eventType}}",
"refund_failed": "فشل استرداد مقابل الحدث {{eventType}} مع {{userName}} في {{date}}.", "refund_failed": "فشل استرداد مقابل الحدث {{eventType}} مع {{userName}} في {{date}}.",
"check_with_provider_and_user": "يُرجى التحقق مع مزود الدفع و{{user}} بشأن كيفية التعامل مع هذا.", "check_with_provider_and_user": "يُرجى التحقق من مزود الدفع لديك و{{user}} بشأن كيفية التعامل مع الأمر.",
"a_refund_failed": "فشل الاسترداد", "a_refund_failed": "فشل الاسترداد",
"awaiting_payment_subject": "في انتظار الدفع: {{eventType}} مع {{name}} في {{date}}", "awaiting_payment_subject": "في انتظار الدفع: {{eventType}} مع {{name}} في {{date}}",
"meeting_awaiting_payment": "اجتماعك في انتظار الدفع", "meeting_awaiting_payment": "اجتماعك في انتظار الدفع",
@ -36,24 +36,24 @@
"refunded": "تم الاسترداد", "refunded": "تم الاسترداد",
"pay_later_instructions": "لقد تلقيت أيضا رسالة بريد إلكتروني مع هذا الرابط، إذا كنت ترغب في الدفع لاحقاً.", "pay_later_instructions": "لقد تلقيت أيضا رسالة بريد إلكتروني مع هذا الرابط، إذا كنت ترغب في الدفع لاحقاً.",
"payment": "الدفعات", "payment": "الدفعات",
"missing_card_fields": "حقول البطاقة المفقودة", "missing_card_fields": "بعض خانات البطاقة مفقودة",
"pay_now": "ادفع الآن", "pay_now": "ادفع الآن",
"codebase_has_to_stay_opensource": "يجب أن يظل مصدر البرنامج مفتوح المصدر، سواء تم تعديله أم لا", "codebase_has_to_stay_opensource": "يجب أن يظل مصدر البرنامج مفتوحًا، سواء تم تعديله أم لا",
"cannot_repackage_codebase": "لا يمكنك إعادة حزم مصدر البيانات أو بيعه", "cannot_repackage_codebase": "لا يمكنك بيع مصدر البرنامج أو تغييره للتصرف فيه (repackage)",
"acquire_license": "احصل على ترخيص تجاري لإزالة هذه الشروط عن طريق البريد الإلكتروني", "acquire_license": "احصل عن طريق البريد الإلكتروني على ترخيص تجاري لإزالة هذه الشروط",
"terms_summary": "ملخص الشروط", "terms_summary": "ملخص الشروط",
"open_env": "افتح .env ووافق على الترخيص", "open_env": "افتح .env ووافق على الترخيص",
"env_changed": "لقد قمت بتغيير .env", "env_changed": "لقد قمت بتغيير .env",
"accept_license": "اقبل الترخيص", "accept_license": "اقبل الترخيص",
"still_waiting_for_approval": "لا يزال يوجد حدث في انتظار الموافقة", "still_waiting_for_approval": "يوجد حدث لا يزال في انتظار الموافقة",
"event_is_still_waiting": "لا يزال طلب الحدث في انتظار الموافقة: {{attendeeName}} - {{date}} - {{eventType}}", "event_is_still_waiting": "لا يزال طلب الحدث في الانتظار: {{attendeeName}} - {{date}} - {{eventType}}",
"no_more_results": "لا يوجد مزيد من النتائج", "no_more_results": "لا يوجد مزيد من النتائج",
"load_more_results": "تحميل المزيد من النتائج", "load_more_results": "تحميل المزيد من النتائج",
"integration_meeting_id": "مُعرّف اجتماع {{integrationName}}: {{meetingId}}", "integration_meeting_id": "مُعرّف اجتماع {{integrationName}}: {{meetingId}}",
"confirmed_event_type_subject": "تم التأكيد: {{eventType}} مع {{name}} في {{date}}", "confirmed_event_type_subject": "تم التأكيد: {{eventType}} مع {{name}} في {{date}}",
"new_event_request": "طلب الحدث الجديد: {{attendeeName}} - {{date}} - {{eventType}}", "new_event_request": "طلب الحدث الجديد: {{attendeeName}} - {{date}} - {{eventType}}",
"confirm_or_reject_request": "تأكيد الطلب أو رفضه", "confirm_or_reject_request": "يمكنك تأكيد الطلب أو رفضه",
"check_bookings_page_to_confirm_or_reject": "تحقق من صفحة الحجوزات لتأكيد الحجز أو رفضه.", "check_bookings_page_to_confirm_or_reject": "تحقق من صفحة عمليات الحجز لديك لتأكيد الحجز أو رفضه.",
"event_awaiting_approval": "الحدث في انتظار موافقتك", "event_awaiting_approval": "الحدث في انتظار موافقتك",
"someone_requested_an_event": "طلب شخص ما جدولة حدث في تقويمك.", "someone_requested_an_event": "طلب شخص ما جدولة حدث في تقويمك.",
"someone_requested_password_reset": "طلب شخص ما رابطا لتغيير كلمة المرور الخاصة بك.", "someone_requested_password_reset": "طلب شخص ما رابطا لتغيير كلمة المرور الخاصة بك.",
@ -61,18 +61,18 @@
"event_awaiting_approval_subject": "في انتظار الموافقة: {{eventType}} مع {{name}} في {{date}}", "event_awaiting_approval_subject": "في انتظار الموافقة: {{eventType}} مع {{name}} في {{date}}",
"event_still_awaiting_approval": "الحدث لا يزال في انتظار موافقتك", "event_still_awaiting_approval": "الحدث لا يزال في انتظار موافقتك",
"your_meeting_has_been_booked": "لقد تم حجز الاجتماع الخاص بك", "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": "تم التحديث - تم إعادة جدولة الحدث الخاص بك", "event_has_been_rescheduled": "تم التحديث - تم إعادة جدولة الحدث الخاص بك",
"hi_user_name": "مرحبا {{name}}", "hi_user_name": "مرحبا {{name}}",
"ics_event_title": "{{eventType}} مع {{name}}", "ics_event_title": "{{eventType}} مع {{name}}",
"new_event_subject": "حدث جديد: {{attendeeName}} - {{date}} - {{eventType}}", "new_event_subject": "حدث جديد: {{attendeeName}} - {{date}} - {{eventType}}",
"join_by_entrypoint": "انضمام بواسطة {{entryPoint}}", "join_by_entrypoint": "الانضمام بواسطة {{entryPoint}}",
"notes": "ملاحظات", "notes": "ملاحظات",
"manage_my_bookings": "إدارة الحجوزات الخاصة بي", "manage_my_bookings": "إدارة الحجوزات الخاصة بي",
"need_to_make_a_change": "هل تحتاج لإجراء تغيير؟", "need_to_make_a_change": "هل تحتاج لإجراء تغيير؟",
"new_event_scheduled": "تم جدولة حدث جديد.", "new_event_scheduled": "تم جدولة حدث جديد.",
"invitee_email": "البريد الإلكتروني للشخص المدعو", "invitee_email": "البريد الإلكتروني للمدعو",
"invitee_timezone": "المنطقة الزمنية للشخص المدعو", "invitee_timezone": "المنطقة الزمنية للمدعو",
"event_type": "نوع الحدث", "event_type": "نوع الحدث",
"enter_meeting": "ادخل الاجتماع", "enter_meeting": "ادخل الاجتماع",
"video_call_provider": "مزود مكالمة الفيديو", "video_call_provider": "مزود مكالمة الفيديو",
@ -90,17 +90,17 @@
"you_have_been_invited": "تم دعوتك للانضمام إلى الفريق {{teamName}}", "you_have_been_invited": "تم دعوتك للانضمام إلى الفريق {{teamName}}",
"user_invited_you": "قام {{user}} بدعوتك للانضمام إلى فريق {{team}} على Cal.com", "user_invited_you": "قام {{user}} بدعوتك للانضمام إلى فريق {{team}} على Cal.com",
"hidden_team_member_title": "أنت في الوضع الخفي في هذا الفريق", "hidden_team_member_title": "أنت في الوضع الخفي في هذا الفريق",
"hidden_team_member_message": "لم يتم الدفع مقابل مقعدك، قم إما بالترقية إلى Pro أو بالسماح لمالك الفريق بمعرفة أنه بإمكانه الدفع مقابل مقعدك.", "hidden_team_member_message": "لم يتم الدفع مقابل مقعدك. يمكنك الترقية إلى Pro أو إعلام مالك الفريق أنه يستطيع الدفع مقابل مقعدك.",
"hidden_team_owner_message": "تحتاج إلى حساب pro لاستخدام الفرق، أنت في الوضع الخفي حتى تقوم بالترقية.", "hidden_team_owner_message": "تحتاج إلى حساب Pro لاستخدام خدمة الفرق، وأنت حاليًا في الوضع الخفي حتى تقوم بالترقية.",
"link_expires": "ملاحظة: تنتهي الصلاحية في غضون {{expiresIn}} من الساعات.", "link_expires": "ملاحظة: تنتهي الصلاحية في غضون {{expiresIn}} من الساعات.",
"upgrade_to_per_seat": "الترقية إلى كل مقعد", "upgrade_to_per_seat": "الترقية إلى \"كل مقعد بشكل منفرد\"",
"team_upgrade_seats_details": "من بين {{memberCount}} من الأعضاء في فريقك، لم يتم الدفع مقابل {{unpaidCount}} مقعد (مقاعد). عندما يكون السعر ${{seatPrice}} شهريًا لكل مقعد، تبلغ التكلفة الإجمالية المقدرة لعضويتك ${{totalCost}} شهريًا.", "team_upgrade_seats_details": "من بين {{memberCount}} من الأعضاء في فريقك، لم يتم الدفع مقابل {{unpaidCount}} من المقاعد. بما أن سعر المقعد ${{seatPrice}} شهريًا، تبلغ التكلفة الإجمالية المقدرة لعضويتك ${{totalCost}} شهريًا.",
"team_upgraded_successfully": "تمت ترقية فريقك بنجاح!", "team_upgraded_successfully": "تمت ترقية فريقك بنجاح!",
"use_link_to_reset_password": "استخدم الرابط أدناه لإعادة تعيين كلمة المرور", "use_link_to_reset_password": "استخدم الرابط أدناه لإعادة تعيين كلمة المرور",
"hey_there": "مرحبًا،", "hey_there": "مرحبًا،",
"forgot_your_password_calcom": "نسيت كلمة المرور الخاصة بك؟ - Cal.com", "forgot_your_password_calcom": "نسيت كلمة المرور الخاصة بك؟ - Cal.com",
"event_type_title": "{{eventTypeTitle}} | نوع الحدث", "event_type_title": "{{eventTypeTitle}} | نوع الحدث",
"delete_webhook_confirmation_message": "هل تريد بالتأكيد حذف الويبهوك هذا؟ لن تتلقى بيانات اجتماع Cal.com بعد الآن على عنوان URL محدد، في الوقت الفعلي، عندما تتم جدولة حدث أو إلغاؤه.", "delete_webhook_confirmation_message": "هل أنت متأكد أنك تريد حذف الويب هوك هذا؟ لن تتلقى بعد الآن بيانات اجتماعات Cal.com على عنوان URL محدد، وفي الوقت الفعلي، عندما تتم جدولة حدث أو يتم إلغاؤه.",
"confirm_delete_webhook": "نعم، قم بحذف الويبهوك", "confirm_delete_webhook": "نعم، قم بحذف الويبهوك",
"edit_webhook": "تعديل الويبهوك", "edit_webhook": "تعديل الويبهوك",
"delete_webhook": "حذف الويب هوك", "delete_webhook": "حذف الويب هوك",
@ -113,10 +113,10 @@
"webhook_created_successfully": "تم إنشاء الويبهوك بنجاح!", "webhook_created_successfully": "تم إنشاء الويبهوك بنجاح!",
"webhook_updated_successfully": "تم تحديث الويبهوك بنجاح!", "webhook_updated_successfully": "تم تحديث الويبهوك بنجاح!",
"webhook_removed_successfully": "تمت إزالة الويبهوك بنجاح!", "webhook_removed_successfully": "تمت إزالة الويبهوك بنجاح!",
"payload_template": "قالب الحمولة", "payload_template": "قالب البيانات",
"dismiss": "تجاهل", "dismiss": "تجاهل",
"no_data_yet": "لا توجد بيانات حتى الآن", "no_data_yet": "لا بيانات حتى الآن",
"ping_test": "اختبار الاتصال", "ping_test": "اختبار الاتصال (ping)",
"add_to_homescreen": "أضف هذا التطبيق إلى الشاشة الرئيسية للوصول بصورة أسرع وتحسين التجربة.", "add_to_homescreen": "أضف هذا التطبيق إلى الشاشة الرئيسية للوصول بصورة أسرع وتحسين التجربة.",
"upcoming": "القادم", "upcoming": "القادم",
"past": "السابق", "past": "السابق",
@ -130,13 +130,13 @@
"sign_out": "تسجيل الخروج", "sign_out": "تسجيل الخروج",
"add_another": "أضف واحدًا آخر", "add_another": "أضف واحدًا آخر",
"until": "حتى", "until": "حتى",
"powered_by": "التشغيل بواسطة", "powered_by": "مدعوم بواسطة",
"unavailable": "غير متاح", "unavailable": "غير متاح",
"set_work_schedule": "ضع جدول عملك", "set_work_schedule": "حدد جدول أعمالك",
"change_bookings_availability": "قم بتغيير الوقت الذي تكون فيه متاحًا للحجوزات", "change_bookings_availability": "تغيير الوقت الذي تكون فيه متاحًا لعمليات الحجز",
"select": "حدد...", "select": "حدد...",
"2fa_confirm_current_password": "قم بتأكيد كلمة المرور الحالية للبدء.", "2fa_confirm_current_password": "قم بتأكيد كلمة المرور الحالية للبدء.",
"2fa_scan_image_or_use_code": "امسح الصورة أدناه ضوئيًا باستخدام تطبيق المصادقة على هاتفك أو أدخل رمز النص يدويًا بدلاً من ذلك.", "2fa_scan_image_or_use_code": "امسح الصورة أدناه ضوئيًا باستخدام تطبيق المصادقة على هاتفك، أو أدخل الرمز النصي يدويًا.",
"text": "النص", "text": "النص",
"multiline_text": "النص متعدد السطور", "multiline_text": "النص متعدد السطور",
"number": "الرقم", "number": "الرقم",
@ -144,25 +144,25 @@
"is_required": "مطلوب", "is_required": "مطلوب",
"required": "مطلوب", "required": "مطلوب",
"input_type": "نوع الإدخال", "input_type": "نوع الإدخال",
"rejected": "تم الرفض", "rejected": "مرفوض",
"unconfirmed": "غير مؤكد", "unconfirmed": "غير مؤكد",
"guests": "الضيوف", "guests": "الضيوف",
"guest": "الضيف", "guest": "الضيف",
"web_conferencing_details_to_follow": "تفاصيل مؤتمرات الويب للمتابعة.", "web_conferencing_details_to_follow": "تفاصيل مؤتمرات الويب القادمة.",
"the_username": "اسم المستخدم", "the_username": "اسم المستخدم",
"username": "اسم المستخدم", "username": "اسم المستخدم",
"is_still_available": "لا يزال متاحًا.", "is_still_available": "لا يزال متاحًا.",
"documentation": "المستندات", "documentation": "المستندات",
"documentation_description": "تعرف على كيفية دمج أدواتنا مع تطبيقك", "documentation_description": "تعرف على كيفية دمج أدواتنا مع تطبيقك",
"api_reference": "مرجع واجهة برمجة التطبيقات", "api_reference": "مرجع واجهة برمجة التطبيقات (API)",
"api_reference_description": "مرجع واجهة برمجة تطبيقات كامل لمكتبتنا", "api_reference_description": "مرجع كامل لواجهة برمجة التطبيقات (API) لمكتبتنا",
"blog": "المدونة", "blog": "المدونة",
"blog_description": "اقرأ أحدث أخبارنا ومقالاتنا", "blog_description": "اقرأ أحدث أخبارنا ومقالاتنا",
"join_our_community": "انضم إلى مجتمعنا", "join_our_community": "انضم إلى مجتمعنا",
"join_our_slack": "انضم إلى فترة السماح لدينا", "join_our_slack": "انضم إلى Slack الخاص بنا",
"claim_username_and_schedule_events": "طالب باسم المستخدم وجدولة الأحداث", "claim_username_and_schedule_events": "احصل على اسم المستخدم وقم بجدولة الأحداث",
"popular_pages": "الصفحات الشائعة", "popular_pages": "الصفحات الشائعة",
"register_now": "تسجيل الآن", "register_now": "التسجيل الآن",
"register": "تسجيل", "register": "تسجيل",
"page_doesnt_exist": "هذه الصفحة غير موجودة.", "page_doesnt_exist": "هذه الصفحة غير موجودة.",
"check_spelling_mistakes_or_go_back": "تحقق من الأخطاء الإملائية أو ارجع إلى الصفحة السابقة.", "check_spelling_mistakes_or_go_back": "تحقق من الأخطاء الإملائية أو ارجع إلى الصفحة السابقة.",
@ -171,23 +171,23 @@
"15min_meeting": "اجتماع لمدة 15 دقيقة", "15min_meeting": "اجتماع لمدة 15 دقيقة",
"30min_meeting": "اجتماع لمدة 30 دقيقة", "30min_meeting": "اجتماع لمدة 30 دقيقة",
"secret_meeting": "اجتماع سري", "secret_meeting": "اجتماع سري",
"login_instead": "تسجيل الدخول بدلاً من ذلك", "login_instead": "تسجيل الدخول بدلًا من ذلك",
"already_have_an_account": "هل لديك حساب بالفعل؟", "already_have_an_account": "هل لديك حساب بالفعل؟",
"create_account": "إنشاء حساب", "create_account": "إنشاء حساب",
"confirm_password": "تأكيد كلمة المرور", "confirm_password": "تأكيد كلمة المرور",
"create_your_account": "إنشاء حساب", "create_your_account": "إنشاء حسابك",
"sign_up": "تسجيل الاشتراك", "sign_up": "تسجيل الاشتراك",
"youve_been_logged_out": "لقد قمت بتسجيل الخروج", "youve_been_logged_out": "لقد قمت بتسجيل الخروج",
"hope_to_see_you_soon": "نأمل أن نراك قريبًا مجددًا!", "hope_to_see_you_soon": "نأمل أن نراك قريبًا مجددًا!",
"logged_out": "تم تسجيل الخروج", "logged_out": "تم تسجيل الخروج",
"please_try_again_and_contact_us": "يُرجى المحاولة مجددًا والاتصال بنا إذا استمرت المشكلة.", "please_try_again_and_contact_us": "يُرجى المحاولة مجددًا والاتصال بنا إذا استمرت المشكلة.",
"incorrect_2fa_code": "الرمز المكون من عاملين غير صحيح.", "incorrect_2fa_code": "رمز المصادقة من عاملين غير صحيح.",
"no_account_exists": "لا يوجد حساب مطابق لعنوان البريد الإلكتروني هذا.", "no_account_exists": "لا يوجد حساب مطابق لعنوان البريد الإلكتروني هذا.",
"2fa_enabled_instructions": "تم تمكين المصادقة من عاملين. يُرجى إدخال الرمز المكون من ستة أرقام من تطبيق المصادقة لديك.", "2fa_enabled_instructions": "تم تمكين المصادقة من عاملين. يُرجى إدخال الرمز المكون من ستة أرقام من تطبيق المصادقة لديك.",
"2fa_enter_six_digit_code": "أدخل الرمز المكون من ستة أرقام من تطبيق المصادقة أدناه.", "2fa_enter_six_digit_code": "أدخل أدناه الرمز المكون من ستة أرقام من تطبيق المصادقة لديك.",
"create_an_account": "إنشاء حساب", "create_an_account": "إنشاء حساب",
"dont_have_an_account": "أليس لديك حساب؟", "dont_have_an_account": "أليس لديك حساب؟",
"2fa_code": "الرمز المكون من عاملين", "2fa_code": "رمز المصادقة من عاملين",
"sign_in_account": "تسجيل الدخول إلى حسابك", "sign_in_account": "تسجيل الدخول إلى حسابك",
"sign_in": "تسجيل الدخول", "sign_in": "تسجيل الدخول",
"go_back_login": "العودة إلى صفحة تسجيل الدخول", "go_back_login": "العودة إلى صفحة تسجيل الدخول",
@ -198,22 +198,22 @@
"done": "تم", "done": "تم",
"check_email_reset_password": "تحقق من البريد الإلكتروني. لقد أرسلنا رابطًا لإعادة تعيين كلمة المرور.", "check_email_reset_password": "تحقق من البريد الإلكتروني. لقد أرسلنا رابطًا لإعادة تعيين كلمة المرور.",
"finish": "إنهاء", "finish": "إنهاء",
"few_sentences_about_yourself": "اكتب بضع جمل عن نفسك. سيظهر هذا على صفحة عنوان url الشخصية لديك.", "few_sentences_about_yourself": "اكتب بضع جمل عن نفسك. سيظهر هذا على صفحة عنوان URL الشخصية لديك.",
"nearly_there": "على وشك الانتهاء", "nearly_there": "على وشك الانتهاء",
"nearly_there_instructions": "آخر شيء، تساعدك كتابة وصف موجز عنك ووضع صورة على الحصول على الحجوزات وإعلام الأشخاص بمَن سيحجزون معه حقًا.", "nearly_there_instructions": "آخر شيء: تساعدك كتابة وصف موجز عنك ووضع صورة في الحصول على عمليات الحجز وإعلام الأشخاص بمَن سيحجزون معه حقًا.",
"set_availability_instructions": "حدد نطاقات الوقت عندما تكون متاحًا على أساس متكرر. يمكنك إنشاء المزيد منها لاحقًا وتعيينها لتقويمات مختلفة.", "set_availability_instructions": "حدد الفترات الزمنية التي تكون متاحًا فيها بشكل متكرر. يمكنك لاحقًا تحديد المزيد منها وربطها مع تقاويم مختلفة.",
"set_availability": "تحديد الوقت الذي تكون فيه متاحًا", "set_availability": "تحديد الوقت الذي تكون فيه متاحًا",
"continue_without_calendar": "المتابعة من دون تقويم", "continue_without_calendar": "المتابعة من دون تقويم",
"connect_your_calendar": "توصيل التقويم لديك", "connect_your_calendar": "ربط التقويم لديك",
"connect_your_calendar_instructions": "قم بتوصيل التقويم لديك للتحقق تلقائيًا من الأوقات المشغولة والأحداث الجديدة أثناء جدولتها.", "connect_your_calendar_instructions": "اربط التقويم لديك للتحقق تلقائيًا من الأوقات المشغولة والأحداث الجديدة أثناء جدولتها.",
"set_up_later": "الإعداد لاحقًا", "set_up_later": "الإعداد لاحقًا",
"current_time": "الوقت الحالي", "current_time": "الوقت الحالي",
"welcome": "مرحبًا", "welcome": "مرحبًا",
"welcome_to_calcom": "مرحبًا بك في Cal.com", "welcome_to_calcom": "مرحبًا بك في Cal.com",
"welcome_instructions": "أخبرنا باسمك وبالمنطقة الزمنية التي توجد فيها. ستتمكن من تحرير هذا لاحقًا.", "welcome_instructions": "أخبرنا باسمك وبالمنطقة الزمنية التي توجد فيها. ستتمكن لاحقًا من تعديل هذا.",
"connect_caldav": "الاتصال بخادم CalDav", "connect_caldav": "الاتصال بخادم CalDav",
"credentials_stored_and_encrypted": "سيتم تخزين بيانات الاعتماد الخاصة بك وتشفيرها.", "credentials_stored_and_encrypted": "سيجري تشفير بياناتك وتخزينها.",
"connect": "اتصال", "connect": "الاتصال",
"try_for_free": "جرّبه مجانًا", "try_for_free": "جرّبه مجانًا",
"create_booking_link_with_calcom": "أنشئ رابط الحجز الخاص بك باستخدام Cal.com", "create_booking_link_with_calcom": "أنشئ رابط الحجز الخاص بك باستخدام Cal.com",
"who": "مَن", "who": "مَن",
@ -222,7 +222,7 @@
"where": "أين", "where": "أين",
"add_to_calendar": "إضافة إلى التقويم", "add_to_calendar": "إضافة إلى التقويم",
"other": "آخر", "other": "آخر",
"emailed_you_and_attendees": "لقد أرسلنا إليك وإلى الحضور الآخرين دعوة تتضمن كل التفاصيل عبر البريد الإلكتروني.", "emailed_you_and_attendees": "لقد أرسلنا إليك وإلى الحضور الآخرين دعوة للتقويم عبر البريد الإلكتروني تتضمن كل التفاصيل.",
"emailed_you_and_any_other_attendees": "تم إرسال هذه المعلومات إليك وإلى الحضور الآخرين عبر البريد الإلكتروني.", "emailed_you_and_any_other_attendees": "تم إرسال هذه المعلومات إليك وإلى الحضور الآخرين عبر البريد الإلكتروني.",
"needs_to_be_confirmed_or_rejected": "لا يزال الحجز الخاص بك يحتاج إلى التأكيد أو الرفض.", "needs_to_be_confirmed_or_rejected": "لا يزال الحجز الخاص بك يحتاج إلى التأكيد أو الرفض.",
"user_needs_to_confirm_or_reject_booking": "لا يزال {{user}} يحتاج إلى تأكيد الحجز أو رفضه.", "user_needs_to_confirm_or_reject_booking": "لا يزال {{user}} يحتاج إلى تأكيد الحجز أو رفضه.",
@ -234,9 +234,9 @@
"reset_password": "إعادة تعيين كلمة المرور", "reset_password": "إعادة تعيين كلمة المرور",
"change_your_password": "تغيير كلمة المرور", "change_your_password": "تغيير كلمة المرور",
"try_again": "حاول مجددًا", "try_again": "حاول مجددًا",
"request_is_expired": "انتهت صلاحية هذا الطلب.", "request_is_expired": "انتهت صلاحية ذلك الطلب.",
"reset_instructions": "أدخل عنوان البريد الإلكتروني المرتبط بحسابك وسنرسل إليك رابطًا لإعادة تعيين كلمة المرور.", "reset_instructions": "أدخل عنوان البريد الإلكتروني المرتبط بحسابك وسنرسل إليك رابطًا لإعادة تعيين كلمة المرور.",
"request_is_expired_instructions": "انتهت صلاحية هذا الطلب. قم بالعودة وأدخل البريد الإلكتروني المرتبط بحسابك وسنرسل إليك رابطًا آخر لإعادة تعيين كلمة المرور.", "request_is_expired_instructions": "انتهت صلاحية ذلك الطلب. قم بالعودة وأدخل البريد الإلكتروني المرتبط بحسابك وسنرسل إليك رابطًا آخر لإعادة تعيين كلمة المرور.",
"whoops": "عذرًا", "whoops": "عذرًا",
"login": "تسجيل الدخول", "login": "تسجيل الدخول",
"success": "تم بنجاح", "success": "تم بنجاح",
@ -251,48 +251,48 @@
"friday_time_error": "وقت غير صالح يوم الجمعة", "friday_time_error": "وقت غير صالح يوم الجمعة",
"saturday_time_error": "وقت غير صالح يوم السبت", "saturday_time_error": "وقت غير صالح يوم السبت",
"error_end_time_before_start_time": "لا يمكن أن يكون وقت الانتهاء قبل وقت البدء", "error_end_time_before_start_time": "لا يمكن أن يكون وقت الانتهاء قبل وقت البدء",
"error_end_time_next_day": "لا يمكن أن يكون وقت الانتهاء أكبر من 24 ساعة", "error_end_time_next_day": "لا يمكن أن يكون وقت الانتهاء أطول من 24 ساعة",
"back_to_bookings": "العودة إلى الحجوزات", "back_to_bookings": "العودة إلى عمليات الحجز",
"free_to_pick_another_event_type": "لا تتردد في اختيار حدث آخر في أي وقت.", "free_to_pick_another_event_type": "لا تتردد في اختيار حدث آخر في أي وقت.",
"cancelled": "تم الإلغاء", "cancelled": "تم الإلغاء",
"cancellation_successful": "تم الإلغاء بنجاح", "cancellation_successful": "تم الإلغاء بنجاح",
"really_cancel_booking": "هل تريد حقًا إلغاء الحجز؟", "really_cancel_booking": "هل تريد حقًا إلغاء الحجز؟",
"cannot_cancel_booking": "لا يمكنك إلغاء هذا الحجز", "cannot_cancel_booking": "لا يمكنك إلغاء هذا الحجز",
"reschedule_instead": "بدلاً من ذلك، يمكنك أيضًا إعادة جدولته.", "reschedule_instead": "بدلًا من ذلك، يمكنك إعادة جدولته.",
"event_is_in_the_past": "الحدث في الماضي", "event_is_in_the_past": "الحدث في الماضي",
"error_with_status_code_occured": "حدث خطأ في رمز الحالة {{status}}.", "error_with_status_code_occured": "حدث خطأ برمز الحالة {{status}}.",
"booking_already_cancelled": "تم إلغاء هذا الحجز بالفعل", "booking_already_cancelled": "تم إلغاء هذا الحجز بالفعل",
"go_back_home": "العودة إلى الشاشة الرئيسية", "go_back_home": "العودة إلى الشاشة الرئيسية",
"or_go_back_home": "أو العودة إلى الشاشة الرئيسية", "or_go_back_home": "أو العودة إلى الشاشة الرئيسية",
"no_availability": "غير متاح", "no_availability": "غير متاح",
"no_meeting_found": "لم يتم العثور على اجتماعات", "no_meeting_found": "لم يتم العثور على اجتماعات",
"no_meeting_found_description": "هذا الاجتماع غير موجود. اتصل بمالك الاجتماع للحصول على الرابط المُحدَّث.", "no_meeting_found_description": "هذا الاجتماع غير موجود. اتصل بصاحب الاجتماع للحصول على الرابط المُحدَّث.",
"no_status_bookings_yet": "لا توجد حجوزات {{status}} حتى الآن", "no_status_bookings_yet": "لا توجد عمليات حجز في حالة {{status}} حتى الآن",
"no_status_bookings_yet_description": "ليست لديك حجوزات {{status}}. {{description}}", "no_status_bookings_yet_description": "ليست لديك عمليات حجز في حالة {{status}}. {{description}}",
"event_between_users": "{{eventName}} بين {{host}} و{{attendeeName}}", "event_between_users": "{{eventName}} بين {{host}} و{{attendeeName}}",
"bookings": "الحجوزات", "bookings": "عمليات الحجز",
"bookings_description": "اطلع على الأحداث القادمة والسابقة المحجوزة من خلال روابط نوع الحدث.", "bookings_description": "اطلع على الأحداث القادمة والسابقة المحجوزة لديك من خلال روابط أنواع الحدث.",
"upcoming_bookings": "بمجرد أن يحجز شخص ما موعدًا معك، سيظهر هنا.", "upcoming_bookings": "بمجرد أن يحجز شخص ما موعدًا معك، سيظهر هنا.",
"past_bookings": "ستظهر حجوزاتك السابقة هنا.", "past_bookings": "ستظهر هنا عمليات حجزك السابقة.",
"cancelled_bookings": "ستظهر حجوزاتك التي تم إلغاؤها هنا.", "cancelled_bookings": "ستظهر هنا عمليات حجزك التي تم إلغاؤها.",
"on": "في", "on": "في",
"and": "و", "and": "و",
"calendar_shows_busy_between": "يظهر التقويم أنك مشغول بين", "calendar_shows_busy_between": "يظهر التقويم أنك مشغول بين",
"troubleshoot": "استكشاف الأخطاء وإصلاحها", "troubleshoot": "استكشاف الأخطاء وإصلاحها",
"troubleshoot_description": "فهم سبب توفر أوقات معينة وحظر أوقات أخرى.", "troubleshoot_description": "فهم سبب توفر أوقات معينة وحظر أوقات أخرى.",
"overview_of_day": "إليك نظرة عامة على يومك في", "overview_of_day": "إليك نظرة عامة على يومك في",
"hover_over_bold_times_tip": "نصيحة: قم بالمرور فوق الأوقات المكتوبة بخط غامق للحصول على طابع زمني كامل", "hover_over_bold_times_tip": "نصيحة: قم بالمرور فوق الأوقات المكتوبة بخط عريض للحصول على الزمن بالكامل",
"start_time": "وقت البدء", "start_time": "وقت البدء",
"end_time": "وقت الانتهاء", "end_time": "وقت الانتهاء",
"buffer": "المخزن المؤقت", "buffer": "الفترة بين الحدثين",
"your_day_starts_at": "يبدأ يومك في", "your_day_starts_at": "يبدأ يومك في",
"your_day_ends_at": "ينتهي يومك في", "your_day_ends_at": "ينتهي يومك في",
"launch_troubleshooter": "تشغيل مستكشف الأخطاء وإصلاحها", "launch_troubleshooter": "تشغيل مستكشف الأخطاء وإصلاحها",
"troubleshoot_availability": "استكشف الأخطاء المتعلقة بأوقات إتاحتك وأصلحها لمعرفة السبب وراء ظهور أوقاتك كما هي.", "troubleshoot_availability": "استكشف الأخطاء المتعلقة بأوقاتك المتاحة لمعرفة السبب وراء ظهور أوقاتك بشكل خطأ.",
"change_available_times": "تغيير الأوقات المتاحة", "change_available_times": "تغيير الأوقات المتاحة",
"change_your_available_times": "قم بتغيير الأوقات المتاحة", "change_your_available_times": "قم بتغيير الأوقات المتاحة",
"change_start_end": "قم بتغيير أوقات البدء والانتهاء في يومك", "change_start_end": "قم بتغيير أوقات البدء والانتهاء في يومك",
"change_start_end_buffer": "قم بتعيين وقت البدء ووقت الانتهاء ليومك والحد الأدنى للوقت الاحتياطي بين اجتماعاتك.", "change_start_end_buffer": "قم بتعيين وقت البدء ووقت الانتهاء ليومك والحد الأدنى للفترة بين اجتماعاتك.",
"current_start_date": "حاليًا، تم تعيين وقت بدء يومك في", "current_start_date": "حاليًا، تم تعيين وقت بدء يومك في",
"start_end_changed_successfully": "تم تغيير أوقات البدء والانتهاء في يومك بنجاح.", "start_end_changed_successfully": "تم تغيير أوقات البدء والانتهاء في يومك بنجاح.",
"and_end_at": "وينتهي في", "and_end_at": "وينتهي في",
@ -300,48 +300,48 @@
"dark": "داكن", "dark": "داكن",
"automatically_adjust_theme": "ضبط السمة تلقائيًا استنادًا إلى تفضيلات المدعوين", "automatically_adjust_theme": "ضبط السمة تلقائيًا استنادًا إلى تفضيلات المدعوين",
"email": "البريد الإلكتروني", "email": "البريد الإلكتروني",
"email_placeholder": "jdoe@example.com", "email_placeholder": "name@example.com",
"full_name": "الاسم بالكامل", "full_name": "الاسم بالكامل",
"browse_api_documentation": "استعراض مستندات واجهة برمجة التطبيقات", "browse_api_documentation": "استعراض مستندات واجهة برمجة التطبيقات (API) لدينا",
"leverage_our_api": "استفد من واجهة برمجة التطبيقات لدينا للتحكم والتخصيص بالكامل.", "leverage_our_api": "استفد من واجهة برمجة التطبيقات (API) لدينا من أجل قدرة كاملة على التحكم والتخصيص.",
"create_webhook": "إنشاء ويبهوك", "create_webhook": "إنشاء الويب هوك",
"booking_cancelled": "تم إلغاء الحجز", "booking_cancelled": "تم إلغاء الحجز",
"booking_rescheduled": "تمت إعادة جدولة الحجز", "booking_rescheduled": "تمت إعادة جدولة الحجز",
"booking_created": "تم إنشاء الحجز", "booking_created": "تم إنشاء الحجز",
"event_triggers": "مشغلات الحدث", "event_triggers": "مشغلات الحدث",
"subscriber_url": "عنوان Url للمشترك", "subscriber_url": "عنوان URL للمشترك",
"create_new_webhook": "إنشاء ويبهوك جديد", "create_new_webhook": "إنشاء ويب هوك جديد",
"create_new_webhook_to_account": "إنشاء ويبهوك جديد لحسابك", "create_new_webhook_to_account": "إنشاء ويب هوك جديد لحسابك",
"new_webhook": "ويبهوك جديد", "new_webhook": "ويب هوك جديد",
"receive_cal_meeting_data": "تلقَّ بيانات اجتماع Cal على عنوان URL محدد، في الوقت الفعلي، عندما تتم إعادة جدولة حدث ما أو يتم إلغاؤه.", "receive_cal_meeting_data": "تلقَّ بيانات اجتماعات Cal على عنوان URL محدد، وفي الوقت الفعلي، عندما تتم جدولة حدث ما أو يتم إلغاؤه.",
"responsive_fullscreen_iframe": "إطار iframe سريع الاستجابة بكامل الشاشة", "responsive_fullscreen_iframe": "إطار iframe سريع الاستجابة بكامل الشاشة",
"loading": "جارٍ التحميل...", "loading": "يجري التحميل...",
"standard_iframe": "إطار iframe القياسي", "standard_iframe": "إطار iframe القياسي",
"iframe_embed": "تضمين إطار iframe", "iframe_embed": "تضمين إطار iframe",
"embed_calcom": "أسهل طريقة لتضمين Cal.com على موقع الويب لديك.", "embed_calcom": "أسهل طريقة لتضمين Cal.com على موقع الويب لديك.",
"integrate_using_embed_or_webhooks": "تكامل مع موقع الويب لديك باستخدام خيارات التضمين لدينا أو احصل على معلومات الحجز في الوقت الفعلي باستخدام روابط ويبهوك المخصصة.", "integrate_using_embed_or_webhooks": "حقق التكامل مع موقع الويب لديك باستخدام خيارات التضمين لدينا، أو احصل على معلومات الحجز في الوقت الفعلي باستخدام إجراءات الويب هوك المخصصة.",
"schedule_a_meeting": "جدولة اجتماع", "schedule_a_meeting": "جدولة اجتماع",
"view_and_manage_billing_details": "عرض تفاصيل الفوترة وإدارتها", "view_and_manage_billing_details": "عرض تفاصيل الفوترة وإدارتها",
"view_and_edit_billing_details": "عرض تفاصيل الفوترة وتحريرها، بالإضافة إلى إلغاء الاشتراك.", "view_and_edit_billing_details": "اعرض تفاصيل الفوترة وعدّلها، بالإضافة إلى إمكانية إلغاء الاشتراك.",
"go_to_billing_portal": "انتقل إلى بوابة الفوترة", "go_to_billing_portal": "انتقل إلى بوابة الفوترة",
"need_anything_else": "هل تحتاج إلى أي شيء آخر؟", "need_anything_else": "هل تحتاج إلى أي شيء آخر؟",
"further_billing_help": "إذا كنت تحتاج إلى أي مساعدة إضافية في الفوترة، فإن فريق الدعم لدينا في انتظارك لتقديم المساعدة.", "further_billing_help": "إذا كنت تحتاج إلى أي مساعدة إضافية بخصوص الفوترة، فإن فريق الدعم لدينا في انتظارك لتقديم المساعدة.",
"contact_our_support_team": "الاتصال بفريق الدعم لدينا", "contact_our_support_team": "الاتصال بفريق الدعم لدينا",
"uh_oh": "عذرًا!", "uh_oh": "عذرًا!",
"no_event_types_have_been_setup": "لم يقم هذا المستخدم بإعداد أي أنواع للحدث حتى الآن.", "no_event_types_have_been_setup": "لم يقم هذا المستخدم بإعداد أي أنواع للحدث حتى الآن.",
"edit_logo": حرير الشعار", "edit_logo": عديل الشعار",
"upload_a_logo": "تحميل شعار", "upload_a_logo": "تحميل شعار",
"remove_logo": "إزالة الشعار", "remove_logo": "إزالة الشعار",
"enable": "تمكين", "enable": "تمكين",
"code": "الرمز", "code": "الرمز",
"code_is_incorrect": "الرمز غير صحيح.", "code_is_incorrect": "الرمز غير صحيح.",
"add_an_extra_layer_of_security": "أضف طبقة أمان إضافية إلى حسابك في حال سرقة كلمة المرور الخاصة بك.", "add_an_extra_layer_of_security": "أضف إلى حسابك طبقة أمان إضافية للاحتياط من سرقة كلمة المرور الخاصة بك.",
"2fa": "المصادقة المكونة من عاملين", "2fa": "المصادقة من عاملين",
"enable_2fa": "تمكين المصادقة المكونة من عاملين", "enable_2fa": "تمكين المصادقة من عاملين",
"disable_2fa": "تعطيل المصادقة المكونة من عاملين", "disable_2fa": "تعطيل المصادقة من عاملين",
"disable_2fa_recommendation": "إذا كنت بحاجة إلى تعطيل المصادقة المكونة من عاملين، فنوصيك بإعادة تمكينها في أقرب وقت ممكن.", "disable_2fa_recommendation": "إذا كنت بحاجة إلى تعطيل المصادقة من عاملين، فنوصيك بإعادة تمكينها في أقرب وقت ممكن.",
"error_disabling_2fa": "خطأ في تعطيل المصادقة المكونة من عاملين", "error_disabling_2fa": "خطأ في تعطيل المصادقة من عاملين",
"error_enabling_2fa": "خطأ في إعداد المصادقة المكونة من عاملين", "error_enabling_2fa": "خطأ في إعداد المصادقة من عاملين",
"security": "الأمان", "security": "الأمان",
"manage_account_security": "إدارة أمان حسابك.", "manage_account_security": "إدارة أمان حسابك.",
"password": "كلمة المرور", "password": "كلمة المرور",
@ -349,9 +349,9 @@
"password_has_been_changed": "تم تغيير كلمة المرور بنجاح.", "password_has_been_changed": "تم تغيير كلمة المرور بنجاح.",
"error_changing_password": "خطأ في تغيير كلمة المرور", "error_changing_password": "خطأ في تغيير كلمة المرور",
"something_went_wrong": "حدث خطأ ما.", "something_went_wrong": "حدث خطأ ما.",
"something_doesnt_look_right": "هل ثمة شيء لا يبدو صحيحًا؟", "something_doesnt_look_right": "هل يوجد شيء ما لا يبدو صحيحًا؟",
"please_try_again": "يُرجى المحاولة مجددًا.", "please_try_again": "يُرجى المحاولة مجددًا.",
"super_secure_new_password": "كلمة المرور الجديدة ذات الأمان الممتاز", "super_secure_new_password": "كلمة المرور الجديدة الآمنة كليًا",
"new_password": "كلمة المرور الجديدة", "new_password": "كلمة المرور الجديدة",
"your_old_password": "كلمة المرور القديمة", "your_old_password": "كلمة المرور القديمة",
"current_password": "كلمة المرور الحالية", "current_password": "كلمة المرور الحالية",
@ -359,15 +359,15 @@
"new_password_matches_old_password": "تتطابق كلمة المرور الجديدة مع كلمة المرور القديمة. يُرجى اختيار كلمة مرور مختلفة.", "new_password_matches_old_password": "تتطابق كلمة المرور الجديدة مع كلمة المرور القديمة. يُرجى اختيار كلمة مرور مختلفة.",
"current_incorrect_password": "كلمة المرور الحالية غير صحيحة", "current_incorrect_password": "كلمة المرور الحالية غير صحيحة",
"incorrect_password": "كلمة المرور غير صحيحة.", "incorrect_password": "كلمة المرور غير صحيحة.",
"1_on_1": "فردي", "1_on_1": "اجتماع 1 مع 1",
"24_h": "24 ساعة", "24_h": "24 ساعة",
"use_setting": "استخدام الإعدادات", "use_setting": "استخدام الإعداد",
"am_pm": "صباحًا/مساءً", "am_pm": "صباحًا/مساءً",
"time_options": "خيارات الوقت", "time_options": "خيارات الوقت",
"january": "يناير", "january": "يناير",
"february": "فبراير", "february": "فبراير",
"march": "مارس", "march": "مارس",
"april": "إبريل", "april": "أبريل",
"may": "مايو", "may": "مايو",
"june": "يونيو", "june": "يونيو",
"july": "يوليو", "july": "يوليو",
@ -385,7 +385,7 @@
"sunday": "الأحد", "sunday": "الأحد",
"all_booked_today": "تم حجز الكل اليوم.", "all_booked_today": "تم حجز الكل اليوم.",
"slots_load_fail": "تعذر تحميل الفترات الزمنية المتاحة.", "slots_load_fail": "تعذر تحميل الفترات الزمنية المتاحة.",
"additional_guests": "مزيد من الضيوف الإضافيين", "additional_guests": "إضافة مزيد من الضيوف",
"your_name": "اسمك", "your_name": "اسمك",
"email_address": "عنوان البريد الإلكتروني", "email_address": "عنوان البريد الإلكتروني",
"location": "الموقع", "location": "الموقع",
@ -402,14 +402,14 @@
"phone_number": "رقم الهاتف", "phone_number": "رقم الهاتف",
"enter_phone_number": "أدخل رقم الهاتف", "enter_phone_number": "أدخل رقم الهاتف",
"reschedule": "إعادة الجدولة", "reschedule": "إعادة الجدولة",
"book_a_team_member": "حجز عضو في الفريق بدلاً من ذلك", "book_a_team_member": "حجز عضو في الفريق بدلًا من ذلك",
"or": "أو", "or": "أو",
"go_back": "العودة", "go_back": "العودة",
"email_or_username": "البريد الإلكتروني أو اسم المستخدم", "email_or_username": "البريد الإلكتروني أو اسم المستخدم",
"send_invite_email": "إرسال دعوة عبر البريد الإلكتروني", "send_invite_email": "إرسال دعوة عبر البريد الإلكتروني",
"role": "الدور", "role": "الدور",
"edit_role": حرير الدور", "edit_role": عديل الدور",
"edit_team": حرير الفريق", "edit_team": عديل الفريق",
"reject": "رفض", "reject": "رفض",
"accept": "قبول", "accept": "قبول",
"leave": "خروج", "leave": "خروج",
@ -424,24 +424,24 @@
"members": "الأعضاء", "members": "الأعضاء",
"member": "العضو", "member": "العضو",
"owner": "المالك", "owner": "المالك",
"admin": "المسؤول", "admin": "المشرف",
"new_member": "العضو الجديد", "new_member": "العضو الجديد",
"invite": "دعوة", "invite": "دعوة",
"invite_new_member": "دعوة عضو جديد", "invite_new_member": "دعوة عضو جديد",
"invite_new_team_member": "دعوة شخص ما إلى فريقك.", "invite_new_team_member": "دعوة شخص ما إلى فريقك.",
"change_member_role": "تغيير دور العضو في الفريق", "change_member_role": "تغيير دور العضو في الفريق",
"disable_cal_branding": "تعطيل علامة Cal.com التجارية", "disable_cal_branding": "تعطيل علامات Cal.com التجارية",
"disable_cal_branding_description": "إخفاء كل علامات Cal.com التجارية من الصفحات العامة.", "disable_cal_branding_description": "إخفاء كل علامات Cal.com التجارية من الصفحات العامة لديك.",
"danger_zone": "منطقة الخطر", "danger_zone": "منطقة الخطر",
"back": "عودة", "back": "عودة",
"cancel": "إلغاء", "cancel": "إلغاء",
"continue": "تابع", "continue": "متابعة",
"confirm": "تأكيد", "confirm": "تأكيد",
"disband_team": "حل الفريق", "disband_team": "تفكيك الفريق",
"disband_team_confirmation_message": "هل تريد بالتأكيد حل هذا الفريق؟ لن يتمكن أي شخص قمت بمشاركة رابط هذا الفريق معه من الحجز باستخدامه بعد الآن.", "disband_team_confirmation_message": "هل تريد بالتأكيد تفكيك هذا الفريق؟ أي شخص شاركت رابط هذا الفريق معه لن يستطيع بعد الآن الحجز باستخدامه.",
"remove_member_confirmation_message": "هل تريد بالتأكيد إزالة هذا العضو من الفريق؟", "remove_member_confirmation_message": "هل تريد بالتأكيد إزالة هذا العضو من الفريق؟",
"confirm_disband_team": "نعم، حل الفريق", "confirm_disband_team": "نعم، أريد تفكيك الفريق",
"confirm_remove_member": "نعم، إزالة العضو", "confirm_remove_member": "نعم، أريد إزالة العضو",
"remove_member": "إزالة العضو", "remove_member": "إزالة العضو",
"manage_your_team": "إدارة الفريق", "manage_your_team": "إدارة الفريق",
"no_teams": "ليس لديك أي فرق حتى الآن.", "no_teams": "ليس لديك أي فرق حتى الآن.",
@ -450,14 +450,14 @@
"delete": "حذف", "delete": "حذف",
"update": "تحديث", "update": "تحديث",
"save": "حفظ", "save": "حفظ",
"pending": "معلّق", "pending": "قيد الانتظار",
"open_options": "فتح الخيارات", "open_options": "فتح الخيارات",
"copy_link": "نسخ الرابط إلى الحدث", "copy_link": "نسخ الرابط إلى الحدث",
"share": "مشاركة", "share": "مشاركة",
"share_event": "هل تمانع في حجز cal أو إرسال الرابط إليّ؟", "share_event": "أيمكنك حجز تقويمي أو إرسال رابطك إلي؟",
"copy_link_team": "نسخ الرابط إلى الفريق", "copy_link_team": "نسخ الرابط إلى الفريق",
"leave_team": "الخروج من الفريق", "leave_team": "الخروج من الفريق",
"confirm_leave_team": "نعم، الخروج من الفريق", "confirm_leave_team": "نعم، أريد الخروج من الفريق",
"leave_team_confirmation_message": "هل تريد بالتأكيد الخروج من هذا الفريق؟ لن تتمكن بعد الآن من الحجز باستخدامه.", "leave_team_confirmation_message": "هل تريد بالتأكيد الخروج من هذا الفريق؟ لن تتمكن بعد الآن من الحجز باستخدامه.",
"user_from_team": "{{user}} من {{team}}", "user_from_team": "{{user}} من {{team}}",
"preview": "معاينة", "preview": "معاينة",
@ -473,126 +473,126 @@
"duration": "المدة", "duration": "المدة",
"minutes": "الدقائق", "minutes": "الدقائق",
"round_robin": "الترتيب الدوري", "round_robin": "الترتيب الدوري",
"round_robin_description": "انتقل عبر الاجتماعات بين أعضاء الفريق المتعددين.", "round_robin_description": "نقل الاجتماعات بشكل دوري بين أعضاء الفريق المتعددين.",
"url": "عنوان URL", "url": "URL",
"hidden": "مخفي", "hidden": "مخفي",
"readonly": "للقراءة فقط", "readonly": "للقراءة فقط",
"plan_description": "أنت حاليًا في خطة {{plan}}.", "plan_description": "أنت حاليًا على خطة {{plan}}.",
"plan_upgrade_invitation": "قم بترقية حسابك إلى خطة pro لفتح كل الميزات التي نقدمها.", "plan_upgrade_invitation": "قم بترقية حسابك إلى خطة Pro لفتح كل الميزات التي نقدمها.",
"plan_upgrade": "تحتاج إلى ترقية خطتك للحصول على أكثر من نوع حدث واحد نشط.", "plan_upgrade": "تحتاج إلى ترقية خطتك للحصول على أكثر من نوع حدث نشط.",
"plan_upgrade_teams": "تحتاج إلى ترقية خطتك لإنشاء فريق.", "plan_upgrade_teams": "تحتاج إلى ترقية خطتك لإنشاء فريق.",
"plan_upgrade_instructions": "يمكنك <1>الترقية هنا</1>.", "plan_upgrade_instructions": "يمكنك <1>الترقية هنا</1>.",
"event_types_page_title": "أنواع الحدث", "event_types_page_title": "أنواع الحدث",
"event_types_page_subtitle": "قم بإنشاء أحداث لمشاركتها حتى يتسنى للأشخاص الحجز في التقويم الخاص بك.", "event_types_page_subtitle": "قم بإنشاء أحداث لمشاركتها حتى يتسنى للأشخاص الحجز في التقويم الخاص بك.",
"new_event_type_btn": "نوع حدث جديد", "new_event_type_btn": "نوع حدث جديد",
"new_event_type_heading": "إنشاء نوع الحدث الأول لديك", "new_event_type_heading": "إنشاء نوع الحدث الأول لديك",
"new_event_type_description": "تتيح لك أنواع الأحداث مشاركة الروابط التي تعرض الأوقات المتاحة في تقويمك وتسمح للأشخاص بإجراء حجوزات معك.", "new_event_type_description": "تتيح لك أنواع الأحداث مشاركة الروابط التي تعرض الأوقات المتاحة في تقويمك وتسمح للأشخاص بالحجز معك.",
"new_event_title": "إضافة نوع حدث جديد", "new_event_title": "إضافة نوع حدث جديد",
"new_event_subtitle": "قم بإنشاء نوع حدث يندرج تحت اسمك أو أحد الفرق.", "new_event_subtitle": "قم بإنشاء نوع حدث يندرج تحت اسمك أو أحد الفرق.",
"new_team_event": "إضافة نوع حدث لفريق جديد", "new_team_event": "إضافة نوع حدث جديد لفريق",
"new_event_description": "قم بإنشاء نوع حدث جديد للأشخاص لحجز أوقات معهم.", "new_event_description": "قم بإنشاء نوع حدث جديد ليحجز الأشخاص من خلاله.",
"event_type_created_successfully": "تم إنشاء نوع الحدث {{eventTypeTitle}} بنجاح", "event_type_created_successfully": "تم إنشاء نوع الحدث {{eventTypeTitle}} بنجاح",
"event_type_updated_successfully": "تم تحديث نوع الحدث {{eventTypeTitle}} بنجاح", "event_type_updated_successfully": "تم تحديث نوع الحدث {{eventTypeTitle}} بنجاح",
"event_type_deleted_successfully": "تم حذف نوع الحدث بنجاح", "event_type_deleted_successfully": "تم حذف نوع الحدث بنجاح",
"web3_metamask_added": "تمت إضافة Metamask بنجاح", "web3_metamask_added": "تمت إضافة Metamask بنجاح",
"web3_metamask_disconnected": "تم فصل Metamask بنجاح", "web3_metamask_disconnected": "تم فصل Metamask بنجاح",
"hours": "ساعات", "hours": "الساعات",
"your_email": "بريدك الإلكتروني", "your_email": "بريدك الإلكتروني",
"change_avatar": "تغيير الصورة الرمزية", "change_avatar": "تغيير الصورة الرمزية",
"language": "اللغة", "language": "اللغة",
"timezone": "المنطقة الزمنية", "timezone": "المنطقة الزمنية",
"first_day_of_week": "أول يوم في الأسبوع", "first_day_of_week": "أول يوم في الأسبوع",
"single_theme": "موضوع واحد", "single_theme": "سمة واحدة",
"brand_color": "لون العلامة التجارية", "brand_color": "لون العلامة التجارية",
"file_not_named": "لم تتم تسمية الملف [idOrSlug]/[user]", "file_not_named": "لم تتم تسمية الملف [user]/[idOrSlug]",
"create_team": "إنشاء فريق", "create_team": "إنشاء فريق",
"name": "الاسم", "name": "الاسم",
"create_new_team_description": "قم بإنشاء فريق جديد للتعاون مع المستخدمين.", "create_new_team_description": "قم بإنشاء فريق جديد للتعاون مع المستخدمين.",
"create_new_team": "إنشاء فريق جديد", "create_new_team": "إنشاء فريق جديد",
"open_invitations": "فتح الدعوات", "open_invitations": "الدعوات المفتوحة",
"new_team": "فريق جديد", "new_team": "فريق جديد",
"create_first_team_and_invite_others": "قم بإنشاء فريقك الأول ودعوة المستخدمين الآخرين للعمل معك.", "create_first_team_and_invite_others": "قم بإنشاء فريقك الأول ودعوة المستخدمين الآخرين للعمل معك.",
"create_team_to_get_started": "إنشاء فريق للبدء", "create_team_to_get_started": "قم بإنشاء فريق للبدء",
"teams": "الفرق", "teams": "الفرق",
"team_billing": "الفوترة الخاصة بالفريق", "team_billing": "الفوترة الخاصة بالفريق",
"upgrade_to_flexible_pro_title": "لقد قمنا بتغيير الفوترة الخاصة بالفرق", "upgrade_to_flexible_pro_title": "لقد قمنا بتغيير الفوترة الخاصة بالفرق",
"upgrade_to_flexible_pro_message": "ثمة أعضاء في فريق لا يملكون مقعدًا. قم بترقية خطتك الاحترافية لتوفير المقاعد المفقودة.", "upgrade_to_flexible_pro_message": "يوجد أعضاء في فريقك من دون مقاعد. قم بترقية خطة Pro خاصتك لتوفير المقاعد الناقصة.",
"changed_team_billing_info": "بدءًا من يناير 2022، سنفرض رسومًا على كل مقعد لأعضاء الفريق. يتمتع أعضاء فريقك الذين لديهم خطة Pro مجانًا الآن بفترة تجريبية مدتها 14 يومًا. بمجرد انتهاء الفترة التجريبية الخاصة بهم، سيتم إخفاء هؤلاء الأعضاء من فريقك ما لم تقم بالترقية الآن.", "changed_team_billing_info": "بدءًا من يناير 2022، أصبحنا نفرض الرسوم على مقاعد أعضاء الفريق بشكل منفرد. حاليًا، أعضاء فريقك الذين كانت لديهم خطة Pro مجانية أصبحوا في فترة تجريبية مدتها 14 يومًا. وبمجرد انتهاء الفترة التجريبية الخاصة بهم، سيجري إخفاء هؤلاء الأعضاء من فريقك ما لم تقم بالترقية الآن.",
"create_manage_teams_collaborative": "قم بإنشاء الفرق وإدارتها لاستخدام الميزات المساعدة.", "create_manage_teams_collaborative": "قم بإنشاء الفرق وإدارتها لاستخدام الميزات التعاونية.",
"only_available_on_pro_plan": "لا تتوفر هذه الميزة إلا في خطة Pro", "only_available_on_pro_plan": "لا تتوفر هذه الميزة إلا في خطة Pro",
"remove_cal_branding_description": "لإزالة العلامة التجارية لخدمة Cal من صفحات الحجوزات، تجب ترقية حسابك إلى حساب Pro.", "remove_cal_branding_description": "لإزالة علامة Cal التجارية من صفحات عمليات الحجز، تحتاج إلى ترقية حسابك إلى حساب Pro.",
"edit_profile_info_description": "قم بتعديل معلومات ملفك الشخصي التي يتم عرضها على رابط الجدولة.", "edit_profile_info_description": "قم بتعديل معلومات ملفك الشخصي المعروضة على رابط الجدولة.",
"change_email_tip": "قد تحتاج إلى تسجيل الخروج والعودة مجددًا لرؤية التغييرات التي تم تنفيذها.", "change_email_tip": "قد تحتاج إلى تسجيل الخروج والعودة مجددًا لرؤية التغييرات التي تم تنفيذها.",
"little_something_about": "نبذة عن نفسك.", "little_something_about": "نبذة عن نفسك.",
"profile_updated_successfully": "تم تحديث الملف الشخصي بنجاح", "profile_updated_successfully": "تم تحديث الملف الشخصي بنجاح",
"your_user_profile_updated_successfully": "تم تحديث الملف الشخصي للمستخدم الخاص بك بنجاح.", "your_user_profile_updated_successfully": "تم تحديث ملفك الشخصي بنجاح.",
"user_cannot_found_db": "يبدو أن المستخدم قد سجل الدخول ولكن لا يمكن العثور عليه في قاعدة البيانات", "user_cannot_found_db": "يبدو أن المستخدم قد سجل الدخول ولكن لا يمكن العثور عليه في قاعدة البيانات",
"embed_and_webhooks": "التضمين والإخطارات على الويب", "embed_and_webhooks": "التضمين والويب هوك",
"enabled": "تم التمكين", "enabled": "تم التمكين",
"disabled": "تم التعطيل", "disabled": "تم التعطيل",
"disable": "تعطيل", "disable": "تعطيل",
"billing": "الفوترة", "billing": "الفوترة",
"manage_your_billing_info": "قم بإدارة معلومات الفوترة لديك وإلغاء اشتراكك.", "manage_your_billing_info": "قم بإدارة معلومات الفوترة لديك وإلغاء اشتراكك.",
"availability": "التوفر", "availability": "الأوقات المتاحة",
"availability_updated_successfully": "تم تحديث التوفر بنجاح", "availability_updated_successfully": "تم تحديث الأوقات المتاحة بنجاح",
"configure_availability": "قم بتكوين الأوقات التي تتوفر فيها خدماتك للحجز.", "configure_availability": "اضبط الأوقات التي تكون متاحًا فيها للحجز.",
"change_weekly_schedule": "قم بتغيير جدولك الأسبوعي", "change_weekly_schedule": "تغيير جدولك الأسبوعي",
"logo": "الشعار", "logo": "الشعار",
"error": "خطأ", "error": "خطأ",
"team_logo": "شعار الفريق", "team_logo": "شعار الفريق",
"add_location": "إضافة موقع", "add_location": "إضافة موقع",
"attendees": "الحاضرون", "attendees": "الحضور",
"add_attendees": "إضافة الحاضرين", "add_attendees": "إضافة الحضور",
"show_advanced_settings": "عرض الإعدادات المتقدمة", "show_advanced_settings": "عرض الإعدادات المتقدمة",
"event_name": "اسم الحدث", "event_name": "اسم الحدث",
"event_name_tooltip": "الاسم الذي سيظهر في التقويمات", "event_name_tooltip": "الاسم الذي سيظهر في التقاويم",
"meeting_with_user": "الاجتماع مع {USER}", "meeting_with_user": "الاجتماع مع {USER}",
"additional_inputs": "إدخالات إضافية", "additional_inputs": "مدخلات إضافية",
"label": "التسمية", "label": "العلامة",
"placeholder": "العنصر النائب", "placeholder": "العنصر النائب",
"type": "النوع", "type": "النوع",
"edit": "تعديل", "edit": "تعديل",
"add_input": "إضافة إدخال", "add_input": "إضافة مُدخَل",
"opt_in_booking": "حجز الاشتراك", "opt_in_booking": "حجز يتطلب موافقة",
"opt_in_booking_description": "يجب التأكيد على الحجز يدويًا قبل دفعه إلى التكاملات وإرسال رسالة تأكيد عبر البريد الإلكتروني.", "opt_in_booking_description": "يجب تأكيد الحجز يدويًا قبل نقله إلى التكاملات وإرسال رسالة تأكيد عبر البريد الإلكتروني.",
"disable_guests": "تعطيل خاصية الضيوف", "disable_guests": "تعطيل خاصية الضيوف",
"disable_guests_description": "قم بتعطيل إضافة مزيد من الضيوف أثناء الحجز.", "disable_guests_description": "قم بتعطيل إضافة مزيد من الضيوف أثناء الحجز.",
"invitees_can_schedule": "يمكن جدولة أوقات المدعوين", "invitees_can_schedule": "يمكن للمدعوين الجدولة",
"date_range": "النطاق الزمني", "date_range": "الفترة الزمنية",
"calendar_days": "أيام التقويم", "calendar_days": "أيام التقويم",
"business_days": "أيام العمل", "business_days": "أيام العمل",
"set_address_place": "تعيين عنوان أو مكان", "set_address_place": "تعيين عنوان أو مكان",
"cal_invitee_phone_number_scheduling": "سيطلب Cal من المدعو لديك إدخال رقم الهاتف قبل الجدولة.", "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_zoom_meeting_url": "سيوفر Cal رابطًا للاجتماع عبر Zoom.",
"cal_provide_tandem_meeting_url": "سيوفر Cal رابطًا للاجتماع عبر Tandem.", "cal_provide_tandem_meeting_url": "سيوفر Cal رابطًا للاجتماع عبر Tandem.",
"cal_provide_video_meeting_url": "سيوفر Cal رابطًا للاجتماع عبر الفيديو على Daily.", "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.", "cal_provide_huddle01_meeting_url": "سيوفر Cal رابطًا للاجتماع عبر الفيديو على Huddle01 web3.",
"require_payment": "يلزم الدفع", "require_payment": "يلزم الدفع",
"commission_per_transaction": "عمولة لكل معاملة", "commission_per_transaction": "عمولة كل معاملة",
"event_type_updated_successfully_description": "تم تحديث نوع الحدث لديك بنجاح.", "event_type_updated_successfully_description": "تم تحديث نوع الحدث لديك بنجاح.",
"hide_event_type": "إخفاء نوع الحدث", "hide_event_type": "إخفاء نوع الحدث",
"edit_location": "تعديل الموقع", "edit_location": "تعديل الموقع",
"into_the_future": "في المستقبل", "into_the_future": "في المستقبل",
"within_date_range": "ضمن نطاق زمني", "within_date_range": "خلال فترة زمنية",
"indefinitely_into_future": "إلى أجل غير مسمى في المستقبل", "indefinitely_into_future": "إلى أجل غير مسمى في المستقبل",
"this_input_will_shown_booking_this_event": "سيتم عرض هذا الإدخال عند حجز هذا الحدث", "this_input_will_shown_booking_this_event": "سيُعرَض هذا المُدخَل عند حجز هذا الحدث",
"add_new_custom_input_field": "إضافة مجال إدخال مخصص جديد", "add_new_custom_input_field": "إضافة خانة إدخال مخصص جديدة",
"quick_chat": "أداة الدردشة السريعة", "quick_chat": "الدردشة السريعة",
"add_new_team_event_type": "إضافة نوع حدث لفريق جديد", "add_new_team_event_type": "إضافة نوع حدث جديد لفريق",
"add_new_event_type": "إضافة نوع حدث جديد", "add_new_event_type": "إضافة نوع حدث جديد",
"new_event_type_to_book_description": "قم بإنشاء نوع حدث جديد للأشخاص لحجز أوقات معهم.", "new_event_type_to_book_description": "قم بإنشاء نوع حدث جديد ليحجز الأشخاص من خلاله.",
"length": "الطول", "length": "الطول",
"minimum_booking_notice": "الحد الأدنى من إشعار الحجز", "minimum_booking_notice": "الحد الأدنى من الوقت للحجز",
"slot_interval": "فواصل الفترات الزمنية", "slot_interval": "الفترات الزمنية بين عمليات الحجز",
"slot_interval_default": "استخدام طول الحدث (الوضع الافتراضي)", "slot_interval_default": "استخدام طول الحدث (الوضع الافتراضي)",
"delete_event_type_description": "هل تريد بالتأكيد حذف هذا النوع من الأحداث؟ لن يتمكن أي شخص قمت بمشاركة هذا الرابط معه من الحجز باستخدامه بعد الآن.", "delete_event_type_description": "هل تريد بالتأكيد حذف نوع الحدث هذا؟ أي شخص شاركت هذا الرابط معه لن يستطيع بعد الآن الحجز باستخدامه.",
"delete_event_type": "حذف نوع الحدث", "delete_event_type": "حذف نوع الحدث",
"confirm_delete_event_type": "نعم، احذف نوع الحدث", "confirm_delete_event_type": "نعم، احذف نوع الحدث",
"delete_account": "حذف الحساب", "delete_account": "حذف الحساب",
"confirm_delete_account": "نعم، احذف الحساب", "confirm_delete_account": "نعم، احذف الحساب",
"delete_account_confirmation_message": "هل تريد بالتأكيد حذف حساب Cal.com؟ لن يتمكن أي شخص قمت بمشاركة رابط حسابك معه من الحجز باستخدامه وسيتم فقدان أي تفضيلات قمت بحفظها.", "delete_account_confirmation_message": "هل تريد بالتأكيد حذف حسابك على Cal.com؟ أي شخص شاركت رابط حسابك معه لن يستطيع بعد الآن الحجز باستخدامه، وستفقد أي تفضيلات حفظتها.",
"integrations": "التكاملات", "integrations": "التكاملات",
"settings": "الإعدادات", "settings": "الإعدادات",
"event_type_moved_successfully": "تم نقل نوع الحدث بنجاح", "event_type_moved_successfully": "تم نقل نوع الحدث بنجاح",
@ -601,61 +601,61 @@
"installed": "تم التثبيت", "installed": "تم التثبيت",
"disconnect": "فصل", "disconnect": "فصل",
"embed_your_calendar": "تضمين تقويمك في صفحة الويب لديك", "embed_your_calendar": "تضمين تقويمك في صفحة الويب لديك",
"connect_your_favourite_apps": "قم بتوصيل تطبيقاتك المفضلة.", "connect_your_favourite_apps": "الربط مع تطبيقاتك المفضلة.",
"automation": "الأتمتة", "automation": "التشغيل التلقائي",
"configure_how_your_event_types_interact": "قم بتكوين كيفية تفاعل نوع الأحداث مع تقويماتك.", "configure_how_your_event_types_interact": "اضبط كيفية تفاعل أنواع الأحداث لديك مع تقاويمك.",
"select_destination_calendar": "إنشاء أحداث في", "select_destination_calendar": "إنشاء أحداث في",
"connect_an_additional_calendar": "توصيل تقويم إضافي", "connect_an_additional_calendar": "ربط تقويم إضافي",
"conferencing": "المؤتمرات", "conferencing": "المؤتمرات عبر الفيديو",
"calendar": "التقويم", "calendar": "التقويم",
"not_installed": "لم يتم التثبيت", "not_installed": "لم يتم التثبيت",
"error_password_mismatch": "كلمات المرور غير متطابقة.", "error_password_mismatch": "كلمات المرور غير متطابقة.",
"error_required_field": "هذا الحقل مطلوب.", "error_required_field": "هذا الحقل مطلوب.",
"status": "الحالة", "status": "الحالة",
"team_view_user_availability": "عرض توفر المستخدم", "team_view_user_availability": "عرض أوقات المستخدم المتاحة",
"team_view_user_availability_disabled": "يحتاج المستخدم إلى قبول الدعوة لعرض التوفر", "team_view_user_availability_disabled": "يحتاج المستخدم إلى قبول الدعوة لعرض الأوقات المتاحة",
"set_as_away": عيين حالتك على بالخارج", "set_as_away": غيير حالتك إلى \"ليس موجودًا\"",
"set_as_free": "تعطيل حالة بالخارج", "set_as_free": "تعطيل حالة \"ليس موجودًا\"",
"user_away": "هذا المستخدم في حالة بالخارج حاليًا.", "user_away": "هذا المستخدم ليس موجودًا حاليًا.",
"user_away_description": "الشخص الذي تحاول حجز خدماته قام بتعيين حالته على بالخارج، ومن ثم لا يقبل حجوزات جديدة.", "user_away_description": "حالة الشخص الذي تحاول حجز خدماته \"ليس موجودًا\"، ولذا فهو لا يقبل عمليات حجز جديدة.",
"meet_people_with_the_same_tokens": "الاجتماع مع الأشخاص باستخدام الرموز المميزة نفسها", "meet_people_with_the_same_tokens": "الاجتماع مع أشخاص بنفس ال tokens",
"only_book_people_and_allow": "ما عليك سوى إجراء حجز والسماح بالحجوزات التي تتم من قِبل الأشخاص الذين يشاركون الرموز المميزة نفسها أو DAOs أو NFTs.", "only_book_people_and_allow": "ما عليك سوى إجراء حجز والسماح بعمليات الحجز من الأشخاص الذين يتشاركون نفس ال tokens أو DAOs أو NFTs.",
"saml_config_deleted_successfully": "تم حذف عملية تكوين SAML بنجاح", "saml_config_deleted_successfully": "تم حذف تكوين SAML بنجاح",
"account_created_with_identity_provider": "تم إنشاء حسابك باستخدام موفر هوية.", "account_created_with_identity_provider": "تم إنشاء حسابك باستخدام مزود هوية.",
"account_managed_by_identity_provider": تم إدارة حسابك بواسطة {{provider}}", "account_managed_by_identity_provider": جري إدارة حسابك بواسطة {{provider}}",
"account_managed_by_identity_provider_description": "لتغيير بريدك الإلكتروني وكلمة المرور وتمكين المصادقة ثنائية العوامل والمزيد، يُرجى زيارة إعدادات حساب {{provider}} لديك.", "account_managed_by_identity_provider_description": "لتغيير بريدك الإلكتروني وكلمة المرور وتمكين المصادقة من عاملين والمزيد، يُرجى زيارة إعدادات حساب {{provider}} لديك.",
"signin_with_google": "تسجيل الدخول إلى Google", "signin_with_google": "تسجيل الدخول عبر Google",
"signin_with_saml": "تسجيل الدخول إلى SAML", "signin_with_saml": "تسجيل الدخول عبر SAML",
"saml_configuration": "تكوين SAML", "saml_configuration": "تكوين SAML",
"delete_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", "confirm_delete_saml_configuration": "نعم، احذف تكوين SAML",
"saml_not_configured_yet": "لم يتم تكوين SAML حتى الآن", "saml_not_configured_yet": "لم يتم تكوين SAML حتى الآن",
"saml_configuration_description": "يُرجى لصق بيانات تعريف SAML المقدمة من موفر الهوية في مربع النص أدناه لتحديث تكوين SAML.", "saml_configuration_description": "يُرجى لصق بيانات تعريف SAML المقدمة من مزود الهوية في مربع النص أدناه لتحديث تكوين SAML.",
"saml_configuration_placeholder": "يُرجى لصق بيانات تعريف SAML المقدمة من موفر الهوية هنا", "saml_configuration_placeholder": "يُرجى لصق بيانات تعريف SAML المقدمة من مزود الهوية هنا",
"saml_configuration_update_failed": "فشل تحديث تكوين SAML", "saml_configuration_update_failed": "فشل تحديث تكوين SAML",
"saml_configuration_delete_failed": "فشل حذف تكوين SAML", "saml_configuration_delete_failed": "فشل حذف تكوين SAML",
"saml_email_required": "يُرجى إدخال بريد إلكتروني حتى نتمكن من العثور على موفر هوية SAML لديك", "saml_email_required": "يُرجى إدخال بريد إلكتروني حتى نتمكن من العثور على مزود هوية SAML الخاص بك",
"you_will_need_to_generate": "ستحتاج إلى إنشاء رمز مميز للوصول من خلال أداة الجدولة القديمة.", "you_will_need_to_generate": "ستحتاج إلى إنشاء access token من أداة الجدولة القديمة لديك.",
"import": "استيراد", "import": "استيراد",
"import_from": "استيراد من", "import_from": "الاستيراد من",
"access_token": "الرمز المميز الخاص بالوصول", "access_token": "Access token",
"visit_roadmap": "المخطط", "visit_roadmap": "المخطط",
"remove": "إزالة", "remove": "إزالة",
"add": "إضافة", "add": "إضافة",
"verify_wallet": "التحقق من المحفظة", "verify_wallet": "تأكيد المحفظة",
"connect_metamask": "توصيل Metamask", "connect_metamask": "توصيل Metamask",
"create_events_on": "إنشاء أحداث في:", "create_events_on": "إنشاء أحداث في:",
"missing_license": "الترخيص مفقود", "missing_license": "الترخيص مفقود",
"signup_requires": "يلزم تقديم ترخيص تجاري", "signup_requires": "يلزم ترخيص تجاري",
"signup_requires_description": "لا تقدم شركة Cal.com, Inc. حاليًا إصدارًا مجانيًا مفتوح المصدر لصفحة التسجيل. للحصول على حق الوصول الكامل إلى مكونات الاشتراك، يجب أن تحصل على ترخيص تجاري. للاستخدام الشخصي، نوصي باستخدام منصة Prisma Data أو أي واجهة أخرى من واجهات Postgres لإنشاء حسابات.", "signup_requires_description": "لا تقدم شركة Cal.com, Inc. حاليًا إصدارًا مجانيًا مفتوح المصدر لصفحة التسجيل. للحصول على حق الوصول الكامل إلى مكونات التسجيل، يجب أن تحصل على ترخيص تجاري. للاستخدام الشخصي، نوصي باستخدام منصة Prisma Data أو أي واجهة أخرى من واجهات Postgres لإنشاء الحسابات.",
"next_steps": "الخطوات التالية", "next_steps": "الخطوات التالية",
"acquire_commercial_license": "تتطلب ترخيصًا تجاريًا", "acquire_commercial_license": "الحصول على ترخيص تجاري",
"the_infrastructure_plan": "تعتمد خطة البنية التحتية على الاستخدام وتتضمن خصومات مناسبة لبدء التشغيل.", "the_infrastructure_plan": "تعتمد خطة البنية التحتية على الاستخدام وتتضمن خصومات مناسبة للأعمال التجارية حديثة الإنشاء.",
"prisma_studio_tip": "إنشاء حساب عبر Prisma Studio", "prisma_studio_tip": "إنشاء حساب عبر Prisma Studio",
"prisma_studio_tip_description": "تعرّف على كيفية إعداد مستخدمك الأول", "prisma_studio_tip_description": "تعرّف على كيفية إعداد مستخدمك الأول",
"contact_sales": "تواصل مع قسم المبيعات", "contact_sales": "تواصل مع قسم المبيعات",
"error_404": "خطأ 404", "error_404": "خطأ 404",
"requires_ownership_of_a_token": "يتطلب امتلاك رمز مميز يعود إلى العنوان التالي:", "requires_ownership_of_a_token": "يتطلب امتلاك token يخص العنوان التالي:",
"example_name": "أسامة منصور" "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", "hover_over_bold_times_tip": "Tip: Najeďte myší na vytučněné časy a zobrazte si úplnou informaci",
"start_time": "Čas začátku", "start_time": "Čas začátku",
"end_time": "Čas konce", "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", "buffer": "Rozestup",
"your_day_starts_at": "Váš den začíná v", "your_day_starts_at": "Váš den začíná v",
"your_day_ends_at": "Váš den končí v", "your_day_ends_at": "Váš den končí v",
@ -311,9 +315,12 @@
"event_triggers": "Eventy na základě akce", "event_triggers": "Eventy na základě akce",
"subscriber_url": "URL pro odběry", "subscriber_url": "URL pro odběry",
"create_new_webhook": "Vytvořit nový webhook", "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", "create_new_webhook_to_account": "Vytvořit nový webhook pro váš účet",
"new_webhook": "Nový webhook", "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_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", "responsive_fullscreen_iframe": "Responzivní iframe na celou obrazovku",
"loading": "Načítám...", "loading": "Načítám...",
"standard_iframe": "Klasický iframe", "standard_iframe": "Klasický iframe",
@ -456,6 +463,7 @@
"open_options": "Otevřít možnosti", "open_options": "Otevřít možnosti",
"copy_link": "Kopírovat odkaz na událost", "copy_link": "Kopírovat odkaz na událost",
"share": "Sdílet", "share": "Sdílet",
"share_event": "Chcete si rezervovat čas nebo poslat váš odkaz?",
"copy_link_team": "Kopírovat odkaz na tým", "copy_link_team": "Kopírovat odkaz na tým",
"leave_team": "Opustit tým", "leave_team": "Opustit tým",
"confirm_leave_team": "Ano, 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", "create_new_webhook_to_account": "Erstellen Sie einen neuen Webhook in Ihrem Account",
"new_webhook": "Neuer Webhook", "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_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", "responsive_fullscreen_iframe": "Responsives Vollbild-iframe",
"loading": "Wird geladen...", "loading": "Wird geladen...",
"standard_iframe": "Standard iframe", "standard_iframe": "Standard iframe",
@ -402,6 +403,7 @@
"phone_number": "Telefonnummer", "phone_number": "Telefonnummer",
"enter_phone_number": "Telefonnummer eingeben", "enter_phone_number": "Telefonnummer eingeben",
"reschedule": "Neuplanen", "reschedule": "Neuplanen",
"reschedule_this": "Stattdessen neu planen",
"book_a_team_member": "Teammitglied stattdessen buchen", "book_a_team_member": "Teammitglied stattdessen buchen",
"or": "ODER", "or": "ODER",
"go_back": "Zurück", "go_back": "Zurück",
@ -435,6 +437,7 @@
"danger_zone": "Achtung", "danger_zone": "Achtung",
"back": "Zurück", "back": "Zurück",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"cancel_event": "Diesen Termin stornieren",
"continue": "Weiter", "continue": "Weiter",
"confirm": "Bestätigen", "confirm": "Bestätigen",
"disband_team": "Team auflösen", "disband_team": "Team auflösen",
@ -453,6 +456,8 @@
"pending": "Ausstehend", "pending": "Ausstehend",
"open_options": "Optionen öffnen", "open_options": "Optionen öffnen",
"copy_link": "Link kopieren", "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", "copy_link_team": "Link zum Team kopieren",
"leave_team": "Team verlassen", "leave_team": "Team verlassen",
"confirm_leave_team": "Ja, Team verlassen", "confirm_leave_team": "Ja, Team verlassen",
@ -460,6 +465,7 @@
"user_from_team": "{{user}} von {{team}}", "user_from_team": "{{user}} von {{team}}",
"preview": "Vorschau", "preview": "Vorschau",
"link_copied": "Link kopiert!", "link_copied": "Link kopiert!",
"link_shared": "Link geteilt!",
"title": "Titel", "title": "Titel",
"description": "Beschreibung", "description": "Beschreibung",
"quick_video_meeting": "Ein schnelles Video-Meeting.", "quick_video_meeting": "Ein schnelles Video-Meeting.",
@ -654,5 +660,8 @@
"contact_sales": "Vertrieb kontaktieren", "contact_sales": "Vertrieb kontaktieren",
"error_404": "Fehler 404", "error_404": "Fehler 404",
"requires_ownership_of_a_token": "Erfordert Besitz eines Tokens, das zu der folgenden Adresse gehört:", "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.", "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_awaiting_approval_subject": "Awaiting Approval: {{eventType}} with {{name}} at {{date}}",
"event_still_awaiting_approval": "An event is still waiting for your approval", "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", "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_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", "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.", "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.", "link_expires": "p.s. It expires in {{expiresIn}} hours.",
"upgrade_to_per_seat": "Upgrade to Per-Seat", "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!", "team_upgraded_successfully": "Your team was upgraded successfully!",
"use_link_to_reset_password": "Use the link below to reset your password", "use_link_to_reset_password": "Use the link below to reset your password",
"hey_there": "Hey there,", "hey_there": "Hey there,",
@ -284,6 +285,10 @@
"hover_over_bold_times_tip": "Tip: Hover over the bold times for a full timestamp", "hover_over_bold_times_tip": "Tip: Hover over the bold times for a full timestamp",
"start_time": "Start time", "start_time": "Start time",
"end_time": "End 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", "buffer": "Buffer",
"your_day_starts_at": "Your day starts at", "your_day_starts_at": "Your day starts at",
"your_day_ends_at": "Your day ends at", "your_day_ends_at": "Your day ends at",
@ -311,9 +316,12 @@
"event_triggers": "Event Triggers", "event_triggers": "Event Triggers",
"subscriber_url": "Subscriber Url", "subscriber_url": "Subscriber Url",
"create_new_webhook": "Create a new webhook", "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", "create_new_webhook_to_account": "Create a new webhook to your account",
"new_webhook": "New Webhook", "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_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", "responsive_fullscreen_iframe": "Responsive full screen iframe",
"loading": "Loading...", "loading": "Loading...",
"standard_iframe": "Standard iframe", "standard_iframe": "Standard iframe",
@ -506,6 +514,8 @@
"first_day_of_week": "First Day of Week", "first_day_of_week": "First Day of Week",
"single_theme": "Single Theme", "single_theme": "Single Theme",
"brand_color": "Brand Color", "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]", "file_not_named": "File is not named [idOrSlug]/[user]",
"create_team": "Create Team", "create_team": "Create Team",
"name": "Name", "name": "Name",
@ -519,7 +529,7 @@
"team_billing": "Team Billing", "team_billing": "Team Billing",
"upgrade_to_flexible_pro_title": "We've changed billing for teams", "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.", "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.", "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", "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.", "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", "hover_over_bold_times_tip": "Astuce : Survolez les heures en gras pour obtenir un horodatage complet",
"start_time": "Début", "start_time": "Début",
"end_time": "Fin", "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", "buffer": "Intervalle",
"your_day_starts_at": "Votre journée commence à", "your_day_starts_at": "Votre journée commence à",
"your_day_ends_at": "Votre journée se termine à", "your_day_ends_at": "Votre journée se termine à",
@ -311,9 +315,12 @@
"event_triggers": "Déclencheurs d'événements", "event_triggers": "Déclencheurs d'événements",
"subscriber_url": "URL de l'abonné", "subscriber_url": "URL de l'abonné",
"create_new_webhook": "Créez un nouveau webhook", "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", "create_new_webhook_to_account": "Créez un nouveau webhook sur votre compte",
"new_webhook": "Nouveau Webhook", "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_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", "responsive_fullscreen_iframe": "iframe plein écran réactif",
"loading": "Chargement...", "loading": "Chargement...",
"standard_iframe": "iframe standard", "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", "hover_over_bold_times_tip": "Dica: passe o rato sobre os tempos ousados para um horário completo",
"start_time": "Hora de início", "start_time": "Hora de início",
"end_time": "Hora de fim", "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", "buffer": "Intervalo",
"your_day_starts_at": "O seu dia começa às", "your_day_starts_at": "O seu dia começa às",
"your_day_ends_at": "O seu dia termina às", "your_day_ends_at": "O seu dia termina às",
@ -311,9 +315,12 @@
"event_triggers": "Causadores de eventos", "event_triggers": "Causadores de eventos",
"subscriber_url": "URL do assinante", "subscriber_url": "URL do assinante",
"create_new_webhook": "Criar um novo webhook", "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", "create_new_webhook_to_account": "Criar um webhook na sua conta",
"new_webhook": "Novo Webhook", "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_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", "responsive_fullscreen_iframe": "Iframe responsivo de ecrã inteiro",
"loading": "A carregar...", "loading": "A carregar...",
"standard_iframe": "Iframe padrão", "standard_iframe": "Iframe padrão",

View File

@ -0,0 +1 @@
{}

View File

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

View File

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

View File

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

View File

@ -5,6 +5,8 @@
:root { :root {
--brand-color: #292929; --brand-color: #292929;
--brand-text-color: #ffffff; --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) */ /* 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 */ /* your primary brand color */
brand: "var(--brand-color)", brand: "var(--brand-color)",
brandcontrast: "var(--brand-text-color)", brandcontrast: "var(--brand-text-color)",
darkmodebrand: "var(--brand-color-dark-mode)",
darkmodebrandcontrast: "var(--brand-text-color-dark-mode)",
black: "#111111", black: "#111111",
gray: { gray: {
50: "#F8F8F8", 50: "#F8F8F8",

View File

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

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

View File

@ -27,7 +27,7 @@
"start": "turbo run start --scope=\"@calcom/web\"", "start": "turbo run start --scope=\"@calcom/web\"",
"test": "turbo run test", "test": "turbo run test",
"test-playwright": "yarn playwright 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" "type-check": "turbo run type-check"
}, },
"devDependencies": { "devDependencies": {

View File

@ -1,8 +1,17 @@
import { Credential } from "@prisma/client";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { randomString } from "@calcom/lib/random";
import type { PartialReference } from "@calcom/types/EventManager"; import type { PartialReference } from "@calcom/types/EventManager";
import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter"; 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 => { const JitsiVideoApiAdapter = (): VideoApiAdapter => {
return { return {
getAvailability: () => { getAvailability: () => {

View File

@ -1,7 +1,7 @@
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
declare global { declare global {
var prisma: PrismaClient; var prisma: PrismaClient | undefined;
} }
export const prisma = 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", "db-up": "docker-compose up -d",
"deploy": "run-s build db-deploy", "deploy": "run-s build db-deploy",
"dx": "yarn db-setup", "dx": "yarn db-setup",
"generate-schemas": "prisma generate" "generate-schemas": "prisma generate && prisma format",
"postinstall": "yarn generate-schemas"
}, },
"devDependencies": { "devDependencies": {
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prisma": "3.9.2", "prisma": "3.10.0",
"ts-node": "^10.2.1", "ts-node": "^10.6.0",
"zod-prisma": "^0.5.4" "zod-prisma": "^0.5.4"
}, },
"dependencies": { "dependencies": {
"@calcom/lib": "*", "@calcom/lib": "*",
"@prisma/client": "3.9.2" "@prisma/client": "3.10.0"
}, },
"main": "index.ts", "main": "index.ts",
"types": "index.d.ts", "types": "index.d.ts",
"files": [
"client",
"zod",
"zod-utils.ts"
],
"prisma": { "prisma": {
"seed": "ts-node ./seed.ts" "seed": "ts-node ./seed.ts"
} }

View File

@ -46,6 +46,7 @@ model EventType {
teamId Int? teamId Int?
bookings Booking[] bookings Booking[]
availability Availability[] availability Availability[]
webhooks Webhook[]
destinationCalendar DestinationCalendar? destinationCalendar DestinationCalendar?
eventName String? eventName String?
customInputs EventTypeCustomInput[] customInputs EventTypeCustomInput[]
@ -58,6 +59,8 @@ model EventType {
requiresConfirmation Boolean @default(false) requiresConfirmation Boolean @default(false)
disableGuests Boolean @default(false) disableGuests Boolean @default(false)
minimumBookingNotice Int @default(120) minimumBookingNotice Int @default(120)
beforeEventBuffer Int @default(0)
afterEventBuffer Int @default(0)
schedulingType SchedulingType? schedulingType SchedulingType?
Schedule Schedule[] Schedule Schedule[]
price Int @default(0) price Int @default(0)
@ -120,6 +123,7 @@ model User {
hideBranding Boolean @default(false) hideBranding Boolean @default(false)
theme String? theme String?
createdDate DateTime @default(now()) @map(name: "created") createdDate DateTime @default(now()) @map(name: "created")
trialEndsAt DateTime?
eventTypes EventType[] @relation("user_eventtype") eventTypes EventType[] @relation("user_eventtype")
credentials Credential[] credentials Credential[]
teams Membership[] teams Membership[]
@ -138,6 +142,7 @@ model User {
Schedule Schedule[] Schedule Schedule[]
webhooks Webhook[] webhooks Webhook[]
brandColor String @default("#292929") brandColor String @default("#292929")
darkBrandColor String @default("#fafafa")
// the location where the events will end up // the location where the events will end up
destinationCalendar DestinationCalendar? destinationCalendar DestinationCalendar?
away Boolean @default(false) away Boolean @default(false)
@ -345,11 +350,13 @@ enum WebhookTriggerEvents {
model Webhook { model Webhook {
id String @id @unique id String @id @unique
userId Int userId Int?
eventTypeId Int?
subscriberUrl String subscriberUrl String
payloadTemplate String? payloadTemplate String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
active Boolean @default(true) active Boolean @default(true)
eventTriggers WebhookTriggerEvents[] 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"], "exclude": ["node_modules"],
"ts-node": { "ts-node": {
"files": true,
"require": ["tsconfig-paths/register"],
"experimentalResolverFeatures": true,
"compilerOptions": { "compilerOptions": {
"module": "CommonJS", "module": "CommonJS",
"types": ["node"] "types": ["node"]

View File

@ -7,5 +7,8 @@
"base.json", "base.json",
"nextjs.json", "nextjs.json",
"react-library.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" && color === "primary" &&
(disabled (disabled
? "border border-transparent bg-gray-400 text-white" ? "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" && color === "secondary" &&
(disabled (disabled
? "border border-gray-200 text-gray-400 bg-white" ? "border border-gray-200 text-gray-400 bg-white"

View File

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

View File

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