diff --git a/.env.example b/.env.example index cebebfa0d4..a2d142392f 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,8 @@ # - You can not repackage or sell the codebase # - Acquire a commercial license to remove these terms by visiting: cal.com/sales NEXT_PUBLIC_LICENSE_CONSENT='' +# To enable enterprise-only features, fill your license key in here +CALCOM_LICENSE_KEY= # *********************************************************************************************************** # - DATABASE ************************************************************************************************ @@ -25,6 +27,7 @@ NEXT_PUBLIC_LICENSE_CONSENT='' NEXT_PUBLIC_WEBAPP_URL='http://localhost:3000' # Change to 'http://localhost:3001' if running the website simultaneously NEXT_PUBLIC_WEBSITE_URL='http://localhost:3000' +NEXT_PUBLIC_CONSOLE_URL='http://localhost:3004' NEXT_PUBLIC_EMBED_LIB_URL='http://localhost:3000/embed/embed.js' # To enable SAML login, set both these variables @@ -70,6 +73,10 @@ NEXT_PUBLIC_ZENDESK_KEY= # Help Scout Config NEXT_PUBLIC_HELPSCOUT_KEY= +# Inbox to send user feedback +SEND_FEEDBACK_EMAIL= + + # This is used so we can bypass emails in auth flows for E2E testing # Set it to "1" if you need to run E2E tests locally NEXT_PUBLIC_IS_E2E= diff --git a/.github/workflows/check-types.yml b/.github/workflows/check-types.yml index 356efd516b..2d20685041 100644 --- a/.github/workflows/check-types.yml +++ b/.github/workflows/check-types.yml @@ -16,7 +16,8 @@ jobs: - name: Checkout repo uses: actions/checkout@v2 with: - fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 2 - name: Use Node ${{ matrix.node }} uses: actions/setup-node@v3 diff --git a/.github/workflows/e2e-embed.yml b/.github/workflows/e2e-embed.yml new file mode 100644 index 0000000000..327a58b448 --- /dev/null +++ b/.github/workflows/e2e-embed.yml @@ -0,0 +1,110 @@ +name: E2E test - embed +on: + push: + branches: [ tests/ci-embed ] + pull_request_target: # So we can test on forks + branches: + - main + # Embed e2e - tests verify booking flow which is applicable to non-embed case also. So, don't ignore apps/web changes. + paths-ignore: + - apps/api/** + - apps/console/** + - apps/docs/** + - apps/swagger/** + - apps/website/** + - apps/web/public/** + - tests/** + - playwright/** +jobs: + test: + timeout-minutes: 20 + name: Testing Embeds + strategy: + matrix: + node: ["14.x"] + os: [ubuntu-latest] + runs-on: ${{ matrix.os }} + + env: + DATABASE_URL: postgresql://postgres:@localhost:5432/calendso + NEXT_PUBLIC_WEBAPP_URL: http://localhost:3000 + NEXT_PUBLIC_WEBSITE_URL: http://localhost:3000 + NEXTAUTH_SECRET: secret + GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }} + GOOGLE_LOGIN_ENABLED: true + # CRON_API_KEY: xxx + CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }} + NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }} + STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }} + STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }} + STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }} + PAYMENT_FEE_PERCENTAGE: 0.005 + PAYMENT_FEE_FIXED: 10 + SAML_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso + SAML_ADMINS: pro@example.com + NEXTAUTH_URL: http://localhost:3000/api/auth + NEXT_PUBLIC_IS_E2E: 1 + # EMAIL_FROM: e2e@cal.com + # EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }} + # EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }} + # EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }} + # EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD }} + # MS_GRAPH_CLIENT_ID: xxx + # MS_GRAPH_CLIENT_SECRET: xxx + # ZOOM_CLIENT_ID: xxx + # ZOOM_CLIENT_SECRET: xxx + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} + services: + postgres: + image: postgres:12.1 + env: + POSTGRES_USER: postgres + POSTGRES_DB: calendso + ports: + - 5432:5432 + + steps: + - name: Checkout repo + uses: actions/checkout@v2 + with: + ref: ${{ github.event.pull_request.head.sha }} # So we can test on forks + fetch-depth: 2 + + - name: Use Node ${{ matrix.node }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node }} + cache: "yarn" + + - name: Cache playwright binaries + uses: actions/cache@v2 + id: playwright-cache + with: + path: | + ~/Library/Caches/ms-playwright + ~/.cache/ms-playwright + ${{ github.workspace }}/node_modules/playwright + key: cache-playwright-${{ hashFiles('**/yarn.lock') }} + restore-keys: cache-playwright- + - run: yarn --frozen-lockfile + - name: Install playwright deps + # if: steps.playwright-cache.outputs.cache-hit != 'true' + run: yarn playwright install --with-deps + - run: yarn embed-tests-prepare + - run: yarn workspace @calcom/embed-core embed-tests-update-snapshots:ci + - run: yarn workspace @calcom/embed-react embed-tests-update-snapshots:ci + + - name: Upload embed-core results + if: ${{ always() }} + uses: actions/upload-artifact@v2 + with: + name: test-results-core + path: packages/embeds/embed-core/playwright/results + + - name: Upload embed-react results + if: ${{ always() }} + uses: actions/upload-artifact@v2 + with: + name: test-results-react + path: packages/embeds/embed-react/playwright/results diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 1eb2a2a8b3..b5bd736d20 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -4,7 +4,7 @@ on: branches: - main paths-ignore: - - public/static/locales/** + - apps/web/public/static/locales/** jobs: test: timeout-minutes: 20 diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 546b1617f3..1f3424f531 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -15,7 +15,8 @@ "Website(3001)", "Embed Core(3100)", "Embed React(3101)", - "Prisma Studio(5555)" + "Prisma Studio(5555)", + "Maildev(587)" ], // Mark as the default build task so cmd/ctrl+shift+b will create them "group": { @@ -65,6 +66,13 @@ "command": "yarn db-studio", "isBackground": false, "problemMatcher": [] + }, + { + "label": "Maildev(587)", + "type": "shell", + "command": "maildev -s 587", + "isBackground": false, + "problemMatcher": [] } ] } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f03a86e01f..b79473e218 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,6 +4,26 @@ Contributions are what make the open source community such an amazing place to b - Before jumping into a PR be sure to search [existing PRs](https://github.com/calcom/cal.com/pulls) or [issues](https://github.com/calcom/cal.com/issues) for an open or closed item that relates to your submission. +## Areas of expertise + +### Legend + +✅ = has knowledge + +🥇 = is their main priority + +⚠️ = is the only one with knowledge + +👀 = has no knowledge but wants to be onboarded + + + + Areas of expertise table + + + + + ## Developing The development branch is `main`. This is the branch that all pull diff --git a/README.md b/README.md index 9dfae9e620..d74e4b345b 100644 --- a/README.md +++ b/README.md @@ -18,22 +18,26 @@ Website · Issues + · + Roadmap

Join Cal.com Slack Product Hunt - Github Stars - Hacker News - License - Commits-per-month - Pricing - Jitsu Tracked - - - - + Github Stars + Hacker News + License + Commits-per-month + Pricing + Jitsu Tracked + Checkly Availability + + + + +

diff --git a/apps/api b/apps/api index a7889b3436..ed2f42fb01 160000 --- a/apps/api +++ b/apps/api @@ -1 +1 @@ -Subproject commit a7889b34368eb37981b5c78953315a6ed5fc97cd +Subproject commit ed2f42fb0195b1afa0bf2edbab1df2126038b273 diff --git a/apps/console b/apps/console index 67476f0e24..b6b26f4792 160000 --- a/apps/console +++ b/apps/console @@ -1 +1 @@ -Subproject commit 67476f0e24871730e4a7b06da99ee18d4f5179ce +Subproject commit b6b26f47922a5404086bf34635338dc6afa9c1d3 diff --git a/apps/web/components/AppsShell.tsx b/apps/web/components/AppsShell.tsx index 51f55c7448..23755ab80d 100644 --- a/apps/web/components/AppsShell.tsx +++ b/apps/web/components/AppsShell.tsx @@ -1,23 +1,21 @@ import { useSession } from "next-auth/react"; import React from "react"; -import { useLocale } from "@calcom/lib/hooks/useLocale"; - import NavTabs from "./NavTabs"; +const tabs = [ + { + name: "app_store", + href: "/apps", + }, + { + name: "installed_apps", + href: "/apps/installed", + }, +]; + export default function AppsShell({ children }: { children: React.ReactNode }) { - const { t } = useLocale(); const { status } = useSession(); - const tabs = [ - { - name: t("app_store"), - href: "/apps", - }, - { - name: t("installed_apps"), - href: "/apps/installed", - }, - ]; return ( <> diff --git a/apps/web/components/BookingsShell.tsx b/apps/web/components/BookingsShell.tsx index 014e379eca..bbf5894dbb 100644 --- a/apps/web/components/BookingsShell.tsx +++ b/apps/web/components/BookingsShell.tsx @@ -1,30 +1,27 @@ import React from "react"; -import { useLocale } from "@lib/hooks/useLocale"; - import NavTabs from "./NavTabs"; -export default function BookingsShell({ children }: { children: React.ReactNode }) { - const { t } = useLocale(); - const tabs = [ - { - name: t("upcoming"), - href: "/bookings/upcoming", - }, - { - name: t("recurring"), - href: "/bookings/recurring", - }, - { - name: t("past"), - href: "/bookings/past", - }, - { - name: t("cancelled"), - href: "/bookings/cancelled", - }, - ]; +const tabs = [ + { + name: "upcoming", + href: "/bookings/upcoming", + }, + { + name: "recurring", + href: "/bookings/recurring", + }, + { + name: "past", + href: "/bookings/past", + }, + { + name: "cancelled", + href: "/bookings/cancelled", + }, +]; +export default function BookingsShell({ children }: { children: React.ReactNode }) { return ( <> diff --git a/apps/web/components/CustomBranding.tsx b/apps/web/components/CustomBranding.tsx index 8dd83e2235..5959a6abaa 100644 --- a/apps/web/components/CustomBranding.tsx +++ b/apps/web/components/CustomBranding.tsx @@ -1,6 +1,6 @@ import { useEffect } from "react"; -import { useBrandColors } from "@calcom/embed-core"; +import { useBrandColors } from "@calcom/embed-core/embed-iframe"; const brandColor = "#292929"; const brandTextColor = "#ffffff"; diff --git a/apps/web/components/EmptyScreen.tsx b/apps/web/components/EmptyScreen.tsx index 4118800cbe..d8ba88fea6 100644 --- a/apps/web/components/EmptyScreen.tsx +++ b/apps/web/components/EmptyScreen.tsx @@ -9,17 +9,17 @@ export default function EmptyScreen({ }: { Icon: SVGComponent; headline: string; - description: string; + description: string | React.ReactElement; }) { return ( <>
-
- +
+
-

{headline}

-

{description}

+

{headline}

+

{description}

diff --git a/apps/web/components/NavTabs.tsx b/apps/web/components/NavTabs.tsx index bc3de08adb..fa42293f46 100644 --- a/apps/web/components/NavTabs.tsx +++ b/apps/web/components/NavTabs.tsx @@ -4,6 +4,8 @@ import Link, { LinkProps } from "next/link"; import { useRouter } from "next/router"; import { FC, Fragment, MouseEventHandler } from "react"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; + import classNames from "@lib/classNames"; import { SVGComponent } from "@lib/types/SVGComponent"; @@ -22,6 +24,7 @@ export interface NavTabProps { const NavTabs: FC = ({ tabs, linkProps, ...props }) => { const router = useRouter(); + const { t } = useLocale(); return ( <>
{/* show bottom navigation for md and smaller (tablet and phones) */} {status === "authenticated" && ( @@ -461,8 +463,10 @@ function UserDropdown({ small }: { small?: boolean }) { }, }); const utils = trpc.useContext(); + const [helpOpen, setHelpOpen] = useState(false); + return ( - + setHelpOpen(false)}> - - { - mutation.mutate({ away: !user?.away }); - utils.invalidateQueries("viewer.me"); - }} - className="flex min-w-max cursor-pointer px-4 py-2 text-sm hover:bg-gray-100 hover:text-gray-900"> - - - - {user?.username && ( - - - {t("view_public_page")} - - + {helpOpen ? ( + + ) : ( + <> + + { + mutation.mutate({ away: !user?.away }); + utils.invalidateQueries("viewer.me"); + }} + className="flex min-w-max cursor-pointer px-4 py-2 text-sm hover:bg-gray-100 hover:text-gray-900"> + + + + {user?.username && ( + + + {" "} + {t("view_public_page")} + + + )} + + + + + + + + + + + + {t("join_our_slack")} + + + + + {t("visit_roadmap")} + + + + + + + + signOut({ callbackUrl: "/auth/logout" })} + className="flex cursor-pointer px-4 py-2 text-sm hover:bg-gray-100 hover:text-gray-900"> + + + )} - - - - - - - - - - - - {t("join_our_slack")} - - - - - {t("visit_roadmap")} - - - - - - - - signOut({ callbackUrl: "/auth/logout" })} - className="flex cursor-pointer px-4 py-2 text-sm hover:bg-gray-100 hover:text-gray-900"> - - ); diff --git a/apps/web/components/availability/NewScheduleButton.tsx b/apps/web/components/availability/NewScheduleButton.tsx index 6960d2ebbc..d39ae7294e 100644 --- a/apps/web/components/availability/NewScheduleButton.tsx +++ b/apps/web/components/availability/NewScheduleButton.tsx @@ -46,20 +46,17 @@ export function NewScheduleButton({ name = "new-schedule" }: { name?: string }) -
+
-
-

{t("new_event_type_to_book_description")}

-
{ createMutation.mutate(values); }}> -
+
diff --git a/apps/web/components/booking/AvailableTimes.tsx b/apps/web/components/booking/AvailableTimes.tsx index a6c89b335c..855e49a038 100644 --- a/apps/web/components/booking/AvailableTimes.tsx +++ b/apps/web/components/booking/AvailableTimes.tsx @@ -29,6 +29,7 @@ type AvailableTimesProps = { username: string | null; }[]; schedulingType: SchedulingType | null; + seatsPerTimeSlot?: number | null; }; const AvailableTimes: FC = ({ @@ -44,6 +45,7 @@ const AvailableTimes: FC = ({ schedulingType, beforeBufferTime, afterBufferTime, + seatsPerTimeSlot, }) => { const { t, i18n } = useLocale(); const router = useRouter(); @@ -105,18 +107,48 @@ const AvailableTimes: FC = ({ bookingUrl.query.user = slot.users; } + // If event already has an attendee add booking id + if (slot.bookingUid) { + bookingUrl.query.bookingUid = slot.bookingUid; + } + return ( ); })} diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 28e97d498a..194d39ceee 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -2,6 +2,7 @@ import { BanIcon, CheckIcon, ClockIcon, + LocationMarkerIcon, PaperAirplaneIcon, PencilAltIcon, XIcon, @@ -16,6 +17,7 @@ import { Frequency as RRuleFrequency } from "rrule"; import classNames from "@calcom/lib/classNames"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import showToast from "@calcom/lib/notification"; import Button from "@calcom/ui/Button"; import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog"; import { Tooltip } from "@calcom/ui/Tooltip"; @@ -23,9 +25,11 @@ import { TextArea } from "@calcom/ui/form/fields"; import { HttpError } from "@lib/core/http/error"; import useMeQuery from "@lib/hooks/useMeQuery"; +import { LocationType } from "@lib/location"; import { parseRecurringDates } from "@lib/parseDate"; import { inferQueryInput, inferQueryOutput, trpc } from "@lib/trpc"; +import { EditLocationDialog } from "@components/dialog/EditLocationDialog"; import { RescheduleDialog } from "@components/dialog/RescheduleDialog"; import TableActions, { ActionType } from "@components/ui/TableActions"; @@ -72,6 +76,7 @@ function BookingListItem(booking: BookingItemProps) { if (!res.ok) { throw new HttpError({ statusCode: res.status }); } + setRejectionDialogIsOpen(false); }, { async onSettled() { @@ -89,8 +94,7 @@ function BookingListItem(booking: BookingItemProps) { booking.listingStatus === "upcoming" && booking.recurringEventId !== null ? t("reject_all") : t("reject"), - onClick: (e) => { - e.stopPropagation(); + onClick: () => { setRejectionDialogIsOpen(true); }, icon: BanIcon, @@ -102,8 +106,7 @@ function BookingListItem(booking: BookingItemProps) { booking.listingStatus === "upcoming" && booking.recurringEventId !== null ? t("confirm_all") : t("confirm"), - onClick: (e) => { - e.stopPropagation(); + onClick: () => { mutation.mutate(true); }, icon: CheckIcon, @@ -120,25 +123,33 @@ function BookingListItem(booking: BookingItemProps) { icon: XIcon, }, { - id: "reschedule", - label: t("reschedule"), - icon: ClockIcon, + id: "edit_booking", + label: t("edit_booking"), + icon: PencilAltIcon, actions: [ { - id: "edit", - icon: PencilAltIcon, - label: t("edit_booking"), + id: "reschedule", + icon: ClockIcon, + label: t("reschedule_booking"), href: `/reschedule/${booking.uid}`, }, { id: "reschedule_request", - icon: ClockIcon, + icon: PaperAirplaneIcon, + iconClassName: "rotate-45 w-[18px] -ml-[2px]", label: t("send_reschedule_request"), - onClick: (e) => { - e.stopPropagation(); + onClick: () => { setIsOpenRescheduleDialog(true); }, }, + { + id: "change_location", + label: t("edit_location"), + onClick: () => { + setIsOpenLocationDialog(true); + }, + icon: LocationMarkerIcon, + }, ], }, ]; @@ -154,6 +165,26 @@ function BookingListItem(booking: BookingItemProps) { const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY"); const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false); + const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false); + const setLocationMutation = trpc.useMutation("viewer.bookings.editLocation", { + onSuccess: () => { + showToast(t("location_updated"), "success"); + setIsOpenLocationDialog(false); + utils.invalidateQueries("viewer.bookings"); + }, + }); + + const saveLocation = (newLocationType: LocationType, details: { [key: string]: string }) => { + let newLocation = newLocationType as string; + if ( + newLocationType === LocationType.InPerson || + newLocationType === LocationType.Link || + newLocationType === LocationType.UserPhone + ) { + newLocation = details[Object.keys(details)[0]]; + } + setLocationMutation.mutate({ bookingId: booking.id, newLocation }); + }; // Calculate the booking date(s) let recurringStrings: string[] = []; @@ -168,6 +199,30 @@ function BookingListItem(booking: BookingItemProps) { ); } + const onClick = () => { + router.push({ + pathname: "/success", + query: { + date: booking.startTime, + type: booking.eventType.id, + eventSlug: booking.eventType.slug, + user: user?.username || "", + name: booking.attendees[0] ? booking.attendees[0].name : undefined, + email: booking.attendees[0] ? booking.attendees[0].email : undefined, + location: booking.location + ? booking.location.includes("integration") + ? (t("web_conferencing_details_to_follow") as string) + : booking.location + : "", + eventName: booking.eventType.eventName || "", + bookingId: booking.id, + recur: booking.recurringEventId, + reschedule: booking.confirmed, + listingStatus: booking.listingStatus, + status: booking.status, + }, + }); + }; return ( <> + {/* NOTE: Should refactor this dialog component as is being rendered multiple times */} @@ -209,114 +270,103 @@ function BookingListItem(booking: BookingItemProps) { - - router.push({ - pathname: "/success", - query: { - date: booking.startTime, - type: booking.eventType.id, - eventSlug: booking.eventType.slug, - user: user?.username || "", - name: booking.attendees[0].name, - email: booking.attendees[0].email, - location: booking.location - ? booking.location.includes("integration") - ? (t("web_conferencing_details_to_follow") as string) - : booking.location - : "", - eventName: booking.eventType.eventName || "", - bookingId: booking.id, - recur: booking.recurringEventId, - reschedule: booking.confirmed, - }, - }) - }> - -
{startTime}
-
- {dayjs(booking.startTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "} - {dayjs(booking.endTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")} -
-
- {booking.recurringCount && - booking.eventType?.recurringEvent?.freq && - booking.listingStatus === "upcoming" && ( -
-
- ( -

{aDate}

- ))}> -

- - {`${t("every_for_freq", { - freq: t( + + +

+
{startTime}
+
+ {dayjs(booking.startTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "} + {dayjs(booking.endTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")} +
+
+ {booking.recurringCount && + booking.eventType?.recurringEvent?.freq && + booking.listingStatus === "upcoming" && ( +
+
+ ( +

{aDate}

+ ))}> +

+ + {`${t("every_for_freq", { + freq: t( + `${RRuleFrequency[booking.eventType.recurringEvent.freq] + .toString() + .toLowerCase()}` + ), + })} ${booking.recurringCount} ${t( `${RRuleFrequency[booking.eventType.recurringEvent.freq] .toString() - .toLowerCase()}` - ), - })} ${booking.recurringCount} ${t( - `${RRuleFrequency[booking.eventType.recurringEvent.freq].toString().toLowerCase()}`, - { count: booking.recurringCount } - )}`} -

-
+ .toLowerCase()}`, + { count: booking.recurringCount } + )}`} +

+ +
-
- )} + )} +
- -
- {!booking.confirmed && !booking.rejected && ( - {t("unconfirmed")} - )} - {!!booking?.eventType?.price && !booking.paid && ( - Pending payment - )} -
- {startTime}:{" "} - - {dayjs(booking.startTime).format("HH:mm")} - {dayjs(booking.endTime).format("HH:mm")} - + +
+
+ {!booking.confirmed && !booking.rejected && ( + {t("unconfirmed")} + )} + {!!booking?.eventType?.price && !booking.paid && ( + Pending payment + )} +
+ {startTime}:{" "} + + {dayjs(booking.startTime).format("HH:mm")} - {dayjs(booking.endTime).format("HH:mm")} + +
-
-
- {booking.eventType?.team && {booking.eventType.team.name}: } - {booking.title} - {!!booking?.eventType?.price && !booking.paid && ( - Pending payment - )} - {!booking.confirmed && !booking.rejected && ( - {t("unconfirmed")} - )} -
- {booking.description && ( -
- "{booking.description}" +
+ {booking.eventType?.team && {booking.eventType.team.name}: } + {booking.title} + {!!booking?.eventType?.price && !booking.paid && ( + Pending payment + )} + {!booking.confirmed && !booking.rejected && ( + {t("unconfirmed")} + )}
- )} + {booking.description && ( +
+ "{booking.description}" +
+ )} - {booking.attendees.length !== 0 && ( - e.stopPropagation()}> - {booking.attendees[0].email} - - )} - {isCancelled && booking.rescheduled && ( -
- -
- )} + {booking.attendees.length !== 0 && ( + e.stopPropagation()}> + {booking.attendees[0].email} + + )} + {isCancelled && booking.rescheduled && ( +
+ +
+ )} +
diff --git a/apps/web/components/booking/DatePicker.tsx b/apps/web/components/booking/DatePicker.tsx index 74aceb83ed..8a3398a9df 100644 --- a/apps/web/components/booking/DatePicker.tsx +++ b/apps/web/components/booking/DatePicker.tsx @@ -7,7 +7,7 @@ import utc from "dayjs/plugin/utc"; import { memoize } from "lodash"; import { useEffect, useRef, useState } from "react"; -import { useEmbedStyles } from "@calcom/embed-core"; +import { useEmbedStyles } from "@calcom/embed-core/embed-iframe"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import classNames from "@lib/classNames"; diff --git a/apps/web/components/booking/pages/AvailabilityPage.tsx b/apps/web/components/booking/pages/AvailabilityPage.tsx index ecca5bdeb2..84eca3e9a2 100644 --- a/apps/web/components/booking/pages/AvailabilityPage.tsx +++ b/apps/web/components/booking/pages/AvailabilityPage.tsx @@ -8,27 +8,31 @@ import { CreditCardIcon, GlobeIcon, InformationCircleIcon, + LocationMarkerIcon, RefreshIcon, + VideoCameraIcon, } from "@heroicons/react/solid"; import * as Collapsible from "@radix-ui/react-collapsible"; import { useContracts } from "contexts/contractsContext"; import dayjs, { Dayjs } from "dayjs"; import customParseFormat from "dayjs/plugin/customParseFormat"; import utc from "dayjs/plugin/utc"; +import { TFunction } from "next-i18next"; import { useRouter } from "next/router"; import { useCallback, useEffect, useMemo, useState } from "react"; import { FormattedNumber, IntlProvider } from "react-intl"; import { Frequency as RRuleFrequency } from "rrule"; +import { AppStoreLocationType, LocationObject, LocationType } from "@calcom/app-store/locations"; import { useEmbedStyles, useIsEmbed, useIsBackgroundTransparent, sdkActionManager, useEmbedNonStylesConfig, -} from "@calcom/embed-core"; +} from "@calcom/embed-core/embed-iframe"; import classNames from "@calcom/lib/classNames"; -import { WEBAPP_URL } from "@calcom/lib/constants"; +import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { localStorage } from "@calcom/lib/webstorage"; @@ -57,6 +61,35 @@ dayjs.extend(customParseFormat); type Props = AvailabilityTeamPageProps | AvailabilityPageProps; +export const locationKeyToString = (location: LocationObject, t: TFunction) => { + switch (location.type) { + case LocationType.InPerson: + return location.address || "In Person"; // If disabled address won't exist on the object + case LocationType.Link: + return location.link || "Link"; // If disabled link won't exist on the object + case LocationType.Phone: + return t("your_number"); + case LocationType.UserPhone: + return t("phone_call"); + case LocationType.GoogleMeet: + return "Google Meet"; + case LocationType.Zoom: + return "Zoom"; + case LocationType.Daily: + return "Cal Video"; + case LocationType.Jitsi: + return "Jitsi"; + case LocationType.Huddle01: + return "Huddle Video"; + case LocationType.Tandem: + return "Tandem"; + case LocationType.Teams: + return "Microsoft Teams"; + default: + return null; + } +}; + const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage, booking }: Props) => { const router = useRouter(); const isEmbed = useIsEmbed(); @@ -203,7 +236,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage .filter((user) => user.name !== profile.name) .map((user) => ({ title: user.name, - image: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/${user.username}/avatar.png`, + image: `${CAL_URL}/${user.username}/avatar.png`, alt: user.name || undefined, })), ].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[] @@ -225,6 +258,25 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage {eventType.description}

)} + {eventType.locations.length === 1 && ( +

+ + {locationKeyToString(eventType.locations[0], t)} +

+ )} + {eventType.locations.length === 1 && ( +

+ {Object.values(AppStoreLocationType).includes( + eventType.locations[0].type as unknown as AppStoreLocationType + ) ? ( + + ) : ( + + )} + + {locationKeyToString(eventType.locations[0], t)} +

+ )}

{eventType.length} {t("minutes")} @@ -278,7 +330,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage .map((user) => ({ title: user.name, alt: user.name, - image: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/${user.username}/avatar.png`, + image: `${CAL_URL}/${user.username}/avatar.png`, })), ].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[] } @@ -297,6 +349,38 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage {eventType.description}

)} + {eventType.locations.length === 1 && ( +

+ {Object.values(AppStoreLocationType).includes( + eventType.locations[0].type as unknown as AppStoreLocationType + ) ? ( + + ) : ( + + )} + + {locationKeyToString(eventType.locations[0], t)} +

+ )} + {eventType.locations.length > 1 && ( +
+
+ +
+

+ {eventType.locations.map((el, i, arr) => { + return ( + + {locationKeyToString(el, t)}{" "} + {arr.length - 1 !== i && ( + {t("or_lowercase")} + )} + + ); + })} +

+
+ )}

{eventType.length} {t("minutes")} @@ -340,12 +424,11 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage

)} - {previousPage === `${WEBAPP_URL}/${profile.slug}` && (
router.back()} />

Go Back

@@ -398,6 +481,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage schedulingType={eventType.schedulingType ?? null} beforeBufferTime={eventType.beforeEventBuffer} afterBufferTime={eventType.afterEventBuffer} + seatsPerTimeSlot={eventType.seatsPerTimeSlot} /> )}
diff --git a/apps/web/components/booking/pages/BookingPage.tsx b/apps/web/components/booking/pages/BookingPage.tsx index 1b1ab61d87..458561742c 100644 --- a/apps/web/components/booking/pages/BookingPage.tsx +++ b/apps/web/components/booking/pages/BookingPage.tsx @@ -24,7 +24,11 @@ import { Frequency as RRuleFrequency } from "rrule"; import { v4 as uuidv4 } from "uuid"; import { z } from "zod"; -import { useEmbedNonStylesConfig, useIsBackgroundTransparent, useIsEmbed } from "@calcom/embed-core"; +import { + useEmbedNonStylesConfig, + useIsBackgroundTransparent, + useIsEmbed, +} from "@calcom/embed-core/embed-iframe"; import classNames from "@calcom/lib/classNames"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { HttpError } from "@calcom/lib/http-error"; @@ -37,7 +41,7 @@ import { asStringOrNull } from "@lib/asStringOrNull"; import { timeZone } from "@lib/clock"; import { ensureArray } from "@lib/ensureArray"; import useTheme from "@lib/hooks/useTheme"; -import { LocationType } from "@lib/location"; +import { LocationObject, LocationType } from "@lib/location"; import createBooking from "@lib/mutations/bookings/create-booking"; import createRecurringBooking from "@lib/mutations/bookings/create-recurring-booking"; import { parseDate, parseRecurringDates } from "@lib/parseDate"; @@ -79,6 +83,7 @@ type BookingFormValues = { customInputs?: { [key: string]: string | boolean; }; + rescheduleReason?: string; }; const BookingPage = ({ @@ -203,10 +208,9 @@ const BookingPage = ({ const eventTypeDetail = { isWeb3Active: false, ...eventType }; - type Location = { type: LocationType; address?: string; link?: string; hostPhoneNumber?: string }; // it would be nice if Prisma at some point in the future allowed for Json; as of now this is not the case. - const locations: Location[] = useMemo( - () => (eventType.locations as Location[]) || [], + const locations: LocationObject[] = useMemo( + () => (eventType.locations as LocationObject[]) || [], [eventType.locations] ); @@ -252,6 +256,7 @@ const BookingPage = ({ email: primaryAttendee.email || "", guests: guestListEmails, notes: booking.description || "", + rescheduleReason: "", customInputs: eventType.customInputs.reduce( (customInputs, input) => ({ ...customInputs, @@ -396,6 +401,7 @@ const BookingPage = ({ timeZone: timeZone(), language: i18n.language, rescheduleUid, + bookingUid: router.query.bookingUid as string, user: router.query.user, location: getLocationValue( booking.locationType ? booking : { ...booking, locationType: selectedLocation } @@ -469,6 +475,21 @@ const BookingPage = ({

{eventType.title}

+ {eventType.seatsPerTimeSlot && ( +

= 0.5 + ? "text-rose-600" + : booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33 + ? "text-yellow-500" + : "text-emerald-400" + } mb-2`}> + {booking + ? eventType.seatsPerTimeSlot - booking.attendees.length + : eventType.seatsPerTimeSlot}{" "} + / {eventType.seatsPerTimeSlot} {t("seats_available")} +

+ )} {eventType?.description && (

@@ -767,18 +788,31 @@ const BookingPage = ({ - + +

+ + + + +
+
+ +
+ {mutation.isError && ( +
+
+ +
+
+

{t("feedback_error")}

+

{t("please_try_again")}

+
+
+ )} +
+
); } diff --git a/apps/web/ee/components/team/availability/TeamAvailabilityModal.tsx b/apps/web/ee/components/team/availability/TeamAvailabilityModal.tsx index 43e5640d8b..985c307e3e 100644 --- a/apps/web/ee/components/team/availability/TeamAvailabilityModal.tsx +++ b/apps/web/ee/components/team/availability/TeamAvailabilityModal.tsx @@ -2,9 +2,10 @@ import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import React, { useState, useEffect } from "react"; -import { WEBSITE_URL } from "@calcom/lib/constants"; +import { WEBAPP_URL } from "@calcom/lib/constants"; +import LicenseRequired from "@ee/components/LicenseRequired"; -import { trpc, inferQueryOutput } from "@lib/trpc"; +import { inferQueryOutput, trpc } from "@lib/trpc"; import Avatar from "@components/ui/Avatar"; import { DatePicker } from "@components/ui/form/DatePicker"; @@ -33,62 +34,66 @@ export default function TeamAvailabilityModal(props: Props) { }, [utils, selectedTimeZone, selectedDate]); return ( -
-
-
- -
- {props.member?.name} - {props.member?.email} + +
+
+
+ +
+ {props.member?.name} + {props.member?.email} +
+
+
+ Date + { + setSelectedDate(dayjs(newDate)); + }} + /> +
+
+ Timezone + setSelectedTimeZone(timezone.value)} + classNamePrefix="react-select" + className="react-select-container mt-1 block w-full rounded-sm border border-gray-300 shadow-sm focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm" + /> +
+
+ Slot Length + setFrequency(newFrequency?.value ?? 30)} - /> -
+ )}
- {props.team && props.member && ( - - )} -
+
); } diff --git a/apps/web/ee/components/team/availability/TeamAvailabilityScreen.tsx b/apps/web/ee/components/team/availability/TeamAvailabilityScreen.tsx index fa5b6d61ee..e81319719a 100644 --- a/apps/web/ee/components/team/availability/TeamAvailabilityScreen.tsx +++ b/apps/web/ee/components/team/availability/TeamAvailabilityScreen.tsx @@ -3,7 +3,7 @@ import React, { useState, useEffect, CSSProperties } from "react"; import AutoSizer from "react-virtualized-auto-sizer"; import { FixedSizeList as List } from "react-window"; -import { WEBSITE_URL } from "@calcom/lib/constants"; +import { CAL_URL } from "@calcom/lib/constants"; import { inferQueryOutput, trpc } from "@lib/trpc"; @@ -46,7 +46,7 @@ export default function TeamAvailabilityScreen(props: Props) { HeaderComponent={
diff --git a/apps/web/ee/components/web3/CryptoSection.tsx b/apps/web/ee/components/web3/CryptoSection.tsx index 4d570429ee..0056c65f85 100644 --- a/apps/web/ee/components/web3/CryptoSection.tsx +++ b/apps/web/ee/components/web3/CryptoSection.tsx @@ -11,6 +11,7 @@ import { Button } from "@calcom/ui/Button"; import { useContracts } from "../../../contexts/contractsContext"; import genericAbi from "../../../web3/abis/abiWithGetBalance.json"; import verifyAccount, { AUTH_MESSAGE } from "../../../web3/utils/verifyAccount"; +import { withLicenseRequired } from "../LicenseRequired"; interface Window { ethereum: AbstractProvider & { selectedAddress: string }; @@ -150,4 +151,4 @@ const CryptoSection = (props: CryptoSectionProps) => { ); }; -export default CryptoSection; +export default withLicenseRequired(CryptoSection); diff --git a/apps/web/ee/lib/helpscout/HelpscoutMenuItem.tsx b/apps/web/ee/lib/helpscout/HelpscoutMenuItem.tsx index 350af9ab4b..016f884da9 100644 --- a/apps/web/ee/lib/helpscout/HelpscoutMenuItem.tsx +++ b/apps/web/ee/lib/helpscout/HelpscoutMenuItem.tsx @@ -22,20 +22,12 @@ export default function HelpscoutMenuItem() { else return ( <> - - - + + {active && } ); diff --git a/apps/web/ee/lib/impersonation/ImpersonationProvider.ts b/apps/web/ee/lib/impersonation/ImpersonationProvider.ts index 331d5ba3c3..6d53b683f1 100644 --- a/apps/web/ee/lib/impersonation/ImpersonationProvider.ts +++ b/apps/web/ee/lib/impersonation/ImpersonationProvider.ts @@ -32,6 +32,10 @@ const ImpersonationProvider = CredentialsProvider({ throw new Error("This user does not exist"); } + if (user.disableImpersonation) { + throw new Error("This user has disabled Impersonation."); + } + // Log impersonations for audit purposes await prisma.impersonations.create({ data: { diff --git a/apps/web/ee/lib/intercom/IntercomMenuItem.tsx b/apps/web/ee/lib/intercom/IntercomMenuItem.tsx index 65688e2488..e7e526c861 100644 --- a/apps/web/ee/lib/intercom/IntercomMenuItem.tsx +++ b/apps/web/ee/lib/intercom/IntercomMenuItem.tsx @@ -13,22 +13,13 @@ export default function IntercomMenuItem() { if (!process.env.NEXT_PUBLIC_INTERCOM_APP_ID) return null; else return ( - - - + ); } diff --git a/apps/web/ee/lib/zendesk/ZendeskMenuItem.tsx b/apps/web/ee/lib/zendesk/ZendeskMenuItem.tsx index 706495f4c2..2d7014f148 100644 --- a/apps/web/ee/lib/zendesk/ZendeskMenuItem.tsx +++ b/apps/web/ee/lib/zendesk/ZendeskMenuItem.tsx @@ -17,20 +17,11 @@ export default function ZendeskMenuItem() { else return ( <> - - - + {active && ( - + {Component.requiresLicense ? ( + + + + ) : ( + + )} ); diff --git a/apps/web/pages/api/auth/[...nextauth].tsx b/apps/web/pages/api/auth/[...nextauth].tsx index ccd947bc00..f63ec6d407 100644 --- a/apps/web/pages/api/auth/[...nextauth].tsx +++ b/apps/web/pages/api/auth/[...nextauth].tsx @@ -10,6 +10,7 @@ import nodemailer, { TransportOptions } from "nodemailer"; import { authenticator } from "otplib"; import path from "path"; +import checkLicense from "@calcom/ee/server/checkLicense"; import { WEBSITE_URL } from "@calcom/lib/constants"; import { symmetricDecrypt } from "@calcom/lib/crypto"; import { defaultCookies } from "@calcom/lib/default-cookies"; @@ -276,8 +277,10 @@ export default NextAuth({ return token; }, async session({ session, token }) { + const hasValidLicense = await checkLicense(process.env.CALCOM_LICENSE_KEY || ""); const calendsoSession: Session = { ...session, + hasValidLicense, user: { ...session.user, id: token.id as number, diff --git a/apps/web/pages/api/availability/[user].ts b/apps/web/pages/api/availability/[user].ts index 324e5f12cc..e9ecc51068 100644 --- a/apps/web/pages/api/availability/[user].ts +++ b/apps/web/pages/api/availability/[user].ts @@ -50,6 +50,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) prisma.eventType.findUnique({ where: { id }, select: { + seatsPerTimeSlot: true, timeZone: true, schedule: { select: { @@ -107,9 +108,34 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) (eventType?.availability.length ? eventType.availability : currentUser.availability) ); + /* Current logic is if a booking is in a time slot mark it as busy, but seats can have more than one attendee so grab + current bookings with a seats event type and display them on the calendar, even if they are full */ + let currentSeats; + if (eventType?.seatsPerTimeSlot) { + currentSeats = await prisma.booking.findMany({ + where: { + eventTypeId: eventTypeId, + startTime: { + gte: dateFrom.format(), + lte: dateTo.format(), + }, + }, + select: { + uid: true, + startTime: true, + _count: { + select: { + attendees: true, + }, + }, + }, + }); + } + res.status(200).json({ busy: bufferedBusyTimes, timeZone, workingHours, + currentSeats, }); } diff --git a/apps/web/pages/api/book/event.ts b/apps/web/pages/api/book/event.ts index 2de43e3ab9..5b830225c2 100644 --- a/apps/web/pages/api/book/event.ts +++ b/apps/web/pages/api/book/event.ts @@ -188,6 +188,7 @@ const getEventTypesFromDB = async (eventTypeId: number) => { metadata: true, destinationCalendar: true, hideCalendarNotes: true, + seatsPerTimeSlot: true, recurringEvent: true, }, }); @@ -200,7 +201,11 @@ const getEventTypesFromDB = async (eventTypeId: number) => { type User = Prisma.UserGetPayload; -type ExtendedBookingCreateBody = BookingCreateBody & { noEmail?: boolean; recurringCount?: number }; +type ExtendedBookingCreateBody = BookingCreateBody & { + noEmail?: boolean; + recurringCount?: number; + rescheduleReason?: string; +}; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const { recurringCount, noEmail, ...reqBody } = req.body as ExtendedBookingCreateBody; @@ -294,6 +299,45 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return g; }); + // For seats, if the booking already exists then we want to add the new attendee to the existing booking + if (reqBody.bookingUid) { + if (!eventType.seatsPerTimeSlot) + return res.status(404).json({ message: "Event type does not have seats" }); + + const booking = await prisma.booking.findUnique({ + where: { + uid: reqBody.bookingUid, + }, + include: { + attendees: true, + }, + }); + if (!booking) return res.status(404).json({ message: "Booking not found" }); + + if (eventType.seatsPerTimeSlot <= booking.attendees.length) + return res.status(409).json({ message: "Booking seats are full" }); + + if (booking.attendees.some((attendee) => attendee.email === invitee[0].email)) + return res.status(409).json({ message: "Already signed up for time slot" }); + + await prisma.booking.update({ + where: { + uid: reqBody.bookingUid, + }, + data: { + attendees: { + create: { + email: invitee[0].email, + name: invitee[0].name, + timeZone: invitee[0].timeZone, + locale: invitee[0].language.locale, + }, + }, + }, + }); + return res.status(201).json(booking); + } + const teamMemberPromises = eventType.schedulingType === SchedulingType.COLLECTIVE ? users.slice(1).map(async function (user) { @@ -637,7 +681,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (originalRescheduledBooking?.uid) { // Use EventManager to conditionally use all needed integrations. - const updateManager = await eventManager.update(evt, originalRescheduledBooking.uid, booking?.id); + const updateManager = await eventManager.update( + evt, + originalRescheduledBooking.uid, + booking?.id, + reqBody.rescheduleReason + ); // This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back // to the default description when we are sending the emails. evt.description = eventType.description; @@ -671,7 +720,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) { ...evt, additionInformation: metadata, - additionalNotes, // Resets back to the addtionalNote input and not the overriden value + additionalNotes, // Resets back to the additionalNote input and not the override value + cancellationReason: reqBody.rescheduleReason, }, reqBody.recurringEventId ? (eventType.recurringEvent as RecurringEvent) : {} ); diff --git a/apps/web/pages/apps/installed.tsx b/apps/web/pages/apps/installed.tsx index 26333c7063..4ac885ee25 100644 --- a/apps/web/pages/apps/installed.tsx +++ b/apps/web/pages/apps/installed.tsx @@ -1,4 +1,3 @@ -import { ClipboardIcon } from "@heroicons/react/solid"; import Image from "next/image"; import React, { useEffect, useState } from "react"; import { JSONObject } from "superjson/dist/types"; diff --git a/apps/web/pages/d/[link]/[slug].tsx b/apps/web/pages/d/[link]/[slug].tsx index 4e77999653..38ca1c2677 100644 --- a/apps/web/pages/d/[link]/[slug].tsx +++ b/apps/web/pages/d/[link]/[slug].tsx @@ -7,6 +7,7 @@ import { RecurringEvent } from "@calcom/types/Calendar"; import { asStringOrNull } from "@lib/asStringOrNull"; import { getWorkingHours } from "@lib/availability"; import { GetBookingType } from "@lib/getBooking"; +import { locationHiddenFilter, LocationObject } from "@lib/location"; import prisma from "@lib/prisma"; import { inferSSRProps } from "@lib/types/inferSSRProps"; @@ -41,6 +42,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => periodCountCalendarDays: true, recurringEvent: true, schedulingType: true, + seatsPerTimeSlot: true, userId: true, schedule: { select: { @@ -53,6 +55,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => minimumBookingNotice: true, beforeEventBuffer: true, afterEventBuffer: true, + locations: true, timeZone: true, metadata: true, slotInterval: true, @@ -131,6 +134,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => notFound: true, }; } + + const locations = hashedLink.eventType.locations + ? (hashedLink.eventType.locations as LocationObject[]) + : []; + const [user] = users; const eventTypeObject = Object.assign({}, hashedLink.eventType, { metadata: {} as JSONObject, @@ -138,6 +146,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => periodStartDate: hashedLink.eventType.periodStartDate?.toString() ?? null, periodEndDate: hashedLink.eventType.periodEndDate?.toString() ?? null, slug, + locations: locationHiddenFilter(locations), }); const schedule = { diff --git a/apps/web/pages/d/[link]/book.tsx b/apps/web/pages/d/[link]/book.tsx index ed99d6c288..52e8af2800 100644 --- a/apps/web/pages/d/[link]/book.tsx +++ b/apps/web/pages/d/[link]/book.tsx @@ -46,6 +46,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { periodEndDate: true, metadata: true, periodCountCalendarDays: true, + seatsPerTimeSlot: true, price: true, currency: true, disableGuests: true, diff --git a/apps/web/pages/event-types/[type].tsx b/apps/web/pages/event-types/[type].tsx index ffaf0fd442..665767a537 100644 --- a/apps/web/pages/event-types/[type].tsx +++ b/apps/web/pages/event-types/[type].tsx @@ -34,6 +34,7 @@ import { z } from "zod"; import { SelectGifInput } from "@calcom/app-store/giphy/components"; import getApps, { getLocationOptions } from "@calcom/app-store/utils"; +import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import showToast from "@calcom/lib/notification"; import { StripeData } from "@calcom/stripe/server"; @@ -50,7 +51,7 @@ import { asStringOrThrow, asStringOrUndefined } from "@lib/asStringOrNull"; import { getSession } from "@lib/auth"; import { HttpError } from "@lib/core/http/error"; import { isSuccessRedirectAvailable } from "@lib/isSuccessRedirectAvailable"; -import { LocationType } from "@lib/location"; +import { LocationObject, LocationType } from "@lib/location"; import prisma from "@lib/prisma"; import { slugify } from "@lib/slugify"; import { trpc } from "@lib/trpc"; @@ -64,6 +65,7 @@ import Shell from "@components/Shell"; import { UpgradeToProDialog } from "@components/UpgradeToProDialog"; import { AvailabilitySelectSkeletonLoader } from "@components/availability/SkeletonLoader"; import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent"; +import { EditLocationDialog } from "@components/dialog/EditLocationDialog"; import RecurringEventController from "@components/eventtype/RecurringEventController"; import CustomInputTypeForm from "@components/pages/eventtypes/CustomInputTypeForm"; import Badge from "@components/ui/Badge"; @@ -72,7 +74,6 @@ import CheckboxField from "@components/ui/form/CheckboxField"; import CheckedSelect from "@components/ui/form/CheckedSelect"; import { DateRangePicker } from "@components/ui/form/DateRangePicker"; import MinutesField from "@components/ui/form/MinutesField"; -import PhoneInput from "@components/ui/form/PhoneInput"; import Select from "@components/ui/form/Select"; import * as RadioArea from "@components/ui/form/radio-area"; import WebhookListContainer from "@components/webhook/WebhookListContainer"; @@ -118,7 +119,13 @@ export type FormValues = { hidden: boolean; hideCalendarNotes: boolean; hashedLink: string | undefined; - locations: { type: LocationType; address?: string; link?: string; hostPhoneNumber?: string }[]; + locations: { + type: LocationType; + address?: string; + link?: string; + hostPhoneNumber?: string; + displayLocationPublicly?: boolean; + }[]; customInputs: EventTypeCustomInput[]; users: string[]; schedule: number; @@ -126,6 +133,7 @@ export type FormValues = { periodDays: number; periodCountCalendarDays: "1" | "0"; periodDates: { startDate: Date; endDate: Date }; + seatsPerTimeSlot: number | null; minimumBookingNotice: number; beforeBufferTime: number; afterBufferTime: number; @@ -172,7 +180,7 @@ const SuccessRedirectEdit = >({ }} readOnly={proUpgradeRequired} type="url" - className=" block w-full rounded-sm border-gray-300 shadow-sm sm:text-sm" + className="block w-full rounded-sm border-gray-300 sm:text-sm" placeholder={t("external_redirect_url")} defaultValue={eventType.successRedirectUrl || ""} {...formMethods.register("successRedirectUrl")} @@ -316,6 +324,10 @@ const EventTypePage = (props: inferSSRProps) => { ); const [tokensList, setTokensList] = useState>([]); + const defaultSeatsPro = 6; + const minSeats = 2; + const [enableSeats, setEnableSeats] = useState(!!eventType.seatsPerTimeSlot); + const periodType = PERIOD_TYPES.find((s) => s.type === eventType.periodType) || PERIOD_TYPES.find((s) => s.type === "UNLIMITED"); @@ -410,109 +422,7 @@ const EventTypePage = (props: inferSSRProps) => { formMethods.getValues("locations").concat({ type: newLocationType, ...details }) ); } - }; - - const LocationOptions = () => { - if (!selectedLocation) { - return null; - } - switch (selectedLocation.value) { - case LocationType.InPerson: - return ( -
- -
- location.type === LocationType.InPerson)?.address - } - /> -
-
- ); - case LocationType.Link: - return ( -
- -
- location.type === LocationType.Link) - ?.link - } - /> - {locationFormMethods.formState.errors.locationLink && ( -

- {locationFormMethods.formState.errors.locationLink.message} -

- )} -
-
- ); - case LocationType.UserPhone: - return ( -
- -
- location.type === LocationType.UserPhone)?.hostPhoneNumber - } - /> - {locationFormMethods.formState.errors.locationPhoneNumber && ( -

- {locationFormMethods.formState.errors.locationPhoneNumber.message} -

- )} -
-
- ); - case LocationType.Phone: - return

{t("cal_invitee_phone_number_scheduling")}

; - /* TODO: Render this dynamically from App Store */ - case LocationType.GoogleMeet: - return

{t("cal_provide_google_meet_location")}

; - case LocationType.Zoom: - return

{t("cal_provide_zoom_meeting_url")}

; - case LocationType.Daily: - return

{t("cal_provide_video_meeting_url")}

; - case LocationType.Jitsi: - return

{t("cal_provide_jitsi_meeting_url")}

; - case LocationType.Huddle01: - return

{t("cal_provide_huddle01_meeting_url")}

; - case LocationType.Tandem: - return

{t("cal_provide_tandem_meeting_url")}

; - case LocationType.Teams: - return

{t("cal_provide_teams_meeting_url")}

; - default: - return null; - } + setShowLocationModal(false); }; const removeCustom = (index: number) => { @@ -543,11 +453,11 @@ const EventTypePage = (props: inferSSRProps) => { endDate: new Date(eventType.periodEndDate || Date.now()), }); - const permalink = `${process.env.NEXT_PUBLIC_WEBSITE_URL}/${ - team ? `team/${team.slug}` : eventType.users[0].username - }/${eventType.slug}`; + const permalink = `${CAL_URL}/${team ? `team/${team.slug}` : eventType.users[0].username}/${ + eventType.slug + }`; - const placeholderHashedLink = `${process.env.NEXT_PUBLIC_WEBSITE_URL}/d/${hashedUrl}/${eventType.slug}`; + const placeholderHashedLink = `${CAL_URL}/d/${hashedUrl}/${eventType.slug}`; const mapUserToValue = ({ id, @@ -560,7 +470,7 @@ const EventTypePage = (props: inferSSRProps) => { }) => ({ value: `${id || ""}`, label: `${name || ""}`, - avatar: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/${username}/avatar.png`, + avatar: `${WEBAPP_URL}/${username}/avatar.png`, }); const formMethods = useForm({ @@ -578,6 +488,7 @@ const EventTypePage = (props: inferSSRProps) => { const locationFormSchema = z.object({ locationType: z.string(), locationAddress: z.string().optional(), + displayLocationPublicly: z.boolean().optional(), locationPhoneNumber: z .string() .refine((val) => isValidPhoneNumber(val)) @@ -590,6 +501,7 @@ const EventTypePage = (props: inferSSRProps) => { locationPhoneNumber?: string; locationAddress?: string; // TODO: We should validate address or fetch the address from googles api to see if its valid? locationLink?: string; // Currently this only accepts links that are HTTPS:// + displayLocationPublicly?: boolean; }>({ resolver: zodResolver(locationFormSchema), }); @@ -602,7 +514,7 @@ const EventTypePage = (props: inferSSRProps) => { ) => { id="slug" aria-labelledby="slug-label" required - className=" block w-full min-w-0 flex-1 rounded-none rounded-r-sm border-gray-300 sm:text-sm" + className="block w-full min-w-0 flex-1 rounded-none rounded-r-sm border-gray-300 sm:text-sm" defaultValue={eventType.slug} {...formMethods.register("slug", { setValueAs: (v) => slugify(v), @@ -1116,7 +1028,7 @@ const EventTypePage = (props: inferSSRProps) => {
@@ -1246,7 +1158,7 @@ const EventTypePage = (props: inferSSRProps) => {
-
+
) => {
-
+
) => {
-
+
{ ) => { ( + render={({ field: { value, onChange } }) => ( ) => { label={t("opt_in_booking")} description={t("opt_in_booking_description")} defaultChecked={eventType.requiresConfirmation} - onChange={(e) => { - formMethods.setValue("requiresConfirmation", e?.target.checked); - }} + disabled={enableSeats} + checked={value} + onChange={(e) => onChange(e?.target.checked)} /> )} /> @@ -1436,6 +1347,9 @@ const EventTypePage = (props: inferSSRProps) => { label={t("disable_guests")} description={t("disable_guests_description")} defaultChecked={eventType.disableGuests} + // If we have seats per booking then we need to disable guests + disabled={enableSeats} + checked={formMethods.watch("disableGuests")} onChange={(e) => { formMethods.setValue("disableGuests", e?.target.checked); }} @@ -1474,7 +1388,7 @@ const EventTypePage = (props: inferSSRProps) => { name="hashedLink" data-testid="generated-hash-url" type="text" - className=" grow select-none border-gray-300 bg-gray-50 text-sm text-gray-500 ltr:rounded-l-sm rtl:rounded-r-sm" + className="grow select-none border-gray-300 bg-gray-50 text-sm text-gray-500 ltr:rounded-l-sm rtl:rounded-r-sm" defaultValue={placeholderHashedLink} /> ) => {
{ if (val) onChange(val.value); }} @@ -1722,7 +1636,7 @@ const EventTypePage = (props: inferSSRProps) => { return ( +
+ ) : ( + <> + + { - if (val) { - locationFormMethods.setValue("locationType", val.value); - locationFormMethods.unregister("locationLink"); - locationFormMethods.unregister("locationAddress"); - locationFormMethods.unregister("locationPhoneNumber"); - setSelectedLocation(val); - } - }} - /> - )} - /> -
- -
- - -
- -
- - + const session = await getSession({ req }); const typeParam = parseInt(asStringOrThrow(query.type)); + if (Number.isNaN(typeParam)) { + return { + notFound: true, + }; + } + if (!session?.user?.id) { return { redirect: { @@ -2219,15 +2209,15 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => price: true, currency: true, destinationCalendar: true, + seatsPerTimeSlot: true, }, }); - if (!rawEventType) throw Error("Event type not found"); - - type Location = { - type: LocationType; - address?: string; - }; + if (!rawEventType) { + return { + notFound: true, + }; + } const credentials = await prisma.credential.findMany({ where: { @@ -2247,7 +2237,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => const eventType = { ...restEventType, recurringEvent: (restEventType.recurringEvent || {}) as RecurringEvent, - locations: locations as unknown as Location[], + locations: locations as unknown as LocationObject[], metadata: (metadata || {}) as JSONObject, isWeb3Active: web3Credentials && web3Credentials.key @@ -2299,7 +2289,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => const teamMembers = eventTypeObject.team ? eventTypeObject.team.members.map((member) => { const user = member.user; - user.avatar = `${process.env.NEXT_PUBLIC_WEBSITE_URL}/${user.username}/avatar.png`; + user.avatar = `${CAL_URL}/${user.username}/avatar.png`; return user; }) : []; diff --git a/apps/web/pages/event-types/index.tsx b/apps/web/pages/event-types/index.tsx index 8dd740e114..fff48aa0a8 100644 --- a/apps/web/pages/event-types/index.tsx +++ b/apps/web/pages/event-types/index.tsx @@ -19,12 +19,12 @@ import Link from "next/link"; import { useRouter } from "next/router"; import React, { Fragment, useEffect, useState } from "react"; -import { WEBAPP_URL } from "@calcom/lib/constants"; +import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import showToast from "@calcom/lib/notification"; import { Button } from "@calcom/ui"; import { Alert } from "@calcom/ui/Alert"; -import { Dialog, DialogTrigger } from "@calcom/ui/Dialog"; +import { Dialog } from "@calcom/ui/Dialog"; import Dropdown, { DropdownMenuContent, DropdownMenuItem, @@ -246,7 +246,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL truncateAfter={4} items={type.users.map((organizer) => ({ alt: organizer.name || "", - image: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/${organizer.username}/avatar.png`, + image: `${WEBAPP_URL}/${organizer.username}/avatar.png`, }))} /> )} @@ -257,7 +257,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL )}> @@ -271,9 +271,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
diff --git a/apps/web/pages/getting-started.tsx b/apps/web/pages/getting-started.tsx index a5acb3ae49..f6b36b101d 100644 --- a/apps/web/pages/getting-started.tsx +++ b/apps/web/pages/getting-started.tsx @@ -36,7 +36,6 @@ import { ClientSuspense } from "@components/ClientSuspense"; import Loader from "@components/Loader"; import Schedule from "@components/availability/Schedule"; import { CalendarListContainer } from "@components/integrations/CalendarListContainer"; -import Text from "@components/ui/Text"; import TimezoneSelect from "@components/ui/form/TimezoneSelect"; import getEventTypes from "../lib/queries/event-types/get-event-types"; @@ -398,10 +397,10 @@ export default function Onboarding(props: inferSSRProps {t("timezone")} - +

{t("current_time")}:  {dayjs().tz(selectedTimeZone).format("LT")} - +

- +

{t("few_sentences_about_yourself")} - +

@@ -582,17 +581,13 @@ export default function Onboarding(props: inferSSRProps
- - {steps[currentStep].title} - - - {steps[currentStep].description} - +

{steps[currentStep].title}

+

{steps[currentStep].description}

- +

Step {currentStep + 1} of {steps.length} - +

{error && } diff --git a/apps/web/pages/settings/billing.tsx b/apps/web/pages/settings/billing.tsx index 7a22a388e6..96acb03822 100644 --- a/apps/web/pages/settings/billing.tsx +++ b/apps/web/pages/settings/billing.tsx @@ -1,14 +1,13 @@ import { ExternalLinkIcon } from "@heroicons/react/solid"; import { ReactNode } from "react"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; import Button from "@calcom/ui/Button"; import { useIntercom } from "@ee/lib/intercom/useIntercom"; -import { useLocale } from "@lib/hooks/useLocale"; import useMeQuery from "@lib/hooks/useMeQuery"; import SettingsShell from "@components/SettingsShell"; -import Shell from "@components/Shell"; type CardProps = { title: string; description: string; className?: string; children: ReactNode }; const Card = ({ title, description, className = "", children }: CardProps): JSX.Element => ( @@ -30,8 +29,8 @@ export default function Billing() { const { boot, show } = useIntercom(); return ( - - + + <>
{data?.plan && ["FREE", "TRIAL"].includes(data.plan) && (
- - + + ); } diff --git a/apps/web/pages/settings/profile.tsx b/apps/web/pages/settings/profile.tsx index a798945022..229421e441 100644 --- a/apps/web/pages/settings/profile.tsx +++ b/apps/web/pages/settings/profile.tsx @@ -5,6 +5,7 @@ import { signOut } from "next-auth/react"; import { useRouter } from "next/router"; import { ComponentProps, FormEvent, RefObject, useEffect, useMemo, useRef, useState } from "react"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; import showToast from "@calcom/lib/notification"; import { Alert } from "@calcom/ui/Alert"; import Button from "@calcom/ui/Button"; @@ -15,7 +16,6 @@ import { withQuery } from "@lib/QueryCell"; import { asStringOrNull, asStringOrUndefined } from "@lib/asStringOrNull"; import { getSession } from "@lib/auth"; import { nameOfDay } from "@lib/core/i18n/weekday"; -import { useLocale } from "@lib/hooks/useLocale"; import { isBrandingHidden } from "@lib/isBrandingHidden"; import prisma from "@lib/prisma"; import { trpc } from "@lib/trpc"; @@ -23,7 +23,6 @@ import { inferSSRProps } from "@lib/types/inferSSRProps"; import ImageUploader from "@components/ImageUploader"; import SettingsShell from "@components/SettingsShell"; -import Shell from "@components/Shell"; import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent"; import Avatar from "@components/ui/Avatar"; import Badge from "@components/ui/Badge"; @@ -488,11 +487,9 @@ export default function Settings(props: Props) { const { t } = useLocale(); return ( - - - } /> - - + + } /> + ); } diff --git a/apps/web/pages/settings/security.tsx b/apps/web/pages/settings/security.tsx index 58e8e57ea3..a88784319a 100644 --- a/apps/web/pages/settings/security.tsx +++ b/apps/web/pages/settings/security.tsx @@ -9,16 +9,16 @@ import { identityProviderNameMap } from "@lib/auth"; import { trpc } from "@lib/trpc"; import SettingsShell from "@components/SettingsShell"; -import Shell from "@components/Shell"; import ChangePasswordSection from "@components/security/ChangePasswordSection"; +import DisableUserImpersonation from "@components/security/DisableUserImpersonation"; import TwoFactorAuthSection from "@components/security/TwoFactorAuthSection"; export default function Security() { const user = trpc.useQuery(["viewer.me"]).data; const { t } = useLocale(); return ( - - + + <> {user && user.identityProvider !== IdentityProvider.CAL ? ( <>
@@ -39,11 +39,12 @@ export default function Security() { +
)} -
-
+ + ); } diff --git a/apps/web/pages/settings/teams/index.tsx b/apps/web/pages/settings/teams/index.tsx index be391687cf..1ad9065c7b 100644 --- a/apps/web/pages/settings/teams/index.tsx +++ b/apps/web/pages/settings/teams/index.tsx @@ -4,21 +4,20 @@ import { useSession } from "next-auth/react"; import { Trans } from "next-i18next"; import { useState } from "react"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Alert } from "@calcom/ui/Alert"; import Button from "@calcom/ui/Button"; -import { useLocale } from "@lib/hooks/useLocale"; import useMeQuery from "@lib/hooks/useMeQuery"; import { trpc } from "@lib/trpc"; import EmptyScreen from "@components/EmptyScreen"; import Loader from "@components/Loader"; import SettingsShell from "@components/SettingsShell"; -import Shell from "@components/Shell"; import TeamCreateModal from "@components/team/TeamCreateModal"; import TeamList from "@components/team/TeamList"; -export default function Teams() { +function Teams() { const { t } = useLocale(); const { status } = useSession(); const loading = status === "loading"; @@ -40,8 +39,8 @@ export default function Teams() { const isFreePlan = me.data?.plan === "FREE"; return ( - - + + <> {!!errorMessage && } {isFreePlan && ( )} {teams.length > 0 && } - - + + ); } + +Teams.requiresLicense = false; + +export default Teams; diff --git a/apps/web/pages/success.tsx b/apps/web/pages/success.tsx index e7482deaa2..678166117a 100644 --- a/apps/web/pages/success.tsx +++ b/apps/web/pages/success.tsx @@ -22,7 +22,7 @@ import { useEmbedNonStylesConfig, useIsBackgroundTransparent, useIsEmbed, -} from "@calcom/embed-core"; +} from "@calcom/embed-core/embed-iframe"; import { getDefaultEvent } from "@calcom/lib/defaultEvents"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { localStorage } from "@calcom/lib/webstorage"; @@ -148,7 +148,7 @@ type SuccessProps = inferSSRProps; export default function Success(props: SuccessProps) { const { t } = useLocale(); const router = useRouter(); - const { location: _location, name, reschedule } = router.query; + const { location: _location, name, reschedule, listingStatus, status } = router.query; const location = Array.isArray(_location) ? _location[0] : _location; const [is24h, setIs24h] = useState(isBrowserLocale24h()); const { data: session } = useSession(); @@ -176,6 +176,7 @@ export default function Success(props: SuccessProps) { const eventName = getEventName(eventNameObject); const needsConfirmation = eventType.requiresConfirmation && reschedule != "true"; + const isCancelled = status === "CANCELLED" || status === "REJECTED"; const telemetry = useTelemetry(); useEffect(() => { telemetry.withJitsu((jitsu) => @@ -238,6 +239,9 @@ export default function Success(props: SuccessProps) { function getTitle(): string { const titleSuffix = props.recurringBookings ? "_recurring" : ""; + if (isCancelled) { + return t("emailed_information_about_cancelled_event"); + } if (needsConfirmation) { if (props.profile.name !== null) { return t("user_needs_to_confirm_or_reject_booking" + titleSuffix, { @@ -298,25 +302,31 @@ export default function Success(props: SuccessProps) {
{giphyImage && !needsConfirmation && ( // eslint-disable-next-line @next/next/no-img-element {"Gif )} - {!giphyImage && !needsConfirmation && ( + {!giphyImage && !needsConfirmation && !isCancelled && ( )} - {needsConfirmation && } + {needsConfirmation && !isCancelled && ( + + )} + {isCancelled && }
-
{t("who")}
-
- {bookingInfo?.user && ( -
-

{bookingInfo.user.name}

-

{bookingInfo.user.email}

+ {(bookingInfo?.user || bookingInfo?.attendees) && ( + <> +
{t("who")}
+
+ {bookingInfo?.user && ( +
+

{bookingInfo.user.name}

+

{bookingInfo.user.email}

+
+ )} + {bookingInfo?.attendees.map((attendee, index) => ( +
+

{attendee.name}

+

{attendee.email}

+
+ ))}
- )} - {bookingInfo?.attendees.map((attendee, index) => ( -
-

{attendee.name}

-

{attendee.email}

-
- ))} -
+ + )} {location && ( <> -
{t("where")}
- {!needsConfirmation && + !isCancelled && (!isCancellationMode ? (
@@ -424,7 +440,7 @@ export default function Success(props: SuccessProps) { theme={userIsOwner ? "light" : props.profile.theme} /> ))} - {userIsOwner && !needsConfirmation && !isCancellationMode && ( + {userIsOwner && !needsConfirmation && !isCancellationMode && !isCancelled && (
{t("add_to_calendar")} @@ -590,6 +606,7 @@ type RecurringBookingsProps = { recurringBookings: SuccessProps["recurringBookings"]; date: dayjs.Dayjs; is24h: boolean; + listingStatus: string; }; function RecurringBookings({ @@ -597,10 +614,11 @@ function RecurringBookings({ eventType, recurringBookings, date, + listingStatus, }: RecurringBookingsProps) { const [moreEventsVisible, setMoreEventsVisible] = useState(false); const { t } = useLocale(); - return !isReschedule && recurringBookings ? ( + return !isReschedule && recurringBookings && listingStatus === "upcoming" ? ( <> {eventType.recurringEvent?.count && recurringBookings.slice(0, 4).map((dateStr, idx) => ( @@ -636,7 +654,7 @@ function RecurringBookings({ )} - ) : !eventType.recurringEvent.freq ? ( + ) : ( <> {date.format("MMMM DD, YYYY")}
@@ -645,7 +663,7 @@ function RecurringBookings({ ({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
- ) : null; + ); } const getEventTypesFromDB = async (id: number) => { diff --git a/apps/web/pages/team/[slug].tsx b/apps/web/pages/team/[slug].tsx index 90a3ba4d6f..2fb6d62576 100644 --- a/apps/web/pages/team/[slug].tsx +++ b/apps/web/pages/team/[slug].tsx @@ -5,8 +5,8 @@ import { GetServerSidePropsContext } from "next"; import Link from "next/link"; import React, { useEffect } from "react"; -import { useIsEmbed } from "@calcom/embed-core"; -import { WEBSITE_URL } from "@calcom/lib/constants"; +import { useIsEmbed } from "@calcom/embed-core/embed-iframe"; +import { CAL_URL } from "@calcom/lib/constants"; import Button from "@calcom/ui/Button"; import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar"; @@ -23,7 +23,6 @@ import { HeadSeo } from "@components/seo/head-seo"; import Team from "@components/team/screens/Team"; import Avatar from "@components/ui/Avatar"; import AvatarGroup from "@components/ui/AvatarGroup"; -import Text from "@components/ui/Text"; export type TeamPageProps = inferSSRProps; function TeamPage({ team }: TeamPageProps) { @@ -68,7 +67,7 @@ function TeamPage({ team }: TeamPageProps) { size={10} items={type.users.map((user) => ({ alt: user.name || "", - image: WEBSITE_URL + "/" + user.username + "/avatar.png" || "", + image: CAL_URL + "/" + user.username + "/avatar.png" || "", }))} />
@@ -93,12 +92,8 @@ function TeamPage({ team }: TeamPageProps) { imageSrc={getPlaceholderAvatar(team.logo, team.name)} className="mx-auto mb-4 h-20 w-20 rounded-full" /> - - {teamName} - - - {team.bio} - +

{teamName}

+

{team.bio}

{(showMembers.isOn || !team.eventTypes.length) && } {!showMembers.isOn && team.eventTypes.length > 0 && ( @@ -147,7 +142,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => ...type, users: type.users.map((user) => ({ ...user, - avatar: WEBSITE_URL + "/" + user.username + "/avatar.png", + avatar: CAL_URL + "/" + user.username + "/avatar.png", })), })); diff --git a/apps/web/pages/team/[slug]/[type].tsx b/apps/web/pages/team/[slug]/[type].tsx index 1034f403d8..4d7dd6a637 100644 --- a/apps/web/pages/team/[slug]/[type].tsx +++ b/apps/web/pages/team/[slug]/[type].tsx @@ -7,6 +7,7 @@ import { RecurringEvent } from "@calcom/types/Calendar"; import { asStringOrNull } from "@lib/asStringOrNull"; import { getWorkingHours } from "@lib/availability"; import getBooking, { GetBookingType } from "@lib/getBooking"; +import { locationHiddenFilter, LocationObject } from "@lib/location"; import prisma from "@lib/prisma"; import { inferSSRProps } from "@lib/types/inferSSRProps"; @@ -71,11 +72,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => beforeEventBuffer: true, afterEventBuffer: true, recurringEvent: true, + locations: true, price: true, currency: true, timeZone: true, slotInterval: true, metadata: true, + seatsPerTimeSlot: true, schedule: { select: { timeZone: true, @@ -106,11 +109,14 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => eventType.schedule = null; + const locations = eventType.locations ? (eventType.locations as LocationObject[]) : []; + const eventTypeObject = Object.assign({}, eventType, { metadata: (eventType.metadata || {}) as JSONObject, periodStartDate: eventType.periodStartDate?.toString() ?? null, periodEndDate: eventType.periodEndDate?.toString() ?? null, recurringEvent: (eventType.recurringEvent || {}) as RecurringEvent, + locations: locationHiddenFilter(locations), }); eventTypeObject.availability = []; diff --git a/apps/web/pages/team/[slug]/book.tsx b/apps/web/pages/team/[slug]/book.tsx index e3978abe1b..b3c496eb4c 100644 --- a/apps/web/pages/team/[slug]/book.tsx +++ b/apps/web/pages/team/[slug]/book.tsx @@ -50,6 +50,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { price: true, currency: true, metadata: true, + seatsPerTimeSlot: true, team: { select: { slug: true, diff --git a/apps/web/playwright/booking-pages.test.ts b/apps/web/playwright/booking-pages.test.ts index f1bc7090c0..700807a2c1 100644 --- a/apps/web/playwright/booking-pages.test.ts +++ b/apps/web/playwright/booking-pages.test.ts @@ -94,8 +94,8 @@ test.describe("pro user", () => { await pro.login(); await page.goto("/bookings/upcoming"); - await page.locator('[data-testid="reschedule"]').nth(0).click(); - await page.locator('[data-testid="edit"]').click(); + await page.locator('[data-testid="edit_booking"]').nth(0).click(); + await page.locator('[data-testid="reschedule"]').click(); await page.waitForNavigation({ url: (url) => { const bookingId = url.searchParams.get("rescheduleUid"); diff --git a/apps/web/playwright/dynamic-booking-pages.test.ts b/apps/web/playwright/dynamic-booking-pages.test.ts index b2990654f7..b57b5cb3b9 100644 --- a/apps/web/playwright/dynamic-booking-pages.test.ts +++ b/apps/web/playwright/dynamic-booking-pages.test.ts @@ -35,8 +35,8 @@ test.describe("dynamic booking", () => { // Logged in await page.goto("/bookings/upcoming"); - await page.locator('[data-testid="reschedule"]').nth(0).click(); - await page.locator('[data-testid="edit"]').click(); + await page.locator('[data-testid="edit_booking"]').nth(0).click(); + await page.locator('[data-testid="reschedule"]').click(); await page.waitForNavigation({ url: (url) => { const bookingId = url.searchParams.get("rescheduleUid"); diff --git a/apps/web/playwright/reschedule.test.ts b/apps/web/playwright/reschedule.test.ts index 5bf89893ef..15ef383890 100644 --- a/apps/web/playwright/reschedule.test.ts +++ b/apps/web/playwright/reschedule.test.ts @@ -28,7 +28,7 @@ test.describe("Reschedule Tests", async () => { await user.login(); await page.goto("/bookings/upcoming"); - await page.locator('[data-testid="reschedule"]').nth(0).click(); + await page.locator('[data-testid="edit_booking"]').nth(0).click(); await page.locator('[data-testid="reschedule_request"]').click(); @@ -87,7 +87,7 @@ test.describe("Reschedule Tests", async () => { await expect(page.locator('[name="name"]')).toBeDisabled(); await expect(page.locator('[name="email"]')).toBeDisabled(); - await expect(page.locator('[name="notes"]')).toBeDisabled(); + await expect(page.locator('[name="rescheduleReason"]')).toBeDisabled(); await page.locator('[data-testid="confirm-reschedule-button"]').click(); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 65a17c061c..e816e4264a 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -435,6 +435,7 @@ "in_person_meeting": "In-person meeting", "link_meeting": "Link meeting", "phone_call": "Phone call", + "your_number": "Your phone number", "phone_number": "Phone Number", "attendee_phone_number": "Attendee Phone Number", "host_phone_number": "Your Phone Number", @@ -628,7 +629,6 @@ "calendar_days": "calendar days", "business_days": "business days", "set_address_place": "Set an address or place", - "set_your_phone_number": "Set a phone number for the meeting", "set_link_meeting": "Set a link to the meeting", "cal_invitee_phone_number_scheduling": "Cal will ask your invitee to enter a phone number before scheduling.", "cal_provide_google_meet_location": "Cal will provide a Google Meet location.", @@ -646,7 +646,6 @@ "into_the_future": "into the future", "within_date_range": "Within a date range", "indefinitely_into_future": "Indefinitely into the future", - "this_input_will_shown_booking_this_event": "This input will be shown when booking this event", "add_new_custom_input_field": "Add new custom input field", "quick_chat": "Quick Chat", "add_new_team_event_type": "Add a new team event type", @@ -767,7 +766,13 @@ "external_redirect_url": "https://example.com/redirect-to-my-success-page", "redirect_url_upgrade_description": "In order to use this feature, you need to upgrade to a Pro account.", "duplicate": "Duplicate", + "offer_seats": "Offer seats", + "offer_seats_description": "Offer seats to bookings (This disables guests & opt in bookings)", + "seats_available": "Seats available", + "number_of_seats": "Number of seats per booking", + "enter_number_of_seats": "Enter number of seats", "you_can_manage_your_schedules": "You can manage your schedules on the Availability page.", + "booking_full": "No more seats available", "api_keys": "API Keys", "api_key_modal_subtitle": "API keys allow you to make API calls for your own account.", "api_keys_subtitle": "Generate API keys to use for accessing your own account.", @@ -809,16 +814,27 @@ "former_time": "Former time", "confirmation_page_gif": "Gif for confirmation page", "search": "Search", - "impersonate":"Impersonate", - "impersonate_user_tip":"All uses of this feature is audited.", - "impersonating_user_warning":"Impersonating username \"{{user}}\".", + "impersonate": "Impersonate", + "user_impersonation_heading":"User Impersonation", + "user_impersonation_description":"Allows our support team to temporarily sign in as you to help us quickly resolve any issues you report to us.", + "impersonate_user_tip": "All uses of this feature is audited.", + "impersonating_user_warning": "Impersonating username \"{{user}}\".", "impersonating_stop_instructions": "<0>Click Here to stop.", - "email_validation_error":"That doesn't look like an email address", + "event_location_changed": "Updated - Your event changed the location", + "location_changed_event_type_subject": "Location Changed: {{eventType}} with {{name}} at {{date}}", + "current_location": "Current Location", + "user_phone": "Your phone number", + "new_location": "New Location", + "no_location": "No location defined", + "set_location": "Set Location", + "update_location": "Update Location", + "location_updated": "Location updated", + "email_validation_error": "That doesn't look like an email address", "place_where_cal_widget_appear": "Place this code in your HTML where you want your Cal widget to appear.", "copy_code": "Copy Code", "code_copied": "Code copied!", - "how_you_want_add_cal_site":"How do you want to add Cal to your site?", - "choose_ways_put_cal_site":"Choose one of the following ways to put Cal on your site.", + "how_you_want_add_cal_site": "How do you want to add Cal to your site?", + "choose_ways_put_cal_site": "Choose one of the following ways to put Cal on your site.", "setting_up_zapier": "Setting up your Zapier integration", "generate_api_key": "Generate Api Key", "your_unique_api_key": "Your unique API key", @@ -827,11 +843,37 @@ "install_zapier_app": "Please first install the Zapier App in the app store.", "go_to_app_store": "Go to App Store", "calendar_error": "Something went wrong, try reconnecting your calendar with all necessary permissions", + "set_your_phone_number": "Set a phone number for the meeting", "calendar_no_busy_slots": "There are no busy slots", + "add_gif": "Add GIF", + "search_giphy": "Search Giphy", + "add_link_from_giphy": "Add link from Giphy", + "add_gif_to_confirmation": "Adding a GIF to confirmation page", + "find_gif_spice_confirmation": "Find GIF to spice up your confirmation page", + "display_location_label":"Display on booking page", + "display_location_info_badge":"Location will be visible before the booking is confirmed", + "share_feedback": "Share feedback", + "resources": "Resources", + "support_documentation": "Support documentation", + "developer_documentation": "Developer Documentation", + "get_in_touch": "Get in touch", + "contact_support": "Contact Support", + "feedback": "Feedback", + "submitted_feedback": "Thank you for your feedback!", + "feedback_error": "Error sending feedback", + "comments": "Comments", "booking_details": "Booking details", "or_lowercase": "or", "nevermind": "Nevermind", "go_to": "Go to: ", "zapier_invite_link": "Zapier Invite Link", - "meeting_url_provided_after_confirmed":"A Meeting URL will be created once the event is confirmed." + "meeting_url_provided_after_confirmed":"A Meeting URL will be created once the event is confirmed.", + "reschedule_optional": "Reason for rescheduling (optional)", + "reschedule_placeholder": "Let others know why you need to reschedule", + "event_cancelled":"This event is cancelled", + "emailed_information_about_cancelled_event": "We emailed you and the other attendees to let them know.", + "this_input_will_shown_booking_this_event": "This input will be shown when booking this event", + "meeting_url_in_conformation_email": "Meeting url is in the confirmation email", + "url_start_with_https": "URL needs to start with http:// or https://", + "number_provided": "Phone number will be provided" } diff --git a/apps/web/server/createContext.ts b/apps/web/server/createContext.ts index 6b4c6cfdc7..c57f1bbe97 100644 --- a/apps/web/server/createContext.ts +++ b/apps/web/server/createContext.ts @@ -44,6 +44,7 @@ async function getUserFromSession({ hideBranding: true, avatar: true, twoFactorEnabled: true, + disableImpersonation: true, identityProvider: true, brandColor: true, darkBrandColor: true, diff --git a/apps/web/server/routers/viewer.tsx b/apps/web/server/routers/viewer.tsx index f32a5d5f1d..9fbae933ca 100644 --- a/apps/web/server/routers/viewer.tsx +++ b/apps/web/server/routers/viewer.tsx @@ -1,29 +1,34 @@ import { BookingStatus, MembershipRole, Prisma } from "@prisma/client"; +import dayjs from "dayjs"; import _ from "lodash"; import { JSONObject } from "superjson/dist/types"; import { z } from "zod"; -import getApps from "@calcom/app-store/utils"; +import getApps, { getLocationOptions } from "@calcom/app-store/utils"; import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager"; import { checkPremiumUsername } from "@calcom/ee/lib/core/checkPremiumUsername"; import { bookingMinimalSelect } from "@calcom/prisma"; import { RecurringEvent } from "@calcom/types/Calendar"; import { checkRegularUsername } from "@lib/core/checkRegularUsername"; +import { sendFeedbackEmail } from "@lib/emails/email-manager"; import jackson from "@lib/jackson"; +import prisma from "@lib/prisma"; import { - isSAMLLoginEnabled, - samlTenantID, - samlProductID, - isSAMLAdmin, hostedCal, - tenantPrefix, + isSAMLAdmin, + isSAMLLoginEnabled, + samlProductID, + samlTenantID, samlTenantProduct, + tenantPrefix, } from "@lib/saml"; import slugify from "@lib/slugify"; +import { getTranslation } from "@server/lib/i18n"; import { apiKeysRouter } from "@server/routers/viewer/apiKeys"; import { availabilityRouter } from "@server/routers/viewer/availability"; +import { bookingsRouter } from "@server/routers/viewer/bookings"; import { eventTypesRouter } from "@server/routers/viewer/eventTypes"; import { TRPCError } from "@trpc/server"; @@ -84,6 +89,7 @@ const loggedInViewerRouter = createProtectedRouter() trialEndsAt: user.trialEndsAt, completedOnboarding: user.completedOnboarding, twoFactorEnabled: user.twoFactorEnabled, + disableImpersonation: user.disableImpersonation, identityProvider: user.identityProvider, brandColor: user.brandColor, darkBrandColor: user.darkBrandColor, @@ -679,6 +685,7 @@ const loggedInViewerRouter = createProtectedRouter() completedOnboarding: z.boolean().optional(), locale: z.string().optional(), timeFormat: z.number().optional(), + disableImpersonation: z.boolean().optional(), }), async resolve({ input, ctx }) { const { user, prisma } = ctx; @@ -891,11 +898,62 @@ const loggedInViewerRouter = createProtectedRouter() throw new TRPCError({ code: "BAD_REQUEST" }); } }, + }) + .mutation("submitFeedback", { + input: z.object({ + rating: z.string(), + comment: z.string(), + }), + async resolve({ input, ctx }) { + const { rating, comment } = input; + + const feedback = { + userId: ctx.user.id, + rating: rating, + comment: comment, + }; + + await ctx.prisma.feedback.create({ + data: { + date: dayjs().toISOString(), + userId: ctx.user.id, + rating: rating, + comment: comment, + }, + }); + + if (process.env.SEND_FEEDBACK_EMAIL && comment) sendFeedbackEmail(feedback); + }, + }) + .query("locationOptions", { + async resolve({ ctx }) { + const credentials = await prisma.credential.findMany({ + where: { + userId: ctx.user.id, + }, + select: { + id: true, + type: true, + key: true, + userId: true, + appId: true, + }, + }); + + const integrations = getApps(credentials); + + const t = await getTranslation(ctx.user.locale ?? "en", "common"); + + const locationOptions = getLocationOptions(integrations, t); + + return locationOptions; + }, }); export const viewerRouter = createRouter() .merge(publicViewerRouter) .merge(loggedInViewerRouter) + .merge("bookings.", bookingsRouter) .merge("eventTypes.", eventTypesRouter) .merge("availability.", availabilityRouter) .merge("teams.", viewerTeamsRouter) diff --git a/apps/web/server/routers/viewer/bookings.tsx b/apps/web/server/routers/viewer/bookings.tsx new file mode 100644 index 0000000000..0437e04dfd --- /dev/null +++ b/apps/web/server/routers/viewer/bookings.tsx @@ -0,0 +1,154 @@ +import { SchedulingType } from "@prisma/client"; +import dayjs from "dayjs"; +import { z } from "zod"; + +import EventManager from "@calcom/core/EventManager"; +import logger from "@calcom/lib/logger"; +import { getTranslation } from "@calcom/lib/server/i18n"; +import type { AdditionInformation, CalendarEvent } from "@calcom/types/Calendar"; + +import { sendLocationChangeEmails } from "@lib/emails/email-manager"; + +import { createProtectedRouter } from "@server/createRouter"; +import { TRPCError } from "@trpc/server"; + +// Common data for all endpoints under webhook +const commonBookingSchema = z.object({ + bookingId: z.number(), +}); + +export const bookingsRouter = createProtectedRouter() + .middleware(async ({ ctx, rawInput, next }) => { + // Endpoints that just read the logged in user's data - like 'list' don't necessary have any input + if (!rawInput) return next({ ctx: { ...ctx, booking: null } }); + + const webhookIdAndEventTypeId = commonBookingSchema.safeParse(rawInput); + if (!webhookIdAndEventTypeId.success) throw new TRPCError({ code: "PARSE_ERROR" }); + + const { bookingId } = webhookIdAndEventTypeId.data; + const booking = await ctx.prisma.booking.findFirst({ + where: { + OR: [ + /* If user is organizer */ + { userId: ctx.user.id, id: bookingId }, + /* Or part of a collective booking */ + { + eventType: { + schedulingType: SchedulingType.COLLECTIVE, + users: { + some: { + id: ctx.user.id, + }, + }, + }, + }, + ], + }, + include: { + attendees: true, + eventType: true, + user: { + include: { destinationCalendar: true }, + }, + destinationCalendar: true, + }, + }); + return next({ ctx: { ...ctx, booking } }); + }) + .middleware(async ({ ctx, next }) => { + // So TS doesn't compain in the previous middleware. + // This means the user doesn't have access to this booking + if (!ctx.booking) throw new TRPCError({ code: "UNAUTHORIZED" }); + // Booking here is non-nullable anymore + return next({ ctx: { ...ctx, booking: ctx.booking } }); + }) + .mutation("editLocation", { + input: commonBookingSchema.extend({ + newLocation: z.string(), + }), + async resolve({ ctx, input }) { + const { bookingId, newLocation: location } = input; + const { booking } = ctx; + + try { + await ctx.prisma.booking.update({ + where: { id: bookingId }, + data: { location }, + }); + + const organizer = await ctx.prisma.user.findFirst({ + where: { + id: booking.userId || 0, + }, + select: { + name: true, + email: true, + timeZone: true, + locale: true, + }, + rejectOnNotFound: true, + }); + + const tOrganizer = await getTranslation(organizer.locale ?? "en", "common"); + + const attendeesListPromises = booking.attendees.map(async (attendee) => { + return { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: { + translate: await getTranslation(attendee.locale ?? "en", "common"), + locale: attendee.locale ?? "en", + }, + }; + }); + + const attendeesList = await Promise.all(attendeesListPromises); + + const evt: CalendarEvent = { + title: booking.title || "", + type: (booking.eventType?.title as string) || booking?.title || "", + description: booking.description || "", + startTime: booking.startTime ? dayjs(booking.startTime).format() : "", + endTime: booking.endTime ? dayjs(booking.endTime).format() : "", + organizer: { + email: organizer.email, + name: organizer.name ?? "Nameless", + timeZone: organizer.timeZone, + language: { translate: tOrganizer, locale: organizer.locale ?? "en" }, + }, + attendees: attendeesList, + uid: booking.uid, + location, + destinationCalendar: booking?.destinationCalendar || booking?.user?.destinationCalendar, + }; + + const eventManager = new EventManager(ctx.user); + const scheduleResult = await eventManager.create(evt); + + const results = scheduleResult.results; + if (results.length > 0 && results.every((res) => !res.success)) { + const error = { + errorCode: "BookingUpdateLocationFailed", + message: "Updating location failed", + }; + logger.error(`Booking ${ctx.user.username} failed`, error, results); + } else { + const metadata: AdditionInformation = {}; + if (results.length) { + metadata.hangoutLink = results[0].createdEvent?.hangoutLink; + metadata.conferenceData = results[0].createdEvent?.conferenceData; + metadata.entryPoints = results[0].createdEvent?.entryPoints; + } + try { + await sendLocationChangeEmails({ ...evt, additionInformation: metadata }); + } catch (error) { + console.log("Error sending LocationChangeEmails"); + } + } + } catch { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); + } + return { message: "Location updated" }; + }, + }); diff --git a/apps/web/server/routers/viewer/webhook.tsx b/apps/web/server/routers/viewer/webhook.tsx index feb0934bea..2b0991a25f 100644 --- a/apps/web/server/routers/viewer/webhook.tsx +++ b/apps/web/server/routers/viewer/webhook.tsx @@ -9,8 +9,64 @@ import sendPayload from "@lib/webhooks/sendPayload"; import { createProtectedRouter } from "@server/createRouter"; import { getTranslation } from "@server/lib/i18n"; +import { TRPCError } from "@trpc/server"; + +// Common data for all endpoints under webhook +const webhookIdAndEventTypeIdSchema = z.object({ + // Webhook ID + id: z.string().optional(), + // Event type ID + eventTypeId: z.number().optional(), +}); export const webhookRouter = createProtectedRouter() + .middleware(async ({ ctx, rawInput, next }) => { + // Endpoints that just read the logged in user's data - like 'list' don't necessary have any input + if (!rawInput) { + return next(); + } + const webhookIdAndEventTypeId = webhookIdAndEventTypeIdSchema.safeParse(rawInput); + if (!webhookIdAndEventTypeId.success) { + throw new TRPCError({ code: "PARSE_ERROR" }); + } + const { eventTypeId, id } = webhookIdAndEventTypeId.data; + + // A webhook is either linked to Event Type or to a user. + if (eventTypeId) { + const team = await ctx.prisma.team.findFirst({ + where: { + eventTypes: { + some: { + id: eventTypeId, + }, + }, + }, + include: { + members: true, + }, + }); + + // Team should be available and the user should be a member of the team + if (!team?.members.some((membership) => membership.userId === ctx.user.id)) { + throw new TRPCError({ + code: "UNAUTHORIZED", + }); + } + } else if (id) { + const authorizedHook = await ctx.prisma.webhook.findFirst({ + where: { + id: id, + userId: ctx.user.id, + }, + }); + if (!authorizedHook) { + throw new TRPCError({ + code: "UNAUTHORIZED", + }); + } + } + return next(); + }) .query("list", { input: z .object({ @@ -42,58 +98,22 @@ export const webhookRouter = createProtectedRouter() eventTypeId: z.number().optional(), appId: z.string().optional().nullable(), }), - async resolve({ ctx, input: { eventTypeId, ...input } }) { - const webhookCreateInput: Prisma.WebhookCreateInput = { - id: v4(), - ...input, - }; - const webhookPayload = { webhooks: { create: webhookCreateInput } }; - let teamId = -1; - if (eventTypeId) { - /* [1] If an eventType is provided, we find the team were it belongs */ - const team = await ctx.prisma.team.findFirst({ - rejectOnNotFound: true, - where: { eventTypes: { some: { id: eventTypeId } } }, - select: { id: true }, + async resolve({ ctx, input }) { + if (input.eventTypeId) { + return await ctx.prisma.webhook.create({ + data: { + id: v4(), + ...input, + }, }); - /* [2] We save the id for later use */ - teamId = team.id; } - await ctx.prisma.user.update({ - where: { id: ctx.user.id }, - /** - * [3] Right now only team eventTypes can have webhooks so we make sure the - * user adding the webhook belongs to the team. - */ - data: eventTypeId - ? { - teams: { - update: { - /* [3.1] Here we make sure the requesting user belongs to the team */ - where: { userId_teamId: { teamId, userId: ctx.user.id } }, - data: { - team: { - update: { - eventTypes: { - update: { - where: { id: eventTypeId }, - data: webhookPayload, - }, - }, - }, - }, - }, - }, - }, - } - : /* [4] If there's no eventTypeId we create it to the current user instead. */ - webhookPayload, + return await ctx.prisma.webhook.create({ + data: { + id: v4(), + userId: ctx.user.id, + ...input, + }, }); - const webhook = await ctx.prisma.webhook.findUnique({ - rejectOnNotFound: true, - where: { id: webhookCreateInput.id }, - }); - return webhook; }, }) .mutation("edit", { diff --git a/apps/website b/apps/website index 100e69ab17..22ad56ec94 160000 --- a/apps/website +++ b/apps/website @@ -1 +1 @@ -Subproject commit 100e69ab175b7a8c6c2bf564ce757b10ff74292e +Subproject commit 22ad56ec9413ff5ee057fc402d999cdcbca1936a diff --git a/package.json b/package.json index bb0d14e792..01c230adbb 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,11 @@ "db-studio": "yarn workspace @calcom/prisma db-studio", "deploy": "turbo run deploy", "dev": "turbo run dev --scope=\"@calcom/web\"", - "dev:website": "yarn predev && turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/website\"", - "dev:api": "yarn predev && turbo run dev --scope=\"@calcom/api\"", - "dev:swagger": "yarn predev && turbo run dev --scope=\"@calcom/api\" --scope=\"@calcom/swagger\"", + "dev:all": "yarn predev && turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/website\" --scope=\"@calcom/console\"", + "dev:api": "yarn predev && turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/api\"", "dev:console": "yarn predev && turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/console\"", + "dev:swagger": "yarn predev && turbo run dev --scope=\"@calcom/api\" --scope=\"@calcom/swagger\"", + "dev:website": "yarn predev && turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/website\"", "docs-dev": "yarn predev && turbo run dev --scope=\"@calcom/docs\"", "docs-build": "turbo run build --scope=\"@calcom/docs\" --include-dependencies", "docs-start": "turbo run start --scope=\"@calcom/docs\"", @@ -40,7 +41,8 @@ "embed-tests-quick": "turbo run embed-tests-quick", "embed-tests": "turbo run embed-tests", "test-e2e": "turbo run test-e2e --concurrency=1", - "type-check": "turbo run type-check" + "type-check": "turbo run type-check", + "embed-tests-prepare": "yarn workspace @calcom/prisma db-reset && yarn build" }, "devDependencies": { "dotenv-checker": "^1.1.5", diff --git a/packages/app-store/giphy/api/get.ts b/packages/app-store/giphy/api/get.ts new file mode 100644 index 0000000000..2afd608615 --- /dev/null +++ b/packages/app-store/giphy/api/get.ts @@ -0,0 +1,64 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { z, ZodError } from "zod"; + +import prisma from "@calcom/prisma"; + +import { GiphyManager } from "../lib"; + +const giphyUrlRegexp = new RegExp("^https://(.*).giphy.com/media/(.*)/giphy.gif(.*)"); + +const getSchema = z.object({ + url: z.string().regex(giphyUrlRegexp, "Giphy URL is invalid"), +}); + +/** + * This is an example endpoint for an app, these will run under `/api/integrations/[...args]` + * @param req + * @param res + */ +async function handler(req: NextApiRequest, res: NextApiResponse) { + const userId = req.session?.user?.id; + if (!userId) { + return res.status(401).json({ message: "You must be logged in to do this" }); + } + try { + const { url } = req.body; + const parsedUrl = new URL(url.replace(/ /g, "")); + // remove query strings if any that could cause trouble in parsing ID from url + const sanitisedUrl = parsedUrl.origin + parsedUrl.pathname; + // Extract Giphy ID from embed url + const matches = giphyUrlRegexp.exec(sanitisedUrl); + if (!matches || matches.length < 3) { + return res.status(400).json({ message: "Giphy URL is invalid" }); + } + const giphyId = matches[2]; + const gifImageUrl = await GiphyManager.getGiphyById(giphyId); + return res.status(200).json({ image: gifImageUrl }); + } catch (error: unknown) { + console.error({ error }); + if (error instanceof Error) { + return res.status(500).json({ message: error.message }); + } + return res.status(500); + } +} + +function validate(handler: (req: NextApiRequest, res: NextApiResponse) => Promise) { + return async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method === "POST") { + try { + getSchema.parse(req.body); + } catch (error) { + if (error instanceof ZodError && error?.name === "ZodError") { + return res.status(400).json(error?.issues); + } + return res.status(402); + } + } else { + return res.status(405); + } + await handler(req, res); + }; +} + +export default validate(handler); diff --git a/packages/app-store/giphy/api/index.ts b/packages/app-store/giphy/api/index.ts index 8a66776473..c8140ff5bb 100644 --- a/packages/app-store/giphy/api/index.ts +++ b/packages/app-store/giphy/api/index.ts @@ -1,2 +1,3 @@ export { default as add } from "./add"; export { default as search } from "./search"; +export { default as get } from "./get"; diff --git a/packages/app-store/giphy/api/search.ts b/packages/app-store/giphy/api/search.ts index 58d3b9d82b..f255447bc0 100644 --- a/packages/app-store/giphy/api/search.ts +++ b/packages/app-store/giphy/api/search.ts @@ -32,8 +32,12 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { }); const locale = user?.locale || "en"; const { keyword, offset } = req.body; - const gifImageUrl = await GiphyManager.searchGiphy(locale, keyword, offset); - return res.status(200).json({ image: gifImageUrl }); + const { gifImageUrl, total } = await GiphyManager.searchGiphy(locale, keyword, offset); + return res.status(200).json({ + image: gifImageUrl, + // rotate results to 0 offset when no more gifs + nextOffset: total === offset + 1 ? 0 : offset + 1, + }); } catch (error: unknown) { if (error instanceof Error) { return res.status(500).json({ message: error.message }); diff --git a/packages/app-store/giphy/components/SearchDialog.tsx b/packages/app-store/giphy/components/SearchDialog.tsx index 600556712e..3105ca7b1b 100644 --- a/packages/app-store/giphy/components/SearchDialog.tsx +++ b/packages/app-store/giphy/components/SearchDialog.tsx @@ -1,13 +1,12 @@ -import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid"; +import { SearchIcon, LinkIcon } from "@heroicons/react/outline"; import { useState } from "react"; import { Dispatch, SetStateAction } from "react"; +import classNames from "@calcom/lib/classNames"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Alert } from "@calcom/ui/Alert"; import Button from "@calcom/ui/Button"; -import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog"; -import { TextField } from "@calcom/ui/form/fields"; -import Loader from "@calcom/web/components/Loader"; +import { Dialog, DialogClose, DialogContent, DialogFooter } from "@calcom/ui/Dialog"; interface ISearchDialog { isOpenDialog: boolean; @@ -15,14 +14,19 @@ interface ISearchDialog { onSave: (url: string) => void; } +const MODE_SEARCH = "search" as const; +const MODE_URL = "url" as const; +type Mode = typeof MODE_SEARCH | typeof MODE_URL; + export const SearchDialog = (props: ISearchDialog) => { const { t } = useLocale(); const [gifImage, setGifImage] = useState(""); - const [offset, setOffset] = useState(0); + const [nextOffset, setNextOffset] = useState(0); const [keyword, setKeyword] = useState(""); const { isOpenDialog, setIsOpenDialog } = props; const [isLoading, setIsLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(""); + const [selectedMode, setSelectedMode] = useState(MODE_SEARCH); const searchGiphy = async (keyword: string, offset: number) => { if (isLoading) { @@ -45,7 +49,7 @@ export const SearchDialog = (props: ISearchDialog) => { setErrorMessage(json?.message || "Something went wrong"); } else { setGifImage(json.image || ""); - setOffset(offset); + setNextOffset(json.nextOffset); if (!json.image) { setErrorMessage("No Result found"); } @@ -54,72 +58,134 @@ export const SearchDialog = (props: ISearchDialog) => { return null; }; + const getGiphyByUrl = async (url: string) => { + if (isLoading) { + return; + } + setIsLoading(true); + setErrorMessage(""); + const res = await fetch("/api/integrations/giphy/get", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + url, + }), + }); + const json = await res.json(); + if (!res.ok) { + setErrorMessage(json?.message || json?.[0]?.message || "Something went wrong"); + } else { + setGifImage(json.image || ""); + if (!json.image) { + setErrorMessage("No Result found"); + } + } + setIsLoading(false); + return null; + }; + + const renderTab = (Icon: any, text: string, mode: Mode) => ( +
{ + setKeyword(""); + setGifImage(""); + setSelectedMode(mode); + }}> + + {text} +
+ ); + + const handleFormSubmit = async (event: React.SyntheticEvent) => { + event.stopPropagation(); + event.preventDefault(); + if (selectedMode === MODE_SEARCH) { + searchGiphy(keyword, 0); + } else if (selectedMode === MODE_URL) { + getGiphyByUrl(keyword); + } + }; + return ( - - -
- { - setKeyword(event.target.value); - }} - name="search" - type="text" - className="mt-2" - labelProps={{ style: { display: "none" } }} - placeholder="Search Giphy" - /> - -
+ {gifImage && (
- {isLoading ? ( - - ) : ( - <> -
- {`Gif +
+ {isLoading ? ( +
+ + + +
-
- -
- - )} + ) : ( + {`Gif + )} +
)} {errorMessage && } + {gifImage && selectedMode === MODE_SEARCH && ( +
+
Not the perfect GIF?
+ +
+ )} { @@ -133,16 +199,16 @@ export const SearchDialog = (props: ISearchDialog) => { diff --git a/packages/app-store/giphy/components/SelectGifInput.tsx b/packages/app-store/giphy/components/SelectGifInput.tsx index ffb3de11b3..739aa1f00e 100644 --- a/packages/app-store/giphy/components/SelectGifInput.tsx +++ b/packages/app-store/giphy/components/SelectGifInput.tsx @@ -1,4 +1,4 @@ -import { SearchIcon, TrashIcon } from "@heroicons/react/solid"; +import { PlusIcon, PencilAltIcon, XIcon } from "@heroicons/react/solid"; import { useState } from "react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -18,19 +18,26 @@ export default function SelectGifInput(props: ISelectGifInput) { return (
{selectedGif && ( -
+
{"Selected
)}
- + {selectedGif ? ( + + ) : ( + + )} + {selectedGif && (