diff --git a/.github/workflows/nextjs-bundle-analysis.yml b/.github/workflows/nextjs-bundle-analysis.yml
index 4c8da86217..c92e6b2045 100644
--- a/.github/workflows/nextjs-bundle-analysis.yml
+++ b/.github/workflows/nextjs-bundle-analysis.yml
@@ -62,18 +62,18 @@ jobs:
- name: Get comment body
id: get-comment-body
- if: success()
+ if: success() && github.event.number
run: |
cd apps/web
body=$(cat .next/analyze/__bundle_analysis_comment.txt)
body="${body//'%'/'%25'}"
body="${body//$'\n'/'%0A'}"
body="${body//$'\r'/'%0D'}"
- echo "{name}={$body}" >> $GITHUB_OUTPUT
+ echo "{body}={$body}" >> $GITHUB_OUTPUT
- name: Find Comment
uses: peter-evans/find-comment@v2
- if: success()
+ if: success() && github.event.number
id: fc
with:
issue-number: ${{ github.event.number }}
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 69d08f19d0..b1feaf8770 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -12,6 +12,14 @@ concurrency:
cancel-in-progress: true
jobs:
+ login:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
changes:
name: Detect changes
runs-on: buildjet-4vcpu-ubuntu-2204
diff --git a/apps/api/pages/api/webhooks/[id]/_patch.ts b/apps/api/pages/api/webhooks/[id]/_patch.ts
index fd0f8db3f5..faba47d863 100644
--- a/apps/api/pages/api/webhooks/[id]/_patch.ts
+++ b/apps/api/pages/api/webhooks/[id]/_patch.ts
@@ -87,6 +87,10 @@ export async function patchHandler(req: NextApiRequest) {
args.data.userId = bodyUserId;
}
+ if (args.data.eventTriggers) {
+ args.data.eventTriggers = [...new Set(args.data.eventTriggers)];
+ }
+
const result = await prisma.webhook.update(args);
return { webhook: schemaWebhookReadPublic.parse(result) };
}
diff --git a/apps/api/pages/api/webhooks/_post.ts b/apps/api/pages/api/webhooks/_post.ts
index 8c36bcbcf6..9647a11738 100644
--- a/apps/api/pages/api/webhooks/_post.ts
+++ b/apps/api/pages/api/webhooks/_post.ts
@@ -87,6 +87,10 @@ async function postHandler(req: NextApiRequest) {
args.data.userId = bodyUserId;
}
+ if (args.data.eventTriggers) {
+ args.data.eventTriggers = [...new Set(args.data.eventTriggers)];
+ }
+
const data = await prisma.webhook.create(args);
return {
diff --git a/apps/web/components/out-of-office/DateRangePicker/DateSelect.css b/apps/web/components/out-of-office/DateRangePicker/DateSelect.css
new file mode 100644
index 0000000000..6ea26081a2
--- /dev/null
+++ b/apps/web/components/out-of-office/DateRangePicker/DateSelect.css
@@ -0,0 +1,110 @@
+.custom-date > .tremor-DateRangePicker-root > .tremor-DateRangePicker-button {
+ box-shadow: none;
+ width: 100%;
+ background-color: transparent;
+ }
+
+ /* Media query for screens larger than 768px */
+ @media (max-width: 639) {
+ .custom-date > .tremor-DateRangePicker-root > .tremor-DateRangePicker-button {
+ max-width: 400px;
+ }
+ }
+
+ .recharts-cartesian-grid-horizontal line{
+ @apply stroke-emphasis
+ }
+
+ .tremor-DateRangePicker-button button{
+ @apply !h-9 !max-h-9 border-default hover:border-emphasis
+ }
+
+ .tremor-DateRangePicker-calendarButton,
+ .tremor-DateRangePicker-dropdownButton {
+ @apply border-subtle bg-default focus-within:ring-emphasis hover:border-subtle dark:focus-within:ring-emphasis hover:bg-subtle text-sm leading-4 placeholder:text-sm placeholder:font-normal focus-within:ring-0;
+ }
+
+ .tremor-DateRangePicker-dropdownModal{
+ @apply divide-none
+ }
+
+ .tremor-DropdownItem-root{
+ @apply !h-9 !max-h-9 bg-default hover:bg-subtle text-default hover:text-emphasis
+ }
+
+ .tremor-DateRangePicker-calendarButtonText,
+ .tremor-DateRangePicker-dropdownButtonText {
+ @apply text-default;
+ }
+
+ .tremor-DateRangePicker-calendarHeaderText{
+ @apply !text-default
+ }
+
+ .tremor-DateRangePicker-calendarHeader svg{
+ @apply text-default
+ }
+
+ .tremor-DateRangePicker-calendarHeader button{
+ @apply hover:bg-emphasis shadow-none focus:ring-0
+ }
+
+
+ .tremor-DateRangePicker-calendarHeader button:hover svg{
+ @apply text-emphasis
+ }
+
+ .tremor-DateRangePicker-calendarButtonIcon{
+ @apply text-default
+ }
+
+ .tremor-DateRangePicker-calendarModal,
+ .tremor-DateRangePicker-dropdownModal {
+ @apply bg-default border-subtle shadow-dropdown
+ }
+
+ .tremor-DateRangePicker-calendarBodyDate button{
+ @apply text-default hover:bg-emphasis
+ }
+
+ .tremor-DateRangePicker-calendarBodyDate button:disabled,
+ .tremor-DateRangePicker-calendarBodyDate button[disabled]{
+ @apply opacity-25
+ }
+
+ .tremor-DateRangePicker-calendarHeader button{
+ @apply border-default text-default
+ }
+
+ .tremor-DateRangePicker-calendarBodyDate .bg-gray-100{
+ @apply bg-subtle
+ }
+
+ .tremor-DateRangePicker-calendarBodyDate .bg-gray-500{
+ @apply !bg-brand-default text-inverted
+ }
+
+
+ .tremor-Card-root {
+ @apply p-5 bg-default;
+ }
+
+ .tremor-TableCell-root {
+ @apply pl-0;
+ }
+
+ .recharts-responsive-container {
+ @apply -mx-4;
+ }
+ .tremor-Card-root > p {
+ @apply mb-2 text-base font-semibold;
+ }
+
+ .tremor-Legend-legendItem {
+ @apply ml-2;
+ }
+
+ .tremor-TableBody-root {
+ @apply divide-subtle;
+ }
+
\ No newline at end of file
diff --git a/apps/web/components/out-of-office/DateRangePicker/index.tsx b/apps/web/components/out-of-office/DateRangePicker/index.tsx
new file mode 100644
index 0000000000..5a20178822
--- /dev/null
+++ b/apps/web/components/out-of-office/DateRangePicker/index.tsx
@@ -0,0 +1,47 @@
+import type { BookingRedirectForm } from "@pages/settings/my-account/out-of-office";
+import { DateRangePicker } from "@tremor/react";
+import type { UseFormSetValue } from "react-hook-form";
+
+import dayjs from "@calcom/dayjs";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+
+import "./DateSelect.css";
+
+interface IOutOfOfficeDateRangeSelectProps {
+ dateRange: [Date | null, Date | null, null];
+ setDateRange: React.Dispatch>;
+ setValue: UseFormSetValue;
+}
+
+const OutOfOfficeDateRangePicker = (props: IOutOfOfficeDateRangeSelectProps) => {
+ const { t } = useLocale();
+ const { dateRange, setDateRange, setValue } = props;
+ return (
+
+ {
+ const [start, end] = datesArray;
+
+ if (start) {
+ setDateRange([start, end as Date | null, null]);
+ }
+ if (start && end) {
+ setValue("startDate", start.toISOString());
+ setValue("endDate", end.toISOString());
+ }
+ }}
+ color="gray"
+ options={undefined}
+ enableDropdown={false}
+ placeholder={t("select_date_range")}
+ enableYearPagination={true}
+ minDate={dayjs().startOf("d").toDate()}
+ maxDate={dayjs().add(2, "y").endOf("d").toDate()}
+ />
+
+ );
+};
+
+export { OutOfOfficeDateRangePicker };
diff --git a/apps/web/package.json b/apps/web/package.json
index e4a982473a..e0b9b60ee5 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
- "version": "3.6.2",
+ "version": "3.6.3",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
diff --git a/apps/web/pages/[user].tsx b/apps/web/pages/[user].tsx
index 349c8be4b6..acb97c9bce 100644
--- a/apps/web/pages/[user].tsx
+++ b/apps/web/pages/[user].tsx
@@ -2,6 +2,8 @@ import type { DehydratedState } from "@tanstack/react-query";
import classNames from "classnames";
import type { GetServerSideProps, InferGetServerSidePropsType } from "next";
import Link from "next/link";
+import { useSearchParams } from "next/navigation";
+import { encode } from "querystring";
import { Toaster } from "react-hot-toast";
import type { z } from "zod";
@@ -11,6 +13,7 @@ import {
useEmbedStyles,
useIsEmbed,
} from "@calcom/embed-core/embed-iframe";
+import { handleUserRedirection } from "@calcom/features/booking-redirect/handle-user";
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
@@ -40,6 +43,7 @@ import { getTemporaryOrgRedirect } from "../lib/getTemporaryOrgRedirect";
export function UserPage(props: InferGetServerSidePropsType) {
const { users, profile, eventTypes, markdownStrippedBio, entity } = props;
+ const searchParams = useSearchParams();
const [user] = users; //To be used when we only have a single user, not dynamic group
useTheme(profile.theme);
@@ -59,6 +63,8 @@ export function UserPage(props: InferGetServerSidePropsType {
@@ -77,6 +83,7 @@ export function UserPage(props: InferGetServerSidePropsType
+ {isRedirect && (
+
+
+ {t("user_redirect_title", {
+ username: fromUserNameRedirected,
+ })}{" "}
+ 🏝️
+
+
+ {t("user_redirect_description", {
+ profile: {
+ username: user.username,
+ },
+ username: fromUserNameRedirected,
+ })}{" "}
+ 😄
+
+
+ )}
= async (cont
const usernameList = getUsernameList(context.query.user as string);
const isOrgContext = isValidOrgDomain && currentOrgDomain;
const dataFetchStart = Date.now();
+ let outOfOffice = false;
+
+ if (usernameList.length === 1) {
+ const result = await handleUserRedirection({ username: usernameList[0] });
+ if (result && result.outOfOffice) {
+ outOfOffice = true;
+ }
+ if (result && result.redirect?.destination) {
+ return result;
+ }
+ }
+
const usersWithoutAvatar = await prisma.user.findMany({
where: {
username: {
@@ -400,11 +438,16 @@ export const getServerSideProps: GetServerSideProps = async (cont
}));
// if profile only has one public event-type, redirect to it
- if (eventTypes.length === 1 && context.query.redirect !== "false") {
+ if (eventTypes.length === 1 && context.query.redirect !== "false" && !outOfOffice) {
+ // Redirect but don't change the URL
+ const urlDestination = `/${user.username}/${eventTypes[0].slug}`;
+ const { query } = context;
+ const urlQuery = new URLSearchParams(encode(query));
+
return {
redirect: {
permanent: false,
- destination: `/${user.username}/${eventTypes[0].slug}`,
+ destination: `${urlDestination}?${urlQuery}`,
},
};
}
@@ -421,7 +464,7 @@ export const getServerSideProps: GetServerSideProps = async (cont
username: user.username,
bio: user.bio,
avatarUrl: user.avatarUrl,
- away: user.away,
+ away: usernameList.length === 1 ? outOfOffice : user.away,
verified: user.verified,
})),
entity: {
diff --git a/apps/web/pages/[user]/[type].tsx b/apps/web/pages/[user]/[type].tsx
index b328800c45..f36ecdc15b 100644
--- a/apps/web/pages/[user]/[type].tsx
+++ b/apps/web/pages/[user]/[type].tsx
@@ -4,6 +4,7 @@ import { z } from "zod";
import { Booker } from "@calcom/atoms";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
+import { handleTypeRedirection } from "@calcom/features/booking-redirect/handle-type";
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
import { getBookingForReschedule, getBookingForSeatedEvent } from "@calcom/features/bookings/lib/get-booking";
@@ -164,7 +165,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
const username = usernames[0];
const { rescheduleUid, bookingUid } = context.query;
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
-
+ let outOfOffice = false;
const isOrgContext = currentOrgDomain && isValidOrgDomain;
if (!isOrgContext) {
@@ -188,7 +189,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
organization: userOrgQuery(context.req, context.params?.orgSlug),
},
select: {
- away: true,
+ id: true,
hideBranding: true,
allowSEOIndexing: true,
},
@@ -199,6 +200,18 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
notFound: true,
} as const;
}
+ // If user is found, quickly verify bookingRedirects
+ const result = await handleTypeRedirection({
+ userId: user.id,
+ username,
+ slug,
+ });
+ if (result && result.outOfOffice) {
+ outOfOffice = true;
+ }
+ if (result && result.redirect?.destination) {
+ return result;
+ }
let booking: GetBookingType | null = null;
if (rescheduleUid) {
@@ -230,7 +243,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
length: eventData.length,
metadata: eventData.metadata,
},
- away: user?.away,
+ away: outOfOffice,
user: username,
slug,
trpcState: ssr.dehydrate(),
diff --git a/apps/web/pages/availability/index.tsx b/apps/web/pages/availability/index.tsx
index d368c2a233..97fcdb0fe2 100644
--- a/apps/web/pages/availability/index.tsx
+++ b/apps/web/pages/availability/index.tsx
@@ -1,4 +1,5 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
+import Link from "next/link";
import { useRouter, usePathname } from "next/navigation";
import { useCallback } from "react";
@@ -104,24 +105,32 @@ export function AvailabilityList({ schedules }: RouterOutputs["viewer"]["availab
/>
) : (
-
-
- {schedules.map((schedule) => (
-
- ))}
-
-
+ <>
+
+
+ {schedules.map((schedule) => (
+
+ ))}
+
+
+
+ {t("temporarily_out_of_office")}{" "}
+
+ {t("add_a_redirect")}
+
+
+ >
)}
>
);
diff --git a/apps/web/pages/settings/my-account/out-of-office/index.tsx b/apps/web/pages/settings/my-account/out-of-office/index.tsx
new file mode 100644
index 0000000000..1524422af4
--- /dev/null
+++ b/apps/web/pages/settings/my-account/out-of-office/index.tsx
@@ -0,0 +1,241 @@
+import { Trash2 } from "lucide-react";
+import React, { useState } from "react";
+import { useForm, useFormState } from "react-hook-form";
+
+import dayjs from "@calcom/dayjs";
+import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
+import { ShellMain } from "@calcom/features/shell/Shell";
+import { useHasTeamPlan } from "@calcom/lib/hooks/useHasPaidPlan";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { trpc } from "@calcom/trpc/react";
+import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
+import { Button, Meta, showToast, Select, SkeletonText, UpgradeTeamsBadge, Switch } from "@calcom/ui";
+import { TableNew, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@calcom/ui";
+
+import PageWrapper from "@components/PageWrapper";
+import { OutOfOfficeDateRangePicker } from "@components/out-of-office/DateRangePicker";
+
+export type BookingRedirectForm = {
+ startDate: string;
+ endDate: string;
+ toTeamUserId: number | null;
+};
+
+const OutOfOfficeSection = () => {
+ const { t } = useLocale();
+ const utils = trpc.useContext();
+
+ const [dateRange, setDateRange] = useState<[Date | null, Date | null, null | null]>([
+ dayjs().startOf("d").toDate(),
+ dayjs().add(1, "d").endOf("d").toDate(),
+ null,
+ ]);
+ const [profileRedirect, setProfileRedirect] = useState(false);
+ const [selectedMember, setSelectedMember] = useState<{ label: string; value: number | null } | null>(null);
+
+ const { handleSubmit, setValue } = useForm({
+ defaultValues: {
+ startDate: dateRange[0]?.toISOString(),
+ endDate: dateRange[1]?.toISOString(),
+ toTeamUserId: null,
+ },
+ });
+
+ const createOutOfOfficeEntry = trpc.viewer.outOfOfficeCreate.useMutation({
+ onSuccess: () => {
+ showToast(t("success_entry_created"), "success");
+ utils.viewer.outOfOfficeEntriesList.invalidate();
+ setProfileRedirect(false);
+ },
+ onError: (error) => {
+ showToast(t(error.message), "error");
+ },
+ });
+
+ const { hasTeamPlan } = useHasTeamPlan();
+ const { data: listMembers } = trpc.viewer.teams.listMembers.useQuery({});
+ const me = useMeQuery();
+ const memberListOptions: {
+ value: number | null;
+ label: string;
+ }[] =
+ listMembers
+ ?.filter((member) => me?.data?.id !== member.id)
+ .map((member) => ({
+ value: member.id || null,
+ label: member.name || "",
+ })) || [];
+
+ return (
+ <>
+
+ >
+ );
+};
+
+const OutOfOfficeEntriesList = () => {
+ const { t } = useLocale();
+ const utils = trpc.useContext();
+ const { data, isLoading } = trpc.viewer.outOfOfficeEntriesList.useQuery();
+ const deleteOutOfOfficeEntryMutation = trpc.viewer.outOfOfficeEntryDelete.useMutation({
+ onSuccess: () => {
+ showToast(t("success_deleted_entry_out_of_office"), "success");
+ utils.viewer.outOfOfficeEntriesList.invalidate();
+ useFormState;
+ },
+ onError: () => {
+ showToast(`An error ocurred`, "error");
+ },
+ });
+ if (data === null || data?.length === 0 || data === undefined) return null;
+ return (
+
+
+
+
+ {t("time_range")}
+ {t("username")}
+
+ {t("action")}
+
+
+
+ {data?.map((item) => (
+
+
+
+ {dayjs(item.start).format("ll")} - {dayjs(item.end).format("ll")}
+
+
+
+ {item.toUser?.username || "N/A"}
+
+
+
+
+ ))}
+ {isLoading && (
+
+ {new Array(6).fill(0).map((_, index) => (
+
+
+
+ ))}
+
+ )}
+
+ {!isLoading && (data === undefined || data.length === 0) && (
+
+
+ {t("no_redirects_found")}
+
+
+ )}
+
+
+
+ );
+};
+
+const OutOfOfficePage = () => {
+ const { t } = useLocale();
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
+
+OutOfOfficePage.getLayout = getLayout;
+OutOfOfficePage.PageWrapper = PageWrapper;
+
+export default OutOfOfficePage;
diff --git a/apps/web/playwright/out-of-office.e2e.ts b/apps/web/playwright/out-of-office.e2e.ts
new file mode 100644
index 0000000000..65264ea4c6
--- /dev/null
+++ b/apps/web/playwright/out-of-office.e2e.ts
@@ -0,0 +1,101 @@
+import { expect } from "@playwright/test";
+import { v4 as uuidv4 } from "uuid";
+
+import dayjs from "@calcom/dayjs";
+import { randomString } from "@calcom/lib/random";
+import prisma from "@calcom/prisma";
+
+import { test } from "./lib/fixtures";
+
+test.describe.configure({ mode: "parallel" });
+test.afterEach(({ users }) => users.deleteAll());
+
+test.describe("Out of office", () => {
+ test("User can create out of office entry", async ({ page, users }) => {
+ const user = await users.create({ name: "userOne" });
+
+ await user.apiLogin();
+
+ await page.goto("/settings/my-account/out-of-office");
+
+ await page.locator("data-testid=create-entry-ooo-redirect").click();
+
+ await expect(page.locator(`data-testid=table-redirect-n-a`)).toBeVisible();
+ });
+
+ test("User can configure booking redirect", async ({ page, users }) => {
+ const user = await users.create({ name: "userOne" });
+ const userTo = await users.create({ name: "userTwo" });
+
+ const team = await prisma.team.create({
+ data: {
+ name: "test-insights",
+ slug: `test-insights-${Date.now()}-${randomString(5)}}`,
+ },
+ });
+
+ // create memberships
+ await prisma.membership.createMany({
+ data: [
+ {
+ userId: user.id,
+ teamId: team.id,
+ accepted: true,
+ role: "ADMIN",
+ },
+ {
+ userId: userTo.id,
+ teamId: team.id,
+ accepted: true,
+ role: "ADMIN",
+ },
+ ],
+ });
+
+ await user.apiLogin();
+
+ await page.goto(`/settings/my-account/out-of-office`);
+
+ await page.getByTestId("profile-redirect-switch").click();
+ await page
+ .getByTestId("team_username_select")
+ .locator("div")
+ .filter({ hasText: "Select team member" })
+ .first()
+ .click();
+ await page.locator("#react-select-2-option-0 div").click();
+
+ // send request
+ await page.getByTestId("create-entry-ooo-redirect").click();
+
+ // expect table-redirect-toUserId to be visible
+ await expect(page.locator(`data-testid=table-redirect-${userTo.username}`)).toBeVisible();
+ });
+
+ test("Profile redirection", async ({ page, users }) => {
+ const user = await users.create({ name: "userOne" });
+ const userTo = await users.create({ name: "userTwo" });
+ const uuid = uuidv4();
+ await prisma.outOfOfficeEntry.create({
+ data: {
+ start: dayjs().startOf("day").toDate(),
+ end: dayjs().startOf("day").add(1, "w").toDate(),
+ uuid,
+ user: { connect: { id: user.id } },
+ toUser: { connect: { id: userTo.id } },
+ createdAt: new Date(),
+ },
+ });
+
+ await page.goto(`/${user.username}`);
+
+ await page.waitForLoadState("networkidle");
+
+ // regex to match username
+ expect(page.url()).toMatch(new RegExp(`/${userTo.username}`));
+
+ await page.goto(`/${userTo.username}/30-min`);
+
+ expect(page.url()).toMatch(new RegExp(`/${userTo.username}/30-min`));
+ });
+});
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json
index 83221e09be..01ae8a772c 100644
--- a/apps/web/public/static/locales/en/common.json
+++ b/apps/web/public/static/locales/en/common.json
@@ -2190,5 +2190,35 @@
"uprade_to_create_instant_bookings": "Upgrade to Enterprise and let guests join an instant call that attendees can jump straight into. This is only for team event types",
"dont_want_to_wait": "Don't want to wait?",
"meeting_started": "Meeting Started",
+ "user_redirect_title": "{{username}} is currently away for a brief period of time.",
+ "user_redirect_description": "In the meantime, {{profile.username}} will be in charge of all the new scheduled meetings on behalf of {{username}}.",
+ "out_of_office": "Out of office",
+ "out_of_office_description": "Configure actions in your profile while you are away.",
+ "send_request": "Send request",
+ "start_date_and_end_date_required": "Start date and end date are required",
+ "start_date_must_be_before_end_date": "Start date must be before end date",
+ "start_date_must_be_in_the_future": "Start date must be in the future",
+ "user_not_found": "User not found",
+ "out_of_office_entry_already_exists": "Out of office entry already exists",
+ "out_of_office_id_required": "Out of office entry id required",
+ "booking_redirect_infinite_not_allowed": "There is already a booking redirect from that user to you.",
+ "success_entry_created": "Successfully created a new entry",
+ "booking_redirect_email_subject": "Booking redirect notification",
+ "booking_redirect_email_title": "Booking Redirect Notification",
+ "booking_redirect_email_description": "You have received a booking redirection from {{toName}} so their profile links will be redirect to yours for the time interval: ",
+ "success_accept_booking_redirect":"You have accepted this booking redirect request.",
+ "success_reject_booking_redirect":"You have rejected this booking redirect request.",
+ "copy_link_booking_redirect_request": "Copy link to share request",
+ "booking_redirect_request_title": "Booking Redirect Request",
+ "select_team_member": "Select team member",
+ "going_away_title": "Going away? Simply mark your profile link as unavailable for a period of time.",
+ "redirect_team_enabled": "Redirect your profile to another team member",
+ "redirect_team_disabled": "Redirect your profile to another team member (Team plan required)",
+ "out_of_office_unavailable_list": "Out of office unavailability list",
+ "success_deleted_entry_out_of_office": "Successfully deleted entry",
+ "temporarily_out_of_office":"Temporarily Out-Of-Office?",
+ "add_a_redirect": "Add a redirect",
+ "create_entry": "Create entry",
+ "time_range": "Time range",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
diff --git a/apps/web/styles/globals.css b/apps/web/styles/globals.css
index ee8fc6fcc3..170c186216 100644
--- a/apps/web/styles/globals.css
+++ b/apps/web/styles/globals.css
@@ -143,7 +143,8 @@ html.todesktop header {
-webkit-app-region: drag;
}
-html.todesktop header button {
+html.todesktop header button,
+html.todesktop header a {
-webkit-app-region: no-drag;
}
diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts
index 8774278fa1..987e0b49f1 100644
--- a/packages/emails/email-manager.ts
+++ b/packages/emails/email-manager.ts
@@ -24,6 +24,8 @@ import AttendeeScheduledEmail from "./templates/attendee-scheduled-email";
import type { EmailVerifyCode } from "./templates/attendee-verify-email";
import AttendeeVerifyEmail from "./templates/attendee-verify-email";
import AttendeeWasRequestedToRescheduleEmail from "./templates/attendee-was-requested-to-reschedule-email";
+import BookingRedirectEmailNotification from "./templates/booking-redirect-notification";
+import type { IBookingRedirect } from "./templates/booking-redirect-notification";
import BrokenIntegrationEmail from "./templates/broken-integration-email";
import DisabledAppEmail from "./templates/disabled-app-email";
import type { Feedback } from "./templates/feedback-email";
@@ -437,3 +439,7 @@ export const sendMonthlyDigestEmails = async (eventData: MonthlyDigestEmailData)
export const sendAdminOrganizationNotification = async (input: OrganizationNotification) => {
await sendEmail(() => new AdminOrganizationNotification(input));
};
+
+export const sendBookingRedirectNotification = async (bookingRedirect: IBookingRedirect) => {
+ await sendEmail(() => new BookingRedirectEmailNotification(bookingRedirect));
+};
diff --git a/packages/emails/src/templates/BookingRedirectEmailNotification.tsx b/packages/emails/src/templates/BookingRedirectEmailNotification.tsx
new file mode 100644
index 0000000000..cbe11a5581
--- /dev/null
+++ b/packages/emails/src/templates/BookingRedirectEmailNotification.tsx
@@ -0,0 +1,33 @@
+import type { IBookingRedirect } from "../../templates/booking-redirect-notification";
+import { BaseEmailHtml } from "../components";
+
+export const BookingRedirectEmailNotification = (
+ props: IBookingRedirect & Partial>
+) => {
+ return (
+
+
+ {props.language("booking_redirect_email_description", {
+ toName: props.toName,
+ })}
+ {props.dates}
+
+
+
+
+ );
+};
diff --git a/packages/emails/src/templates/index.ts b/packages/emails/src/templates/index.ts
index 1d7f642203..072401fb86 100644
--- a/packages/emails/src/templates/index.ts
+++ b/packages/emails/src/templates/index.ts
@@ -31,3 +31,4 @@ export { OrganisationAccountVerifyEmail } from "./OrganizationAccountVerifyEmail
export { OrgAutoInviteEmail } from "./OrgAutoInviteEmail";
export { MonthlyDigestEmail } from "./MonthlyDigestEmail";
export { AdminOrganizationNotificationEmail } from "./AdminOrganizationNotificationEmail";
+export { BookingRedirectEmailNotification } from "./BookingRedirectEmailNotification";
diff --git a/packages/emails/templates/booking-redirect-notification.ts b/packages/emails/templates/booking-redirect-notification.ts
new file mode 100644
index 0000000000..db51fe7385
--- /dev/null
+++ b/packages/emails/templates/booking-redirect-notification.ts
@@ -0,0 +1,36 @@
+import type { TFunction } from "next-i18next";
+
+import { APP_NAME } from "@calcom/lib/constants";
+
+import { renderEmail } from "..";
+import BaseEmail from "./_base-email";
+
+export interface IBookingRedirect {
+ language: TFunction;
+ fromEmail: string;
+ toEmail: string;
+ toName: string;
+ dates: string;
+}
+
+export default class BookingRedirectNotification extends BaseEmail {
+ bookingRedirect: IBookingRedirect;
+
+ constructor(bookingRedirect: IBookingRedirect) {
+ super();
+ this.name = "BOOKING_REDIRECT_NOTIFICATION";
+ this.bookingRedirect = bookingRedirect;
+ }
+
+ protected async getNodeMailerPayload(): Promise> {
+ return {
+ to: `${this.bookingRedirect.toEmail} <${this.bookingRedirect.toName}>`,
+ from: `${APP_NAME} <${this.getMailerOptions().from}>`,
+ subject: this.bookingRedirect.language("booking_redirect_email_subject"),
+ html: await renderEmail("BookingRedirectEmailNotification", {
+ ...this.bookingRedirect,
+ }),
+ text: "",
+ };
+ }
+}
diff --git a/packages/features/booking-redirect/handle-type.ts b/packages/features/booking-redirect/handle-type.ts
new file mode 100644
index 0000000000..732113d7a8
--- /dev/null
+++ b/packages/features/booking-redirect/handle-type.ts
@@ -0,0 +1,71 @@
+import prisma from "@calcom/prisma";
+
+interface HandleTypeRedirectionProps {
+ userId: number;
+ slug: string;
+ username: string;
+}
+
+export const handleTypeRedirection = async (props: HandleTypeRedirectionProps) => {
+ const { userId, slug, username } = props;
+ const outOfOfficeEntryActive = await prisma.outOfOfficeEntry.findFirst({
+ select: {
+ userId: true,
+ start: true,
+ end: true,
+ toUserId: true,
+ toUser: {
+ select: {
+ id: true,
+ username: true,
+ },
+ },
+ },
+ where: {
+ userId: userId,
+ start: {
+ lte: new Date(),
+ },
+ end: {
+ gte: new Date(),
+ },
+ },
+ });
+
+ let redirectToSameEventSlug = false;
+
+ // @TODO: Should I validate if new user has same type as slug?
+ if (outOfOfficeEntryActive) {
+ if (outOfOfficeEntryActive.toUserId === null) {
+ return {
+ outOfOffice: true,
+ };
+ }
+ if (outOfOfficeEntryActive?.toUser) {
+ const eventType = await prisma.eventType.findFirst({
+ select: {
+ slug: true,
+ userId: true,
+ },
+ where: {
+ slug,
+ userId: outOfOfficeEntryActive.toUserId,
+ },
+ });
+
+ if (eventType) {
+ redirectToSameEventSlug = true;
+ }
+
+ return {
+ redirect: {
+ destination: `/${outOfOfficeEntryActive.toUser.username}${
+ redirectToSameEventSlug ? `/${slug}` : ""
+ }?redirected=true&username=${username}`,
+ permanent: false,
+ },
+ };
+ }
+ }
+ return {};
+};
diff --git a/packages/features/booking-redirect/handle-user.ts b/packages/features/booking-redirect/handle-user.ts
new file mode 100644
index 0000000000..a7c7281592
--- /dev/null
+++ b/packages/features/booking-redirect/handle-user.ts
@@ -0,0 +1,59 @@
+import prisma from "@calcom/prisma";
+
+interface HandleUserRedirectionProps {
+ username: string;
+}
+
+export const handleUserRedirection = async (props: HandleUserRedirectionProps) => {
+ const { username } = props;
+ const fromUser = await prisma.user.findFirst({
+ where: {
+ username,
+ },
+ select: {
+ id: true,
+ },
+ });
+
+ if (fromUser) {
+ // If user is found quickly verify bookingRedirect
+ const outOfOfficeEntryActive = await prisma.outOfOfficeEntry.findFirst({
+ select: {
+ toUser: {
+ select: {
+ username: true,
+ },
+ },
+ toUserId: true,
+ userId: true,
+ start: true,
+ end: true,
+ },
+ where: {
+ userId: fromUser.id,
+ start: {
+ lte: new Date(),
+ },
+ end: {
+ gte: new Date(),
+ },
+ },
+ });
+
+ if (outOfOfficeEntryActive && outOfOfficeEntryActive.toUserId === null) {
+ return {
+ outOfOffice: true,
+ };
+ }
+
+ if (outOfOfficeEntryActive && outOfOfficeEntryActive.toUser?.username) {
+ return {
+ redirect: {
+ destination: `/${outOfOfficeEntryActive.toUser.username}?redirected=true&username=${username}`,
+ permanent: false,
+ },
+ };
+ }
+ }
+ return {};
+};
diff --git a/packages/features/bookings/Booker/Booker.tsx b/packages/features/bookings/Booker/Booker.tsx
index 15749a95a6..9c4b204209 100644
--- a/packages/features/bookings/Booker/Booker.tsx
+++ b/packages/features/bookings/Booker/Booker.tsx
@@ -1,6 +1,6 @@
import { LazyMotion, m, AnimatePresence } from "framer-motion";
import dynamic from "next/dynamic";
-import { usePathname, useRouter } from "next/navigation";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef } from "react";
import StickyBox from "react-sticky-box";
import { shallow } from "zustand/shallow";
@@ -81,6 +81,12 @@ const BookerComponent = ({
const hasDarkBackground = isEmbed && embedType !== "inline";
const embedUiConfig = useEmbedUiConfig();
+ const { t } = useLocale();
+
+ const searchParams = useSearchParams();
+ const isRedirect = searchParams?.get("redirected") === "true" || false;
+ const fromUserNameRedirected = searchParams?.get("username") || "";
+
// 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
// But if it's mobile view, there is only one layout supported which is 'mobile'
const layout = isEmbed ? (isMobile ? "mobile" : validateLayout(embedUiConfig.layout) || _layout) : _layout;
@@ -221,6 +227,7 @@ const BookerComponent = ({
return (
<>
{event.data ? : null}
+
{bookerState !== "booking" && event.data?.isInstantEvent && (
+ {/* redirect from other user profile */}
+ {isRedirect && (
+
+
+ {t("user_redirect_title", {
+ username: fromUserNameRedirected,
+ })}{" "}
+ 🏝️
+
+
+ {t("user_redirect_description", {
+ profile: {
+ username: username,
+ },
+ username: fromUserNameRedirected,
+ })}{" "}
+ 😄
+
+
+ )}
[] = [];
for (const reminder of remindersToDelete) {
- const deletePromise = client.request({
- url: `/v3/user/scheduled_sends/${reminder.referenceId}`,
- method: "DELETE",
- });
+ const deletePromise = deleteScheduledSend(reminder.referenceId);
deletePromises.push(deletePromise);
}
@@ -73,14 +74,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const cancelUpdatePromises: Promise
[] = [];
for (const reminder of remindersToCancel) {
- const cancelPromise = client.request({
- url: "/v3/user/scheduled_sends",
- method: "POST",
- body: {
- batch_id: reminder.referenceId,
- status: "cancel",
- },
- });
+ const cancelPromise = cancelScheduledEmail(reminder.referenceId);
const updatePromise = prisma.workflowReminder.update({
where: {
@@ -222,12 +216,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
}
if (emailContent.emailSubject.length > 0 && !emailBodyEmpty && sendTo) {
- const batchIdResponse = await client.request({
- url: "/v3/mail/batch",
- method: "POST",
- });
-
- const batchId = batchIdResponse[1].batch_id;
+ const batchId = await getBatchId();
if (reminder.workflowStep.action !== WorkflowActions.EMAIL_ADDRESS) {
sendEmailPromises.push(
@@ -298,12 +287,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
!!reminder.booking.user?.hideBranding
);
if (emailContent.emailSubject.length > 0 && !emailBodyEmpty && sendTo) {
- const batchIdResponse = await client.request({
- url: "/v3/mail/batch",
- method: "POST",
- });
-
- const batchId = batchIdResponse[1].batch_id;
+ const batchId = await getBatchId();
sendEmailPromises.push(
sendSendgridMail(
@@ -343,7 +327,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
});
});
- res.status(200).json({ message: `${unscheduledReminders.length} Emails scheduled` });
+ res.status(200).json({ message: `${unscheduledReminders.length} Emails to schedule` });
}
export default defaultHandler({
diff --git a/packages/features/ee/workflows/lib/reminders/providers/sendgridProvider.ts b/packages/features/ee/workflows/lib/reminders/providers/sendgridProvider.ts
index f4ba166af8..b09bc046a3 100644
--- a/packages/features/ee/workflows/lib/reminders/providers/sendgridProvider.ts
+++ b/packages/features/ee/workflows/lib/reminders/providers/sendgridProvider.ts
@@ -77,3 +77,35 @@ export function sendSendgridMail(
sendAt: mailData.sendAt,
});
}
+
+export function cancelScheduledEmail(referenceId: string | null) {
+ if (!referenceId) {
+ console.info("No referenceId provided, skip canceling email");
+ return Promise.resolve();
+ }
+
+ assertSendgrid();
+
+ return client.request({
+ url: "/v3/user/scheduled_sends",
+ method: "POST",
+ body: {
+ batch_id: referenceId,
+ status: "cancel",
+ },
+ });
+}
+
+export function deleteScheduledSend(referenceId: string | null) {
+ if (!referenceId) {
+ console.info("No referenceId provided, skip deleting scheduledSend");
+ return Promise.resolve();
+ }
+
+ assertSendgrid();
+
+ return client.request({
+ url: `/v3/user/scheduled_sends/${referenceId}`,
+ method: "DELETE",
+ });
+}
diff --git a/packages/features/insights/filters/DateSelect.css b/packages/features/insights/filters/DateSelect.css
index 4b024872fb..6512dbf96b 100644
--- a/packages/features/insights/filters/DateSelect.css
+++ b/packages/features/insights/filters/DateSelect.css
@@ -11,12 +11,12 @@
}
}
-.recharts-cartesian-grid-horizontal line{
- @apply stroke-emphasis
+.recharts-cartesian-grid-horizontal line {
+ @apply stroke-emphasis;
}
-.tremor-DateRangePicker-button button{
- @apply !h-9 !max-h-9 border-default hover:border-emphasis
+.tremor-DateRangePicker-button button {
+ @apply border-default hover:border-emphasis !h-9 !max-h-9;
}
.tremor-DateRangePicker-calendarButton,
@@ -24,12 +24,12 @@
@apply border-subtle bg-default focus-within:ring-emphasis hover:border-subtle dark:focus-within:ring-emphasis hover:bg-subtle text-sm leading-4 placeholder:text-sm placeholder:font-normal focus-within:ring-0;
}
-.tremor-DateRangePicker-dropdownModal{
- @apply divide-none
+.tremor-DateRangePicker-dropdownModal {
+ @apply divide-none;
}
-.tremor-DropdownItem-root{
- @apply !h-9 !max-h-9 bg-default hover:bg-subtle text-default hover:text-emphasis
+.tremor-DropdownItem-root {
+ @apply bg-default hover:bg-subtle text-default hover:text-emphasis !h-9 !max-h-9;
}
.tremor-DateRangePicker-calendarButtonText,
@@ -37,56 +37,54 @@
@apply text-default;
}
-.tremor-DateRangePicker-calendarHeaderText{
- @apply !text-default
+.tremor-DateRangePicker-calendarHeaderText {
+ @apply !text-default;
}
-.tremor-DateRangePicker-calendarHeader svg{
- @apply text-default
+.tremor-DateRangePicker-calendarHeader svg {
+ @apply text-default;
}
-.tremor-DateRangePicker-calendarHeader button{
- @apply hover:bg-emphasis shadow-none focus:ring-0
+.tremor-DateRangePicker-calendarHeader button {
+ @apply hover:bg-emphasis shadow-none focus:ring-0;
}
-
-.tremor-DateRangePicker-calendarHeader button:hover svg{
- @apply text-emphasis
+.tremor-DateRangePicker-calendarHeader button:hover svg {
+ @apply text-emphasis;
}
-.tremor-DateRangePicker-calendarButtonIcon{
- @apply text-default
+.tremor-DateRangePicker-calendarButtonIcon {
+ @apply text-default;
}
.tremor-DateRangePicker-calendarModal,
.tremor-DateRangePicker-dropdownModal {
- @apply bg-default border-subtle shadow-dropdown
+ @apply bg-default border-subtle shadow-dropdown;
}
-.tremor-DateRangePicker-calendarBodyDate button{
- @apply text-default hover:bg-emphasis
+.tremor-DateRangePicker-calendarBodyDate button {
+ @apply text-default hover:bg-emphasis;
}
.tremor-DateRangePicker-calendarBodyDate button:disabled,
-.tremor-DateRangePicker-calendarBodyDate button[disabled]{
- @apply opacity-25
+.tremor-DateRangePicker-calendarBodyDate button[disabled] {
+ @apply opacity-25;
}
-.tremor-DateRangePicker-calendarHeader button{
- @apply border-default text-default
+.tremor-DateRangePicker-calendarHeader button {
+ @apply border-default text-default;
}
-.tremor-DateRangePicker-calendarBodyDate .bg-gray-100{
- @apply bg-subtle
+.tremor-DateRangePicker-calendarBodyDate .bg-gray-100 {
+ @apply bg-subtle;
}
-.tremor-DateRangePicker-calendarBodyDate .bg-gray-500{
- @apply !bg-brand-default text-inverted
+.tremor-DateRangePicker-calendarBodyDate .bg-gray-500 {
+ @apply !bg-brand-default text-inverted;
}
-
.tremor-Card-root {
- @apply p-5 bg-default;
+ @apply bg-default p-5;
}
.tremor-TableCell-root {
diff --git a/packages/features/settings/layouts/SettingsLayout.tsx b/packages/features/settings/layouts/SettingsLayout.tsx
index 704bdd52d8..e207ad7740 100644
--- a/packages/features/settings/layouts/SettingsLayout.tsx
+++ b/packages/features/settings/layouts/SettingsLayout.tsx
@@ -43,6 +43,7 @@ const tabs: VerticalTabItemProps[] = [
{ name: "calendars", href: "/settings/my-account/calendars" },
{ name: "conferencing", href: "/settings/my-account/conferencing" },
{ name: "appearance", href: "/settings/my-account/appearance" },
+ { name: "out_of_office", href: "/settings/my-account/out-of-office" },
// TODO
// { name: "referrals", href: "/settings/my-account/referrals" },
],
diff --git a/packages/features/shell/Shell.tsx b/packages/features/shell/Shell.tsx
index 5f3d015314..994332ca4b 100644
--- a/packages/features/shell/Shell.tsx
+++ b/packages/features/shell/Shell.tsx
@@ -420,6 +420,7 @@ function UserDropdown({ small }: UserDropdownProps) {
setMenuOpen((menuOpen) => !menuOpen)}>