Compare commits

...
34 Commits
Author SHA1 Message Date
9441da9d4e fix: custom meeting link overflowing (#9804)
release.yml / release (push) Canceled after 0s
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-06-26 19:35:55 +00:00
Pradumn Kumar 1510740cb2 fix: adds triggerEvent attribute to meeting ended webhook (#9806) 2023-06-26 14:35:32 -04:00
Keith Williams 8da68cc1fe chore: Added test SendGrid env variables (#9807) 2023-06-26 18:59:39 +02:00
Crowdin Bot 6dbc8dd820 New Crowdin translations by Github Action 2023-06-26 15:25:09 +00:00
Hariom Balhara 4ada428bcb fix: Embed - Better support for layout config(without flash of default toggle) (#9714) 2023-06-26 17:24:19 +02:00
Crowdin Bot 6adaf0d3e8 New Crowdin translations by Github Action 2023-06-26 14:11:17 +00:00
Peer Richelsen c9e6acfa69 Update common.json (#9788) 2023-06-26 19:40:13 +05:30
Hariom Balhara 2272da5ee7 test: New Booker Tests for Embed (#9666) 2023-06-26 15:27:24 +02:00
Carina WollendorferandCarinaWolli 79b3875af3 fix: add UAE to list of non alphanumeric sender id support (#9752)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-06-26 15:25:27 +02:00
Peer Richelsen d16b6fce8f Update package.json 2023-06-26 15:13:31 +02:00
Gaurav Goswami 40b35bdbe4 bug/changed icon (#9801) 2023-06-26 13:00:19 +00:00
Keith Williams 0af76ec5ab chore: Updated staleness check to start with old content (#9800) 2023-06-26 14:49:41 +02:00
Ayush Mainali f7b20da640 fix: fixed layout shift issue of top banner (#9791) 2023-06-26 14:12:57 +02:00
Ritik KumarandCarina Wollendorfer eedf6913b8 feat: allow duplicate availability (#9615)
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2023-06-26 07:35:20 -04:00
78393a2b8b feat: disable workflows and webhooks for orgs + UI changes [CAL-1873] (#9504)
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Leo Giovanetti <hello@leog.me>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Efraín Rochín <roae.85@gmail.com>
2023-06-26 06:37:59 -04:00
Kinjalk Bajpai b3d64104b0 fix: Removing 2fa text that appears twice (#9762) 2023-06-25 15:21:39 +00:00
9266ba1e48 fix: don't show tool-tip when negative or zero recurring events (#9612)
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-06-25 15:09:32 +00:00
Ujwal Kumar ec212e4317 fix-borders-on-weekly-view (#9770) 2023-06-25 17:04:25 +02:00
Kinjalk Bajpai 02d4f0542a fix: Broken Heroku Image in README.md (#9772) 2023-06-25 16:59:36 +02:00
Crowdin Bot 0aae07ae2f New Crowdin translations by Github Action 2023-06-23 20:50:05 +00:00
Daniel Roe 80ab6c9c98 fix: add space to make link clickable (#9763) 2023-06-23 22:49:20 +02:00
Afzal Sayed 86ba0766fc Prevent invalid api call (#9757) 2023-06-23 22:48:38 +02:00
Crowdin Bot 0adde17f38 New Crowdin translations by Github Action 2023-06-23 20:03:43 +00:00
Crowdin Bot 9e4700f9df New Crowdin translations by Github Action 2023-06-23 20:02:48 +00:00
Leo Giovanetti f4a87c66b3 Adding team and org registration suggestion (#9764) 2023-06-23 22:01:54 +02:00
0c06f1f6cb feat: Add google spam warning on booking confirmation page for gmail users ( CALCOM-9247) (#9531)
* feat: Add google spam warning on booking confirmation page for gmail users

* Update apps/web/pages/booking/[uid].tsx

Co-authored-by: alannnc <alannnc@gmail.com>

---------

Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: alannnc <alannnc@gmail.com>
2023-06-23 17:13:16 +00:00
Hariom Balhara b731dfd421 fix: Error during booking when default app is a Static Link Video App and Event has Organizer default location selected. (#9734) 2023-06-23 19:04:34 +02:00
sean-brydon 0b704b9ed4 test: Remove mockdate and replate with vitest system time (#9750) 2023-06-23 16:10:43 +00:00
Soham DattaandBailey Pumfleet a0ece0d77e fix: added loading skeleton for teams profile view (#9746)
Co-authored-by: Bailey Pumfleet <bailey@pumfleet.co.uk>
2023-06-23 13:14:18 +00:00
Crowdin Bot 7839381911 New Crowdin translations by Github Action 2023-06-23 12:52:50 +00:00
Hariom Balhara 6a976f5b42 feat: New Booker: Disable dialog form in mobile in embed (#9748) 2023-06-23 18:22:03 +05:30
Peer Richelsenandalannnc 8718905474 docs: added "commerical" to API readme (#9636)
* Update README.md

* Update apps/api/README.md

* Update apps/api/README.md

---------

Co-authored-by: alannnc <alannnc@gmail.com>
2023-06-23 11:06:01 +02:00
Jaideep Guntupalli ff968d3867 fix: Added relevant colors to preset and replaced with tailwind colors (#9733) 2023-06-23 09:05:37 +00:00
Jaideep Guntupalliandsean-brydon 8f5e6370b0 feat: Added Storybook Controls (#9676)
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2023-06-23 09:04:26 +00:00
112 changed files with 1585 additions and 787 deletions
+1
View File
@@ -16,6 +16,7 @@ jobs:
steps:
- uses: actions/stale@v7
with:
ascending: true
days-before-issue-stale: 60
days-before-issue-close: -1
days-before-pr-stale: 14
+2
View File
@@ -31,6 +31,8 @@ env:
# INPUT_ENV_MS_GRAPH_CLIENT_SECRET: xxx
# INPUT_ENV_ZOOM_CLIENT_ID: xxx
# INPUT_ENV_ZOOM_CLIENT_SECRET: xxx
INPUT_ENV_SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
INPUT_ENV_SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
INPUT_ENV_TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
INPUT_ENV_TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
+1 -1
View File
@@ -321,7 +321,7 @@ Cal.com, Inc. does not provide official support for Docker, but we will accept f
### Heroku
<a href="https://heroku.com/deploy?template=https://github.com/calcom/cal.com">
<img width="185px" height="auto" src="https://herokucdn.com/deploy/button.svg" alt="Deploy">
<img width="185px" height="auto" src="https://www.herokucdn.com/deploy/button.svg" alt="Deploy">
</a>
### Railway
+1 -1
View File
@@ -7,7 +7,7 @@
<a href="https://cal.com/docs/enterprise-features/api#api-server-specifications">Read the API docs</a>
</div>
# Cal.com Public API
# Commercial Cal.com Public API
Welcome to the Public API ("/apps/api") of the Cal.com.
@@ -209,6 +209,7 @@
--cal-bg-success: #E2FBE8;
--cal-bg-attention: #FCEED8;
--cal-bg-error: #F9E3E2;
--cal-bg-dark-error: #752522;
/* Borders */
@@ -216,6 +217,7 @@
--cal-border: #D1D5DB;
--cal-border-subtle:#E5E7EB;
--cal-border-muted:#F3F4F6;
--cal-border-error: #aa2e26;
/* Content/Text */
--cal-text-emphasis: #111827;
@@ -251,6 +253,7 @@
--cal-bg-success: #E2FBE8;
--cal-bg-attention: #FCEED8;
--cal-bg-error: #F9E3E2;
--cal-bg-dark-error: #752522;
/* Borders */
@@ -258,6 +261,7 @@
--cal-border: #444444;
--cal-border-subtle: #2B2B2B;
--cal-border-muted: #1C1C1C;
--cal-border-error: #aa2e26;
/* Content/Text */
--cal-text-emphasis: #F3F4F6;
+80 -18
View File
@@ -10,7 +10,7 @@ import { components } from "react-select";
import type { BookerLayout } from "@calcom/features/bookings/Booker/types";
import { useFlagMap } from "@calcom/features/flags/context/provider";
import { APP_NAME, EMBED_LIB_URL, WEBAPP_URL } from "@calcom/lib/constants";
import { APP_NAME, EMBED_LIB_URL, IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { BookerLayouts } from "@calcom/prisma/zod-utils";
import {
@@ -38,13 +38,21 @@ const enum Theme {
dark = "dark",
}
const EMBED_CAL_ORIGIN = WEBAPP_URL;
const EMBED_CAL_JS_URL = `${WEBAPP_URL}/embed/embed.js`;
type PreviewState = {
inline: {
width: string;
height: string;
};
theme: Theme;
floatingPopup: Record<string, string | boolean | undefined>;
floatingPopup: {
config?: {
layout: BookerLayouts;
};
[key: string]: string | boolean | undefined | Record<string, string>;
};
elementClick: Record<string, string>;
palette: {
brandColor: string;
@@ -175,14 +183,24 @@ const Codes: Record<string, Record<string, (...args: any[]) => string>> = {
return code`
import Cal, { getCalApi } from "@calcom/embed-react";
import { useEffect } from "react";
function MyComponent() {
export default function MyApp() {
useEffect(()=>{
(async function () {
const cal = await getCalApi();
${uiInstructionCode}
})();
}, [])
return <Cal calLink="${calLink}" style={{width:"${width}",height:"${height}",overflow:"scroll"}} />;
return <Cal
calLink="${calLink}"
style={{width:"${width}",height:"${height}",overflow:"scroll"}}
${previewState.layout ? "config={{layout: '" + previewState.layout + "'}}" : ""}${
IS_SELF_HOSTED
? `
calOrigin="${EMBED_CAL_ORIGIN}"
calJsUrl="${EMBED_CAL_JS_URL}"`
: ""
}
/>;
};`;
},
"floating-popup": ({
@@ -193,38 +211,60 @@ function MyComponent() {
uiInstructionCode: string;
}) => {
return code`
import Cal, { getCalApi } from "@calcom/embed-react";
import { getCalApi } from "@calcom/embed-react";
import { useEffect } from "react";
function MyComponent() {
export default function App() {
useEffect(()=>{
(async function () {
const cal = await getCalApi();
const cal = await getCalApi(${IS_SELF_HOSTED ? `"${EMBED_CAL_JS_URL}"` : ""});
cal("floatingButton", ${floatingButtonArg});
${uiInstructionCode}
})();
}, [])
};`;
},
"element-click": ({ calLink, uiInstructionCode }: { calLink: string; uiInstructionCode: string }) => {
"element-click": ({
calLink,
uiInstructionCode,
previewState,
}: {
calLink: string;
uiInstructionCode: string;
previewState: PreviewState;
}) => {
return code`
import Cal, { getCalApi } from "@calcom/embed-react";
import { getCalApi } from "@calcom/embed-react";
import { useEffect } from "react";
function MyComponent() {
export default function App() {
useEffect(()=>{
(async function () {
const cal = await getCalApi();
const cal = await getCalApi(${IS_SELF_HOSTED ? `"${EMBED_CAL_JS_URL}"` : ""});
${uiInstructionCode}
})();
}, [])
return <button data-cal-link="${calLink}" />;
return <button
data-cal-link="${calLink}"${IS_SELF_HOSTED ? `\ndata-cal-origin="${EMBED_CAL_ORIGIN}"` : ""}
${`data-cal-config='${JSON.stringify({
layout: previewState.layout,
})}'`}
>Click me</button>;
};`;
},
},
HTML: {
inline: ({ calLink, uiInstructionCode }: { calLink: string; uiInstructionCode: string }) => {
inline: ({
calLink,
uiInstructionCode,
previewState,
}: {
calLink: string;
uiInstructionCode: string;
previewState: PreviewState;
}) => {
return code`Cal("inline", {
elementOrSelector:"#my-cal-inline",
calLink: "${calLink}"
calLink: "${calLink}",
layout: "${previewState.layout}"
});
${uiInstructionCode}`;
@@ -240,8 +280,22 @@ ${uiInstructionCode}`;
return code`Cal("floatingButton", ${floatingButtonArg});
${uiInstructionCode}`;
},
"element-click": ({ calLink, uiInstructionCode }: { calLink: string; uiInstructionCode: string }) => {
return code`// Important: Make sure to add \`data-cal-link="${calLink}"\` attribute to the element you want to open Cal on click
"element-click": ({
calLink,
uiInstructionCode,
previewState,
}: {
calLink: string;
uiInstructionCode: string;
previewState: PreviewState;
}) => {
return code`
// Important: Please add following attributes to the element you want to open Cal on click
// \`data-cal-link="${calLink}"\`
// \`data-cal-config='${JSON.stringify({
layout: previewState.layout,
})}'\`
${uiInstructionCode}`;
},
},
@@ -298,6 +352,7 @@ const getEmbedTypeSpecificString = ({
} else if (embedType === "floating-popup") {
const floatingButtonArg = {
calLink,
...(IS_SELF_HOSTED ? { calOrigin: EMBED_CAL_ORIGIN } : null),
...previewState.floatingPopup,
};
return frameworkCodes[embedType]({
@@ -710,13 +765,13 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
height: "100%",
},
theme: Theme.auto,
layout: BookerLayouts.MONTH_VIEW,
floatingPopup: {},
elementClick: {},
hideEventTypeDetails: false,
palette: {
brandColor: "#000000",
},
layout: BookerLayouts.MONTH_VIEW,
});
const close = () => {
@@ -778,8 +833,8 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
name: "ui",
arg: {
theme: previewState.theme,
hideEventTypeDetails: previewState.hideEventTypeDetails,
layout: previewState.layout,
hideEventTypeDetails: previewState.hideEventTypeDetails,
styles: {
branding: {
...previewState.palette,
@@ -1102,8 +1157,15 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
return;
}
setPreviewState((previewState) => {
const config = {
...(previewState.floatingPopup.config ?? {}),
layout: option.value,
};
return {
...previewState,
floatingPopup: {
config,
},
layout: option.value,
};
});
@@ -561,7 +561,7 @@ const BookingPage = ({
recurringStrings.slice(0, 5).map((timeFormatted, key) => {
return <p key={key}>{timeFormatted}</p>;
})}
{!rescheduleUid && eventType.recurringEvent?.freq && recurringStrings.length > 5 && (
{!rescheduleUid && eventType?.recurringEvent?.freq && recurringStrings.length > 5 && (
<div className="flex">
<Tooltip
content={recurringStrings.slice(5).map((timeFormatted, key) => (
@@ -3,7 +3,7 @@ import { useForm } from "react-hook-form";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Dialog, DialogContent, Form, Label, PasswordField } from "@calcom/ui";
import { Button, Dialog, DialogContent, Form, PasswordField } from "@calcom/ui";
import TwoFactor from "@components/auth/TwoFactor";
@@ -75,8 +75,6 @@ const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuth
{...form.register("password")}
className="border-default mt-1 block w-full rounded-md border px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-black"
/>
<Label className="mt-4"> {t("2fa_code")}</Label>
<TwoFactor center={false} />
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
</div>
@@ -3,7 +3,7 @@ import { useForm } from "react-hook-form";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Dialog, DialogContent, Form, Label, PasswordField } from "@calcom/ui";
import { Button, Dialog, DialogContent, Form, PasswordField } from "@calcom/ui";
import TwoFactor from "@components/auth/TwoFactor";
@@ -82,8 +82,6 @@ const DisableTwoFactorAuthModal = ({
{...form.register("password")}
className="border-default mt-1 block w-full rounded-md border px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-black"
/>
<Label className="mt-4"> {t("2fa_code")}</Label>
<TwoFactor center={false} />
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
+3 -3
View File
@@ -100,7 +100,7 @@ plugins.push(withAxiom);
const userTypeRouteRegExp = `/:user((?!${pages.join("/|")})[^/]*)/:type((?!book$)[^/]+)`;
const teamTypeRouteRegExp = "/team/:slug/:type((?!book$)[^/]+)";
const privateLinkRouteRegExp = "/d/:link/:slug((?!book$)[^/]+)";
const embedUserTypeRouteRegExp = `/:user((?!${pages.join("|")}).*)/:type/embed`;
const embedUserTypeRouteRegExp = `/:user((?!${pages.join("/|")})[^/]*)/:type/embed`;
const embedTeamTypeRouteRegExp = "/team/:slug/:type/embed";
/** @type {import("next").NextConfig} */
@@ -312,11 +312,11 @@ const nextConfig = {
afterFiles.push(
...[
{
source: `/:user((?!${pages.join("|")}).*)/:type/embed`,
source: embedUserTypeRouteRegExp,
destination: "/new-booker/:user/:type/embed",
},
{
source: "/team/:slug/:type/embed",
source: embedTeamTypeRouteRegExp,
destination: "/new-booker/team/:slug/:type/embed",
},
]
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.0.6",
"version": "3.0.7",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -162,7 +162,6 @@
"copy-webpack-plugin": "^11.0.0",
"detect-port": "^1.3.0",
"env-cmd": "^10.1.0",
"mockdate": "^3.0.5",
"module-alias": "^2.2.2",
"msw": "^0.42.3",
"postcss": "^8.4.18",
+89 -41
View File
@@ -3,22 +3,37 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { orgDomainConfig, subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
import { DOCS_URL, JOIN_SLACK, WEBSITE_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HeadSeo } from "@calcom/ui";
import { BookOpen, Check, ChevronRight, FileText, Slack } from "@calcom/ui/components/icon";
import { BookOpen, Check, ChevronRight, FileText, Slack, Shield } from "@calcom/ui/components/icon";
import PageWrapper from "@components/PageWrapper";
import { ssgInit } from "@server/lib/ssg";
enum pageType {
ORG = "org",
TEAM = "team",
USER = "user",
OTHER = "other",
}
export default function Custom404() {
const { t } = useLocale();
const router = useRouter();
const [username] = router.asPath.replace("%20", "-").split(/[?#]/);
const [username, setUsername] = useState<string>("");
const [currentPageType, setCurrentPageType] = useState<pageType>(pageType.USER);
const links = [
{
title: "Enterprise",
description: "Learn more about organizations and subdomains in our enterprise plan.",
icon: Shield,
href: `${WEBSITE_URL}/enterprise`,
},
{
title: t("documentation"),
description: t("documentation_description"),
@@ -33,10 +48,29 @@ export default function Custom404() {
},
];
const [url, setUrl] = useState(`${WEBSITE_URL}/signup?username=`);
const [url, setUrl] = useState(`${WEBSITE_URL}/signup`);
useEffect(() => {
setUrl(`${WEBSITE_URL}/signup?username=${username.replace("/", "")}`);
}, [username]);
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(window.location.host);
const [routerUsername] = router.asPath.replace("%20", "-").split(/[?#]/);
if (!isValidOrgDomain || !currentOrgDomain) {
const splitPath = routerUsername.split("/");
if (splitPath[1] === "team" && splitPath.length === 3) {
// Accessing a non-existent team
setUsername(splitPath[2]);
setCurrentPageType(pageType.TEAM);
setUrl(`${WEBSITE_URL}/signup?callbackUrl=settings/teams/new%3Fslug%3D${username.replace("/", "")}`);
} else {
setUsername(routerUsername);
setUrl(`${WEBSITE_URL}/signup?username=${username.replace("/", "")}`);
}
} else {
setUsername(currentOrgDomain);
setCurrentPageType(pageType.ORG);
setUrl(
`${WEBSITE_URL}/signup?callbackUrl=settings/organizations/new%3Fslug%3D${username.replace("/", "")}`
);
}
}, []);
const isSuccessPage = router.asPath.startsWith("/booking");
const isSubpage = router.asPath.includes("/", 2) || isSuccessPage;
@@ -80,6 +114,8 @@ export default function Custom404() {
);
}
if (!username) return null;
return (
<>
<HeadSeo
@@ -202,13 +238,13 @@ export default function Custom404() {
<h1 className="font-cal text-emphasis mt-2 text-4xl font-extrabold sm:text-5xl">
{isSuccessPage ? "Booking not found" : t("page_doesnt_exist")}
</h1>
{isSubpage ? (
{isSubpage && currentPageType !== pageType.TEAM ? (
<span className="mt-2 inline-block text-lg ">
{t("check_spelling_mistakes_or_go_back")}
</span>
) : isCalcom ? (
<a target="_blank" href={url} className="mt-2 inline-block text-lg" rel="noreferrer">
{t("the_username")}{" "}
{t(`404_the_${currentPageType.toLowerCase()}`)}{" "}
<strong className="text-blue-500">
{new URL(WEBSITE_URL).hostname}
{username}
@@ -217,18 +253,17 @@ export default function Custom404() {
</a>
) : (
<span className="mt-2 inline-block text-lg">
{t("the_username")}{" "}
{t(`404_the_${currentPageType.toLowerCase()}`)}{" "}
<strong className="text-lgtext-green-500 mt-2 inline-block">{username}</strong>{" "}
{t("is_still_available")}
</span>
)}
</div>
<div className="mt-12">
<h2 className="text-subtle text-sm font-semibold uppercase tracking-wide">
{t("popular_pages")}
</h2>
{!isSubpage && isCalcom && (
<ul role="list" className="mt-4">
{((!isSubpage && isCalcom) ||
currentPageType === pageType.ORG ||
currentPageType === pageType.TEAM) && (
<ul role="list" className="my-4">
<li className="border-2 border-green-500 px-4 py-2">
<a
href={url}
@@ -245,11 +280,20 @@ export default function Custom404() {
<span className="focus-within:ring-empthasis rounded-sm focus-within:ring-2 focus-within:ring-offset-2">
<span className="focus:outline-none">
<span className="absolute inset-0" aria-hidden="true" />
{t("register")} <strong className="text-green-500">{username}</strong>
{t("register")}{" "}
<strong className="text-green-500">{`${
currentPageType === pageType.TEAM
? `${new URL(WEBSITE_URL).host}/team/`
: ""
}${username}${
currentPageType === pageType.ORG ? `.${subdomainSuffix()}` : ""
}`}</strong>
</span>
</span>
</h3>
<p className="text-subtle text-base">{t("claim_username_and_schedule_events")}</p>
<p className="text-subtle text-base">
{t(`404_claim_entity_${currentPageType.toLowerCase()}`)}
</p>
</div>
<div className="flex-shrink-0 self-center">
<ChevronRight className="text-muted h-5 w-5" aria-hidden="true" />
@@ -258,33 +302,37 @@ export default function Custom404() {
</li>
</ul>
)}
<ul role="list" className="border-subtle divide-subtle mt-4 divide-y">
{links.map((link, linkIdx) => (
<li key={linkIdx} className="px-4 py-2">
<Link
href={link.href}
className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse">
<div className="flex-shrink-0">
<span className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg">
<link.icon className="text-default h-6 w-6" aria-hidden="true" />
</span>
</div>
<div className="min-w-0 flex-1">
<h3 className="text-emphasis text-base font-medium">
<span className="focus-within:ring-empthasis rounded-sm focus-within:ring-2 focus-within:ring-offset-2">
<span className="absolute inset-0" aria-hidden="true" />
{link.title}
<h2 className="text-subtle text-sm font-semibold uppercase tracking-wide">
{t("popular_pages")}
</h2>
<ul role="list" className="border-subtle divide-subtle divide-y">
{links
.filter((_, idx) => currentPageType === pageType.ORG || idx !== 0)
.map((link, linkIdx) => (
<li key={linkIdx} className="px-4 py-2">
<Link
href={link.href}
className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse">
<div className="flex-shrink-0">
<span className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg">
<link.icon className="text-default h-6 w-6" aria-hidden="true" />
</span>
</h3>
<p className="text-subtle text-base">{link.description}</p>
</div>
<div className="flex-shrink-0 self-center">
<ChevronRight className="text-muted h-5 w-5" aria-hidden="true" />
</div>
</Link>
</li>
))}
</div>
<div className="min-w-0 flex-1">
<h3 className="text-emphasis text-base font-medium">
<span className="focus-within:ring-empthasis rounded-sm focus-within:ring-2 focus-within:ring-offset-2">
<span className="absolute inset-0" aria-hidden="true" />
{link.title}
</span>
</h3>
<p className="text-subtle text-base">{link.description}</p>
</div>
<div className="flex-shrink-0 self-center">
<ChevronRight className="text-muted h-5 w-5" aria-hidden="true" />
</div>
</Link>
</li>
))}
<li className="px-4 py-2">
<a
href={JOIN_SLACK}
+17
View File
@@ -1,4 +1,5 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useRouter } from "next/router";
import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules";
import Shell from "@calcom/features/shell/Shell";
@@ -20,6 +21,8 @@ export function AvailabilityList({ schedules }: RouterOutputs["viewer"]["availab
const meQuery = trpc.viewer.me.useQuery();
const router = useRouter();
const deleteMutation = trpc.viewer.availability.schedule.delete.useMutation({
onMutate: async ({ scheduleId }) => {
await utils.viewer.availability.list.cancel();
@@ -67,6 +70,19 @@ export function AvailabilityList({ schedules }: RouterOutputs["viewer"]["availab
},
});
const duplicateMutation = trpc.viewer.availability.schedule.duplicate.useMutation({
onSuccess: async ({ schedule }) => {
await router.push(`/availability/${schedule.id}`);
showToast(t("schedule_created_successfully", { scheduleName: schedule.name }), "success");
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
},
});
// Adds smooth delete button - item fades and old item slides into place
const [animationParentRef] = useAutoAnimate<HTMLUListElement>();
@@ -96,6 +112,7 @@ export function AvailabilityList({ schedules }: RouterOutputs["viewer"]["availab
isDeletable={schedules.length !== 1}
updateDefault={updateMutation.mutate}
deleteFunction={deleteMutation.mutate}
duplicateFunction={duplicateMutation.mutate}
/>
))}
</ul>
+26 -1
View File
@@ -49,8 +49,9 @@ import type { Prisma } from "@calcom/prisma/client";
import { BookingStatus } from "@calcom/prisma/enums";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { Button, EmailInput, HeadSeo, Badge, useCalcomTheme } from "@calcom/ui";
import { Button, EmailInput, HeadSeo, Badge, useCalcomTheme, Alert } from "@calcom/ui";
import { X, ExternalLink, ChevronLeft, Check, Calendar } from "@calcom/ui/components/icon";
import { AlertCircle } from "@calcom/ui/components/icon";
import { timeZone } from "@lib/clock";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
@@ -127,6 +128,10 @@ export default function Success(props: SuccessProps) {
? props?.bookingInfo?.attendees?.[0]?.name
: "Nameless";
const attendees = props?.bookingInfo?.attendees;
const isGmail = !!attendees.find((attendee) => attendee.email.includes("gmail.com"));
const [is24h, setIs24h] = useState(isBrowserLocale24h());
const { data: session } = useSession();
@@ -705,6 +710,26 @@ export default function Success(props: SuccessProps) {
</>
)}
</div>
{isGmail && (
<Alert
className="main -mb-20 mt-4 inline-block text-left sm:-mt-4 sm:mb-4 sm:w-full sm:max-w-xl sm:align-middle"
severity="warning"
message={
<div>
<p className="font-semibold">{t("google_new_spam_policy")}</p>
<span className="underline">
<a
target="_blank"
href="https://cal.com/blog/google-s-new-spam-policy-may-be-affecting-your-invitations">
{t("resolve")}
</a>
</span>
</div>
}
CustomIcon={AlertCircle}
customIconColor="text-attention dark:text-orange-200"
/>
)}
</div>
</div>
</div>
@@ -172,7 +172,7 @@
"guests": "الضيوف",
"guest": "الضيف",
"web_conferencing_details_to_follow": "تفاصيل مؤتمرات الويب القادمة.",
"the_username": "اسم المستخدم",
"404_the_user": "اسم المستخدم",
"username": "اسم المستخدم",
"is_still_available": "لا يزال متاحًا.",
"documentation": "المستندات",
@@ -183,7 +183,7 @@
"blog_description": "اقرأ أحدث أخبارنا ومقالاتنا",
"join_our_community": "انضم إلى مجتمعنا",
"join_our_slack": "انضم إلى Slack الخاص بنا",
"claim_username_and_schedule_events": "احصل على اسم المستخدم وقم بجدولة الأحداث",
"404_claim_entity_user": "احصل على اسم المستخدم وقم بجدولة الأحداث",
"popular_pages": "الصفحات الشائعة",
"register_now": "التسجيل الآن",
"register": "تسجيل",
@@ -231,7 +231,6 @@
"finish": "إنهاء",
"few_sentences_about_yourself": "اكتب بضع جمل عن نفسك. سيظهر هذا على صفحة عنوان URL الشخصية لديك.",
"nearly_there": "أوشكت على الانتهاء!",
"nearly_there_instructions": "آخر شيء: تساعدك كتابة وصف موجز عنك ووضع صورة في الحصول على عمليات الحجز وإعلام الأشخاص بمَن سيحجزون معه حقًا.",
"set_availability_instructions": "حدد الفترات الزمنية التي تكون متاحًا فيها بشكل متكرر. يمكنك لاحقًا تحديد المزيد منها وربطها مع تقاويم مختلفة.",
"set_availability": "تحديد الوقت الذي تكون فيه متاحًا",
"continue_without_calendar": "المتابعة من دون تقويم",
@@ -172,7 +172,7 @@
"guests": "Hosté",
"guest": "Host",
"web_conferencing_details_to_follow": "Podrobnosti k videohovoru, které je třeba následovat.",
"the_username": "Uživatelské jméno",
"404_the_user": "Uživatelské jméno",
"username": "Uživatelské jméno",
"is_still_available": "je stále dostupný.",
"documentation": "Dokumentace",
@@ -183,7 +183,7 @@
"blog_description": "Přečtěte si naše nejnovější novinky a články",
"join_our_community": "Připojte se k naší komunitě",
"join_our_slack": "Připojte se k našemu Slacku",
"claim_username_and_schedule_events": "Nárokujte si své uživatelské jméno a plánujte události",
"404_claim_entity_user": "Nárokujte si své uživatelské jméno a plánujte události",
"popular_pages": "Oblíbené stránky",
"register_now": "Zaregistrovat se nyní",
"register": "Registrovat se",
@@ -231,7 +231,6 @@
"finish": "Dokončit",
"few_sentences_about_yourself": "Pár vět o vás. Budou se zobrazovat na URL vaší osobní stránky.",
"nearly_there": "Téměř hotovo!",
"nearly_there_instructions": "A na závěr: Stručný text o vás a fotografie vám značně pomůžou v získávání rezervací. A ostatní díky tomu budou vědět, koho si rezervují.",
"set_availability_instructions": "Určete si časové intervaly, kdy jste pravidelně dostupní. Později můžete vytvořit další a přiřadit je různým kalendářům.",
"set_availability": "Nastavte, jak jste dostupní",
"continue_without_calendar": "Pokračovat bez kalendáře",
@@ -165,7 +165,7 @@
"guests": "Gæster",
"guest": "Gæst",
"web_conferencing_details_to_follow": "Webkonference detaljer følger i bekræftelses-e-mailen.",
"the_username": "Brugernavnet",
"404_the_user": "Brugernavnet",
"username": "Brugernavn",
"is_still_available": "er stadig tilgængeligt.",
"documentation": "Dokumentation",
@@ -176,7 +176,7 @@
"blog_description": "Læs vores seneste nyheder og artikler",
"join_our_community": "Tilmeld dig vores fællesskab",
"join_our_slack": "Tilmeld dig vores Slack",
"claim_username_and_schedule_events": "Få dit brugernavn og planlæg begivenheder",
"404_claim_entity_user": "Få dit brugernavn og planlæg begivenheder",
"popular_pages": "Populære sider",
"register_now": "Tilmeld dig nu",
"register": "Registrér",
@@ -222,7 +222,6 @@
"finish": "Afslut",
"few_sentences_about_yourself": "Et par sætninger om dig selv. Dette vil blive vist på din personlige url side.",
"nearly_there": "Du er der næsten!",
"nearly_there_instructions": "Til sidst, en kort beskrivelse om dig og et foto vil virkelig hjælpe dig med at få bookinger og lade folk vide, hvem de booker med.",
"set_availability_instructions": "Definér tidsintervaller hvor du er tilgængelig på en tilbagevendende basis. Du kan oprette flere af disse senere og tildele dem til forskellige kalendere.",
"set_availability": "Angiv din tilgængelighed",
"continue_without_calendar": "Fortsæt uden kalender",
@@ -172,7 +172,7 @@
"guests": "Gäste",
"guest": "Gast",
"web_conferencing_details_to_follow": "Web-Konferenzdetails folgen.",
"the_username": "Der Benutzername",
"404_the_user": "Der Benutzername",
"username": "Benutzername",
"is_still_available": "ist noch verfügbar.",
"documentation": "Dokumentation",
@@ -183,7 +183,7 @@
"blog_description": "Lesen Sie unsere Neuigkeiten und Artikel",
"join_our_community": "Treten Sie unserer Community bei",
"join_our_slack": "Treten Sie unserem Slack bei",
"claim_username_and_schedule_events": "Benutzername registrieren und Termine vergeben",
"404_claim_entity_user": "Benutzername registrieren und Termine vergeben",
"popular_pages": "Beliebte Seiten",
"register_now": "Jetzt registrieren",
"register": "Registrieren",
@@ -231,7 +231,6 @@
"finish": "Fertig",
"few_sentences_about_yourself": "Schreiben Sie eine kurze Beschreibung, welche auf Ihrer persönlichen URL-Seite erscheinen wird.",
"nearly_there": "Fast fertig",
"nearly_there_instructions": "Zuletzt, eine kurze Beschreibung über Sie und ein Foto hilft, mehr Buchungen zu machen, da Kunden dann besser wissen, wen sie buchen.",
"set_availability_instructions": "Definieren Sie Zeiträume, wenn Sie wiederkehrend verfügbar sind. Sie können später weitere Zeiträume erstellen und verschiedenen Kalendern zuordnen.",
"set_availability": "Verfügbarkeit festlegen",
"continue_without_calendar": "Ohne Kalender fortfahren",
@@ -187,7 +187,7 @@
"guests": "Guests",
"guest": "Guest",
"web_conferencing_details_to_follow": "Web conferencing details to follow in the confirmation email.",
"the_username": "The username",
"404_the_user": "The username",
"username": "Username",
"is_still_available": "is still available.",
"documentation": "Documentation",
@@ -198,7 +198,7 @@
"blog_description": "Read our latest news and articles",
"join_our_community": "Join our community",
"join_our_slack": "Join our Slack",
"claim_username_and_schedule_events": "Claim your username and schedule events",
"404_claim_entity_user": "Claim your username and schedule events",
"popular_pages": "Popular pages",
"register_now": "Register now",
"register": "Register",
@@ -249,7 +249,7 @@
"organization_general_description": "Manage settings for your team language and timezone",
"few_sentences_about_yourself": "A few sentences about yourself. This will appear on your personal url page.",
"nearly_there": "Nearly there!",
"nearly_there_instructions": "Last thing, a brief description about you and a photo really help you get bookings and let people know who they’re booking with.",
"nearly_there_instructions": "Last thing, a brief description about you and a photo really helps you get bookings and let people know who they’re booking with.",
"set_availability_instructions": "Define ranges of time when you are available on a recurring basis. You can create more of these later and assign them to different calendars.",
"set_availability": "Set your availability",
"continue_without_calendar": "Continue without calendar",
@@ -1896,9 +1896,15 @@
"my_profile": "My Profile",
"my_settings": "My Settings",
"sender_id_info": "Name or number shown as the sender of an SMS (some countries do not allow alphanumeric sender IDs)",
"google_new_spam_policy": "Google’s new spam policy could prevent you from receiving any email and calendar notifications about this booking.",
"resolve": "Resolve",
"no_organization_slug": "There was an error creating teams for this organization. Missing URL slug.",
"org_name": "Organization name",
"org_url": "Organization URL",
"copy_link_org": "Copy link to organization",
"404_the_org": "The organization",
"404_the_team": "The team",
"404_claim_entity_org": "Claim your subdomain for your organization",
"404_claim_entity_team": "Claim this team and start managing schedules collectively",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -172,7 +172,7 @@
"guests": "Invitados",
"guest": "Invitado",
"web_conferencing_details_to_follow": "Los detalles a seguir de su conferencia, en el correo de confirmación.",
"the_username": "El Nombre de Usuario",
"404_the_user": "El Nombre de Usuario",
"username": "Usuario",
"is_still_available": "aún está disponible.",
"documentation": "Documentación",
@@ -183,7 +183,7 @@
"blog_description": "Lee nuestras últimas noticias y artículos",
"join_our_community": "Únete a nuestra comunidad",
"join_our_slack": "Únete a nuestro Slack",
"claim_username_and_schedule_events": "Reclama tu nombre de usuario y programa eventos",
"404_claim_entity_user": "Reclama tu nombre de usuario y programa eventos",
"popular_pages": "Páginas Populares",
"register_now": "Regístrate Ahora",
"register": "Registrar",
@@ -231,7 +231,6 @@
"finish": "Terminar",
"few_sentences_about_yourself": "Unas pocas frases sobre ti mismo. Esto aparecerá en tu página de url personal.",
"nearly_there": "¡Ya casi está!",
"nearly_there_instructions": "Por último, una breve descripción de ti y una foto realmente te ayudan a hacer tus reservas y a informar a la gente con quién está reservando.",
"set_availability_instructions": "Defina los intervalos de tiempo cuando esté disponible de forma recurrente. Puede crear más tarde y asignarlos a diferentes calendarios.",
"set_availability": "Establecer Disponibilidad",
"continue_without_calendar": "Continuar sin calendario",
+23 -11
View File
@@ -187,7 +187,7 @@
"guests": "Invités",
"guest": "Invité(e)",
"web_conferencing_details_to_follow": "Les détails du rendez-vous sont à retrouver dans l'e-mail de confirmation.",
"the_username": "Le nom d'utilisateur",
"404_the_user": "Le nom d'utilisateur",
"username": "Nom d'utilisateur",
"is_still_available": "est toujours disponible.",
"documentation": "Documentation",
@@ -198,7 +198,7 @@
"blog_description": "Lire nos dernières actualités et articles",
"join_our_community": "Rejoignez notre communauté",
"join_our_slack": "Rejoindre notre Slack",
"claim_username_and_schedule_events": "Réservez votre nom d'utilisateur et planifiez des événements",
"404_claim_entity_user": "Réservez votre nom d'utilisateur et planifiez des événements",
"popular_pages": "Pages populaires",
"register_now": "S'inscrire maintenant",
"register": "S'inscrire",
@@ -339,6 +339,11 @@
"cancelling_event_recurring": "L'événement est une instance d'un événement récurrent.",
"cancelling_all_recurring": "Voici toutes les instances restantes dans l'événement récurrent.",
"error_with_status_code_occured": "Une erreur avec le code de statut {{status}} est survenue.",
"error_event_type_url_duplicate": "Un type d'événement avec cette URL existe déjà.",
"error_event_type_unauthorized_create": "Vous n'êtes pas autorisé à créer cet événement",
"error_event_type_unauthorized_update": "Vous n'êtes pas autorisé à modifier cet événement",
"error_workflow_unauthorized_create": "Vous n'êtes pas autorisé à créer ce workflow",
"error_schedule_unauthorized_create": "Vous n'êtes pas autorisé à créer ce planning",
"booking_already_cancelled": "Cette réservation a déjà été annulée",
"booking_already_accepted_rejected": "Cette réservation a déjà été acceptée ou refusée.",
"go_back_home": "Retour à la page d'accueil",
@@ -621,7 +626,7 @@
"default_duration_no_options": "Veuillez d'abord choisir les durées disponibles",
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
"minutes": "minutes",
"round_robin": "Round-Robin",
"round_robin": "Round-robin",
"round_robin_description": "Alternez vos rendez-vous entre plusieurs membres d'équipe.",
"managed_event": "Événement géré",
"username_placeholder": "nom d'utilisateur",
@@ -1405,7 +1410,7 @@
"manage_billing": "Gérer la facturation",
"manage_billing_description": "Gérez tout ce qui est en lien avec la facturation.",
"billing_freeplan_title": "Vous êtes actuellement sur le plan Gratuit",
"billing_freeplan_description": "Nous travaillons mieux en équipe. Élargissez vos workflows avec le Round-Robin et les événements collectifs et produisez des formulaires de routage avancés.",
"billing_freeplan_description": "Nous travaillons mieux en équipe. Élargissez vos workflows avec les événements round-robin et collectifs et produisez des formulaires de routage avancés.",
"billing_freeplan_cta": "Essayer maintenant",
"billing_portal": "Portail de facturation",
"billing_help_cta": "Contacter l'assistance",
@@ -1487,11 +1492,11 @@
"collective_scheduling": "Planification collective",
"make_it_easy_to_book": "Facilitez la réservation de votre équipe lorsque tout le monde est disponible.",
"find_the_best_person": "Trouvez la meilleure personne disponible et alternez à travers votre équipe.",
"fixed_round_robin": "Round-Robin fixe",
"add_one_fixed_attendee": "Ajoutez un participant fixe et le Round-Robin via un certain nombre de participants.",
"fixed_round_robin": "Round-robin fixe",
"add_one_fixed_attendee": "Ajoutez un participant fixe et le round-robin via un certain nombre de participants.",
"calcom_is_better_with_team": "{{appName}} est meilleur en équipe",
"the_calcom_team": "L'équipe {{companyName}}",
"add_your_team_members": "Ajoutez les membres de votre équipe à vos types d'événements. Utilisez la planification collective pour inclure tout le monde ou trouvez la personne la plus appropriée avec la planification Round-Robin.",
"add_your_team_members": "Ajoutez les membres de votre équipe à vos types d'événements. Utilisez la planification collective pour inclure tout le monde ou trouvez la personne la plus appropriée avec la planification round-robin.",
"booking_limit_reached": "La limite de réservation pour ce type d'événement a été atteinte",
"duration_limit_reached": "La limite de durée pour ce type d'événement a été atteinte",
"admin_has_disabled": "Un administrateur a désactivé {{appName}}",
@@ -1572,7 +1577,7 @@
"assignment": "Affectation",
"fixed_hosts": "Hôtes fixes",
"add_fixed_hosts": "Ajouter des hôtes fixes",
"round_robin_hosts": "Hôtes Round-Robin",
"round_robin_hosts": "Hôtes round-robin",
"minimum_round_robin_hosts_count": "Nombre d'hôtes requis pour participer",
"hosts": "Hôtes",
"upgrade_to_enable_feature": "Vous devez créer une équipe pour activer cette fonctionnalité. Cliquez pour créer une équipe.",
@@ -1652,8 +1657,8 @@
"create_a_managed_event": "Créer un type d'événement géré",
"meetings_are_better_with_the_right": "Les rendez-vous sont meilleurs avec les bons membres d'équipe. Invitez-les maintenant.",
"create_a_one_one_template": "Créez un modèle unique pour un type d'événement et distribuez-le à plusieurs membres.",
"collective_or_roundrobin": "Collective ou Round-Robin",
"book_your_team_members": "Réservez les membres de votre équipe entre eux avec des événements collectifs ou mettez en place un cycle pour obtenir la bonne personne avec le Round-Robin.",
"collective_or_roundrobin": "Collective ou round-robin",
"book_your_team_members": "Réservez les membres de votre équipe entre eux avec des événements collectifs ou mettez en place un cycle pour obtenir la bonne personne avec le round-robin.",
"event_no_longer_attending_subject": "Ne participe plus à {{title}} le {{date}}",
"no_longer_attending": "Vous ne participez plus à cet événement",
"attendee_no_longer_attending_subject": "Un participant ne participe plus à {{title}} le {{date}}",
@@ -1835,6 +1840,7 @@
"pop_up_element_click": "Clic sur un élément",
"open_dialog_with_element_click": "Ouvre votre boîte de dialogue Cal lorsque quelqu'un clique sur un élément.",
"need_help_embedding": "Besoin d'aide ? Consultez nos guides pour intégrer Cal sur Wix, Squarespace ou WordPress, consultez nos questions fréquentes ou explorez les options d'intégration avancées.",
"book_my_cal": "Prendre rendez-vous",
"invite_as": "Inviter en tant que",
"form_updated_successfully": "Formulaire mis à jour avec succès.",
"disable_attendees_confirmation_emails": "Désactiver les e-mails de confirmation par défaut pour les participants",
@@ -1848,7 +1854,7 @@
"first_event_type_webhook_description": "Créez votre premier webhook pour ce type d'événement",
"create_for": "Créer pour",
"setup_organization": "Configurer une organisation",
"organization_banner_description": "Créez un environnement où vos équipes peuvent créer des applications partagées, des workflows et des types d'événements avec une planification Round-Robin et collective.",
"organization_banner_description": "Créez un environnement où vos équipes peuvent créer des applications partagées, des workflows et des types d'événements avec une planification round-robin et collective.",
"organization_banner_title": "Gérer les organisations avec plusieurs équipes",
"set_up_your_organization": "Configurer votre organisation",
"organizations_description": "Les organisations sont des environnements partagés dans lesquels les équipes peuvent créer des types d'événements partagés, des applications, des workflows, etc.",
@@ -1890,9 +1896,15 @@
"my_profile": "Mon profil",
"my_settings": "Mes paramètres",
"sender_id_info": "Nom ou numéro affiché comme expéditeur d'un SMS (certains pays n'autorisent pas les ID d'expéditeur alphanumériques)",
"google_new_spam_policy": "La nouvelle politique anti-spam de Google peut vous empêcher de recevoir des notifications par e-mail ou sur votre calendrier concernant cette réservation.",
"resolve": "Résoudre",
"no_organization_slug": "Une erreur s'est produite lors de la création des équipes pour cette organisation. Le slug du lien est manquant.",
"org_name": "Nom de l'organisation",
"org_url": "Lien de l'organisation",
"copy_link_org": "Copier le lien vers l’organisation",
"404_the_org": "L'organisation",
"404_the_team": "L'équipe",
"404_claim_entity_org": "Réservez votre sous-domaine pour votre organisation",
"404_claim_entity_team": "Revendiquez cette équipe et commencez à gérer vos plannings collectivement",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Ajoutez vos nouvelles chaînes ci-dessus ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -179,7 +179,7 @@
"guests": "אורחים",
"guest": "אורח",
"web_conferencing_details_to_follow": "פרטי שיחת הוועידה המקוונת, הכלולים בדוא\"ל האישור, שיש לפעול על פיהם.",
"the_username": "שם המשתמש",
"404_the_user": "שם המשתמש",
"username": "שם משתמש",
"is_still_available": "עדיין זמין.",
"documentation": "תיעוד",
@@ -190,7 +190,7 @@
"blog_description": "לעיון בחדשות ובמאמרים האחרונים שלנו",
"join_our_community": "הצטרף לקהילה שלנו",
"join_our_slack": "הצטרף לצ׳אט שלנו ב-Slack",
"claim_username_and_schedule_events": "קבל את שם המשתמש שלך והתחל לקבוע מועדים לאירועים",
"404_claim_entity_user": "קבל את שם המשתמש שלך והתחל לקבוע מועדים לאירועים",
"popular_pages": "דפים פופולריים",
"register_now": "להירשם עכשיו",
"register": "הרשמה",
@@ -239,7 +239,6 @@
"finish": "סיום",
"few_sentences_about_yourself": "מספר משפטים אודותיך. המידע הזה יופיע בדף ה-URL האישי שלך.",
"nearly_there": "כמעט סיימנו!",
"nearly_there_instructions": "דבר אחרון: תיאור קצר אודותיך בתוספת תמונה ממש עוזרים להשיג הזמנות ומאפשרים לאנשים לדעת עם מי הם עומדים להיפגש.",
"set_availability_instructions": "הגדר טווחי זמן שבהם אתה זמין באופן קבוע. ניתן יהיה ליצור טווחי זמן נוספים מאוחר יותר ולהקצות אותם ללוחות שנה אחרים.",
"set_availability": "ציין את הזמינות שלך",
"continue_without_calendar": "להמשיך בלי לוח שנה",
@@ -161,7 +161,7 @@
"guests": "Gosti",
"guest": "Gost",
"web_conferencing_details_to_follow": "Pojedinosti o web konferenciji slijede u potvrdnoj e-poruci.",
"the_username": "Korisničko ime",
"404_the_user": "Korisničko ime",
"username": "Korisničko ime",
"is_still_available": "je i dalje dostupno.",
"documentation": "Dokumentacija",
@@ -172,7 +172,7 @@
"blog_description": "Pročitajte naše najnovije vijesti i članke",
"join_our_community": "Pridružite se našoj zajednici",
"join_our_slack": "Pridružite se na naš Slack",
"claim_username_and_schedule_events": "Zatražite svoje korisničko ime i zakažite događaje",
"404_claim_entity_user": "Zatražite svoje korisničko ime i zakažite događaje",
"popular_pages": "Popularne stranice",
"register_now": "Registriraj se sad",
"register": "Registriraj se",
@@ -218,7 +218,6 @@
"finish": "Završi",
"few_sentences_about_yourself": "Nekoliko rečenica o sebi. Ovo će se pojaviti na vašoj osobnoj stranici.",
"nearly_there": "Skoro pa gotovo!",
"nearly_there_instructions": "Posljednja stvar, vaš kratki opis i fotografija stvarno pomažu da dobijete rezervacije i daju ljudima do znanja kod koga rezerviraju.",
"set_availability_instructions": "Definirajte periode kad ste stalno dostupni. Kasnije možete kreirati više njih i dodijeliti ih različitim kalendarima.",
"set_availability": "Postavite svoju dostupnost",
"continue_without_calendar": "Nastavite bez kalendara",
@@ -180,7 +180,7 @@
"guests": "Ospiti",
"guest": "Ospite",
"web_conferencing_details_to_follow": "Dettagli della conferenza web da seguire.",
"the_username": "Il nome utente",
"404_the_user": "Il nome utente",
"username": "Username",
"is_still_available": "è ancora disponibile.",
"documentation": "Documentazione",
@@ -191,7 +191,7 @@
"blog_description": "Leggi le nostre ultime notizie e articoli",
"join_our_community": "Unisciti alla nostra community",
"join_our_slack": "Unisciti al nostro Slack",
"claim_username_and_schedule_events": "Richiedi il tuo nome utente e pianifica eventi",
"404_claim_entity_user": "Richiedi il tuo nome utente e pianifica eventi",
"popular_pages": "Pagine popolari",
"register_now": "Registrati ora",
"register": "Registrati",
@@ -240,7 +240,6 @@
"finish": "Fine",
"few_sentences_about_yourself": "Alcune frasi su di te. Questo apparirà sulla tua pagina url personale.",
"nearly_there": "Ci siamo quasi",
"nearly_there_instructions": "Ultima cosa, una breve descrizione di te e una foto ti aiutano davvero a ottenere prenotazioni e far sapere alle persone con chi stanno prenotando.",
"set_availability_instructions": "Definisci intervalli di tempo di quando sei disponibile su una base ricorrente. Puoi crearne di più in seguito e assegnarli a calendari diversi.",
"set_availability": "Imposta la tua disponibilità",
"continue_without_calendar": "Continua senza calendario",
@@ -172,7 +172,7 @@
"guests": "ゲスト",
"guest": "ゲスト",
"web_conferencing_details_to_follow": "ウェブ会議の詳細には確認メールでお送りします。",
"the_username": "ユーザー名",
"404_the_user": "ユーザー名",
"username": "ユーザー名",
"is_still_available": "まだ利用可能。",
"documentation": "文書",
@@ -183,7 +183,7 @@
"blog_description": "最新のニュースや記事を読む",
"join_our_community": "コミュニティーに参加する",
"join_our_slack": "Slack に参加する",
"claim_username_and_schedule_events": "ユーザー名を獲得してイベントをスケジュールする",
"404_claim_entity_user": "ユーザー名を獲得してイベントをスケジュールする",
"popular_pages": "人気のページ",
"register_now": "今すぐ登録",
"register": "登録",
@@ -231,7 +231,6 @@
"finish": "完了",
"few_sentences_about_yourself": "あなたについてのいくつかの文章。これはあなたの個人URLページに表示されます。",
"nearly_there": "あと少しで完了です!",
"nearly_there_instructions": "最後に、あなたについての簡単な説明と写真は、あなたが予約を取得し、誰と一緒に予約しているかを人々に知らせるのに役立ちます。",
"set_availability_instructions": "定期的に利用できる時間の範囲を定義します。後でこれらをさらに作成し、別のカレンダーに割り当てることができます。",
"set_availability": "利用可否の設定",
"continue_without_calendar": "カレンダーなしで続行",
@@ -173,7 +173,7 @@
"guests": "게스트",
"guest": "게스트",
"web_conferencing_details_to_follow": "따라야 할 웹 회의 세부 정보.",
"the_username": "사용자 이름",
"404_the_user": "사용자 이름",
"username": "사용자 이름",
"is_still_available": "아직 사용할 수 있습니다.",
"documentation": "정보 관리",
@@ -184,7 +184,7 @@
"blog_description": "최신 뉴스 및 기사 읽기",
"join_our_community": "커뮤니티 가입하기",
"join_our_slack": "슬랙 가입하기",
"claim_username_and_schedule_events": "사용자 이름을 입력하고 이벤트를 만드세요.",
"404_claim_entity_user": "사용자 이름을 입력하고 이벤트를 만드세요.",
"popular_pages": "인기 페이지",
"register_now": "지금 등록하세요",
"register": "등록하기",
@@ -232,7 +232,6 @@
"finish": "끝",
"few_sentences_about_yourself": "자신에 대해 몇 마디를 써보세요. 개인 URL 페이지에 보이는 내용입니다.",
"nearly_there": "거의 다 되었습니다!",
"nearly_there_instructions": "마지막으로, 당신에 대해 간단한 소개와 사진을 올려주세요. 소개와 사진은 예약에 도움을 줍니다.",
"set_availability_instructions": "주기적으로 시간이 되는 때가 언제인지 알려주세요. 나중에 더 추가할 수 있습니다.",
"set_availability": "언제 여유가 있는지 설정하세요.",
"continue_without_calendar": "캘린더 없이 계속하기",
@@ -172,7 +172,7 @@
"guests": "Bezoekers",
"guest": "Gast",
"web_conferencing_details_to_follow": "Details van de web conferentie zullen volgen.",
"the_username": "De gebruikersnaam",
"404_the_user": "De gebruikersnaam",
"username": "Gebruikersnaam",
"is_still_available": "is nog steeds beschikbaar.",
"documentation": "Documentatie",
@@ -183,7 +183,7 @@
"blog_description": "Lees ons laatste nieuws en artikelen",
"join_our_community": "Word lid van onze community",
"join_our_slack": "Bezoek onze Slack",
"claim_username_and_schedule_events": "Claim uw gebruikersnaam en plan evenementen",
"404_claim_entity_user": "Claim uw gebruikersnaam en plan evenementen",
"popular_pages": "Populaire pagina's",
"register_now": "Registreer nu",
"register": "Registreer",
@@ -231,7 +231,6 @@
"finish": "Voltooien",
"few_sentences_about_yourself": "Een korte introductie over uzelf. Dit wordt weergegeven op uw persoonlijke pagina.",
"nearly_there": "Bijna klaar!",
"nearly_there_instructions": "Tot slot, een korte beschrijving van u en een foto helpt u echt boekingen te krijgen en laat iedereen weten bij wie ze zijn geboekt.",
"set_availability_instructions": "Bepaal tijdbereiken waarin u periodiek beschikbaar bent. U kunt op een later tijdstip terugkomen en hier meer van aanmaken en toewijzen aan verschillende kalenders.",
"set_availability": "Beschikbaarheid bepalen",
"continue_without_calendar": "Doorgaan zonder kalender",
@@ -162,7 +162,7 @@
"guests": "Gjester",
"guest": "Gjest",
"web_conferencing_details_to_follow": "Detaljer følger i e-postbekreftelsen.",
"the_username": "Brukernavnet",
"404_the_user": "Brukernavnet",
"username": "Brukernavn",
"is_still_available": "er fortsatt tilgjengelig.",
"documentation": "Dokumentasjon",
@@ -173,7 +173,7 @@
"blog_description": "Les våre siste nyheter og artikler",
"join_our_community": "Bli med i fellesskapet vårt",
"join_our_slack": "Bli med i vår Slack",
"claim_username_and_schedule_events": "Velg brukernavnet ditt og planlegg hendelser",
"404_claim_entity_user": "Velg brukernavnet ditt og planlegg hendelser",
"popular_pages": "Populære sider",
"register_now": "Registrer deg nå",
"register": "Registrer",
@@ -219,7 +219,6 @@
"finish": "Fullfør",
"few_sentences_about_yourself": "Noen setninger om deg selv. Dette vil vises på din personlige url-side.",
"nearly_there": "Nesten der!",
"nearly_there_instructions": "Siste ting, en kort beskrivelse om deg og et bilde hjelper deg virkelig med å få bookinger og forteller folk hvem de booker med.",
"set_availability_instructions": "Definer tidsintervaller når du er tilgjengelig på en gjentakende basis. Du kan lage flere av disse senere og tilordne dem til forskjellige kalendere.",
"set_availability": "Angi din tilgjengelighet",
"continue_without_calendar": "Fortsett uten kalender",
@@ -172,7 +172,7 @@
"guests": "Goście",
"guest": "Gość",
"web_conferencing_details_to_follow": "Szczegóły konferencji internetowych, które należy podążać.",
"the_username": "Nazwa użytkownika",
"404_the_user": "Nazwa użytkownika",
"username": "Nazwa użytkownika",
"is_still_available": "jest nadal dostępny.",
"documentation": "Dokumentacja",
@@ -183,7 +183,7 @@
"blog_description": "Przeczytaj nasze najnowsze wiadomości i artykuły",
"join_our_community": "Dołącz do naszej społeczności",
"join_our_slack": "Dołącz do naszego Slack",
"claim_username_and_schedule_events": "Odbierz swoją nazwę użytkownika i harmonogram wydarzeń",
"404_claim_entity_user": "Odbierz swoją nazwę użytkownika i harmonogram wydarzeń",
"popular_pages": "Popularne strony",
"register_now": "Załóż konto teraz",
"register": "Rejestracja",
@@ -231,7 +231,6 @@
"finish": "Ukończ",
"few_sentences_about_yourself": "Kilka zdań o Tobie. To pojawi się na Twojej osobistej stronie URL.",
"nearly_there": "Prawie gotowe!",
"nearly_there_instructions": "Ostatnia rzecz, krótki opis na temat Ciebie i zdjęcie naprawdę pomoże Ci uzyskać rezerwacje i poinformować ludzi o tym, z kim rezerwują.",
"set_availability_instructions": "Określ zakresy czasu, kiedy jesteś generalnie dostępny/a. Możesz utworzyć ich więcej później i przypisać je do różnych kalendarzy.",
"set_availability": "Ustaw swoją dostępność",
"continue_without_calendar": "Kontynuuj bez kalendarza",
@@ -172,7 +172,7 @@
"guests": "Participantes",
"guest": "Participante",
"web_conferencing_details_to_follow": "Os detalhes para a vídeo chamada serão fornecidos em breve.",
"the_username": "O nome de usuário",
"404_the_user": "O nome de usuário",
"username": "Nome de usuário",
"is_still_available": "ainda está disponível.",
"documentation": "Documentação",
@@ -183,7 +183,7 @@
"blog_description": "Leia as nossas últimas notícias e artigos",
"join_our_community": "Junte-se à nossa comunidade",
"join_our_slack": "Junte-se ao nosso Slack",
"claim_username_and_schedule_events": "Obtenha o seu nome de usuário e agende eventos",
"404_claim_entity_user": "Obtenha o seu nome de usuário e agende eventos",
"popular_pages": "Páginas populares",
"register_now": "Inscreva-se agora",
"register": "Inscrição",
@@ -231,7 +231,6 @@
"finish": "Finalizar",
"few_sentences_about_yourself": "Fale um pouco sobre você. Isto será exibido em sua página pessoal.",
"nearly_there": "Quase lá!",
"nearly_there_instructions": "Por último, uma breve descrição de você mesmo e uma foto ajudam a conseguir agendamentos e a mostrar às pessoas com quem elas estão agendando.",
"set_availability_instructions": "Definir períodos em que você está disponível de forma recorrente. Mais tarde você poderá criar outros períodos e atribuí-los a diferentes calendários.",
"set_availability": "Definir disponibilidade",
"continue_without_calendar": "Continuar sem calendário",
@@ -172,7 +172,7 @@
"guests": "Convidados",
"guest": "Convidado",
"web_conferencing_details_to_follow": "Detalhes a seguir sobre a Vídeo Conferencia.",
"the_username": "O nome de utilizador",
"404_the_user": "O nome de utilizador",
"username": "Nome de utilizador",
"is_still_available": "ainda está disponível.",
"documentation": "Documentação",
@@ -183,7 +183,7 @@
"blog_description": "Leia as nossas últimas notícias e artigos",
"join_our_community": "Junte-se à nossa comunidade",
"join_our_slack": "Junte-se ao nosso Slack",
"claim_username_and_schedule_events": "Obtenha o seu nome de utilizador e agende eventos",
"404_claim_entity_user": "Obtenha o seu nome de utilizador e agende eventos",
"popular_pages": "Páginas populares",
"register_now": "Registe-se Agora",
"register": "Registar",
@@ -231,7 +231,6 @@
"finish": "Terminar",
"few_sentences_about_yourself": "Algumas frases sobre si. Isto será mostrado na sua página pessoal.",
"nearly_there": "Está quase",
"nearly_there_instructions": "Por último, uma breve descrição sobre si e uma fotografia ajudam a obter reservas e a mostrar às pessoas com quem estão a reservar.",
"set_availability_instructions": "Definir períodos em que está disponível numa base recorrente. Mais tarde poderá criar mais destes, e atribuí-los a diferentes calendários.",
"set_availability": "Definir disponibilidade",
"continue_without_calendar": "Continuar sem calendário",
@@ -172,7 +172,7 @@
"guests": "Vizitatori",
"guest": "Oaspete",
"web_conferencing_details_to_follow": "Urmează detalii despre conferința web.",
"the_username": "Numele de utilizator",
"404_the_user": "Numele de utilizator",
"username": "Nume de utilizator",
"is_still_available": "este încă disponibil.",
"documentation": "Documentație",
@@ -183,7 +183,7 @@
"blog_description": "Citește cele mai recente noutăți și articole",
"join_our_community": "Alătură-te comunității noastre",
"join_our_slack": "Alătură-te Slack-ului nostru",
"claim_username_and_schedule_events": "Revendică-ți numele de utilizator și programul evenimentelor",
"404_claim_entity_user": "Revendică-ți numele de utilizator și programul evenimentelor",
"popular_pages": "Pagini populare",
"register_now": "Înregistrează-te acum",
"register": "Înregistrează",
@@ -231,7 +231,6 @@
"finish": "Finalizeaza",
"few_sentences_about_yourself": "Câteva propoziții despre tine. Aceasta va apărea pe pagina de url personală.",
"nearly_there": "Încă puțin!",
"nearly_there_instructions": "În ultimul rând, o scurtă descriere despre tine și o fotografie te ajută să obții rezervări și să spui cu cine fac rezervarea.",
"set_availability_instructions": "Definiți intervalele de timp atunci când sunteți disponibil în mod recurent. Puteți crea mai multe dintre acestea mai târziu și atribuiți-le calendarelor diferite.",
"set_availability": "Setează-ți disponibilitatea",
"continue_without_calendar": "Continuă fără calendar",
@@ -172,7 +172,7 @@
"guests": "Участники",
"guest": "Гость",
"web_conferencing_details_to_follow": "Информация о веб-конференции будет добавлена позднее.",
"the_username": "Имя пользователя",
"404_the_user": "Имя пользователя",
"username": "Имя пользователя",
"is_still_available": "все еще доступно.",
"documentation": "Документация",
@@ -183,7 +183,7 @@
"blog_description": "Прочитайте наши последние новости и статьи",
"join_our_community": "Присоединяйтесь к нашему сообществу",
"join_our_slack": "Присоединяйтесь к нашему Slack",
"claim_username_and_schedule_events": "Забронируйте своё имя пользователя и начните планировать встречи",
"404_claim_entity_user": "Забронируйте своё имя пользователя и начните планировать встречи",
"popular_pages": "Популярные страницы",
"register_now": "Зарегистрироваться",
"register": "Регистрация",
@@ -231,7 +231,6 @@
"finish": "Завершить",
"few_sentences_about_yourself": "Напишите несколько предложений о себе. Они будут отображаться на вашей публичной странице.",
"nearly_there": "Почти готово!",
"nearly_there_instructions": "И последнее: добавьте краткую информацию о вас и фотографию чтобы помочь людям понять, с кем они бронируют встречу.",
"set_availability_instructions": "Определите диапазон времени, в который вы обычно доступны. В дальнейшем можно будет добавить ещё диапазоны и назначить их разным календарям.",
"set_availability": "Задайте время в которое вы доступны",
"continue_without_calendar": "Продолжить без подключения календаря",
@@ -179,7 +179,7 @@
"guests": "Gosti",
"guest": "Gost",
"web_conferencing_details_to_follow": "Slede detalji veb konferencije.",
"the_username": "Korisničko ime",
"404_the_user": "Korisničko ime",
"username": "Korisničko ime",
"is_still_available": "je i dalje dostupno.",
"documentation": "Dokumentacija",
@@ -190,7 +190,7 @@
"blog_description": "Pročitajte naše najnovije vesti i članke",
"join_our_community": "Pridružite se našoj zajednici",
"join_our_slack": "Pridružite se na naš Slack",
"claim_username_and_schedule_events": "Zatražite svoje korisničko ime i zakažite događaje",
"404_claim_entity_user": "Zatražite svoje korisničko ime i zakažite događaje",
"popular_pages": "Popularne stranice",
"register_now": "Registrujte se sad",
"register": "Registruj se",
@@ -239,7 +239,6 @@
"finish": "Završi",
"few_sentences_about_yourself": "Par rečenica o vama. Ovo će se pojaviti na vašoj ličnoj url stranici.",
"nearly_there": "Skoro pa gotovo!",
"nearly_there_instructions": "Na kraju, kratak opis o vama i slika mogu vam pomoći oko rezervacija i da obavestite druge s kim zapravo rezervišu.",
"set_availability_instructions": "Definišite periode kad ste stalno dostupni. Kasnije možete kreirati više njih i dodeliti ih različitim kalendarima.",
"set_availability": "Podesite svoju dostupnost",
"continue_without_calendar": "Nastavite bez kalendara",
@@ -172,7 +172,7 @@
"guests": "Gäster",
"guest": "Gäst",
"web_conferencing_details_to_follow": "Information om videokonferens följer.",
"the_username": "Användarnamnet",
"404_the_user": "Användarnamnet",
"username": "Användarnamn",
"is_still_available": "är fortfarande tillgänglig.",
"documentation": "Dokumentation",
@@ -183,7 +183,7 @@
"blog_description": "Läs våra senaste nyheter och artiklar",
"join_our_community": "Gå med i vårt community",
"join_our_slack": "Gå med i vår Slack",
"claim_username_and_schedule_events": "Tjinga ditt användarnamn och schemalägga händelser",
"404_claim_entity_user": "Tjinga ditt användarnamn och schemalägga händelser",
"popular_pages": "Populära sidor",
"register_now": "Registrera dig nu",
"register": "Registrera",
@@ -231,7 +231,6 @@
"finish": "Slutför",
"few_sentences_about_yourself": "Några meningar om dig själv. Detta kommer att visas på din personliga webbsida.",
"nearly_there": "Nästan där!",
"nearly_there_instructions": "Sist en kort beskrivning om dig och ett foto som hjälper dig att få bokningar och låter folk veta vem de bokar med.",
"set_availability_instructions": "Definiera tidsintervall när du är tillgänglig på en återkommande basis. Du kan skapa fler av dessa senare och tilldela dem till olika kalendrar.",
"set_availability": "Ställ in din tillgänglighet",
"continue_without_calendar": "Fortsätt utan kalender",
@@ -172,7 +172,7 @@
"guests": "Davetliler",
"guest": "Davetli",
"web_conferencing_details_to_follow": "Onay e-postasından izlenecek web konferansı ayrıntıları.",
"the_username": "Kullanıcı adı",
"404_the_user": "Kullanıcı adı",
"username": "Kullanıcı adı",
"is_still_available": "hâlâ müsait.",
"documentation": "Belgeler",
@@ -183,7 +183,7 @@
"blog_description": "En yeni haber ve makalelerimizi okuyun",
"join_our_community": "Topluluğumuza katılın",
"join_our_slack": "Slack'imize katılın",
"claim_username_and_schedule_events": "Kullanıcı adınızı alın ve etkinlikler planlayın",
"404_claim_entity_user": "Kullanıcı adınızı alın ve etkinlikler planlayın",
"popular_pages": "Popüler sayfalar",
"register_now": "Hemen kaydol",
"register": "Kaydol",
@@ -231,7 +231,6 @@
"finish": "Bitir",
"few_sentences_about_yourself": "Birkaç cümleyle kendinizden bahsedin. Bu, kişisel URL sayfanızda görünecektir.",
"nearly_there": "Neredeyse bitti!",
"nearly_there_instructions": "Son bir şey: Kendiniz hakkında kısa bir açıklama yazmak ve bir resim koymak, rezervasyon almanıza ve insanların gerçekte kiminle rezervasyon yaptıklarını bilmelerine yardımcı olur.",
"set_availability_instructions": "Sürekli olarak müsait olduğunuz zaman aralıklarını tanımlayın. Daha sonra başka zaman aralıkları oluşturabilir ve bunları farklı takvimlere atayabilirsiniz.",
"set_availability": "Müsaitlik durumunuzu ayarlayın",
"continue_without_calendar": "Takvim olmadan devam et",
@@ -172,7 +172,7 @@
"guests": "Гості",
"guest": "Гість",
"web_conferencing_details_to_follow": "Відомості про вебконференцію буде надано в листі з підтвердженням.",
"the_username": "Ім’я користувача",
"404_the_user": "Ім’я користувача",
"username": "Ім’я користувача",
"is_still_available": "ще доступне.",
"documentation": "Документація",
@@ -183,7 +183,7 @@
"blog_description": "Читайте наші останні новини та статті",
"join_our_community": "Долучайтеся до нашої спільноти",
"join_our_slack": "Приєднуйтеся до нас у Slack",
"claim_username_and_schedule_events": "Виберіть собі ім’я користувача та почніть планувати заходи",
"404_claim_entity_user": "Виберіть собі ім’я користувача та почніть планувати заходи",
"popular_pages": "Популярні сторінки",
"register_now": "Зареєструватися",
"register": "Реєстрація",
@@ -231,7 +231,6 @@
"finish": "Готово",
"few_sentences_about_yourself": "Кілька речень про вас. Ця інформація з’явиться на сторінці за вашою URL-адресою.",
"nearly_there": "Майже готово!",
"nearly_there_instructions": "І останнє: стислі відомості про вас і ваше фото заохочуватимуть інших створювати записи. Так інші бачитимуть, з ким вони домовляються.",
"set_availability_instructions": "Визначте повторювані діапазони часу, у які ви доступні. Ви зможете додати більше таких діапазонів пізніше та призначити їх різним календарям.",
"set_availability": "Укажіть, коли ви доступні",
"continue_without_calendar": "Продовжити без календаря",
@@ -172,7 +172,7 @@
"guests": "Khách",
"guest": "Khách",
"web_conferencing_details_to_follow": "Thông tin chi tiết về hội nghị trực tuyến sẽ có sau.",
"the_username": "Tên người dùng",
"404_the_user": "Tên người dùng",
"username": "Tên người dùng",
"is_still_available": "vẫn có sẵn.",
"documentation": "Tài liệu",
@@ -183,7 +183,7 @@
"blog_description": "Đọc tin tức và bài báo mới nhất của chúng tôi",
"join_our_community": "Tham gia cộng đồng của chúng tôi",
"join_our_slack": "Tham gia cộng đồng Slack của chúng tôi",
"claim_username_and_schedule_events": "Lấy tên người dùng của bạn và lên lịch sự kiện",
"404_claim_entity_user": "Lấy tên người dùng của bạn và lên lịch sự kiện",
"popular_pages": "Các trang phổ biến",
"register_now": "Đăng ký ngay",
"register": "Đăng ký",
@@ -231,7 +231,6 @@
"finish": "Hoàn thành",
"few_sentences_about_yourself": "Vài câu về bản thân bạn - nó sẽ xuất hiện trên trang cá nhân của bạn.",
"nearly_there": "Sắp xong rồi!",
"nearly_there_instructions": "Điều cuối cùng, mô tả ngắn gọn về bạn và một bức ảnh để giúp bạn nhận lịch hẹn dễ hơn và cho mọi người biết họ đang đặt lịch hẹn với ai.",
"set_availability_instructions": "Xác định những phạm vi thời gian bạn có thể chấp nhận lịch hẹn định kỳ. Bạn có thể tạo thêm phạm vi thời gian này sau và gán chúng vào các lịch khác nhau.",
"set_availability": "Đặt lịch khả dụng của bạn",
"continue_without_calendar": "Tiếp tục mà không có lịch",
@@ -172,7 +172,7 @@
"guests": "访客",
"guest": "访客",
"web_conferencing_details_to_follow": "网络会议详情将随后提供。",
"the_username": "用户名",
"404_the_user": "用户名",
"username": "用户名",
"is_still_available": "仍然可用。",
"documentation": "文档",
@@ -183,7 +183,7 @@
"blog_description": "阅读我们的最新消息和文章",
"join_our_community": "加入我们的社区",
"join_our_slack": "加入我们的Slack",
"claim_username_and_schedule_events": "确认您的用户名和日程安排",
"404_claim_entity_user": "确认您的用户名和日程安排",
"popular_pages": "热门页面",
"register_now": "立即注册",
"register": "注册",
@@ -231,7 +231,6 @@
"finish": "完成",
"few_sentences_about_yourself": "请写一段简单的自我介绍,该介绍将会展示在您的个人链接页面上。",
"nearly_there": "马上完成",
"nearly_there_instructions": "最后请写一段简短的自我介绍并上传一张照片,这将让人们知道他们是谁在预约,从而帮助您获得更多的预约。",
"set_availability_instructions": "设置您通常可预约的时间范围。您可以稍后创建更多时间范围并将其分配到不同的日历。",
"set_availability": "设置您的可预约状态",
"continue_without_calendar": "无日历继续",
@@ -172,7 +172,7 @@
"guests": "賓客",
"guest": "賓客",
"web_conferencing_details_to_follow": "可以關注的網路會議細節。",
"the_username": "使用者名稱",
"404_the_user": "使用者名稱",
"username": "使用者名稱",
"is_still_available": "仍可以使用。",
"documentation": "說明文件",
@@ -183,7 +183,7 @@
"blog_description": "閱讀最新消息與文章",
"join_our_community": "加入社群",
"join_our_slack": "加入 Slack",
"claim_username_and_schedule_events": "領取使用者名稱,就可以預定活動",
"404_claim_entity_user": "領取使用者名稱,就可以預定活動",
"popular_pages": "人氣頁面",
"register_now": "立即註冊",
"register": "註冊",
@@ -231,7 +231,6 @@
"finish": "結束",
"few_sentences_about_yourself": "一些關於自己的句子,這會顯示在個人網址頁。",
"nearly_there": "就快好了",
"nearly_there_instructions": "最後,簡單的個人簡介和照片,對於獲得預約會很有幫助,也讓人們知道是跟誰預約。",
"set_availability_instructions": "以定期的方式設定開放時間範圍。之後還可以繼續設定,並且歸類到各個行事曆。",
"set_availability": "設定開放時間",
"continue_without_calendar": "在沒有行事曆的情況下繼續",
+4
View File
@@ -18,6 +18,7 @@
--cal-bg-success: #e2fbe8;
--cal-bg-attention: #fceed8;
--cal-bg-error: #f9e3e2;
--cal-bg-dark-error: #752522;
/* Borders */
--cal-border-emphasis: #9ca3af;
@@ -25,6 +26,7 @@
--cal-border-subtle: #e5e7eb;
--cal-border-booker: #e5e7eb;
--cal-border-muted: #f3f4f6;
--cal-border-error: #aa2e26;
/* Content/Text */
--cal-text-emphasis: #111827;
@@ -60,6 +62,7 @@
--cal-bg-success: #e2fbe8;
--cal-bg-attention: #fceed8;
--cal-bg-error: #f9e3e2;
--cal-bg-dark-error: #752522;
/* Borders */
--cal-border-emphasis: #575757;
@@ -67,6 +70,7 @@
--cal-border-subtle: #2b2b2b;
--cal-border-booker: #2b2b2b;
--cal-border-muted: #1c1c1c;
--cal-border-error: #aa2e26;
/* Content/Text */
--cal-text-emphasis: #f3f4f6;
@@ -1,9 +1,11 @@
import MockDate from "mockdate";
import { expect, it } from "vitest";
import { expect, it, beforeAll, vi } from "vitest";
import { getAggregateWorkingHours } from "@calcom/core/getAggregateWorkingHours";
MockDate.set("2021-06-20T11:59:59Z");
beforeAll(() => {
vi.setSystemTime(new Date("2021-06-20T11:59:59Z"));
})
const HAWAII_AND_NEWYORK_TEAM = [
{
@@ -1,11 +1,13 @@
import type { Availability } from "@prisma/client";
import MockDate from "mockdate";
import { expect, it } from "vitest";
import { expect, it, beforeAll, vi } from "vitest";
import dayjs from "@calcom/dayjs";
import { getAvailabilityFromSchedule } from "@calcom/lib/availability";
MockDate.set("2021-06-20T11:59:59Z");
beforeAll(() => {
vi.setSystemTime(new Date("2021-06-20T11:59:59Z"));
})
//parse "hh:mm-hh:mm" into <Availability> object
const parseWorkingHours = (workingHours: string) => {
+4 -3
View File
@@ -1,10 +1,11 @@
import MockDate from "mockdate";
import { expect, it } from "vitest";
import { expect, it, vi, beforeAll } from "vitest";
import dayjs from "@calcom/dayjs";
import { getWorkingHours } from "@calcom/lib/availability";
MockDate.set("2021-06-20T11:59:59Z");
beforeAll(() => {
vi.setSystemTime(new Date("2021-06-20T11:59:59Z"));
})
it("correctly translates Availability (UTC+0) to UTC workingHours", async () => {
expect(
+8 -2
View File
@@ -47,8 +47,8 @@ describe('next.config.js - RegExp', ()=>{
// privateLinkRouteRegExp = "/d/:link/:slug((?!book$)[^/]+)";
privateLinkRouteRegExp = getRegExpFromNextJsRewriteRegExp("/d/(?<link>[^/]+)/(?<slug>((?!book$)[^/]+))");
// embedUserTypeRouteRegExp = `/:user((?!${pages.join("|")}).*)/:type/embed`;
embedUserTypeRouteRegExp = getRegExpFromNextJsRewriteRegExp(`/(?<user>((?!${pages.join("|")}).*))/(?<type>[^/]+)/embed`);
// embedUserTypeRouteRegExp = `/:user((?!${pages.join("/|")})[^/]*)/:type/embed`;
embedUserTypeRouteRegExp = getRegExpFromNextJsRewriteRegExp(`/(?<user>((?!${pages.join("/|")})[^/]*))/(?<type>[^/]+)/embed`);
// embedTeamTypeRouteRegExp = "/team/:slug/:type/embed";
embedTeamTypeRouteRegExp = getRegExpFromNextJsRewriteRegExp("/team/(?<slug>[^/]+)/(?<type>[^/]+)/embed");
@@ -94,6 +94,12 @@ describe('next.config.js - RegExp', ()=>{
type:'30'
})
// Edgecase of username starting with team also works
expect(embedUserTypeRouteRegExp.exec('/teampro/30/embed')?.groups).toContain({
user: 'teampro',
type: '30'
})
expect(teamTypeRouteRegExp.exec('/team/seeded/30')?.groups).toContain({
slug: 'seeded',
type: '30'
+5 -3
View File
@@ -1,11 +1,13 @@
import MockDate from "mockdate";
import { describe, expect, it } from "vitest";
import { describe, expect, it, beforeAll, vi } from "vitest";
import dayjs from "@calcom/dayjs";
import { MINUTES_DAY_END, MINUTES_DAY_START } from "@calcom/lib/availability";
import getSlots from "@calcom/lib/slots";
MockDate.set("2021-06-20T11:59:59Z");
beforeAll(() => {
vi.setSystemTime(new Date("2021-06-20T11:59:59Z"));
})
describe("Tests the slot logic", () => {
it("can fit 24 hourly slots for an empty day", async () => {
+11 -4
View File
@@ -50,6 +50,11 @@ export const DailyLocationType = "integrations:daily";
export const MeetLocationType = "integrations:google:meet";
/**
* This isn't an actual location app type. It is a special value that informs to use the Organizer's default conferencing app during booking
*/
export const OrganizerDefaultConferencingAppType = "conferencing";
export enum DefaultEventLocationTypeEnum {
/**
* Booker Address
@@ -68,6 +73,7 @@ export enum DefaultEventLocationTypeEnum {
*/
UserPhone = "userPhone",
Link = "link",
// Same as `OrganizerDefaultConferencingAppType`
Conferencing = "conferencing",
}
@@ -324,10 +330,11 @@ export const getEventLocationWithType = (
return location;
};
// FIXME: It assumes that type would be sent mostly now. If just in case a value and not type is sent(when old frontend sends requests to new backend), below forEach won't be able to find a match and thus bookingLocation would still be correct equal to reqBody.location
// We must handle the situation where frontend doesn't send us the value because it doesn't have it(displayLocationPublicly not set)
// But we want to store the actual location(except dynamic URL based location type) so that Emails, Calendars pick the value only.
// TODO: We must store both type as well as value so that we know the type of data that we are having. Is it an address or a phone number? This is to be done post v2.0
/**
* It converts a static link based video location type(e.g. integrations:campfire_video) to it's value (e.g. https://campfire.to/my_link) set in the eventType.
* If the type provided is already a value(when displayLocationPublicly is on), it would just return that.
* For, dynamic link based video location apps, it doesn't do anything.
*/
export const getLocationValueForDB = (
bookingLocationTypeOrValue: EventLocationType["type"],
eventLocations: LocationObject[]
@@ -1,6 +1,7 @@
import schedule from "node-schedule";
import prisma from "@calcom/prisma";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
export async function scheduleTrigger(
booking: { id: number; endTime: Date; scheduledJobs: string[] },
@@ -13,7 +14,7 @@ export async function scheduleTrigger(
`${subscriber.appId}_${subscriber.id}`,
booking.endTime,
async function () {
const body = JSON.stringify(booking);
const body = JSON.stringify({ triggerEvent: WebhookTriggerEvents.MEETING_ENDED, ...booking });
await fetch(subscriberUrl, {
method: "POST",
body,
+2
View File
@@ -25,6 +25,7 @@ module.exports = {
success: "var(--cal-bg-success)",
attention: "var(--cal-bg-attention)",
error: "var(--cal-bg-error)",
darkerror: "var(--cal-bg-dark-error)",
black: "#111111",
brand: {
default: "var(--cal-brand,#111827)",
@@ -63,6 +64,7 @@ module.exports = {
subtle: `var(--cal-border-subtle, ${subtleColor})`,
muted: "var(--cal-border-muted, #F3F4F6)",
booker: `var(--cal-border-booker, ${subtleColor})`,
error: "var(--cal-border-error, #AA2E26)",
},
textColor: {
emphasis: "var(--cal-text-emphasis, #111827)",
+5 -1
View File
@@ -13,13 +13,15 @@
--cal-bg-success: #E2FBE8;
--cal-bg-attention: #FCEED8;
--cal-bg-error: #F9E3E2;
--cal-bg-dark-error: #752522;
/* Borders */
--cal-border-emphasis:#9CA3AF;
--cal-border: #D1D5DB:
--cal-border: #D1D5DB;
--cal-border-subtle:#E5E7EB;
--cal-border-muted:#F3F4F6;
--cal-border-error: #aa2e26;
/* Content/Text */
--cal-text-emphasis: #111827;
@@ -56,6 +58,7 @@
--cal-bg-success: #E2FBE8;
--cal-bg-attention: #FCEED8;
--cal-bg-error: #F9E3E2;
--cal-bg-dark-error: #752522;
/* Borders */
@@ -63,6 +66,7 @@
--cal-border: #444444;
--cal-border-subtle:#2b2b2b;
--cal-border-muted:#1c1c1c;
--cal-border-error: #aa2e26;
/* Content/Text */
--cal-text-emphasis: #f3f4f6;
+10 -2
View File
@@ -23,9 +23,17 @@ const getVideoAdapters = async (withCredentials: CredentialPayload[]): Promise<V
for (const cred of withCredentials) {
const appName = cred.type.split("_").join(""); // Transform `zoom_video` to `zoomvideo`;
const app = await appStore[appName as keyof typeof appStore]();
const appImportFn = appStore[appName as keyof typeof appStore];
if (app && "lib" in app && "VideoApiAdapter" in app.lib) {
// Static Link Video Apps don't exist in packages/app-store/index.ts(it's manually maintained at the moment) and they aren't needed there anyway.
const app = appImportFn ? await appImportFn() : null;
if (!app) {
log.error(`Couldn't get adapter for ${appName}`);
continue;
}
if ("lib" in app && "VideoApiAdapter" in app.lib) {
const makeVideoApiAdapter = app.lib.VideoApiAdapter as VideoApiAdapterFactory;
const videoAdapter = makeVideoApiAdapter(cred);
videoAdapters.push(videoAdapter);
+11 -1
View File
@@ -168,7 +168,9 @@
<a href="?only=ns:default">[Dark Theme][Guests(janedoe@example.com and test@example.com)](Default Namespace)</a>
</h3>
<button onclick="Cal('ui',{theme:'light'})">Toggle to Light</button>
<button onclick="Cal('ui',{layout:'week_view'})">Toggle to Week View</button>
<button onclick="Cal('ui',{layout:'month_view'})">Toggle to Month View</button>
<button onclick="Cal('ui',{layout:'column_view'})">Toggle to Column View</button>
<i class="last-action"> You would see last Booking page action in my place </i>
<div>
<div class="place" style="width: 100%"></div>
@@ -253,6 +255,14 @@
<i>Note that one of the embeds would stay in loading state as they are using the same namespace and it is not supported to have more than 1 embeds using same namespace</i>
</div>
</div>
<div class="inline-embed-container" id="cal-booking-place-weekView">
<h3><a href="?only=ns:weekView">Test Weekly View</a></h3>
<div class="place" style="width: 100%"></div>
</div>
<div class="inline-embed-container" id="cal-booking-place-columnView">
<h3><a href="?only=ns:columnView">Test Column View</a></h3>
<div class="place" style="width: 100%"></div>
</div>
<script src="./playground.ts"></script>
</body>
</html>
+56
View File
@@ -344,3 +344,59 @@ if (only === "all" || only == "ns:floatingButton") {
},
});
}
if (only === "all" || only == "ns:weekView") {
// Create a namespace "second". It can be accessed as Cal.ns.second with the exact same API as Cal
Cal("init", "weekView", {
debug: true,
origin: "http://localhost:3000",
});
Cal.ns.weekView(
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
{
elementOrSelector: "#cal-booking-place-weekView .place",
calLink: "pro/paid",
config: {
iframeAttrs: {
id: "cal-booking-place-weekView-iframe",
},
layout: "week_view",
},
}
);
Cal.ns.weekView("on", {
action: "*",
callback,
});
}
if (only === "all" || only == "ns:columnView") {
// Create a namespace "second". It can be accessed as Cal.ns.second with the exact same API as Cal
Cal("init", "columnView", {
debug: true,
origin: "http://localhost:3000",
});
Cal.ns.columnView(
"inline",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
{
elementOrSelector: "#cal-booking-place-columnView .place",
calLink: "pro/paid",
config: {
iframeAttrs: {
id: "cal-booking-place-columnView-iframe",
},
layout: "column_view",
},
}
);
Cal.ns.columnView("on", {
action: "*",
callback,
});
}
@@ -101,7 +101,7 @@ async function selectFirstAvailableTimeSlotNextMonth(frame: Frame, page: Page) {
await frame.click('[data-testid="time"]');
}
export async function bookFirstEvent(username: string, frame: Frame, page: Page) {
export async function bookFirstEvent(username: string, frame: Frame, page: Page, bookerVariant: string) {
// Click first event type on Profile Page
await frame.click('[data-testid="event-type-link"]');
await frame.waitForURL((url) => {
@@ -125,9 +125,11 @@ export async function bookFirstEvent(username: string, frame: Frame, page: Page)
// Remove /embed from the end if present.
const eventSlug = new URL(frame.url()).pathname.replace(/\/embed$/, "");
await selectFirstAvailableTimeSlotNextMonth(frame, page);
await frame.waitForURL((url) => {
return url.pathname.includes(`/${username}/book`);
});
if (bookerVariant !== "new-booker") {
await frame.waitForURL((url) => {
return url.pathname.includes(`/${username}/book`);
});
}
// expect(await page.screenshot()).toMatchSnapshot("booking-page.png");
// --- fill form
await frame.fill('[name="name"]', "Embed User");
@@ -150,11 +152,13 @@ export async function bookFirstEvent(username: string, frame: Frame, page: Page)
return booking;
}
export async function rescheduleEvent(username: string, frame: Frame, page: Page) {
export async function rescheduleEvent(username: string, frame: Frame, page: Page, bookerVariant: string) {
await selectFirstAvailableTimeSlotNextMonth(frame, page);
await frame.waitForURL((url: { pathname: string | string[] }) => {
return url.pathname.includes(`/${username}/book`);
});
if (bookerVariant !== "new-booker") {
await frame.waitForURL((url: { pathname: string | string[] }) => {
return url.pathname.includes(`/${username}/book`);
});
}
// --- fill form
await frame.press('[name="email"]', "Enter");
await frame.click("[data-testid=confirm-reschedule-button]");
@@ -3,6 +3,7 @@ import { expect } from "@playwright/test";
import { test } from "@calcom/web/playwright/lib/fixtures";
import type { Fixtures } from "@calcom/web/playwright/lib/fixtures";
import { testBothBookers } from "@calcom/web/playwright/lib/new-booker";
import {
todo,
@@ -17,10 +18,12 @@ async function bookFirstFreeUserEventThroughEmbed({
addEmbedListeners,
page,
getActionFiredDetails,
bookerVariant,
}: {
addEmbedListeners: Fixtures["addEmbedListeners"];
page: Page;
getActionFiredDetails: Fixtures["getActionFiredDetails"];
bookerVariant: string;
}) {
const embedButtonLocator = page.locator('[data-cal-link="free"]').first();
await page.goto("/");
@@ -40,11 +43,11 @@ async function bookFirstFreeUserEventThroughEmbed({
if (!embedIframe) {
throw new Error("Embed iframe not found");
}
const booking = await bookFirstEvent("free", embedIframe, page);
const booking = await bookFirstEvent("free", embedIframe, page, bookerVariant);
return booking;
}
test.describe("Popup Tests", () => {
testBothBookers.describe("Popup Tests", (bookerVariant) => {
test.afterEach(async () => {
await deleteAllBookingsByEmail("embed-user@example.com");
});
@@ -72,7 +75,7 @@ test.describe("Popup Tests", () => {
if (!embedIframe) {
throw new Error("Embed iframe not found");
}
const { uid: bookingId } = await bookFirstEvent("free", embedIframe, page);
const { uid: bookingId } = await bookFirstEvent("free", embedIframe, page, bookerVariant);
const booking = await getBooking(bookingId);
expect(booking.attendees.length).toBe(1);
@@ -85,6 +88,7 @@ test.describe("Popup Tests", () => {
page,
addEmbedListeners,
getActionFiredDetails,
bookerVariant,
});
});
@@ -97,7 +101,7 @@ test.describe("Popup Tests", () => {
if (!embedIframe) {
throw new Error("Embed iframe not found");
}
await rescheduleEvent("free", embedIframe, page);
await rescheduleEvent("free", embedIframe, page, bookerVariant);
});
});
@@ -1,35 +1,38 @@
import { expect } from "@playwright/test";
import { test } from "@calcom/web/playwright/lib/fixtures";
import { testBothBookers } from "@calcom/web/playwright/lib/new-booker";
import { bookFirstEvent, deleteAllBookingsByEmail, getEmbedIframe, todo } from "../lib/testUtils";
test("Inline Iframe - Configured with Dark Theme", async ({
page,
getActionFiredDetails,
addEmbedListeners,
}) => {
await deleteAllBookingsByEmail("embed-user@example.com");
await addEmbedListeners("");
await page.goto("/?only=ns:default");
const calNamespace = "";
const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/pro" });
expect(embedIframe).toBeEmbedCalLink(calNamespace, getActionFiredDetails, {
pathname: "/pro",
searchParams: {
theme: "dark",
},
testBothBookers.describe("Inline Iframe", (bookerVariant) => {
test("Inline Iframe - Configured with Dark Theme", async ({
page,
getActionFiredDetails,
addEmbedListeners,
}) => {
await deleteAllBookingsByEmail("embed-user@example.com");
await addEmbedListeners("");
await page.goto("/?only=ns:default");
const calNamespace = "";
const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/pro" });
expect(embedIframe).toBeEmbedCalLink(calNamespace, getActionFiredDetails, {
pathname: "/pro",
searchParams: {
theme: "dark",
},
});
// expect(await page.screenshot()).toMatchSnapshot("event-types-list.png");
if (!embedIframe) {
throw new Error("Embed iframe not found");
}
await bookFirstEvent("pro", embedIframe, page, bookerVariant);
await deleteAllBookingsByEmail("embed-user@example.com");
});
// expect(await page.screenshot()).toMatchSnapshot("event-types-list.png");
if (!embedIframe) {
throw new Error("Embed iframe not found");
}
await bookFirstEvent("pro", embedIframe, page);
await deleteAllBookingsByEmail("embed-user@example.com");
todo(
"Ensure that on all pages - [user], [user]/[type], team/[slug], team/[slug]/book, UI styling works if these pages are directly linked in embed"
);
todo("Check that UI Configuration doesn't work for Free Plan");
});
todo(
"Ensure that on all pages - [user], [user]/[type], team/[slug], team/[slug]/book, UI styling works if these pages are directly linked in embed"
);
todo("Check that UI Configuration doesn't work for Free Plan");
@@ -1,25 +1,28 @@
import { expect } from "@playwright/test";
import { test } from "@calcom/web/playwright/lib/fixtures";
import { testBothBookers } from "@calcom/web/playwright/lib/new-booker";
test("Preview - embed-core should load", async ({ page }) => {
await page.goto("http://localhost:3000/embed/preview.html");
const libraryLoaded = await page.evaluate(() => {
return new Promise((resolve) => {
setInterval(() => {
if (
(
window as unknown as {
Cal: {
__css: string;
};
}
).Cal.__css
) {
resolve(true);
}
}, 1000);
testBothBookers.describe("Preview", () => {
test("Preview - embed-core should load", async ({ page }) => {
await page.goto("http://localhost:3000/embed/preview.html");
const libraryLoaded = await page.evaluate(() => {
return new Promise((resolve) => {
setInterval(() => {
if (
(
window as unknown as {
Cal: {
__css: string;
};
}
).Cal.__css
) {
resolve(true);
}
}, 1000);
});
});
expect(libraryLoaded).toBe(true);
});
expect(libraryLoaded).toBe(true);
});
+42 -11
View File
@@ -3,7 +3,7 @@ import type { CSSProperties } from "react";
import { useState, useEffect } from "react";
import type { BookerStore } from "@calcom/features/bookings/Booker/store";
import type { BookerLayouts } from "@calcom/prisma/zod-utils";
import { BookerLayouts } from "@calcom/prisma/zod-utils";
import type { Message } from "./embed";
import { sdkActionManager } from "./sdk-event";
@@ -23,6 +23,9 @@ export type UiConfig = {
type SetStyles = React.Dispatch<React.SetStateAction<EmbedStyles>>;
type setNonStylesConfig = React.Dispatch<React.SetStateAction<EmbedNonStylesConfig>>;
/**
* This is in-memory persistence needed so that when user browses through the embed, the configurations from the instructions aren't lost.
*/
const embedStore = {
// Store all embed styles here so that as and when new elements are mounted, styles can be applied to it.
styles: {} as EmbedStyles | undefined,
@@ -37,7 +40,11 @@ const embedStore = {
setTheme: undefined as ((arg0: EmbedThemeConfig) => void) | undefined,
theme: undefined as UiConfig["theme"],
uiConfig: undefined as Omit<UiConfig, "styles" | "theme"> | undefined,
setUiConfig: undefined as ((arg0: UiConfig) => void) | undefined,
/**
* We maintain a list of all setUiConfig setters that are in use at the moment so that we can update all those components.
*/
setUiConfig: [] as ((arg0: UiConfig) => void)[],
layout: BookerLayouts,
};
declare global {
@@ -183,9 +190,21 @@ export const useEmbedTheme = () => {
return theme;
};
/**
* It serves following purposes
* - Gives consistent values for ui config even after Soft Navigation. When a new React component mounts, it would ensure that the component get's the correct value of ui config
* - Ensures that all the components using useEmbedUiConfig are updated when ui config changes. It is done by maintaining a list of all non-stale setters.
*/
export const useEmbedUiConfig = () => {
const [uiConfig, setUiConfig] = useState(embedStore.uiConfig || {});
embedStore.setUiConfig = setUiConfig;
embedStore.setUiConfig.push(setUiConfig);
useEffect(() => {
return () => {
const foundAtIndex = embedStore.setUiConfig.findIndex((item) => item === setUiConfig);
// Keep removing the setters that are stale
embedStore.setUiConfig.splice(foundAtIndex, 1);
};
});
return uiConfig;
};
@@ -291,7 +310,7 @@ const methods = {
if (stylesConfig) {
console.warn(
"Cal.com Embed: `styles` prop is deprecated. Use `cssVarsPerTheme` instead to achieve the same effect. Here is a list of CSS variables that are supported.https://github.com/calcom/cal.com/blob/main/packages/config/tailwind-preset.js#L19"
"Cal.com Embed: `styles` prop is deprecated. Use `cssVarsPerTheme` instead to achieve the same effect. Here is a list of CSS variables that are supported. https://github.com/calcom/cal.com/blob/main/packages/config/tailwind-preset.js#L19"
);
}
@@ -307,19 +326,18 @@ const methods = {
}
}
// Set the value here so that if setUiConfig state isn't available and later it's defined,it uses this value
embedStore.uiConfig = uiConfig;
// Merge new values over the old values
uiConfig = {
...embedStore.uiConfig,
...uiConfig,
};
if (uiConfig.cssVarsPerTheme) {
window.CalEmbed.applyCssVars(uiConfig.cssVarsPerTheme);
}
if (embedStore.setUiConfig) {
embedStore.setUiConfig(uiConfig);
}
if (uiConfig.layout) {
window.CalEmbed.setLayout?.(uiConfig.layout);
runAllUiSetters(uiConfig);
}
setEmbedStyles(stylesConfig || {});
@@ -440,6 +458,13 @@ if (isBrowser) {
log("Embed SDK loaded", { isEmbed: window?.isEmbed?.() || false });
const url = new URL(document.URL);
embedStore.theme = window?.getEmbedTheme?.();
embedStore.uiConfig = {
// TODO: Add theme as well here
// theme:
layout: url.searchParams.get("layout") as BookerLayouts,
};
if (url.searchParams.get("prerender") !== "true" && window?.isEmbed?.()) {
log("Initializing embed-iframe");
// HACK
@@ -492,3 +517,9 @@ if (isBrowser) {
});
}
}
function runAllUiSetters(uiConfig: UiConfig) {
// Update EmbedStore so that when a new react component mounts, useEmbedUiConfig can get the persisted value from embedStore.uiConfig
embedStore.uiConfig = uiConfig;
embedStore.setUiConfig.forEach((setUiConfig) => setUiConfig(uiConfig));
}
+13
View File
@@ -1,4 +1,6 @@
/// <reference types="../env" />
import type { BookerLayouts } from "@calcom/prisma/zod-utils";
import { FloatingButton } from "./FloatingButton/FloatingButton";
import { Inline } from "./Inline/inline";
import { ModalBox } from "./ModalBox/ModalBox";
@@ -125,10 +127,21 @@ type SingleInstruction = SingleInstructionMap[keyof SingleInstructionMap];
export type Instruction = SingleInstruction | SingleInstruction[];
export type InstructionQueue = Instruction[];
/**
* All types of config that are critical to be processed as soon as possible are provided as query params to the iframe
*/
export type PrefillAndIframeAttrsConfig = Record<string, string | string[] | Record<string, string>> & {
// TODO: iframeAttrs shouldn't be part of it as that configures the iframe element and not the iframed app.
iframeAttrs?: Record<string, string> & {
id?: string;
};
// TODO: It should have a dedicated prefill prop
// prefill: {},
// TODO: Move layout and theme as nested props of ui as it makes it clear that these two can be configured using `ui` instruction as well any time.
// ui: {layout; theme}
layout?: `${BookerLayouts}`;
theme?: EmbedThemeConfig;
};
@@ -2,8 +2,9 @@ import { expect } from "@playwright/test";
import { getEmbedIframe } from "@calcom/embed-core/playwright/lib/testUtils";
import { test } from "@calcom/web/playwright/lib/fixtures";
import { testBothBookers } from "@calcom/web/playwright/lib/new-booker";
test.describe("Inline Embed", () => {
testBothBookers.describe("Inline Embed", () => {
test("should verify that the iframe got created with correct URL", async ({
page,
getActionFiredDetails,
+33 -18
View File
@@ -1,11 +1,11 @@
import { LazyMotion, domAnimation, m, AnimatePresence } from "framer-motion";
import dynamic from "next/dynamic";
import { useEffect, useRef, useMemo } from "react";
import { useEffect, useRef } from "react";
import StickyBox from "react-sticky-box";
import { shallow } from "zustand/shallow";
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
import { useEmbedUiConfig } from "@calcom/embed-core/embed-iframe";
import { useEmbedUiConfig, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import classNames from "@calcom/lib/classNames";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import { BookerLayouts, defaultBookerLayoutSettings } from "@calcom/prisma/zod-utils";
@@ -20,10 +20,9 @@ import { BookerSection } from "./components/Section";
import { Away, NotFound } from "./components/Unavailable";
import { extraDaysConfig, fadeInLeft, getBookerSizeClassNames, useBookerResizeAnimation } from "./config";
import { useBookerStore, useInitializeBookerStore } from "./store";
import type { BookerProps } from "./types";
import type { BookerLayout, BookerProps } from "./types";
import { useEvent } from "./utils/event";
import { validateLayout } from "./utils/layout";
import { getQueryParam } from "./utils/query-param";
import { useBrandColors } from "./utils/use-brand-colors";
const PoweredBy = dynamic(() => import("@calcom/ee/components/PoweredBy"));
@@ -46,10 +45,14 @@ const BookerComponent = ({
const rescheduleUid =
typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("rescheduleUid") : null;
const event = useEvent();
const [layout, setLayout] = useBookerStore((state) => [state.layout, state.setLayout], shallow);
if (typeof window !== "undefined") {
window.CalEmbed.setLayout = setLayout;
}
const [_layout, setLayout] = useBookerStore((state) => [state.layout, state.setLayout], shallow);
const isEmbed = useIsEmbed();
const embedUiConfig = useEmbedUiConfig();
// In Embed we give preference to embed configuration for the layout.If that's not set, we use the App configuration for the event layout
const layout = isEmbed ? validateLayout(embedUiConfig.layout) || _layout : _layout;
const [bookerState, setBookerState] = useBookerStore((state) => [state.state, state.setState], shallow);
const selectedDate = useBookerStore((state) => state.selectedDate);
const [selectedTimeslot, setSelectedTimeslot] = useBookerStore(
@@ -60,13 +63,10 @@ const BookerComponent = ({
const extraDays = isTablet ? extraDaysConfig[layout].tablet : extraDaysConfig[layout].desktop;
const bookerLayouts = event.data?.profile?.bookerLayouts || defaultBookerLayoutSettings;
const animationScope = useBookerResizeAnimation(layout, bookerState);
const isEmbed = typeof window !== "undefined" && window?.isEmbed?.();
// We only want the initial url value, that's why we memo it. The embed seems to change the url, which sometimes drops
// the layout query param.
const layoutFromQueryParam = useMemo(() => validateLayout(getQueryParam("layout") as BookerLayouts), []);
// I would expect isEmbed to be not needed here as it's handled in derived variable layout, but somehow removing it breaks the views.
const defaultLayout = isEmbed
? layoutFromQueryParam || BookerLayouts.MONTH_VIEW
? validateLayout(embedUiConfig.layout) || bookerLayouts.defaultLayout
: bookerLayouts.defaultLayout;
useBrandColors({
@@ -107,13 +107,27 @@ const BookerComponent = ({
}
}, [layout]);
const embedUiConfig = useEmbedUiConfig();
const hideEventTypeDetails = isEmbed ? embedUiConfig.hideEventTypeDetails : false;
if (event.isSuccess && !event.data) {
return <NotFound />;
}
// In Embed, a Dialog doesn't look good, we disable it intentionally for the layouts that support showing Form without Dialog(i.e. no-dialog Form)
const shouldShowFormInDialogMap: Record<BookerLayout, boolean> = {
// mobile supports showing the Form without Dialog
mobile: !isEmbed,
// We don't show Dialog in month_view currently. Can be easily toggled though as it supports no-dialog Form
month_view: false,
// week_view doesn't support no-dialog Form
// When it's supported, disable it for embed
week_view: true,
// column_view doesn't support no-dialog Form
// When it's supported, disable it for embed
column_view: true,
};
const shouldShowFormInDialog = shouldShowFormInDialogMap[layout];
return (
<>
{event.data ? <BookingPageTagManager eventType={event.data} /> : null}
@@ -134,7 +148,8 @@ const BookerComponent = ({
!isEmbed && "sm:transition-[width] sm:duration-300",
isEmbed && layout === BookerLayouts.MONTH_VIEW && "border-booker sm:border-booker-width",
!isEmbed && layout === BookerLayouts.MONTH_VIEW && "border-subtle",
layout === BookerLayouts.MONTH_VIEW && isEmbed && "mt-20"
// We don't want any margins for Embed. Any margin needed should be added by Embed user.
layout === BookerLayouts.MONTH_VIEW && isEmbed && "mt-0"
)}>
<AnimatePresence>
<BookerSection
@@ -142,7 +157,7 @@ const BookerComponent = ({
className={classNames(
layout === BookerLayouts.MONTH_VIEW && "fixed right-3 top-3 z-10",
(layout === BookerLayouts.COLUMN_VIEW || layout === BookerLayouts.WEEK_VIEW) &&
"bg-muted sticky top-0 z-10"
"bg-default dark:bg-muted sticky top-0 z-10"
)}>
<Header
enabledLayouts={bookerLayouts.enabledLayouts}
@@ -174,7 +189,7 @@ const BookerComponent = ({
area="main"
className="border-subtle sticky top-0 ml-[-1px] h-full p-6 md:w-[var(--booker-main-width)] md:border-l"
{...fadeInLeft}
visible={bookerState === "booking" && layout === BookerLayouts.MONTH_VIEW}>
visible={bookerState === "booking" && !shouldShowFormInDialog}>
<BookEventForm onCancel={() => setSelectedTimeslot(null)} />
</BookerSection>
@@ -232,7 +247,7 @@ const BookerComponent = ({
</div>
<BookFormAsModal
visible={layout !== BookerLayouts.MONTH_VIEW && bookerState === "booking"}
visible={bookerState === "booking" && shouldShowFormInDialog}
onCancel={() => setSelectedTimeslot(null)}
/>
</>
@@ -52,7 +52,7 @@ export function Header({
}
return (
<div className="border-subtle relative z-10 flex border-b border-l px-5 py-4">
<div className="border-default relative z-10 flex border-b border-l px-5 py-4">
<div className="flex items-center gap-3">
<h3 className="min-w-[150px] text-base font-semibold leading-4">
{selectedDate.format("MMM D")}-{selectedDate.add(extraDays, "days").format("D")},{" "}
@@ -82,7 +82,7 @@ export const EventMetaBlock = ({
) : (
<>{!!Icon && <Icon className="relative z-20 mr-2 mt-[2px] h-4 w-4 flex-shrink-0" />}</>
)}
<div className={classNames("relative z-10", contentClassName)}>{children}</div>
<div className={classNames("relative z-10 max-w-full break-words", contentClassName)}>{children}</div>
</div>
);
};
@@ -44,12 +44,14 @@ export const EventOccurences = ({ event }: { event: PublicEvent }) => {
{recurringStrings.slice(0, 5).map((timeFormatted, key) => (
<p key={key}>{timeFormatted}</p>
))}
<Tooltip
{recurringStrings.length > 5 && (
<Tooltip
content={recurringStrings.slice(5).map((timeFormatted, key) => (
<p key={key}>{timeFormatted}</p>
))}>
<p className=" text-sm">+ {t("plus_more", { count: recurringStrings.length - 5 })}</p>
</Tooltip>
)}
</>
);
}
@@ -11,6 +11,7 @@ import z from "zod";
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { metadata as GoogleMeetMetadata } from "@calcom/app-store/googlevideo/_metadata";
import type { LocationObject } from "@calcom/app-store/locations";
import { OrganizerDefaultConferencingAppType } from "@calcom/app-store/locations";
import { getLocationValueForDB } from "@calcom/app-store/locations";
import { MeetLocationType } from "@calcom/app-store/locations";
import type { EventTypeAppsList } from "@calcom/app-store/utils";
@@ -761,7 +762,9 @@ async function handler(
}));
let locationBodyString = location;
let defaultLocationUrl = undefined;
// TODO: It's definition should be moved to getLocationValueForDb
let organizerOrFirstDynamicGroupMemberDefaultLocationUrl = undefined;
if (dynamicUserList.length > 1) {
users = users.sort((a, b) => {
@@ -771,7 +774,8 @@ async function handler(
});
const firstUsersMetadata = userMetadataSchema.parse(users[0].metadata);
locationBodyString = firstUsersMetadata?.defaultConferencingApp?.appLink || locationBodyString;
defaultLocationUrl = firstUsersMetadata?.defaultConferencingApp?.appLink;
organizerOrFirstDynamicGroupMemberDefaultLocationUrl =
firstUsersMetadata?.defaultConferencingApp?.appLink;
}
if (
@@ -838,14 +842,16 @@ async function handler(
const [organizerUser] = users;
const tOrganizer = await getTranslation(organizerUser?.locale ?? "en", "common");
// use host default
if (isTeamEventType && locationBodyString === "conferencing") {
if (isTeamEventType && locationBodyString === OrganizerDefaultConferencingAppType) {
const metadataParseResult = userMetadataSchema.safeParse(organizerUser.metadata);
const organizerMetadata = metadataParseResult.success ? metadataParseResult.data : undefined;
if (organizerMetadata) {
const app = getAppFromSlug(organizerMetadata?.defaultConferencingApp?.appSlug);
locationBodyString = app?.appData?.location?.type || locationBodyString;
defaultLocationUrl = organizerMetadata?.defaultConferencingApp?.appLink;
organizerOrFirstDynamicGroupMemberDefaultLocationUrl =
organizerMetadata?.defaultConferencingApp?.appLink;
} else {
locationBodyString = "";
}
@@ -877,7 +883,11 @@ async function handler(
const seed = `${organizerUser.username}:${dayjs(reqBody.start).utc().format()}:${new Date().getTime()}`;
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
const bookingLocation = getLocationValueForDB(locationBodyString, eventType.locations);
// For static link based video apps, it would have the static URL value instead of it's type(e.g. integrations:campfire_video)
// This ensures that createMeeting isn't called for static video apps as bookingLocation becomes just a regular value for them.
const bookingLocation = organizerOrFirstDynamicGroupMemberDefaultLocationUrl
? organizerOrFirstDynamicGroupMemberDefaultLocationUrl
: getLocationValueForDB(locationBodyString, eventType.locations);
const customInputs = getCustomInputsResponses(reqBody, eventType.customInputs);
const teamMemberPromises =
@@ -2026,7 +2036,8 @@ async function handler(
metadata.conferenceData = results[0].createdEvent?.conferenceData;
metadata.entryPoints = results[0].createdEvent?.entryPoints;
handleAppsStatus(results, booking);
videoCallUrl = metadata.hangoutLink || defaultLocationUrl || videoCallUrl;
videoCallUrl =
metadata.hangoutLink || organizerOrFirstDynamicGroupMemberDefaultLocationUrl || videoCallUrl;
}
if (noEmail !== true) {
let isHostConfirmationEmailsDisabled = false;
@@ -62,7 +62,7 @@ export function Calendar(props: CalendarComponentProps) {
<DateValues containerNavRef={containerNav} days={days} />
<div className="relative flex flex-auto">
<CurrentTime />
<div className="bg-default dark:bg-muted ring-muted sticky left-0 z-10 w-14 flex-none ring-1" />
<div className="bg-default dark:bg-muted ring-muted sticky border-default border-l border-r left-0 z-10 w-14 flex-none ring-1" />
<div
className="grid flex-auto grid-cols-1 grid-rows-1 [--disabled-gradient-background:#F8F9FB] [--disabled-gradient-foreground:#E6E7EB] dark:[--disabled-gradient-background:#262626] dark:[--disabled-gradient-foreground:#393939]"
style={{
@@ -34,7 +34,7 @@ export function DateValues({ days, containerNavRef }: Props) {
})}
</div>
<div className="text-subtle -mr-px hidden auto-cols-fr text-sm leading-6 sm:flex ">
<div className="col-end-1 w-14" />
<div className="col-end-1 w-14 border-default border-l" />
{days.map((day) => {
const isToday = dayjs().isSame(day, "day");
return (
@@ -55,15 +55,31 @@ export const Template = (args) => <Calendar {...args} />;
</Canvas>
<Canvas>
<Story name="Onclick Handlers">
<Calendar
startHour={8}
endHour={17}
events={events}
onEventClick={(e) => alert(e.title)}
onEmptyCellClick={(date) => alert(date.toString())}
sortEvents
hoverEventDuration={30}
/>
<Story name="Onclick Handlers"
args={{
startHour: 8,
endHour: 17,
hoverEventDuration: 30,
}}
argTypes={{
startHour: {
control: { type: "number", min: 0, max: 23, step: 1 },
},
endHour: {
control: { type: "number", min: 0, max: 23, step: 1 },
},
hoverEventDuration: {
control: { type: "number", min: 0, max: 60, step: 1 },
},
}}
>
{({...args}) => (
<Calendar {...args}
events={events}
onEventClick={(e) => alert(e.title)}
onEmptyCellClick={(date) => alert(date.toString())}
sortEvents
/>
)}
</Story>
</Canvas>
@@ -141,13 +141,18 @@ export const CreateANewOrganizationForm = () => {
const telemetry = useTelemetry();
const [serverErrorMessage, setServerErrorMessage] = useState<string | null>(null);
const [showVerifyCode, setShowVerifyCode] = useState(false);
const { slug } = router.query;
const newOrganizationFormMethods = useForm<{
name: string;
slug: string;
adminEmail: string;
adminUsername: string;
}>();
}>({
defaultValues: {
slug: `${slug}`,
},
});
const watchAdminEmail = newOrganizationFormMethods.watch("adminEmail");
const createOrganizationMutation = trpc.viewer.organizations.create.useMutation({
@@ -216,7 +221,9 @@ export const CreateANewOrganizationForm = () => {
const domain = extractDomainFromEmail(e?.target.value);
newOrganizationFormMethods.setValue("adminEmail", e?.target.value);
newOrganizationFormMethods.setValue("adminUsername", e?.target.value.split("@")[0]);
newOrganizationFormMethods.setValue("slug", domain);
if (newOrganizationFormMethods.getValues("slug") === "") {
newOrganizationFormMethods.setValue("slug", domain);
}
newOrganizationFormMethods.setValue(
"name",
domain.charAt(0).toUpperCase() + domain.slice(1)
@@ -15,21 +15,27 @@ import { ArrowRight } from "@calcom/ui/components/icon";
import type { NewTeamFormValues } from "../lib/types";
const querySchema = z.object({
returnTo: z.string(),
returnTo: z.string().optional(),
slug: z.string().optional(),
});
export const CreateANewTeamForm = () => {
const { t } = useLocale();
const router = useRouter();
const telemetry = useTelemetry();
const returnToParsed = querySchema.safeParse(router.query);
const parsedQuery = querySchema.safeParse(router.query);
console.log({ parsedQuery });
const [serverErrorMessage, setServerErrorMessage] = useState<string | null>(null);
const returnToParam =
(returnToParsed.success ? getSafeRedirectUrl(returnToParsed.data.returnTo) : "/settings/teams") ||
(parsedQuery.success ? getSafeRedirectUrl(parsedQuery.data.returnTo) : "/settings/teams") ||
"/settings/teams";
const newTeamFormMethods = useForm<NewTeamFormValues>();
const newTeamFormMethods = useForm<NewTeamFormValues>({
defaultValues: {
slug: parsedQuery.success ? parsedQuery.data.slug : "",
},
});
const createTeamMutation = trpc.viewer.teams.create.useMutation({
onSuccess: (data) => {
@@ -1,6 +1,5 @@
import classNames from "classnames";
import { signIn } from "next-auth/react";
import { useState } from "react";
import { WEBAPP_URL } from "@calcom/lib/constants";
@@ -231,7 +230,7 @@ export default function MemberListItem(props: Props) {
<DropdownMenuItem className="outline-none">
<DropdownItem
disabled={!props.member.accepted}
href={!props.member.accepted?undefined : "/" + props.member.username}
href={!props.member.accepted ? undefined : "/" + props.member.username}
target="_blank"
type="button"
StartIcon={ExternalLink}>
@@ -16,6 +16,7 @@ import objectKeys from "@calcom/lib/objectKeys";
import turndown from "@calcom/lib/turndownService";
import { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { SkeletonContainer, SkeletonText } from "@calcom/ui";
import {
Avatar,
Button,
@@ -78,6 +79,7 @@ const ProfileView = () => {
const { data: team, isLoading } = trpc.viewer.teams.get.useQuery(
{ teamId: Number(router.query.id) },
{
enabled: !!router.query.id,
onError: () => {
router.push("/settings");
},
@@ -148,7 +150,7 @@ const ProfileView = () => {
return (
<>
<Meta title={t("profile")} description={t("profile_team_description")} />
{!isLoading && (
{!isLoading ? (
<>
{isAdmin ? (
<Form
@@ -326,6 +328,40 @@ const ProfileView = () => {
</Dialog>
)}
</>
) : (
<>
<SkeletonContainer as="form">
<div className="flex items-center">
<div className="ms-4">
<SkeletonContainer>
<div className="bg-emphasis h-16 w-16 rounded-full" />
</SkeletonContainer>
</div>
</div>
<hr className="border-subtle my-8" />
<SkeletonContainer>
<div className="mt-8">
<SkeletonText className="h-6 w-48" />
</div>
</SkeletonContainer>
<SkeletonContainer>
<div className="mt-8">
<SkeletonText className="h-6 w-48" />
</div>
</SkeletonContainer>
<div className="mt-8">
<SkeletonContainer>
<div className="bg-emphasis h-24 rounded-md" />
</SkeletonContainer>
<SkeletonText className="mt-4 h-12 w-32" />
</div>
<SkeletonContainer>
<div className="mt-8">
<SkeletonText className="h-9 w-24" />
</div>
</SkeletonContainer>
</SkeletonContainer>
</>
)}
</>
);
@@ -19,7 +19,7 @@ export const DeleteDialog = (props: IDeleteDialog) => {
const deleteMutation = trpc.viewer.workflows.delete.useMutation({
onSuccess: async () => {
await utils.viewer.workflows.list.invalidate();
await utils.viewer.workflows.filteredList.invalidate();
additionalFunction();
showToast(t("workflow_deleted_successfully"), "success");
setIsOpenDialog(false);
@@ -4,7 +4,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import { trpc } from "@calcom/trpc/react";
import type { SVGComponent } from "@calcom/types/SVGComponent";
import { CreateButton, showToast, EmptyScreen as ClassicEmptyScreen } from "@calcom/ui";
import { showToast, EmptyScreen as ClassicEmptyScreen, CreateButtonWithTeamsList } from "@calcom/ui";
import { Smartphone, Mail, Zap } from "@calcom/ui/components/icon";
type WorkflowExampleType = {
@@ -31,15 +31,7 @@ function WorkflowExample(props: WorkflowExampleType) {
);
}
export default function EmptyScreen(props: {
profileOptions: {
label: string | null;
image?: string | null;
teamId: number | null | undefined;
slug: string | null;
}[];
isFilteredView: boolean;
}) {
export default function EmptyScreen(props: { isFilteredView: boolean }) {
const { t } = useLocale();
const router = useRouter();
@@ -86,9 +78,8 @@ export default function EmptyScreen(props: {
{t("no_workflows_description")}
</p>
<div className="mt-8 ">
<CreateButton
<CreateButtonWithTeamsList
subtitle={t("new_workflow_subtitle").toUpperCase()}
options={props.profileOptions}
createFunction={(teamId?: number) => createMutation.mutate({ teamId })}
buttonText={t("create_workflow")}
isLoading={createMutation.isLoading}
@@ -4,6 +4,8 @@ import { useRouter } from "next/router";
import { useState } from "react";
import classNames from "@calcom/lib/classNames";
import { CAL_URL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import {
@@ -16,12 +18,14 @@ import {
DropdownMenuTrigger,
Tooltip,
Badge,
Avatar,
} from "@calcom/ui";
import { Edit2, Link as LinkIcon, MoreHorizontal, Trash2 } from "@calcom/ui/components/icon";
import { useOrgBrandingValues } from "../../organizations/hooks";
import { subdomainSuffix } from "../../organizations/lib/orgDomains";
import { getActionIcon } from "../lib/getActionIcon";
import { DeleteDialog } from "./DeleteDialog";
import EmptyScreen from "./EmptyScreen";
export type WorkflowType = Workflow & {
team: {
@@ -29,6 +33,7 @@ export type WorkflowType = Workflow & {
name: string;
members: Membership[];
slug: string | null;
logo?: string | null;
} | null;
steps: WorkflowStep[];
activeOn: {
@@ -45,21 +50,17 @@ export type WorkflowType = Workflow & {
};
interface Props {
workflows: WorkflowType[] | undefined;
profileOptions: {
image?: string | null;
label: string | null;
teamId: number | null | undefined;
slug: string | null;
}[];
hasNoWorkflows?: boolean;
}
export default function WorkflowListPage({ workflows, profileOptions, hasNoWorkflows }: Props) {
export default function WorkflowListPage({ workflows }: Props) {
const { t } = useLocale();
const utils = trpc.useContext();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [workflowToDeleteId, setwWorkflowToDeleteId] = useState(0);
const router = useRouter();
const orgBranding = useOrgBrandingValues();
const urlPrefix = orgBranding ? `${orgBranding.slug}.${subdomainSuffix()}` : CAL_URL;
return (
<>
{workflows && workflows.length > 0 ? (
@@ -143,16 +144,41 @@ export default function WorkflowListPage({ workflows, profileOptions, hasNoWorkf
)}
</Badge>
</li>
{workflow.teamId && (
<li>
<Badge variant="gray">
<>{workflow.team?.name}</>
</Badge>
</li>
)}
<div className="block md:hidden">
{workflow.team?.name && (
<li>
<Badge variant="gray">
<>{workflow.team.name}</>
</Badge>
</li>
)}
</div>
</ul>
</div>
</Link>
<div>
<div className="hidden md:block">
{workflow.team?.name && (
<Badge className="mr-4 mt-1 p-[1px] px-2" variant="gray">
<Avatar
alt={workflow.team?.name || ""}
href={
workflow.team?.id
? `/settings/teams/${workflow.team?.id}/profile`
: "/settings/my-account/profile"
}
imageSrc={getPlaceholderAvatar(
workflow?.team.logo,
workflow.team?.name as string
)}
size="xxs"
className="mt-[3px] inline-flex justify-center"
/>
<div>{workflow.team.name}</div>
</Badge>
)}
</div>
</div>
<div className="flex flex-shrink-0">
<div className="hidden sm:block">
@@ -222,12 +248,12 @@ export default function WorkflowListPage({ workflows, profileOptions, hasNoWorkf
setIsOpenDialog={setDeleteDialogOpen}
workflowId={workflowToDeleteId}
additionalFunction={async () => {
await utils.viewer.workflows.list.invalidate();
await utils.viewer.workflows.filteredList.invalidate();
}}
/>
</div>
) : (
<EmptyScreen profileOptions={profileOptions} isFilteredView={!hasNoWorkflows} />
<></>
)}
</>
);
@@ -85,4 +85,5 @@ const noAlphanumericSenderIdSupport = [
"+84",
"+260",
"+61",
"+971",
];
+34 -89
View File
@@ -1,7 +1,7 @@
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import type { Dispatch, SetStateAction } from "react";
import { useState, useEffect } from "react";
import { useState } from "react";
import Shell from "@calcom/features/shell/Shell";
import { classNames } from "@calcom/lib";
@@ -9,11 +9,14 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import { trpc } from "@calcom/trpc/react";
import { AnimatedPopover, Avatar, CreateButton, showToast } from "@calcom/ui";
import { AnimatedPopover, Avatar, showToast, CreateButtonWithTeamsList } from "@calcom/ui";
import { FilterResults } from "../../../filters/components/FilterResults";
import { TeamsFilter } from "../../../filters/components/TeamsFilter";
import { getTeamsFiltersFromQuery } from "../../../filters/lib/getTeamsFiltersFromQuery";
import LicenseRequired from "../../common/components/LicenseRequired";
import EmptyScreen from "../components/EmptyScreen";
import SkeletonLoader from "../components/SkeletonLoaderList";
import type { WorkflowType } from "../components/WorkflowListPage";
import WorkflowList from "../components/WorkflowListPage";
function WorkflowsPage() {
@@ -25,9 +28,11 @@ function WorkflowsPage() {
teamIds: [],
});
const { data: allWorkflowsData, isLoading } = trpc.viewer.workflows.list.useQuery();
const filters = getTeamsFiltersFromQuery(router.query);
const [filteredWorkflows, setFilteredWorkflows] = useState<WorkflowType[]>([]);
const queryRes = trpc.viewer.workflows.filteredList.useQuery({
filters,
});
const createMutation = trpc.viewer.workflows.create.useMutation({
onSuccess: async ({ workflow }) => {
@@ -46,50 +51,6 @@ function WorkflowsPage() {
},
});
const query = trpc.viewer.workflows.getByViewer.useQuery();
useEffect(() => {
const allWorkflows = allWorkflowsData?.workflows;
if (allWorkflows && allWorkflows.length > 0) {
const filtered = allWorkflows.filter((workflow) => {
if (checkedFilterItems.teamIds.includes(workflow.teamId || 0)) return workflow;
if (!workflow.teamId) {
if (!!workflow.userId && workflow.userId === checkedFilterItems.userId) return workflow;
}
});
setFilteredWorkflows(filtered);
} else {
setFilteredWorkflows([]);
}
}, [checkedFilterItems, allWorkflowsData]);
useEffect(() => {
if (session.status !== "loading" && !query.isLoading) {
if (!query.data) return;
setCheckedFilterItems({
userId: session.data?.user.id || null,
teamIds: query.data.profiles
.map((profile) => {
if (!!profile.teamId) {
return profile.teamId;
}
})
.filter((teamId): teamId is number => !!teamId),
});
}
}, [session.status, query.isLoading, allWorkflowsData]);
const profileOptions = query?.data?.profiles
.filter((profile) => !profile.readOnly)
.map((profile) => {
return {
teamId: profile.teamId,
label: profile.name || profile.slug,
image: profile.image,
slug: profile.slug,
};
});
return (
<Shell
heading={t("workflows")}
@@ -97,58 +58,42 @@ function WorkflowsPage() {
subtitle={t("workflows_to_automate_notifications")}
hideHeadingOnMobile
CTA={
query?.data?.profiles.length === 1 &&
session.data?.hasValidLicense &&
allWorkflowsData?.workflows &&
allWorkflowsData?.workflows.length &&
profileOptions ? (
<CreateButton
session.data?.hasValidLicense ? (
<CreateButtonWithTeamsList
subtitle={t("new_workflow_subtitle").toUpperCase()}
options={profileOptions}
createFunction={(teamId?: number) => {
createMutation.mutate({ teamId });
}}
isLoading={createMutation.isLoading}
disableMobileButton={true}
onlyShowWithNoTeams={true}
/>
) : null
}>
<LicenseRequired>
{isLoading ? (
<SkeletonLoader />
) : (
<>
{query?.data?.profiles &&
query?.data?.profiles.length > 1 &&
allWorkflowsData?.workflows &&
allWorkflowsData.workflows.length &&
profileOptions ? (
<div className="mb-4 flex">
<Filter
profiles={query.data.profiles}
checked={checkedFilterItems}
setChecked={setCheckedFilterItems}
<>
{queryRes.data?.totalCount ? (
<div className="flex">
<TeamsFilter />
<div className="ml-auto">
<CreateButtonWithTeamsList
subtitle={t("new_workflow_subtitle").toUpperCase()}
createFunction={(teamId?: number) => createMutation.mutate({ teamId })}
isLoading={createMutation.isLoading}
disableMobileButton={true}
onlyShowWithTeams={true}
/>
<div className="ml-auto">
<CreateButton
subtitle={t("new_workflow_subtitle").toUpperCase()}
options={profileOptions}
createFunction={(teamId?: number) => createMutation.mutate({ teamId })}
isLoading={createMutation.isLoading}
disableMobileButton={true}
/>
</div>
</div>
) : null}
{profileOptions && profileOptions?.length ? (
<WorkflowList
workflows={filteredWorkflows}
profileOptions={profileOptions}
hasNoWorkflows={!allWorkflowsData?.workflows || allWorkflowsData?.workflows.length === 0}
/>
) : null}
</>
)}
</div>
) : null}
<FilterResults
queryRes={queryRes}
emptyScreen={<EmptyScreen isFilteredView={false} />}
noResultsScreen={<EmptyScreen isFilteredView={true} />}
SkeletonLoader={SkeletonLoader}>
<WorkflowList workflows={queryRes.data?.filtered} />
</FilterResults>
</>
</LicenseRequired>
</Shell>
);
@@ -271,7 +271,7 @@ function WorkflowPage() {
</div>
{workflow && workflow.team && (
<Badge className="ml-4 mt-1" variant="gray">
{workflow.team.slug}
{workflow.team.name}
</Badge>
)}
{readOnly && (
@@ -5,6 +5,7 @@ import { forwardRef } from "react";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { AnimatedPopover, Avatar, Divider, VerticalDivider } from "@calcom/ui";
@@ -84,28 +85,30 @@ export const TeamsFilter = ({
label={t("yours")}
/>
<Divider />
{teams?.map((team) => (
<FilterCheckboxField
key={team.id}
id={team.name}
label={team.name}
checked={!!query.teamIds?.includes(team.id)}
onChange={(e) => {
if (e.target.checked) {
pushItemToKey("teamIds", team.id);
} else if (!e.target.checked) {
removeItemByKeyAndValue("teamIds", team.id);
{teams
?.filter((team) => !teamMetadataSchema.parse(team.metadata)?.isOrganization)
.map((team) => (
<FilterCheckboxField
key={team.id}
id={team.name}
label={team.name}
checked={!!query.teamIds?.includes(team.id)}
onChange={(e) => {
if (e.target.checked) {
pushItemToKey("teamIds", team.id);
} else if (!e.target.checked) {
removeItemByKeyAndValue("teamIds", team.id);
}
}}
icon={
<Avatar
alt={team?.name}
imageSrc={getPlaceholderAvatar(team.logo, team?.name as string)}
size="xs"
/>
}
}}
icon={
<Avatar
alt={team?.name}
imageSrc={getPlaceholderAvatar(team.logo, team?.name as string)}
size="xs"
/>
}
/>
))}
/>
))}
</FilterCheckboxFieldsContainer>
</AnimatedPopover>
{showVerticalDivider && <VerticalDivider />}
+1 -2
View File
@@ -20,7 +20,6 @@
"zustand": "^4.3.2"
},
"devDependencies": {
"@testing-library/react-hooks": "^8.0.1",
"mockdate": "^3.0.5"
"@testing-library/react-hooks": "^8.0.1"
}
}
@@ -15,7 +15,8 @@ import {
DropdownMenuTrigger,
showToast,
} from "@calcom/ui";
import { Globe, MoreHorizontal, Trash, Clock } from "@calcom/ui/components/icon";
import { Globe, MoreHorizontal, Trash, Star, Copy } from "@calcom/ui/components/icon";
export function ScheduleListItem({
schedule,
@@ -23,6 +24,7 @@ export function ScheduleListItem({
displayOptions,
updateDefault,
isDeletable,
duplicateFunction,
}: {
schedule: RouterOutputs["viewer"]["availability"]["list"]["schedules"][number];
deleteFunction: ({ scheduleId }: { scheduleId: number }) => void;
@@ -32,6 +34,7 @@ export function ScheduleListItem({
};
isDeletable: boolean;
updateDefault: ({ scheduleId, isDefault }: { scheduleId: number; isDefault: boolean }) => void;
duplicateFunction: ({ scheduleId }: { scheduleId: number }) => void;
}) {
const { t, i18n } = useLocale();
@@ -91,7 +94,7 @@ export function ScheduleListItem({
{!schedule.isDefault && (
<DropdownItem
type="button"
StartIcon={Clock}
StartIcon={Star}
onClick={() => {
updateDefault({
scheduleId: schedule.id,
@@ -102,6 +105,19 @@ export function ScheduleListItem({
</DropdownItem>
)}
</DropdownMenuItem>
<DropdownMenuItem className="outline-none">
<DropdownItem
type="button"
data-testid={"schedule-duplicate" + schedule.id}
StartIcon={Copy}
onClick={() => {
duplicateFunction({
scheduleId: schedule.id,
});
}}>
{t("duplicate")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem className="min-w-40 focus:ring-muted">
<DropdownItem
type="button"
+2 -2
View File
@@ -199,8 +199,8 @@ const Layout = (props: LayoutProps) => {
{/* todo: only run this if timezone is different */}
<TimezoneChangeDialog />
<div style={{ paddingTop: `${bannersHeight}px` }} className="flex min-h-screen flex-col">
<div ref={bannerRef} className="fixed top-0 z-10 w-full divide-y divide-black">
<div className="flex min-h-screen flex-col">
<div ref={bannerRef} className="sticky top-0 z-10 w-full divide-y divide-black">
<TeamsUpgradeBanner />
<OrgUpgradeBanner />
<ImpersonatingBanner />
@@ -1,4 +1,3 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { Suspense } from "react";
@@ -6,20 +5,9 @@ import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { WebhooksByViewer } from "@calcom/trpc/server/routers/viewer/webhook/getByViewer.handler";
import {
Button,
Meta,
SkeletonText,
EmptyScreen,
Dropdown,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownItem,
DropdownMenuLabel,
} from "@calcom/ui";
import { Meta, EmptyScreen, CreateButtonWithTeamsList } from "@calcom/ui";
import { Avatar } from "@calcom/ui";
import { Plus, Link as LinkIcon } from "@calcom/ui/components/icon";
import { Link as LinkIcon } from "@calcom/ui/components/icon";
import { getLayout } from "../../settings/layouts/SettingsLayout";
import { WebhookListItem, WebhookListSkeleton } from "../components";
@@ -33,14 +21,24 @@ const WebhooksView = () => {
enabled: router.isReady,
});
const profiles = data?.profiles.filter((profile) => !profile.readOnly);
return (
<>
<Meta
title="Webhooks"
description={t("add_webhook_description", { appName: APP_NAME })}
CTA={data && data.webhookGroups.length > 0 ? <NewWebhookButton profiles={profiles} /> : <></>}
CTA={
data && data.webhookGroups.length > 0 ? (
<CreateButtonWithTeamsList
subtitle={t("create_for").toUpperCase()}
createFunction={(teamId?: number) => {
router.push(`webhooks/new${teamId ? `?teamId=${teamId}` : ""}`);
}}
data-testid="new_webhook"
/>
) : (
<></>
)
}
/>
<div>
<Suspense fallback={<WebhookListSkeleton />}>
@@ -51,68 +49,6 @@ const WebhooksView = () => {
);
};
const NewWebhookButton = ({
teamId,
profiles,
}: {
teamId?: number | null;
profiles?: {
readOnly?: boolean | undefined;
slug: string | null;
name: string | null;
image?: string | undefined;
teamId: number | null | undefined;
}[];
}) => {
const { t, isLocaleReady } = useLocale();
const url = new URL(`${WEBAPP_URL}/settings/developer/webhooks/new`);
if (!!teamId) {
url.searchParams.set("teamId", `${teamId}`);
}
const href = url.href;
if (!profiles || profiles.length < 2) {
return (
<Button color="primary" data-testid="new_webhook" StartIcon={Plus} href={href}>
{isLocaleReady ? t("new") : <SkeletonText className="h-4 w-24" />}
</Button>
);
}
return (
<Dropdown>
<DropdownMenuTrigger asChild>
<Button color="primary" StartIcon={Plus}>
{isLocaleReady ? t("new") : <SkeletonText className="h-4 w-24" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={14} align="end">
<DropdownMenuLabel>
<div className="text-xs">{t("create_for").toUpperCase()}</div>
</DropdownMenuLabel>
{profiles.map((profile, idx) => (
<DropdownMenuItem key={profile.slug}>
<DropdownItem
type="button"
StartIcon={(props) => (
<Avatar
alt={profile.slug || ""}
imageSrc={profile.image || `${WEBAPP_URL}/${profile.name}/avatar.png`}
size="sm"
{...props}
/>
)}>
<Link href={`webhooks/new${profile.teamId ? `?teamId=${profile.teamId}` : ""}`}>
{profile.name}
</Link>
</DropdownItem>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</Dropdown>
);
};
const WebhooksList = ({ webhooksByViewer }: { webhooksByViewer: WebhooksByViewer }) => {
const { t } = useLocale();
const router = useRouter();
@@ -166,7 +102,15 @@ const WebhooksList = ({ webhooksByViewer }: { webhooksByViewer: WebhooksByViewer
Icon={LinkIcon}
headline={t("create_your_first_webhook")}
description={t("create_your_first_webhook_description", { appName: APP_NAME })}
buttonRaw={<NewWebhookButton profiles={profiles} />}
buttonRaw={
<CreateButtonWithTeamsList
subtitle={t("create_for").toUpperCase()}
createFunction={(teamId?: number) => {
router.push(`webhooks/new${teamId ? `?teamId=${teamId}` : ""}`);
}}
data-testid="new_webhook"
/>
}
/>
)}
</>
@@ -2,6 +2,7 @@ import authedProcedure from "../../../../procedures/authedProcedure";
import { router } from "../../../../trpc";
import { ZCreateInputSchema } from "./create.schema";
import { ZDeleteInputSchema } from "./delete.schema";
import { ZScheduleDuplicateSchema } from "./duplicate.schema";
import { ZGetInputSchema } from "./get.schema";
import { ZUpdateInputSchema } from "./update.schema";
@@ -10,6 +11,7 @@ type ScheduleRouterHandlerCache = {
create?: typeof import("./create.handler").createHandler;
delete?: typeof import("./delete.handler").deleteHandler;
update?: typeof import("./update.handler").updateHandler;
duplicate?: typeof import("./duplicate.handler").duplicateHandler;
};
const UNSTABLE_HANDLER_CACHE: ScheduleRouterHandlerCache = {};
@@ -78,4 +80,22 @@ export const scheduleRouter = router({
input,
});
}),
duplicate: authedProcedure.input(ZScheduleDuplicateSchema).mutation(async ({ input, ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.duplicate) {
UNSTABLE_HANDLER_CACHE.duplicate = await import("./duplicate.handler").then(
(mod) => mod.duplicateHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.duplicate) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.duplicate({
ctx,
input,
});
}),
});
@@ -0,0 +1,69 @@
import { prisma } from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../../trpc";
import type { TScheduleDuplicateSchema } from "./duplicate.schema";
import type { Prisma } from ".prisma/client";
type DuplicateScheduleOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TScheduleDuplicateSchema;
};
export const duplicateHandler = async ({ ctx, input }: DuplicateScheduleOptions) => {
try {
const { scheduleId } = input;
const { user } = ctx;
const schedule = await prisma.schedule.findUnique({
where: {
id: scheduleId,
},
select: {
id: true,
userId: true,
name: true,
availability: true,
timeZone: true,
},
});
if (!schedule || schedule.userId !== user.id) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
}
const { availability } = schedule;
const data: Prisma.ScheduleCreateInput = {
name: `${schedule.name} (Copy)`,
user: {
connect: {
id: user.id,
},
},
timeZone: schedule.timeZone ?? user.timeZone,
availability: {
createMany: {
data: availability.map((schedule) => ({
days: schedule.days,
startTime: schedule.startTime,
endTime: schedule.endTime,
date: schedule.date,
})),
},
},
};
const newSchedule = await prisma.schedule.create({
data,
});
return { schedule: newSchedule };
} catch (error) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
};
@@ -0,0 +1,7 @@
import { z } from "zod";
export const ZScheduleDuplicateSchema = z.object({
scheduleId: z.number(),
});
export type TScheduleDuplicateSchema = z.infer<typeof ZScheduleDuplicateSchema>;
@@ -68,6 +68,12 @@ const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
...baseEventTypeSelect,
});
export const compareMembership = (mship1: MembershipRole, mship2: MembershipRole) => {
const mshipToNumber = (mship: MembershipRole) =>
Object.keys(MembershipRole).findIndex((mmship) => mmship === mship);
return mshipToNumber(mship1) > mshipToNumber(mship2);
};
export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => {
const { prisma } = ctx;
@@ -214,12 +220,6 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) =>
membershipRole: membership.role,
}));
const compareMembership = (mship1: MembershipRole, mship2: MembershipRole) => {
const mshipToNumber = (mship: MembershipRole) =>
Object.keys(MembershipRole).findIndex((mmship) => mmship === mship);
return mshipToNumber(mship1) > mshipToNumber(mship2);
};
const filterTeamsEventTypesBasedOnInput = (eventType: ReturnType<typeof mapEventType>) => {
if (!input?.filters || !hasFilter(input?.filters)) {
return true;
@@ -2,10 +2,13 @@ import { CAL_URL } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
import type { Webhook } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
import { compareMembership } from "../eventTypes/getByViewer.handler";
type GetByViewerOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
@@ -57,6 +60,8 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
id: true,
name: true,
slug: true,
parentId: true,
metadata: true,
members: {
select: {
userId: true,
@@ -91,19 +96,44 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
},
});
const teamWebhookGroups: WebhookGroup[] = user.teams.map((membership) => ({
const teamMemberships = user.teams.map((membership) => ({
teamId: membership.team.id,
profile: {
name: membership.team.name,
slug: "team/" + membership.team.slug,
image: `${CAL_URL}/team/${membership.team.slug}/avatar.png`,
},
metadata: {
readOnly: membership.role !== MembershipRole.ADMIN && membership.role !== MembershipRole.OWNER,
},
webhooks: membership.team.webhooks,
membershipRole: membership.role,
}));
const teamWebhookGroups: WebhookGroup[] = user.teams
.filter((mmship) => {
const metadata = teamMetadataSchema.parse(mmship.team.metadata);
return !metadata?.isOrganization;
})
.map((membership) => {
const orgMembership = teamMemberships.find(
(teamM) => teamM.teamId === membership.team.parentId
)?.membershipRole;
return {
teamId: membership.team.id,
profile: {
name: membership.team.name,
slug: membership.team.slug
? !membership.team.parentId
? `/team`
: "" + membership.team.slug
: null,
image: `${CAL_URL}/team/${membership.team.slug}/avatar.png`,
},
metadata: {
readOnly:
membership.role ===
(membership.team.parentId
? orgMembership && compareMembership(orgMembership, membership.role)
? orgMembership
: MembershipRole.MEMBER
: MembershipRole.MEMBER),
},
webhooks: membership.team.webhooks,
};
});
webhookGroups = webhookGroups.concat(teamWebhookGroups);
return {
@@ -112,6 +142,7 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
teamId: group.teamId,
...group.profile,
...group.metadata,
image: `${CAL_URL}/${group.profile.slug}/avatar.png`,
})),
};
};
@@ -3,6 +3,7 @@ import { router } from "../../../trpc";
import { ZActivateEventTypeInputSchema } from "./activateEventType.schema";
import { ZCreateInputSchema } from "./create.schema";
import { ZDeleteInputSchema } from "./delete.schema";
import { ZFilteredListInputSchema } from "./filteredList.schema";
import { ZGetInputSchema } from "./get.schema";
import { ZGetVerifiedNumbersInputSchema } from "./getVerifiedNumbers.schema";
import { ZListInputSchema } from "./list.schema";
@@ -21,7 +22,7 @@ type WorkflowsRouterHandlerCache = {
verifyPhoneNumber?: typeof import("./verifyPhoneNumber.handler").verifyPhoneNumberHandler;
getVerifiedNumbers?: typeof import("./getVerifiedNumbers.handler").getVerifiedNumbersHandler;
getWorkflowActionOptions?: typeof import("./getWorkflowActionOptions.handler").getWorkflowActionOptionsHandler;
getByViewer?: typeof import("./getByViewer.handler").getByViewerHandler;
filteredList?: typeof import("./filteredList.handler").filteredListHandler;
};
const UNSTABLE_HANDLER_CACHE: WorkflowsRouterHandlerCache = {};
@@ -197,21 +198,21 @@ export const workflowsRouter = router({
ctx,
});
}),
getByViewer: authedProcedure.query(async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.getByViewer) {
UNSTABLE_HANDLER_CACHE.getByViewer = await import("./getByViewer.handler").then(
(mod) => mod.getByViewerHandler
filteredList: authedProcedure.input(ZFilteredListInputSchema).query(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.filteredList) {
UNSTABLE_HANDLER_CACHE.filteredList = await import("./filteredList.handler").then(
(mod) => mod.filteredListHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.getByViewer) {
if (!UNSTABLE_HANDLER_CACHE.filteredList) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.getByViewer({
return UNSTABLE_HANDLER_CACHE.filteredList({
ctx,
input,
});
}),
});
@@ -0,0 +1,143 @@
import type { WorkflowType } from "@calcom/ee/workflows/components/WorkflowListPage";
import { hasFilter } from "@calcom/features/filters/lib/hasFilter";
import type { Prisma, PrismaClient } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/client";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import type { TFilteredListInputSchema } from "./filteredList.schema";
type FilteredListOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
prisma: PrismaClient;
};
input: TFilteredListInputSchema;
};
const includedFields = {
activeOn: {
select: {
eventType: {
select: {
id: true,
title: true,
parentId: true,
_count: {
select: {
children: true,
},
},
},
},
},
},
steps: true,
team: {
select: {
id: true,
slug: true,
name: true,
members: true,
logo: true,
},
},
};
export const filteredListHandler = async ({ ctx, input }: FilteredListOptions) => {
const { prisma, user } = ctx;
const filters = input?.filters;
const filtered = filters && hasFilter(filters);
const allWorkflows = await prisma.workflow.findMany({
where: {
OR: [
{
userId: user.id,
},
{
team: {
members: {
some: {
userId: user.id,
accepted: true,
},
},
},
},
],
},
include: includedFields,
orderBy: {
id: "asc",
},
});
if (!filtered) {
const workflowsWithReadOnly: WorkflowType[] = allWorkflows.map((workflow) => {
const readOnly = !!workflow.team?.members?.find(
(member) => member.userId === ctx.user.id && member.role === MembershipRole.MEMBER
);
return { readOnly, ...workflow };
});
return {
filtered: workflowsWithReadOnly,
totalCount: allWorkflows.length,
};
}
const where = {
OR: [] as Prisma.WorkflowWhereInput[],
};
if (filtered) {
if (!!filters.teamIds) {
where.OR.push({
team: {
id: {
in: filters.teamIds ?? [],
},
members: {
some: {
userId: user.id,
accepted: true,
},
},
},
});
}
if (!!filters.userIds) {
where.OR.push({
userId: {
in: filters.userIds,
},
teamId: null,
});
}
const filteredWorkflows = await prisma.workflow.findMany({
where,
include: includedFields,
orderBy: {
id: "asc",
},
});
const workflowsWithReadOnly: WorkflowType[] = filteredWorkflows.map((workflow) => {
const readOnly = !!workflow.team?.members?.find(
(member) => member.userId === ctx.user.id && member.role === MembershipRole.MEMBER
);
return { readOnly, ...workflow };
});
return {
filtered: workflowsWithReadOnly,
totalCount: allWorkflows.length,
};
}
};
@@ -0,0 +1,11 @@
import { z } from "zod";
import { filterQuerySchemaStrict } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
export const ZFilteredListInputSchema = z
.object({
filters: filterQuerySchemaStrict.optional(),
})
.nullish();
export type TFilteredListInputSchema = z.infer<typeof ZFilteredListInputSchema>;
@@ -28,6 +28,7 @@ export const getHandler = async ({ ctx, input }: GetOptions) => {
id: true,
slug: true,
members: true,
name: true,
},
},
time: true,
@@ -1,121 +0,0 @@
import { CAL_URL } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
type GetByViewerOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
};
export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
const user = await prisma.user.findUnique({
where: {
id: ctx.user.id,
},
select: {
id: true,
username: true,
avatar: true,
name: true,
startTime: true,
endTime: true,
bufferTime: true,
workflows: {
select: {
id: true,
name: true,
},
},
teams: {
where: {
accepted: true,
},
select: {
role: true,
team: {
select: {
id: true,
name: true,
slug: true,
members: {
select: {
userId: true,
},
},
workflows: {
select: {
id: true,
name: true,
},
},
},
},
},
},
},
});
if (!user) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
const userWorkflows = user.workflows;
type WorkflowGroup = {
teamId?: number | null;
profile: {
slug: (typeof user)["username"];
name: (typeof user)["name"];
image?: string;
};
metadata?: {
readOnly: boolean;
};
workflows: typeof userWorkflows;
};
let workflowGroups: WorkflowGroup[] = [];
const image = user?.username ? `${CAL_URL}/${user.username}/avatar.png` : undefined;
workflowGroups.push({
teamId: null,
profile: {
slug: user.username,
name: user.name,
image,
},
workflows: userWorkflows,
metadata: {
readOnly: false,
},
});
workflowGroups = ([] as WorkflowGroup[]).concat(
workflowGroups,
user.teams.map((membership) => ({
teamId: membership.team.id,
profile: {
name: membership.team.name,
slug: "team/" + membership.team.slug,
image: `${CAL_URL}/team/${membership.team.slug}/avatar.png`,
},
metadata: {
readOnly: membership.role === MembershipRole.MEMBER,
},
workflows: membership.team.workflows,
}))
);
return {
workflowGroups: workflowGroups.filter((groupBy) => !!groupBy.workflows?.length),
profiles: workflowGroups.map((group) => ({
teamId: group.teamId,
...group.profile,
...group.metadata,
})),
};
};
@@ -1 +0,0 @@
export {};
@@ -698,6 +698,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
id: true,
slug: true,
members: true,
name: true,
},
},
steps: {
+6 -2
View File
@@ -16,9 +16,10 @@ export interface AlertProps {
// @TODO: Success and info shouldn't exist as per design?
severity: "success" | "warning" | "error" | "info" | "neutral";
CustomIcon?: IconType;
customIconColor?: string;
}
export const Alert = forwardRef<HTMLDivElement, AlertProps>((props, ref) => {
const { severity, iconClassName, CustomIcon } = props;
const { severity, iconClassName, CustomIcon, customIconColor } = props;
return (
<div
@@ -35,7 +36,10 @@ export const Alert = forwardRef<HTMLDivElement, AlertProps>((props, ref) => {
<div className="relative flex md:flex-row">
{CustomIcon ? (
<div className="flex-shrink-0">
<CustomIcon aria-hidden="true" className={classNames("text-default h-5 w-5", iconClassName)} />
<CustomIcon
aria-hidden="true"
className={classNames(`h-5 w-5`, iconClassName, customIconColor ?? "text-default")}
/>
</div>
) : (
<div className="flex-shrink-0">
+32 -18
View File
@@ -35,23 +35,37 @@ Each alert has a state to represent neutral, positive or negative responses.
<Canvas>
<Story
name="Alert">
<VariantsTable titles={['Default']} columnMinWidth={150}>
<VariantRow variant="Success">
<Alert severity="success" title="Summarise what happened" message="Describe what can be done about it here." />
</VariantRow>
<VariantRow variant="Warning">
<Alert severity="warning" title="Summarise what happened" message="Describe what can be done about it here." />
</VariantRow>
<VariantRow variant="Error">
<Alert severity="error" title="Summarise what happened" message="Describe what can be done about it here." />
</VariantRow>
<VariantRow variant="Neutral">
<Alert severity="neutral" title="Summarise what happened" message="Describe what can be done about it here." />
</VariantRow>
<VariantRow variant="Info">
<Alert severity="info" title="Summarise what happened" message="Describe what can be done about it here." />
</VariantRow>
</VariantsTable>
name="Alert"
args={{
severity: 'success',
title: 'Summarise what happened',
message: 'Describe what can be done about it here.',
}}
argTypes={{
severity: {
control: {
type: 'inline-radio',
options: ['success', 'warning', 'error', 'neutral', 'info'],
},
},
title: {
control: {
type: 'text'
}
},
message: {
control: {
type: 'text'
}
},
}}
>
{({ severity, title, message }) => (
<VariantsTable titles={['Default']} columnMinWidth={150}>
<VariantRow variant={severity}>
<Alert severity={severity} title={title} message={message} />
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>
+2 -1
View File
@@ -12,7 +12,7 @@ import { Tooltip } from "../tooltip";
export type AvatarProps = {
className?: string;
size: "xs" | "xsm" | "sm" | "md" | "mdLg" | "lg" | "xl";
size: "xxs" | "xs" | "xsm" | "sm" | "md" | "mdLg" | "lg" | "xl";
imageSrc?: Maybe<string>;
title?: string;
alt: string;
@@ -24,6 +24,7 @@ export type AvatarProps = {
};
const sizesPropsBySize = {
xxs: "w-3.5 h-3.5 min-w-3.5 min-h-3.5", // 14px
xs: "w-4 h-4 min-w-4 min-h-4 max-h-4", // 16px
xsm: "w-5 h-5 min-w-5 min-h-5", // 20px
sm: "w-6 h-6 min-w-6 min-h-6", // 24px
@@ -125,14 +125,29 @@ Avatar group can be composed differently based on the number of user profile.
<Canvas>
<Story
name="Avatar">
<VariantsTable titles={['Default']} columnMinWidth={150}>
<VariantRow variant="Sm">
<Avatar size="sm" alt="Avatar Story" gravatarFallbackMd5="Ui@CAL.com" />
name="Avatar"
args={{
size: 'sm',
alt: 'Avatar Story',
gravatarFallbackMd5: 'Ui@CAL.com',
}}
argTypes={{
size: {
control: {
type: 'inline-radio',
options: ['sm', 'lg'],
},
},
alt: { control: 'text' },
gravatarFallbackMd5: { control: 'text' },
}}
>
{({ size, alt, gravatarFallbackMd5 }) => (
<VariantsTable titles={['Default']} columnMinWidth={150}>
<VariantRow variant={size}>
<Avatar size={size} alt={alt} gravatarFallbackMd5={gravatarFallbackMd5} />
</VariantRow>
<VariantRow variant="Lg">
<Avatar size="lg" alt="Avatar Story" gravatarFallbackMd5="Ui@CAL.com" />
</VariantRow>
</VariantsTable>
</VariantsTable>
)}
</Story>
</Canvas>
+26 -22
View File
@@ -56,28 +56,32 @@ Alet badge is used in conjunction with an item, profile or label to indicate num
<Title offset title="Badge" suffix="Variants" />
<Canvas>
<Story name="All Variants">
<VariantsTable titles={['Default','With Dot', 'With Icon']} columnMinWidth={150}>
<VariantRow variant="Default">
<Badge variant="default" >Badge text</Badge>
<Badge variant="default" withDot>Badge text</Badge>
<Badge variant="default" startIcon={Plus}>Badge text</Badge>
<Story name="All Variants"
args={{
severity: 'default',
label: 'Badge text',
}}
argTypes={{
severity: {
control: {
type: 'inline-radio',
options: ['default', 'success', 'gray', 'error'],
},
},
label: {
control: {
type: 'text',
},
},
}}>
{({ severity, label }) => (
<VariantsTable titles={['Default','With Dot', 'With Icon']} columnMinWidth={150}>
<VariantRow variant={severity}>
<Badge variant={severity}>{label}</Badge>
<Badge variant={severity} withDot>{label}</Badge>
<Badge variant={severity} startIcon={Plus}>{label}</Badge>
</VariantRow>
<VariantRow variant="Success">
<Badge variant="success" >Badge text</Badge>
<Badge variant="success" withDot>Badge text</Badge>
<Badge variant="success" startIcon={Plus}>Badge text</Badge>
</VariantRow>
<VariantRow variant="gray">
<Badge variant="gray" >Badge text</Badge>
<Badge variant="gray" withDot>Badge text</Badge>
<Badge variant="gray" startIcon={Plus}>Badge text</Badge>
</VariantRow>
<VariantRow variant="error">
<Badge variant="error" >Badge text</Badge>
<Badge variant="error" withDot>Badge text</Badge>
<Badge variant="error" startIcon={Plus}>Badge text</Badge>
</VariantRow>
</VariantsTable>
</VariantsTable>
)}
</Story>
</Canvas>

Some files were not shown because too many files have changed in this diff Show More