diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index e9074b895e..0fcce9f3ed 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -28,7 +28,7 @@ jobs: with: repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }} organization-name: calcom - ignore-labels: "app-store, ai, authentication, automated-testing, platform, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier" + ignore-labels: "admin, app-store, ai, authentication, automated-testing, devops, platform, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier" apply-labels-from-issue: runs-on: ubuntu-latest diff --git a/.github/workflows/nextjs-bundle-analysis.yml b/.github/workflows/nextjs-bundle-analysis.yml index 4c8da86217..4e2fec6a9a 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/apps/api/pages/api/bookings/[id]/_delete.ts b/apps/api/pages/api/bookings/[id]/_delete.ts index d67a9bb4df..451c8d05d4 100644 --- a/apps/api/pages/api/bookings/[id]/_delete.ts +++ b/apps/api/pages/api/bookings/[id]/_delete.ts @@ -33,7 +33,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform * type: boolean * description: Delete all remaining bookings * - in: query - * name: reason + * name: cancellationReason * required: false * schema: * type: string diff --git a/apps/api/pages/api/teams/[teamId]/_patch.ts b/apps/api/pages/api/teams/[teamId]/_patch.ts index e1cfe2a865..cdc11dfc7d 100644 --- a/apps/api/pages/api/teams/[teamId]/_patch.ts +++ b/apps/api/pages/api/teams/[teamId]/_patch.ts @@ -58,6 +58,7 @@ export async function patchHandler(req: NextApiRequest) { const { prisma, body, userId } = req; const data = schemaTeamUpdateBodyParams.parse(body); const { teamId } = schemaQueryTeamId.parse(req.query); + /** Only OWNERS and ADMINS can edit teams */ const _team = await prisma.team.findFirst({ include: { members: true }, @@ -65,6 +66,18 @@ export async function patchHandler(req: NextApiRequest) { }); if (!_team) throw new HttpError({ statusCode: 401, message: "Unauthorized: OWNER or ADMIN required" }); + const slugAlreadyExists = await prisma.team.findFirst({ + where: { + slug: { + mode: "insensitive", + equals: data.slug, + }, + }, + }); + + if (slugAlreadyExists && data.slug !== _team.slug) + throw new HttpError({ statusCode: 409, message: "Team slug already exists" }); + // Check if parentId is related to this user if (data.parentId && data.parentId === teamId) { throw new HttpError({ diff --git a/apps/api/pages/api/webhooks/[id]/_patch.ts b/apps/api/pages/api/webhooks/[id]/_patch.ts index fd0f8db3f5..0a9b63f0de 100644 --- a/apps/api/pages/api/webhooks/[id]/_patch.ts +++ b/apps/api/pages/api/webhooks/[id]/_patch.ts @@ -69,7 +69,12 @@ import { schemaWebhookEditBodyParams, schemaWebhookReadPublic } from "~/lib/vali export async function patchHandler(req: NextApiRequest) { const { prisma, query, userId, isAdmin } = req; const { id } = schemaQueryIdAsString.parse(query); - const { eventTypeId, userId: bodyUserId, ...data } = schemaWebhookEditBodyParams.parse(req.body); + const { + eventTypeId, + userId: bodyUserId, + eventTriggers, + ...data + } = schemaWebhookEditBodyParams.parse(req.body); const args: Prisma.WebhookUpdateArgs = { where: { id }, data }; if (eventTypeId) { @@ -87,6 +92,11 @@ export async function patchHandler(req: NextApiRequest) { args.data.userId = bodyUserId; } + if (args.data.eventTriggers) { + const eventTriggersSet = new Set(eventTriggers); + args.data.eventTriggers = Array.from(eventTriggersSet); + } + 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..e08d59d29d 100644 --- a/apps/api/pages/api/webhooks/_post.ts +++ b/apps/api/pages/api/webhooks/_post.ts @@ -66,7 +66,12 @@ import { schemaWebhookCreateBodyParams, schemaWebhookReadPublic } from "~/lib/va */ async function postHandler(req: NextApiRequest) { const { userId, isAdmin, prisma } = req; - const { eventTypeId, userId: bodyUserId, ...body } = schemaWebhookCreateBodyParams.parse(req.body); + const { + eventTypeId, + userId: bodyUserId, + eventTriggers, + ...body + } = schemaWebhookCreateBodyParams.parse(req.body); const args: Prisma.WebhookCreateArgs = { data: { id: uuidv4(), ...body } }; // If no event type, we assume is for the current user. If admin we run more checks below... @@ -87,6 +92,11 @@ async function postHandler(req: NextApiRequest) { args.data.userId = bodyUserId; } + if (args.data.eventTriggers) { + const eventTriggersSet = new Set(eventTriggers); + args.data.eventTriggers = Array.from(eventTriggersSet); + } + const data = await prisma.webhook.create(args); return { diff --git a/apps/web/app/AppDirSSRHOC.tsx b/apps/web/app/AppDirSSRHOC.tsx new file mode 100644 index 0000000000..87b419ca49 --- /dev/null +++ b/apps/web/app/AppDirSSRHOC.tsx @@ -0,0 +1,17 @@ +import type { GetServerSideProps, GetServerSidePropsContext } from "next"; +import { notFound, redirect } from "next/navigation"; + +export const withAppDir = + (getServerSideProps: GetServerSideProps) => async (context: GetServerSidePropsContext) => { + const ssrResponse = await getServerSideProps(context); + + if ("redirect" in ssrResponse) { + redirect(ssrResponse.redirect.destination); + } + + if ("notFound" in ssrResponse) { + notFound(); + } + + return ssrResponse.props; + }; diff --git a/apps/web/app/WithEmbedSSR.tsx b/apps/web/app/WithEmbedSSR.tsx new file mode 100644 index 0000000000..c19ead200d --- /dev/null +++ b/apps/web/app/WithEmbedSSR.tsx @@ -0,0 +1,57 @@ +import type { GetServerSidePropsContext } from "next"; +import { isNotFoundError } from "next/dist/client/components/not-found"; +import { getURLFromRedirectError, isRedirectError } from "next/dist/client/components/redirect"; +import { notFound, redirect } from "next/navigation"; + +import { WEBAPP_URL } from "@calcom/lib/constants"; + +export type EmbedProps = { + isEmbed?: boolean; +}; + +export default function withEmbedSsrAppDir>( + getData: (context: GetServerSidePropsContext) => Promise +) { + return async (context: GetServerSidePropsContext): Promise => { + const { embed, layout } = context.query; + + try { + const props = await getData(context); + + return { + ...props, + isEmbed: true, + }; + } catch (e) { + if (isRedirectError(e)) { + const destinationUrl = getURLFromRedirectError(e); + let urlPrefix = ""; + + // Get the URL parsed from URL so that we can reliably read pathname and searchParams from it. + const destinationUrlObj = new URL(destinationUrl, WEBAPP_URL); + + // If it's a complete URL, use the origin as the prefix to ensure we redirect to the same domain. + if (destinationUrl.search(/^(http:|https:).*/) !== -1) { + urlPrefix = destinationUrlObj.origin; + } else { + // Don't use any prefix for relative URLs to ensure we stay on the same domain + urlPrefix = ""; + } + + const destinationQueryStr = destinationUrlObj.searchParams.toString(); + // Make sure that redirect happens to /embed page and pass on embed query param as is for preserving Cal JS API namespace + const newDestinationUrl = `${urlPrefix}${destinationUrlObj.pathname}/embed?${ + destinationQueryStr ? `${destinationQueryStr}&` : "" + }layout=${layout}&embed=${embed}`; + + redirect(newDestinationUrl); + } + + if (isNotFoundError(e)) { + notFound(); + } + + throw e; + } + }; +} diff --git a/apps/web/app/future/booking/[uid]/embed/page.tsx b/apps/web/app/future/booking/[uid]/embed/page.tsx new file mode 100644 index 0000000000..d3fcef1feb --- /dev/null +++ b/apps/web/app/future/booking/[uid]/embed/page.tsx @@ -0,0 +1,10 @@ +import OldPage from "@pages/booking/[uid]"; +import withEmbedSsrAppDir from "app/WithEmbedSSR"; +import { WithLayout } from "app/layoutHOC"; + +import { getData } from "../page"; + +const getEmbedData = withEmbedSsrAppDir(getData); + +// @ts-expect-error Type '(context: GetServerSidePropsContext) => Promise' is not assignable to type '(arg: { +export default WithLayout({ getLayout: null, getData: getEmbedData, Page: OldPage }); diff --git a/apps/web/app/future/booking/[uid]/page.tsx b/apps/web/app/future/booking/[uid]/page.tsx new file mode 100644 index 0000000000..e0db2efa6f --- /dev/null +++ b/apps/web/app/future/booking/[uid]/page.tsx @@ -0,0 +1,204 @@ +import OldPage from "@pages/booking/[uid]"; +import { _generateMetadata } from "app/_utils"; +import { WithLayout } from "app/layoutHOC"; +import type { GetServerSidePropsContext } from "next"; +import { notFound } from "next/navigation"; +import { z } from "zod"; + +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { getBookingWithResponses } from "@calcom/features/bookings/lib/get-booking"; +import { parseRecurringEvent } from "@calcom/lib"; +import { getDefaultEvent } from "@calcom/lib/defaultEvents"; +import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat"; +import prisma from "@calcom/prisma"; +import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; + +import { getRecurringBookings, handleSeatsEventTypeOnBooking, getEventTypesFromDB } from "@lib/booking"; + +import { ssrInit } from "@server/lib/ssr"; + +const stringToBoolean = z + .string() + .optional() + .transform((val) => val === "true"); + +const querySchema = z.object({ + uid: z.string(), + email: z.string().optional(), + eventTypeSlug: z.string().optional(), + cancel: stringToBoolean, + allRemainingBookings: stringToBoolean, + changes: stringToBoolean, + reschedule: stringToBoolean, + isSuccessBookingPage: stringToBoolean, + formerTime: z.string().optional(), + seatReferenceUid: z.string().optional(), +}); + +export const generateMetadata = async () => + await _generateMetadata( + () => "", + () => "" + ); + +export const getData = async (context: GetServerSidePropsContext) => { + const ssr = await ssrInit(context); + const session = await getServerSession(context); + let tz: string | null = null; + let userTimeFormat: number | null = null; + let requiresLoginToUpdate = false; + if (session) { + const user = await ssr.viewer.me.fetch(); + tz = user.timeZone; + userTimeFormat = user.timeFormat; + } + + const parsedQuery = querySchema.safeParse(context.query); + + if (!parsedQuery.success) { + notFound(); + } + + const { uid, eventTypeSlug, seatReferenceUid } = parsedQuery.data; + + const { uid: maybeUid } = await maybeGetBookingUidFromSeat(prisma, uid); + const bookingInfoRaw = await prisma.booking.findFirst({ + where: { + uid: maybeUid, + }, + select: { + title: true, + id: true, + uid: true, + description: true, + customInputs: true, + smsReminderNumber: true, + recurringEventId: true, + startTime: true, + endTime: true, + location: true, + status: true, + metadata: true, + cancellationReason: true, + responses: true, + rejectionReason: true, + user: { + select: { + id: true, + name: true, + email: true, + username: true, + timeZone: true, + }, + }, + attendees: { + select: { + name: true, + email: true, + timeZone: true, + }, + }, + eventTypeId: true, + eventType: { + select: { + eventName: true, + slug: true, + timeZone: true, + }, + }, + seatsReferences: { + select: { + referenceUid: true, + }, + }, + }, + }); + + if (!bookingInfoRaw) { + notFound(); + } + + const eventTypeRaw = !bookingInfoRaw.eventTypeId + ? getDefaultEvent(eventTypeSlug || "") + : await getEventTypesFromDB(bookingInfoRaw.eventTypeId); + if (!eventTypeRaw) { + notFound(); + } + + if (eventTypeRaw.seatsPerTimeSlot && !seatReferenceUid && !session) { + requiresLoginToUpdate = true; + } + + const bookingInfo = getBookingWithResponses(bookingInfoRaw); + // @NOTE: had to do this because Server side cant return [Object objects] + // probably fixable with json.stringify -> json.parse + bookingInfo["startTime"] = (bookingInfo?.startTime as Date)?.toISOString() as unknown as Date; + bookingInfo["endTime"] = (bookingInfo?.endTime as Date)?.toISOString() as unknown as Date; + + eventTypeRaw.users = !!eventTypeRaw.hosts?.length + ? eventTypeRaw.hosts.map((host) => host.user) + : eventTypeRaw.users; + + if (!eventTypeRaw.users.length) { + if (!eventTypeRaw.owner) { + notFound(); + } + + eventTypeRaw.users.push({ + ...eventTypeRaw.owner, + }); + } + + const eventType = { + ...eventTypeRaw, + periodStartDate: eventTypeRaw.periodStartDate?.toString() ?? null, + periodEndDate: eventTypeRaw.periodEndDate?.toString() ?? null, + metadata: EventTypeMetaDataSchema.parse(eventTypeRaw.metadata), + recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent), + customInputs: customInputSchema.array().parse(eventTypeRaw.customInputs), + }; + + const profile = { + name: eventType.team?.name || eventType.users[0]?.name || null, + email: eventType.team ? null : eventType.users[0].email || null, + theme: (!eventType.team?.name && eventType.users[0]?.theme) || null, + brandColor: eventType.team ? null : eventType.users[0].brandColor || null, + darkBrandColor: eventType.team ? null : eventType.users[0].darkBrandColor || null, + slug: eventType.team?.slug || eventType.users[0]?.username || null, + }; + + if (bookingInfo !== null && eventType.seatsPerTimeSlot) { + await handleSeatsEventTypeOnBooking(eventType, bookingInfo, seatReferenceUid, session?.user.id); + } + + const payment = await prisma.payment.findFirst({ + where: { + bookingId: bookingInfo.id, + }, + select: { + success: true, + refunded: true, + currency: true, + amount: true, + paymentOption: true, + }, + }); + + return { + themeBasis: eventType.team ? eventType.team.slug : eventType.users[0]?.username, + hideBranding: eventType.team ? eventType.team.hideBranding : eventType.users[0].hideBranding, + profile, + eventType, + recurringBookings: await getRecurringBookings(bookingInfo.recurringEventId), + dehydratedState: ssr.dehydrate(), + dynamicEventName: bookingInfo?.eventType?.eventName || "", + bookingInfo, + paymentStatus: payment, + ...(tz && { tz }), + userTimeFormat, + requiresLoginToUpdate, + }; +}; + +// @ts-expect-error Argument of type '{ req: { headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; }; }' is not assignable to parameter of type 'GetServerSidePropsContext'. +export default WithLayout({ getLayout: null, getData, Page: OldPage }); diff --git a/apps/web/app/future/enterprise/layout.tsx b/apps/web/app/future/enterprise/layout.tsx new file mode 100644 index 0000000000..9bf51a70f5 --- /dev/null +++ b/apps/web/app/future/enterprise/layout.tsx @@ -0,0 +1,5 @@ +import { WithLayout } from "app/layoutHOC"; + +import { getLayout } from "@calcom/features/MainLayoutAppDir"; + +export default WithLayout({ getLayout })<"L">; diff --git a/apps/web/app/future/enterprise/page.tsx b/apps/web/app/future/enterprise/page.tsx new file mode 100644 index 0000000000..3be9790f06 --- /dev/null +++ b/apps/web/app/future/enterprise/page.tsx @@ -0,0 +1,11 @@ +import { _generateMetadata } from "app/_utils"; + +import EnterprisePage from "@components/EnterprisePage"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("create_your_org"), + (t) => t("create_your_org_description") + ); + +export default EnterprisePage; diff --git a/apps/web/app/future/insights/page.tsx b/apps/web/app/future/insights/page.tsx new file mode 100644 index 0000000000..0b82a624c6 --- /dev/null +++ b/apps/web/app/future/insights/page.tsx @@ -0,0 +1,26 @@ +import LegacyPage from "@pages/insights/index"; +import { _generateMetadata } from "app/_utils"; +import { WithLayout } from "app/layoutHOC"; +import { notFound } from "next/navigation"; + +import { getLayout } from "@calcom/features/MainLayoutAppDir"; +import { getFeatureFlagMap } from "@calcom/features/flags/server/utils"; + +export const generateMetadata = async () => + await _generateMetadata( + () => "Insights", + (t) => t("insights_subtitle") + ); + +async function getData() { + const prisma = await import("@calcom/prisma").then((mod) => mod.default); + const flags = await getFeatureFlagMap(prisma); + + if (flags.insights === false) { + return notFound(); + } + + return {}; +} + +export default WithLayout({ getLayout, getData, Page: LegacyPage }); diff --git a/apps/web/app/future/maintenance/page.tsx b/apps/web/app/future/maintenance/page.tsx new file mode 100644 index 0000000000..4995ffb772 --- /dev/null +++ b/apps/web/app/future/maintenance/page.tsx @@ -0,0 +1,13 @@ +import LegacyPage from "@pages/maintenance"; +import { _generateMetadata } from "app/_utils"; +import { WithLayout } from "app/layoutHOC"; + +import { APP_NAME } from "@calcom/lib/constants"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => `${t("under_maintenance")} | ${APP_NAME}`, + (t) => t("under_maintenance_description", { appName: APP_NAME }) + ); + +export default WithLayout({ getLayout: null, Page: LegacyPage })<"P">; diff --git a/apps/web/app/future/more/page.tsx b/apps/web/app/future/more/page.tsx new file mode 100644 index 0000000000..c6eb75321a --- /dev/null +++ b/apps/web/app/future/more/page.tsx @@ -0,0 +1,4 @@ +import Page from "@pages/more"; +import { WithLayout } from "app/layoutHOC"; + +export default WithLayout({ getLayout: null, Page })<"P">; diff --git a/apps/web/app/future/reschedule/[uid]/embed/page.tsx b/apps/web/app/future/reschedule/[uid]/embed/page.tsx new file mode 100644 index 0000000000..52bef3e486 --- /dev/null +++ b/apps/web/app/future/reschedule/[uid]/embed/page.tsx @@ -0,0 +1,21 @@ +import { getServerSideProps } from "@pages/reschedule/[uid]"; +import { withAppDir } from "app/AppDirSSRHOC"; +import type { Params } from "next/dist/shared/lib/router/utils/route-matcher"; +import { cookies, headers } from "next/headers"; + +import { buildLegacyCtx } from "@lib/buildLegacyCtx"; +import withEmbedSsr from "@lib/withEmbedSsr"; + +type PageProps = Readonly<{ + params: Params; +}>; + +const Page = async ({ params }: PageProps) => { + const legacyCtx = buildLegacyCtx(headers(), cookies(), params); + // @ts-expect-error Argument of type '{ query: Params; params: Params; req: { headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; }; }' + await withAppDir(withEmbedSsr(getServerSideProps))(legacyCtx); + + return null; +}; + +export default Page; diff --git a/apps/web/app/future/reschedule/[uid]/page.tsx b/apps/web/app/future/reschedule/[uid]/page.tsx new file mode 100644 index 0000000000..40fbf05617 --- /dev/null +++ b/apps/web/app/future/reschedule/[uid]/page.tsx @@ -0,0 +1,30 @@ +import OldPage, { getServerSideProps as _getServerSideProps } from "@pages/reschedule/[uid]"; +import { withAppDir } from "app/AppDirSSRHOC"; +import { _generateMetadata } from "app/_utils"; +import type { Params } from "next/dist/shared/lib/router/utils/route-matcher"; +import { headers, cookies } from "next/headers"; + +import { buildLegacyCtx } from "@lib/buildLegacyCtx"; + +export const generateMetadata = async () => + await _generateMetadata( + () => "", + () => "" + ); + +type PageProps = Readonly<{ + params: Params; +}>; + +const getData = withAppDir(_getServerSideProps); + +const Page = async ({ params }: PageProps) => { + const legacyCtx = buildLegacyCtx(headers(), cookies(), params); + + // @ts-expect-error Argument of type '{ query: Params; params: Params; req: { headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; }; }' + await getData(legacyCtx); + + return ; +}; + +export default Page; diff --git a/apps/web/app/future/settings/my-account/appearance/layout.tsx b/apps/web/app/future/settings/my-account/appearance/layout.tsx new file mode 100644 index 0000000000..230bfea4d1 --- /dev/null +++ b/apps/web/app/future/settings/my-account/appearance/layout.tsx @@ -0,0 +1,5 @@ +import { WithLayout } from "app/layoutHOC"; + +import { getLayout } from "@calcom/features/settings/layouts/SettingsLayoutAppDir"; + +export default WithLayout({ getLayout }); diff --git a/apps/web/app/future/settings/my-account/appearance/page.tsx b/apps/web/app/future/settings/my-account/appearance/page.tsx new file mode 100644 index 0000000000..73def1169e --- /dev/null +++ b/apps/web/app/future/settings/my-account/appearance/page.tsx @@ -0,0 +1,10 @@ +import Page from "@pages/settings/my-account/appearance"; +import { _generateMetadata } from "app/_utils"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("appearance"), + (t) => t("appearance_description") + ); + +export default Page; diff --git a/apps/web/app/future/settings/my-account/calendars/layout.tsx b/apps/web/app/future/settings/my-account/calendars/layout.tsx new file mode 100644 index 0000000000..230bfea4d1 --- /dev/null +++ b/apps/web/app/future/settings/my-account/calendars/layout.tsx @@ -0,0 +1,5 @@ +import { WithLayout } from "app/layoutHOC"; + +import { getLayout } from "@calcom/features/settings/layouts/SettingsLayoutAppDir"; + +export default WithLayout({ getLayout }); diff --git a/apps/web/app/future/settings/my-account/calendars/page.tsx b/apps/web/app/future/settings/my-account/calendars/page.tsx new file mode 100644 index 0000000000..b44eeadcbe --- /dev/null +++ b/apps/web/app/future/settings/my-account/calendars/page.tsx @@ -0,0 +1,10 @@ +import Page from "@pages/settings/my-account/calendars"; +import { _generateMetadata } from "app/_utils"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("calendars"), + (t) => t("calendars_description") + ); + +export default Page; diff --git a/apps/web/app/future/settings/my-account/conferencing/layout.tsx b/apps/web/app/future/settings/my-account/conferencing/layout.tsx new file mode 100644 index 0000000000..230bfea4d1 --- /dev/null +++ b/apps/web/app/future/settings/my-account/conferencing/layout.tsx @@ -0,0 +1,5 @@ +import { WithLayout } from "app/layoutHOC"; + +import { getLayout } from "@calcom/features/settings/layouts/SettingsLayoutAppDir"; + +export default WithLayout({ getLayout }); diff --git a/apps/web/app/future/settings/my-account/conferencing/page.tsx b/apps/web/app/future/settings/my-account/conferencing/page.tsx new file mode 100644 index 0000000000..ce3edb408d --- /dev/null +++ b/apps/web/app/future/settings/my-account/conferencing/page.tsx @@ -0,0 +1,10 @@ +import Page from "@pages/settings/my-account/conferencing"; +import { _generateMetadata } from "app/_utils"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("conferencing"), + (t) => t("conferencing_description") + ); + +export default Page; diff --git a/apps/web/app/future/settings/my-account/general/layout.tsx b/apps/web/app/future/settings/my-account/general/layout.tsx new file mode 100644 index 0000000000..230bfea4d1 --- /dev/null +++ b/apps/web/app/future/settings/my-account/general/layout.tsx @@ -0,0 +1,5 @@ +import { WithLayout } from "app/layoutHOC"; + +import { getLayout } from "@calcom/features/settings/layouts/SettingsLayoutAppDir"; + +export default WithLayout({ getLayout }); diff --git a/apps/web/app/future/settings/my-account/general/page.tsx b/apps/web/app/future/settings/my-account/general/page.tsx new file mode 100644 index 0000000000..a768e8f608 --- /dev/null +++ b/apps/web/app/future/settings/my-account/general/page.tsx @@ -0,0 +1,10 @@ +import Page from "@pages/settings/my-account/general"; +import { _generateMetadata } from "app/_utils"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("general"), + (t) => t("general_description") + ); + +export default Page; diff --git a/apps/web/app/future/settings/my-account/profile/layout.tsx b/apps/web/app/future/settings/my-account/profile/layout.tsx new file mode 100644 index 0000000000..230bfea4d1 --- /dev/null +++ b/apps/web/app/future/settings/my-account/profile/layout.tsx @@ -0,0 +1,5 @@ +import { WithLayout } from "app/layoutHOC"; + +import { getLayout } from "@calcom/features/settings/layouts/SettingsLayoutAppDir"; + +export default WithLayout({ getLayout }); diff --git a/apps/web/app/future/settings/my-account/profile/page.tsx b/apps/web/app/future/settings/my-account/profile/page.tsx new file mode 100644 index 0000000000..2d850a0df6 --- /dev/null +++ b/apps/web/app/future/settings/my-account/profile/page.tsx @@ -0,0 +1,10 @@ +import Page from "@pages/settings/my-account/profile"; +import { _generateMetadata } from "app/_utils"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("profile"), + (t) => t("profile_description") + ); + +export default Page; diff --git a/apps/web/app/future/settings/security/impersonation/layout.tsx b/apps/web/app/future/settings/security/impersonation/layout.tsx new file mode 100644 index 0000000000..230bfea4d1 --- /dev/null +++ b/apps/web/app/future/settings/security/impersonation/layout.tsx @@ -0,0 +1,5 @@ +import { WithLayout } from "app/layoutHOC"; + +import { getLayout } from "@calcom/features/settings/layouts/SettingsLayoutAppDir"; + +export default WithLayout({ getLayout }); diff --git a/apps/web/app/future/settings/security/impersonation/page.tsx b/apps/web/app/future/settings/security/impersonation/page.tsx new file mode 100644 index 0000000000..2fa8e4fec2 --- /dev/null +++ b/apps/web/app/future/settings/security/impersonation/page.tsx @@ -0,0 +1,10 @@ +import Page from "@pages/settings/security/impersonation"; +import { _generateMetadata } from "app/_utils"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("impersonation"), + (t) => t("impersonation_description") + ); + +export default Page; diff --git a/apps/web/app/future/settings/security/password/layout.tsx b/apps/web/app/future/settings/security/password/layout.tsx new file mode 100644 index 0000000000..230bfea4d1 --- /dev/null +++ b/apps/web/app/future/settings/security/password/layout.tsx @@ -0,0 +1,5 @@ +import { WithLayout } from "app/layoutHOC"; + +import { getLayout } from "@calcom/features/settings/layouts/SettingsLayoutAppDir"; + +export default WithLayout({ getLayout }); diff --git a/apps/web/app/future/settings/security/password/page.tsx b/apps/web/app/future/settings/security/password/page.tsx new file mode 100644 index 0000000000..acf5fe37b2 --- /dev/null +++ b/apps/web/app/future/settings/security/password/page.tsx @@ -0,0 +1,10 @@ +import Page from "@pages/settings/security/password"; +import { _generateMetadata } from "app/_utils"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("password"), + (t) => t("password_description") + ); + +export default Page; diff --git a/apps/web/app/future/settings/security/sso/layout.tsx b/apps/web/app/future/settings/security/sso/layout.tsx new file mode 100644 index 0000000000..230bfea4d1 --- /dev/null +++ b/apps/web/app/future/settings/security/sso/layout.tsx @@ -0,0 +1,5 @@ +import { WithLayout } from "app/layoutHOC"; + +import { getLayout } from "@calcom/features/settings/layouts/SettingsLayoutAppDir"; + +export default WithLayout({ getLayout }); diff --git a/apps/web/app/future/settings/security/sso/page.tsx b/apps/web/app/future/settings/security/sso/page.tsx new file mode 100644 index 0000000000..996ac7c913 --- /dev/null +++ b/apps/web/app/future/settings/security/sso/page.tsx @@ -0,0 +1,11 @@ +import { _generateMetadata } from "app/_utils"; + +import Page from "@calcom/features/ee/sso/page/user-sso-view"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("sso_configuration"), + (t) => t("sso_configuration_description") + ); + +export default Page; diff --git a/apps/web/app/future/settings/security/two-factor-auth/layout.tsx b/apps/web/app/future/settings/security/two-factor-auth/layout.tsx new file mode 100644 index 0000000000..230bfea4d1 --- /dev/null +++ b/apps/web/app/future/settings/security/two-factor-auth/layout.tsx @@ -0,0 +1,5 @@ +import { WithLayout } from "app/layoutHOC"; + +import { getLayout } from "@calcom/features/settings/layouts/SettingsLayoutAppDir"; + +export default WithLayout({ getLayout }); diff --git a/apps/web/app/future/settings/security/two-factor-auth/page.tsx b/apps/web/app/future/settings/security/two-factor-auth/page.tsx new file mode 100644 index 0000000000..55e16afd17 --- /dev/null +++ b/apps/web/app/future/settings/security/two-factor-auth/page.tsx @@ -0,0 +1,10 @@ +import Page from "@pages/settings/security/two-factor-auth"; +import { _generateMetadata } from "app/_utils"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("two_factor_auth"), + (t) => t("add_an_extra_layer_of_security") + ); + +export default Page; diff --git a/apps/web/app/future/workflows/[workflow]/page.tsx b/apps/web/app/future/workflows/[workflow]/page.tsx new file mode 100644 index 0000000000..f0c5c5eaa6 --- /dev/null +++ b/apps/web/app/future/workflows/[workflow]/page.tsx @@ -0,0 +1,43 @@ +import { _generateMetadata } from "app/_utils"; +import { WithLayout } from "app/layoutHOC"; +import { type GetServerSidePropsContext } from "next"; +import { headers, cookies } from "next/headers"; +import { notFound } from "next/navigation"; +import { z } from "zod"; + +import LegacyPage from "@calcom/features/ee/workflows/pages/workflow"; + +import { buildLegacyCtx } from "@lib/buildLegacyCtx"; + +const querySchema = z.object({ + workflow: z.string(), +}); + +export const generateMetadata = async ({ params }: { params: Record }) => { + const { workflow } = await getProps( + buildLegacyCtx(headers(), cookies(), params) as unknown as GetServerSidePropsContext + ); + return await _generateMetadata( + () => workflow ?? "Untitled", + () => "" + ); +}; + +async function getProps(context: GetServerSidePropsContext) { + const safeParams = querySchema.safeParse(context.params); + + console.log("Built workflow page:", safeParams); + if (!safeParams.success) { + return notFound(); + } + return { workflow: safeParams.data.workflow }; +} + +export const generateStaticParams = () => []; + +// @ts-expect-error getData arg +export default WithLayout({ getLayout: null, getData: getProps, Page: LegacyPage })<"P">; +export const dynamic = "force-static"; +// generate segments on demand +export const dynamicParams = true; +export const revalidate = 10; diff --git a/apps/web/app/future/workflows/page.tsx b/apps/web/app/future/workflows/page.tsx new file mode 100644 index 0000000000..78ea4d910b --- /dev/null +++ b/apps/web/app/future/workflows/page.tsx @@ -0,0 +1,13 @@ +import { _generateMetadata } from "app/_utils"; +import { WithLayout } from "app/layoutHOC"; + +import { getLayout } from "@calcom/features/MainLayoutAppDir"; +import LegacyPage from "@calcom/features/ee/workflows/pages/index"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("workflows"), + (t) => t("workflows_to_automate_notifications") + ); + +export default WithLayout({ getLayout, Page: LegacyPage })<"P">; diff --git a/apps/web/app/layoutHOC.tsx b/apps/web/app/layoutHOC.tsx index 5e41ed1184..29a2b56b7f 100644 --- a/apps/web/app/layoutHOC.tsx +++ b/apps/web/app/layoutHOC.tsx @@ -7,7 +7,7 @@ import PageWrapper from "@components/PageWrapperAppDir"; type WithLayoutParams> = { getLayout: ((page: React.ReactElement) => React.ReactNode) | null; - Page?: (props: T) => React.ReactElement; + Page?: (props: T) => React.ReactElement | null; getData?: (arg: ReturnType) => Promise; }; diff --git a/apps/web/components/EnterprisePage.tsx b/apps/web/components/EnterprisePage.tsx new file mode 100644 index 0000000000..87886245fe --- /dev/null +++ b/apps/web/components/EnterprisePage.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { ShellMain } from "@calcom/features/shell/Shell"; +import { UpgradeTip } from "@calcom/features/tips"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Button, ButtonGroup } from "@calcom/ui"; +import { BarChart, CreditCard, Globe, Lock, Paintbrush, Users } from "@calcom/ui/components/icon"; + +export default function EnterprisePage() { + const { t } = useLocale(); + + const features = [ + { + icon: , + title: t("branded_subdomain"), + description: t("branded_subdomain_description"), + }, + { + icon: , + title: t("org_insights"), + description: t("org_insights_description"), + }, + { + icon: , + title: t("extensive_whitelabeling"), + description: t("extensive_whitelabeling_description"), + }, + { + icon: , + title: t("unlimited_teams"), + description: t("unlimited_teams_description"), + }, + { + icon: , + title: t("unified_billing"), + description: t("unified_billing_description"), + }, + { + icon: , + title: t("advanced_managed_events"), + description: t("advanced_managed_events_description"), + }, + ]; + return ( +
+ + + + + + +
+ }> + <>Create Org + + + + ); +} diff --git a/apps/web/components/eventtype/EventTypeSingleLayout.tsx b/apps/web/components/eventtype/EventTypeSingleLayout.tsx index 4450e096d3..4b8671c472 100644 --- a/apps/web/components/eventtype/EventTypeSingleLayout.tsx +++ b/apps/web/components/eventtype/EventTypeSingleLayout.tsx @@ -115,7 +115,7 @@ function getNavigation(props: { { name: "workflows", href: `/event-types/${eventType.id}?tabName=workflows`, - icon: PhoneCall, + icon: Zap, info: `${enabledWorkflowsNumber} ${t("active")}`, }, ]; @@ -218,7 +218,7 @@ function EventTypeSingleLayout({ navigation.push({ name: "instant_tab_title", href: `/event-types/${eventType.id}?tabName=instant`, - icon: Zap, + icon: PhoneCall, info: `instant_event_tab_description`, }); } 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/lib/booking.ts b/apps/web/lib/booking.ts new file mode 100644 index 0000000000..1bff1aa195 --- /dev/null +++ b/apps/web/lib/booking.ts @@ -0,0 +1,170 @@ +import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields"; +import prisma from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; +import { BookingStatus } from "@calcom/prisma/enums"; +import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; + +export const getEventTypesFromDB = async (id: number) => { + const userSelect = { + id: true, + name: true, + username: true, + hideBranding: true, + theme: true, + brandColor: true, + darkBrandColor: true, + email: true, + timeZone: true, + }; + const eventType = await prisma.eventType.findUnique({ + where: { + id, + }, + select: { + id: true, + title: true, + description: true, + length: true, + eventName: true, + recurringEvent: true, + requiresConfirmation: true, + userId: true, + successRedirectUrl: true, + customInputs: true, + locations: true, + price: true, + currency: true, + bookingFields: true, + disableGuests: true, + timeZone: true, + owner: { + select: userSelect, + }, + users: { + select: userSelect, + }, + hosts: { + select: { + user: { + select: userSelect, + }, + }, + }, + team: { + select: { + slug: true, + name: true, + hideBranding: true, + }, + }, + workflows: { + select: { + workflow: { + select: { + id: true, + steps: true, + }, + }, + }, + }, + metadata: true, + seatsPerTimeSlot: true, + seatsShowAttendees: true, + seatsShowAvailabilityCount: true, + periodStartDate: true, + periodEndDate: true, + }, + }); + + if (!eventType) { + return eventType; + } + + const metadata = EventTypeMetaDataSchema.parse(eventType.metadata); + + return { + isDynamic: false, + ...eventType, + bookingFields: getBookingFieldsWithSystemFields(eventType), + metadata, + }; +}; + +export const handleSeatsEventTypeOnBooking = async ( + eventType: { + seatsPerTimeSlot?: number | null; + seatsShowAttendees: boolean | null; + seatsShowAvailabilityCount: boolean | null; + [x: string | number | symbol]: unknown; + }, + bookingInfo: Partial< + Prisma.BookingGetPayload<{ + include: { + attendees: { select: { name: true; email: true } }; + seatsReferences: { select: { referenceUid: true } }; + user: { + select: { + id: true; + name: true; + email: true; + username: true; + timeZone: true; + }; + }; + }; + }> + >, + seatReferenceUid?: string, + userId?: number +) => { + if (eventType?.seatsPerTimeSlot !== null) { + // @TODO: right now bookings with seats doesn't save every description that its entered by every user + delete bookingInfo.description; + } else { + return; + } + // @TODO: If handling teams, we need to do more check ups for this. + if (bookingInfo?.user?.id === userId) { + return; + } + + if (!eventType.seatsShowAttendees) { + const seatAttendee = await prisma.bookingSeat.findFirst({ + where: { + referenceUid: seatReferenceUid, + }, + include: { + attendee: { + select: { + name: true, + email: true, + }, + }, + }, + }); + + if (seatAttendee) { + const attendee = bookingInfo?.attendees?.find((a) => { + return a.email === seatAttendee.attendee?.email; + }); + bookingInfo["attendees"] = attendee ? [attendee] : []; + } else { + bookingInfo["attendees"] = []; + } + } + return bookingInfo; +}; + +export async function getRecurringBookings(recurringEventId: string | null) { + if (!recurringEventId) return null; + const recurringBookings = await prisma.booking.findMany({ + where: { + recurringEventId, + status: BookingStatus.ACCEPTED, + }, + select: { + startTime: true, + }, + }); + return recurringBookings.map((obj) => obj.startTime.toString()); +} diff --git a/apps/web/lib/booking/[uid]/getServerSideProps.tsx b/apps/web/lib/booking/[uid]/getServerSideProps.tsx new file mode 100644 index 0000000000..e10af55fb1 --- /dev/null +++ b/apps/web/lib/booking/[uid]/getServerSideProps.tsx @@ -0,0 +1,199 @@ +import type { GetServerSidePropsContext } from "next"; +import { z } from "zod"; + +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { getBookingWithResponses } from "@calcom/features/bookings/lib/get-booking"; +import { parseRecurringEvent } from "@calcom/lib"; +import { getDefaultEvent } from "@calcom/lib/defaultEvents"; +import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat"; +import prisma from "@calcom/prisma"; +import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; + +import { ssrInit } from "@server/lib/ssr"; + +const stringToBoolean = z + .string() + .optional() + .transform((val) => val === "true"); + +const querySchema = z.object({ + uid: z.string(), + email: z.string().optional(), + eventTypeSlug: z.string().optional(), + cancel: stringToBoolean, + allRemainingBookings: stringToBoolean, + changes: stringToBoolean, + reschedule: stringToBoolean, + isSuccessBookingPage: stringToBoolean, + formerTime: z.string().optional(), + seatReferenceUid: z.string().optional(), +}); + +export async function getServerSideProps(context: GetServerSidePropsContext) { + // this is needed to prevent bundling of lib/booking to the client bundle + // usually functions that are used in getServerSideProps are tree shaken from client bundle + // but not in case when they are exported. So we have to dynamically load them, or to copy paste them to the /future/page. + + const { getRecurringBookings, handleSeatsEventTypeOnBooking, getEventTypesFromDB } = await import( + "@lib/booking" + ); + + const ssr = await ssrInit(context); + const session = await getServerSession(context); + let tz: string | null = null; + let userTimeFormat: number | null = null; + let requiresLoginToUpdate = false; + if (session) { + const user = await ssr.viewer.me.fetch(); + tz = user.timeZone; + userTimeFormat = user.timeFormat; + } + + const parsedQuery = querySchema.safeParse(context.query); + + if (!parsedQuery.success) return { notFound: true } as const; + const { uid, eventTypeSlug, seatReferenceUid } = parsedQuery.data; + + const { uid: maybeUid } = await maybeGetBookingUidFromSeat(prisma, uid); + const bookingInfoRaw = await prisma.booking.findFirst({ + where: { + uid: maybeUid, + }, + select: { + title: true, + id: true, + uid: true, + description: true, + customInputs: true, + smsReminderNumber: true, + recurringEventId: true, + startTime: true, + endTime: true, + location: true, + status: true, + metadata: true, + cancellationReason: true, + responses: true, + rejectionReason: true, + user: { + select: { + id: true, + name: true, + email: true, + username: true, + timeZone: true, + }, + }, + attendees: { + select: { + name: true, + email: true, + timeZone: true, + }, + }, + eventTypeId: true, + eventType: { + select: { + eventName: true, + slug: true, + timeZone: true, + }, + }, + seatsReferences: { + select: { + referenceUid: true, + }, + }, + }, + }); + if (!bookingInfoRaw) { + return { + notFound: true, + } as const; + } + + const eventTypeRaw = !bookingInfoRaw.eventTypeId + ? getDefaultEvent(eventTypeSlug || "") + : await getEventTypesFromDB(bookingInfoRaw.eventTypeId); + if (!eventTypeRaw) { + return { + notFound: true, + } as const; + } + + if (eventTypeRaw.seatsPerTimeSlot && !seatReferenceUid && !session) { + requiresLoginToUpdate = true; + } + + const bookingInfo = getBookingWithResponses(bookingInfoRaw); + // @NOTE: had to do this because Server side cant return [Object objects] + // probably fixable with json.stringify -> json.parse + bookingInfo["startTime"] = (bookingInfo?.startTime as Date)?.toISOString() as unknown as Date; + bookingInfo["endTime"] = (bookingInfo?.endTime as Date)?.toISOString() as unknown as Date; + + eventTypeRaw.users = !!eventTypeRaw.hosts?.length + ? eventTypeRaw.hosts.map((host) => host.user) + : eventTypeRaw.users; + + if (!eventTypeRaw.users.length) { + if (!eventTypeRaw.owner) + return { + notFound: true, + } as const; + eventTypeRaw.users.push({ + ...eventTypeRaw.owner, + }); + } + + const eventType = { + ...eventTypeRaw, + periodStartDate: eventTypeRaw.periodStartDate?.toString() ?? null, + periodEndDate: eventTypeRaw.periodEndDate?.toString() ?? null, + metadata: EventTypeMetaDataSchema.parse(eventTypeRaw.metadata), + recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent), + customInputs: customInputSchema.array().parse(eventTypeRaw.customInputs), + }; + + const profile = { + name: eventType.team?.name || eventType.users[0]?.name || null, + email: eventType.team ? null : eventType.users[0].email || null, + theme: (!eventType.team?.name && eventType.users[0]?.theme) || null, + brandColor: eventType.team ? null : eventType.users[0].brandColor || null, + darkBrandColor: eventType.team ? null : eventType.users[0].darkBrandColor || null, + slug: eventType.team?.slug || eventType.users[0]?.username || null, + }; + + if (bookingInfo !== null && eventType.seatsPerTimeSlot) { + await handleSeatsEventTypeOnBooking(eventType, bookingInfo, seatReferenceUid, session?.user.id); + } + + const payment = await prisma.payment.findFirst({ + where: { + bookingId: bookingInfo.id, + }, + select: { + success: true, + refunded: true, + currency: true, + amount: true, + paymentOption: true, + }, + }); + + return { + props: { + themeBasis: eventType.team ? eventType.team.slug : eventType.users[0]?.username, + hideBranding: eventType.team ? eventType.team.hideBranding : eventType.users[0].hideBranding, + profile, + eventType, + recurringBookings: await getRecurringBookings(bookingInfo.recurringEventId), + trpcState: ssr.dehydrate(), + dynamicEventName: bookingInfo?.eventType?.eventName || "", + bookingInfo, + paymentStatus: payment, + ...(tz && { tz }), + userTimeFormat, + requiresLoginToUpdate, + }, + }; +} diff --git a/apps/web/package.json b/apps/web/package.json index e4a982473a..e63635ef6f 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.4", "private": true, "scripts": { "analyze": "ANALYZE=true next build", @@ -39,7 +39,7 @@ "@calcom/tsconfig": "*", "@calcom/ui": "*", "@daily-co/daily-js": "^0.37.0", - "@formkit/auto-animate": "^0.8.1", + "@formkit/auto-animate": "1.0.0-beta.5", "@glidejs/glide": "^3.5.2", "@hookform/error-message": "^2.0.0", "@hookform/resolvers": "^2.9.7", diff --git a/apps/web/pages/[user].tsx b/apps/web/pages/[user].tsx index 349c8be4b6..5c8b506074 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,10 +13,12 @@ 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"; import EmptyPage from "@calcom/features/eventtypes/components/EmptyPage"; +import { DEFAULT_DARK_BRAND_COLOR, DEFAULT_LIGHT_BRAND_COLOR } from "@calcom/lib/constants"; import { getUsernameList } from "@calcom/lib/defaultEvents"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery"; @@ -40,6 +44,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 +64,8 @@ export function UserPage(props: InferGetServerSidePropsType { @@ -77,6 +84,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: { @@ -374,9 +413,9 @@ export const getServerSideProps: GetServerSideProps = async (cont name: user.name || user.username || "", image: user.avatar, theme: user.theme, - brandColor: user.brandColor, + brandColor: user.brandColor ?? DEFAULT_LIGHT_BRAND_COLOR, avatarUrl: user.avatarUrl, - darkBrandColor: user.darkBrandColor, + darkBrandColor: user.darkBrandColor ?? DEFAULT_DARK_BRAND_COLOR, allowSEOIndexing: user.allowSEOIndexing ?? true, username: user.username, organization: { @@ -400,11 +439,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 +465,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/api/auth/oauth/refreshToken.ts b/apps/web/pages/api/auth/oauth/refreshToken.ts index a302cc8bf6..c0c3d63815 100644 --- a/apps/web/pages/api/auth/oauth/refreshToken.ts +++ b/apps/web/pages/api/auth/oauth/refreshToken.ts @@ -55,6 +55,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const payload: OAuthTokenPayload = { userId: decodedRefreshToken.userId, + teamId: decodedRefreshToken.teamId, scope: decodedRefreshToken.scope, token_type: "Access Token", clientId: client_id, 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/booking/[uid].tsx b/apps/web/pages/booking/[uid].tsx index afada8d7a1..cdb7f67978 100644 --- a/apps/web/pages/booking/[uid].tsx +++ b/apps/web/pages/booking/[uid].tsx @@ -1,7 +1,8 @@ +"use client"; + import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible"; import classNames from "classnames"; import { createEvent } from "ics"; -import type { GetServerSidePropsContext } from "next"; import { useSession } from "next-auth/react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; @@ -22,35 +23,28 @@ import { useIsBackgroundTransparent, useIsEmbed, } from "@calcom/embed-core/embed-iframe"; -import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { Price } from "@calcom/features/bookings/components/event-meta/Price"; import { SMS_REMINDER_NUMBER_FIELD, SystemField } from "@calcom/features/bookings/lib/SystemField"; -import { getBookingWithResponses } from "@calcom/features/bookings/lib/get-booking"; -import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields"; -import { parseRecurringEvent } from "@calcom/lib"; import { APP_NAME } from "@calcom/lib/constants"; import { formatToLocalizedDate, formatToLocalizedTime, formatToLocalizedTimezone, } from "@calcom/lib/date-fns"; -import { getDefaultEvent } from "@calcom/lib/defaultEvents"; import useGetBrandingColours from "@calcom/lib/getBrandColours"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery"; import useTheme from "@calcom/lib/hooks/useTheme"; import { getEveryFreqFor } from "@calcom/lib/recurringStrings"; -import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat"; import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/timeFormat"; import { localStorage } from "@calcom/lib/webstorage"; -import prisma from "@calcom/prisma"; -import type { Prisma } from "@calcom/prisma/client"; import { BookingStatus } from "@calcom/prisma/enums"; -import { bookingMetadataSchema, customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; +import { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; import { Alert, Badge, Button, EmailInput, HeadSeo, useCalcomTheme } from "@calcom/ui"; import { AlertCircle, Calendar, Check, ChevronLeft, ExternalLink, X } from "@calcom/ui/components/icon"; +import { getServerSideProps } from "@lib/booking/[uid]/getServerSideProps"; import { timeZone } from "@lib/clock"; import type { inferSSRProps } from "@lib/types/inferSSRProps"; @@ -58,23 +52,7 @@ import PageWrapper from "@components/PageWrapper"; import CancelBooking from "@components/booking/CancelBooking"; import EventReservationSchema from "@components/schemas/EventReservationSchema"; -import { ssrInit } from "@server/lib/ssr"; - -const useBrandColors = ({ - brandColor, - darkBrandColor, -}: { - brandColor?: string | null; - darkBrandColor?: string | null; -}) => { - const brandTheme = useGetBrandingColours({ - lightVal: brandColor, - darkVal: darkBrandColor, - }); - useCalcomTheme(brandTheme); -}; - -type SuccessProps = inferSSRProps; +export { getServerSideProps }; const stringToBoolean = z .string() @@ -94,6 +72,22 @@ const querySchema = z.object({ seatReferenceUid: z.string().optional(), }); +const useBrandColors = ({ + brandColor, + darkBrandColor, +}: { + brandColor?: string | null; + darkBrandColor?: string | null; +}) => { + const brandTheme = useGetBrandingColours({ + lightVal: brandColor, + darkVal: darkBrandColor, + }); + useCalcomTheme(brandTheme); +}; + +type SuccessProps = inferSSRProps; + export default function Success(props: SuccessProps) { const { t } = useLocale(); const router = useRouter(); @@ -925,329 +919,3 @@ export function RecurringBookings({ ); } - -const getEventTypesFromDB = async (id: number) => { - const userSelect = { - id: true, - name: true, - username: true, - hideBranding: true, - theme: true, - brandColor: true, - darkBrandColor: true, - email: true, - timeZone: true, - }; - const eventType = await prisma.eventType.findUnique({ - where: { - id, - }, - select: { - id: true, - title: true, - description: true, - length: true, - eventName: true, - recurringEvent: true, - requiresConfirmation: true, - userId: true, - successRedirectUrl: true, - customInputs: true, - locations: true, - price: true, - currency: true, - bookingFields: true, - disableGuests: true, - timeZone: true, - owner: { - select: userSelect, - }, - users: { - select: userSelect, - }, - hosts: { - select: { - user: { - select: userSelect, - }, - }, - }, - team: { - select: { - slug: true, - name: true, - hideBranding: true, - }, - }, - workflows: { - select: { - workflow: { - select: { - id: true, - steps: true, - }, - }, - }, - }, - metadata: true, - seatsPerTimeSlot: true, - seatsShowAttendees: true, - seatsShowAvailabilityCount: true, - periodStartDate: true, - periodEndDate: true, - }, - }); - - if (!eventType) { - return eventType; - } - - const metadata = EventTypeMetaDataSchema.parse(eventType.metadata); - - return { - isDynamic: false, - ...eventType, - bookingFields: getBookingFieldsWithSystemFields(eventType), - metadata, - }; -}; - -const handleSeatsEventTypeOnBooking = async ( - eventType: { - seatsPerTimeSlot?: number | null; - seatsShowAttendees: boolean | null; - seatsShowAvailabilityCount: boolean | null; - [x: string | number | symbol]: unknown; - }, - bookingInfo: Partial< - Prisma.BookingGetPayload<{ - include: { - attendees: { select: { name: true; email: true } }; - seatsReferences: { select: { referenceUid: true } }; - user: { - select: { - id: true; - name: true; - email: true; - username: true; - timeZone: true; - }; - }; - }; - }> - >, - seatReferenceUid?: string, - userId?: number -) => { - if (eventType?.seatsPerTimeSlot !== null) { - // @TODO: right now bookings with seats doesn't save every description that its entered by every user - delete bookingInfo.description; - } else { - return; - } - // @TODO: If handling teams, we need to do more check ups for this. - if (bookingInfo?.user?.id === userId) { - return; - } - - if (!eventType.seatsShowAttendees) { - const seatAttendee = await prisma.bookingSeat.findFirst({ - where: { - referenceUid: seatReferenceUid, - }, - include: { - attendee: { - select: { - name: true, - email: true, - }, - }, - }, - }); - - if (seatAttendee) { - const attendee = bookingInfo?.attendees?.find((a) => { - return a.email === seatAttendee.attendee?.email; - }); - bookingInfo["attendees"] = attendee ? [attendee] : []; - } else { - bookingInfo["attendees"] = []; - } - } - return bookingInfo; -}; - -export async function getServerSideProps(context: GetServerSidePropsContext) { - const ssr = await ssrInit(context); - const session = await getServerSession(context); - let tz: string | null = null; - let userTimeFormat: number | null = null; - let requiresLoginToUpdate = false; - if (session) { - const user = await ssr.viewer.me.fetch(); - tz = user.timeZone; - userTimeFormat = user.timeFormat; - } - - const parsedQuery = querySchema.safeParse(context.query); - - if (!parsedQuery.success) return { notFound: true } as const; - const { uid, eventTypeSlug, seatReferenceUid } = parsedQuery.data; - - const { uid: maybeUid } = await maybeGetBookingUidFromSeat(prisma, uid); - const bookingInfoRaw = await prisma.booking.findFirst({ - where: { - uid: maybeUid, - }, - select: { - title: true, - id: true, - uid: true, - description: true, - customInputs: true, - smsReminderNumber: true, - recurringEventId: true, - startTime: true, - endTime: true, - location: true, - status: true, - metadata: true, - cancellationReason: true, - responses: true, - rejectionReason: true, - user: { - select: { - id: true, - name: true, - email: true, - username: true, - timeZone: true, - }, - }, - attendees: { - select: { - name: true, - email: true, - timeZone: true, - }, - }, - eventTypeId: true, - eventType: { - select: { - eventName: true, - slug: true, - timeZone: true, - }, - }, - seatsReferences: { - select: { - referenceUid: true, - }, - }, - }, - }); - if (!bookingInfoRaw) { - return { - notFound: true, - } as const; - } - - const eventTypeRaw = !bookingInfoRaw.eventTypeId - ? getDefaultEvent(eventTypeSlug || "") - : await getEventTypesFromDB(bookingInfoRaw.eventTypeId); - if (!eventTypeRaw) { - return { - notFound: true, - } as const; - } - - if (eventTypeRaw.seatsPerTimeSlot && !seatReferenceUid && !session) { - requiresLoginToUpdate = true; - } - - const bookingInfo = getBookingWithResponses(bookingInfoRaw); - // @NOTE: had to do this because Server side cant return [Object objects] - // probably fixable with json.stringify -> json.parse - bookingInfo["startTime"] = (bookingInfo?.startTime as Date)?.toISOString() as unknown as Date; - bookingInfo["endTime"] = (bookingInfo?.endTime as Date)?.toISOString() as unknown as Date; - - eventTypeRaw.users = !!eventTypeRaw.hosts?.length - ? eventTypeRaw.hosts.map((host) => host.user) - : eventTypeRaw.users; - - if (!eventTypeRaw.users.length) { - if (!eventTypeRaw.owner) - return { - notFound: true, - } as const; - eventTypeRaw.users.push({ - ...eventTypeRaw.owner, - }); - } - - const eventType = { - ...eventTypeRaw, - periodStartDate: eventTypeRaw.periodStartDate?.toString() ?? null, - periodEndDate: eventTypeRaw.periodEndDate?.toString() ?? null, - metadata: EventTypeMetaDataSchema.parse(eventTypeRaw.metadata), - recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent), - customInputs: customInputSchema.array().parse(eventTypeRaw.customInputs), - }; - - const profile = { - name: eventType.team?.name || eventType.users[0]?.name || null, - email: eventType.team ? null : eventType.users[0].email || null, - theme: (!eventType.team?.name && eventType.users[0]?.theme) || null, - brandColor: eventType.team ? null : eventType.users[0].brandColor || null, - darkBrandColor: eventType.team ? null : eventType.users[0].darkBrandColor || null, - slug: eventType.team?.slug || eventType.users[0]?.username || null, - }; - - if (bookingInfo !== null && eventType.seatsPerTimeSlot) { - await handleSeatsEventTypeOnBooking(eventType, bookingInfo, seatReferenceUid, session?.user.id); - } - - const payment = await prisma.payment.findFirst({ - where: { - bookingId: bookingInfo.id, - }, - select: { - success: true, - refunded: true, - currency: true, - amount: true, - paymentOption: true, - }, - }); - - return { - props: { - themeBasis: eventType.team ? eventType.team.slug : eventType.users[0]?.username, - hideBranding: eventType.team ? eventType.team.hideBranding : eventType.users[0].hideBranding, - profile, - eventType, - recurringBookings: await getRecurringBookings(bookingInfo.recurringEventId), - trpcState: ssr.dehydrate(), - dynamicEventName: bookingInfo?.eventType?.eventName || "", - bookingInfo, - paymentStatus: payment, - ...(tz && { tz }), - userTimeFormat, - requiresLoginToUpdate, - }, - }; -} - -async function getRecurringBookings(recurringEventId: string | null) { - if (!recurringEventId) return null; - const recurringBookings = await prisma.booking.findMany({ - where: { - recurringEventId, - status: BookingStatus.ACCEPTED, - }, - select: { - startTime: true, - }, - }); - return recurringBookings.map((obj) => obj.startTime.toString()); -} diff --git a/apps/web/pages/booking/[uid]/embed.tsx b/apps/web/pages/booking/[uid]/embed.tsx index 5d6b405e57..3e004ab3e6 100644 --- a/apps/web/pages/booking/[uid]/embed.tsx +++ b/apps/web/pages/booking/[uid]/embed.tsx @@ -1,6 +1,7 @@ -import withEmbedSsr from "@lib/withEmbedSsr"; +"use client"; -import { getServerSideProps as _getServerSideProps } from "../[uid]"; +import { getServerSideProps as _getServerSideProps } from "@lib/booking/[uid]/getServerSideProps"; +import withEmbedSsr from "@lib/withEmbedSsr"; export { default } from "../[uid]"; diff --git a/apps/web/pages/enterprise/index.tsx b/apps/web/pages/enterprise/index.tsx index 9854d5c6bc..4bddaef51d 100644 --- a/apps/web/pages/enterprise/index.tsx +++ b/apps/web/pages/enterprise/index.tsx @@ -1,74 +1,17 @@ -import { getLayout } from "@calcom/features/MainLayout"; -import { ShellMain } from "@calcom/features/shell/Shell"; -import { UpgradeTip } from "@calcom/features/tips"; -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { Button, ButtonGroup } from "@calcom/ui"; -import { BarChart, CreditCard, Globe, Lock, Paintbrush, Users } from "@calcom/ui/components/icon"; +"use client"; +import { getLayout } from "@calcom/features/MainLayout"; + +import EnterprisePage from "@components/EnterprisePage"; import PageWrapper from "@components/PageWrapper"; -export default function EnterprisePage() { - const { t } = useLocale(); +const ProxifiedEnterprisePage = new Proxy<{ + (): JSX.Element; + PageWrapper?: typeof PageWrapper; + getLayout?: typeof getLayout; +}>(EnterprisePage, {}); - const features = [ - { - icon: , - title: t("branded_subdomain"), - description: t("branded_subdomain_description"), - }, - { - icon: , - title: t("org_insights"), - description: t("org_insights_description"), - }, - { - icon: , - title: t("extensive_whitelabeling"), - description: t("extensive_whitelabeling_description"), - }, - { - icon: , - title: t("unlimited_teams"), - description: t("unlimited_teams_description"), - }, - { - icon: , - title: t("unified_billing"), - description: t("unified_billing_description"), - }, - { - icon: , - title: t("advanced_managed_events"), - description: t("advanced_managed_events_description"), - }, - ]; - return ( -
- - - - - - -
- }> - <>Create Org - - - - ); -} +ProxifiedEnterprisePage.PageWrapper = PageWrapper; +ProxifiedEnterprisePage.getLayout = getLayout; -EnterprisePage.PageWrapper = PageWrapper; -EnterprisePage.getLayout = getLayout; +export default ProxifiedEnterprisePage; diff --git a/apps/web/pages/insights/index.tsx b/apps/web/pages/insights/index.tsx index 0c0dd0b60b..35a6fa2cca 100644 --- a/apps/web/pages/insights/index.tsx +++ b/apps/web/pages/insights/index.tsx @@ -1,3 +1,5 @@ +"use client"; + import { getLayout } from "@calcom/features/MainLayout"; import { getFeatureFlagMap } from "@calcom/features/flags/server/utils"; import { diff --git a/apps/web/pages/maintenance.tsx b/apps/web/pages/maintenance.tsx index 9d787ab453..38aa6f4e41 100644 --- a/apps/web/pages/maintenance.tsx +++ b/apps/web/pages/maintenance.tsx @@ -1,3 +1,5 @@ +"use client"; + import Head from "next/head"; import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants"; diff --git a/apps/web/pages/more.tsx b/apps/web/pages/more.tsx index 17ed23ccb4..d746d327ac 100644 --- a/apps/web/pages/more.tsx +++ b/apps/web/pages/more.tsx @@ -1,3 +1,5 @@ +"use client"; + import Shell, { MobileNavigationMoreItems } from "@calcom/features/shell/Shell"; import { useLocale } from "@calcom/lib/hooks/useLocale"; diff --git a/apps/web/pages/reschedule/[uid].tsx b/apps/web/pages/reschedule/[uid].tsx index efea8ceb06..8e3c8ed2c4 100644 --- a/apps/web/pages/reschedule/[uid].tsx +++ b/apps/web/pages/reschedule/[uid].tsx @@ -1,3 +1,4 @@ +// page can be a server component import type { GetServerSidePropsContext } from "next"; import { URLSearchParams } from "url"; import { z } from "zod"; diff --git a/apps/web/pages/reschedule/[uid]/embed.tsx b/apps/web/pages/reschedule/[uid]/embed.tsx index 5d6b405e57..034b8ee719 100644 --- a/apps/web/pages/reschedule/[uid]/embed.tsx +++ b/apps/web/pages/reschedule/[uid]/embed.tsx @@ -1,3 +1,5 @@ +"use client"; + import withEmbedSsr from "@lib/withEmbedSsr"; import { getServerSideProps as _getServerSideProps } from "../[uid]"; diff --git a/apps/web/pages/settings/my-account/appearance.tsx b/apps/web/pages/settings/my-account/appearance.tsx index a8b479974a..e4173758ec 100644 --- a/apps/web/pages/settings/my-account/appearance.tsx +++ b/apps/web/pages/settings/my-account/appearance.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useState } from "react"; import { Controller, useForm } from "react-hook-form"; import type { z } from "zod"; @@ -98,10 +100,15 @@ const AppearanceView = ({ reset: resetBookerLayoutThemeReset, } = bookerLayoutFormMethods; + const DEFAULT_BRAND_COLOURS = { + light: user.brandColor ?? DEFAULT_LIGHT_BRAND_COLOR, + dark: user.darkBrandColor ?? DEFAULT_DARK_BRAND_COLOR, + }; + const brandColorsFormMethods = useForm({ defaultValues: { - brandColor: user.brandColor || DEFAULT_LIGHT_BRAND_COLOR, - darkBrandColor: user.darkBrandColor || DEFAULT_DARK_BRAND_COLOR, + brandColor: DEFAULT_BRAND_COLOURS.light, + darkBrandColor: DEFAULT_BRAND_COLOURS.dark, }, }); @@ -231,12 +238,12 @@ const AppearanceView = ({ (

{t("light_brand_color")}

{ try { @@ -260,12 +267,12 @@ const AppearanceView = ({ (

{t("dark_brand_color")}

{ try { diff --git a/apps/web/pages/settings/my-account/calendars.tsx b/apps/web/pages/settings/my-account/calendars.tsx index 522d7fc4ec..4d38f4dca1 100644 --- a/apps/web/pages/settings/my-account/calendars.tsx +++ b/apps/web/pages/settings/my-account/calendars.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Fragment } from "react"; import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; diff --git a/apps/web/pages/settings/my-account/conferencing.tsx b/apps/web/pages/settings/my-account/conferencing.tsx index 58691f1643..2f1b273d02 100644 --- a/apps/web/pages/settings/my-account/conferencing.tsx +++ b/apps/web/pages/settings/my-account/conferencing.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useReducer } from "react"; import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal"; diff --git a/apps/web/pages/settings/my-account/general.tsx b/apps/web/pages/settings/my-account/general.tsx index 330e155c7f..b1e5bca542 100644 --- a/apps/web/pages/settings/my-account/general.tsx +++ b/apps/web/pages/settings/my-account/general.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useSession } from "next-auth/react"; import { useState } from "react"; import { Controller, useForm } from "react-hook-form"; 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 ( + <> +
{ + createOutOfOfficeEntry.mutate(data); + setValue("toTeamUserId", null); + setSelectedMember(null); + })}> +
+ {/* Add startDate and end date inputs */} +
+ {/* Add toggle to enable/disable redirect */} +
+ { + setProfileRedirect(state); + }} + label={hasTeamPlan ? t("redirect_team_enabled") : t("redirect_team_disabled")} + /> + {!hasTeamPlan && ( +
+ +
+ )} +
+
+ {profileRedirect && ( +
+

{t("team_member")}

+