diff --git a/.env.example b/.env.example index f6f6188a1b..520ce80b27 100644 --- a/.env.example +++ b/.env.example @@ -87,7 +87,7 @@ CRON_ENABLE_APP_SYNC=false # Application Key for symmetric encryption and decryption # must be 32 bytes for AES256 encryption algorithm -# You can use: `openssl rand -base64 24` to generate one +# You can use: `openssl rand -base64 32` to generate one CALENDSO_ENCRYPTION_KEY= # Intercom Config diff --git a/apps/ai/src/app/api/receive/route.ts b/apps/ai/src/app/api/receive/route.ts index 63d6e5d0e4..68bbc51168 100644 --- a/apps/ai/src/app/api/receive/route.ts +++ b/apps/ai/src/app/api/receive/route.ts @@ -88,7 +88,7 @@ export const POST = async (request: NextRequest) => { // User is not a cal.com user or is using an unverified email. if (!signature || !user) { await sendEmail({ - html: `Thanks for your interest in Cal.ai! To get started, Make sure you have a cal.com account with this email address.`, + html: `Thanks for your interest in Cal.ai! To get started, Make sure you have a cal.com account with this email address and then install Cal.ai here: go.cal.com/ai.`, subject: `Re: ${subject}`, text: `Thanks for your interest in Cal.ai! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`, to: envelope.from, diff --git a/apps/api/lib/validations/webhook.ts b/apps/api/lib/validations/webhook.ts index 91d8560195..71219d2fa0 100644 --- a/apps/api/lib/validations/webhook.ts +++ b/apps/api/lib/validations/webhook.ts @@ -20,6 +20,7 @@ export const schemaWebhookCreateParams = z payloadTemplate: z.string().optional().nullable(), eventTypeId: z.number().optional(), userId: z.number().optional(), + secret: z.string().optional().nullable(), // API shouldn't mess with Apps webhooks yet (ie. Zapier) // appId: z.string().optional().nullable(), }) @@ -31,6 +32,7 @@ export const schemaWebhookEditBodyParams = schemaWebhookBaseBodyParams .merge( z.object({ eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array().optional(), + secret: z.string().optional().nullable(), }) ) .partial() diff --git a/apps/api/pages/api/webhooks/[id]/_patch.ts b/apps/api/pages/api/webhooks/[id]/_patch.ts index 35c2810f39..fd0f8db3f5 100644 --- a/apps/api/pages/api/webhooks/[id]/_patch.ts +++ b/apps/api/pages/api/webhooks/[id]/_patch.ts @@ -51,6 +51,9 @@ import { schemaWebhookEditBodyParams, schemaWebhookReadPublic } from "~/lib/vali * eventTypeId: * type: number * description: The event type ID if this webhook should be associated with only that event type + * secret: + * type: string + * description: The secret to verify the authenticity of the received payload * tags: * - webhooks * externalDocs: diff --git a/apps/api/pages/api/webhooks/_post.ts b/apps/api/pages/api/webhooks/_post.ts index 2a99c903e8..8c36bcbcf6 100644 --- a/apps/api/pages/api/webhooks/_post.ts +++ b/apps/api/pages/api/webhooks/_post.ts @@ -49,6 +49,9 @@ import { schemaWebhookCreateBodyParams, schemaWebhookReadPublic } from "~/lib/va * eventTypeId: * type: number * description: The event type ID if this webhook should be associated with only that event type + * secret: + * type: string + * description: The secret to verify the authenticity of the received payload * tags: * - webhooks * externalDocs: diff --git a/apps/web/components/AppListCard.tsx b/apps/web/components/AppListCard.tsx index 7252a8ffc6..2f9547fbb7 100644 --- a/apps/web/components/AppListCard.tsx +++ b/apps/web/components/AppListCard.tsx @@ -60,14 +60,18 @@ export default function AppListCard(props: AppListCardProps) { const pathname = usePathname(); useEffect(() => { - if (shouldHighlight && highlight) { - const timer = setTimeout(() => { - setHighlight(false); + if (shouldHighlight && highlight && searchParams !== null && pathname !== null) { + timeoutRef.current = setTimeout(() => { const _searchParams = new URLSearchParams(searchParams); _searchParams.delete("hl"); - router.replace(`${pathname}?${_searchParams.toString()}`); + _searchParams.delete("category"); // this comes from params, not from search params + + setHighlight(false); + + const stringifiedSearchParams = _searchParams.toString(); + + router.replace(`${pathname}${stringifiedSearchParams !== "" ? `?${stringifiedSearchParams}` : ""}`); }, 3000); - timeoutRef.current = timer; } return () => { if (timeoutRef.current) { @@ -75,8 +79,7 @@ export default function AppListCard(props: AppListCardProps) { timeoutRef.current = null; } }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [highlight, pathname, router, searchParams, shouldHighlight]); return (
diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index e3f4fa7b22..f600c6e697 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -226,6 +226,7 @@ function BookingListItem(booking: BookingItemProps) { }; const startTime = dayjs(booking.startTime) + .tz(user?.timeZone) .locale(language) .format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY"); const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false); diff --git a/apps/web/components/booking/CancelBooking.tsx b/apps/web/components/booking/CancelBooking.tsx index 76297831fa..693cd7ba34 100644 --- a/apps/web/components/booking/CancelBooking.tsx +++ b/apps/web/components/booking/CancelBooking.tsx @@ -1,4 +1,4 @@ -import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useRouter } from "next/navigation"; import { useCallback, useState } from "react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -26,9 +26,6 @@ type Props = { }; export default function CancelBooking(props: Props) { - const pathname = usePathname(); - const searchParams = useSearchParams(); - const asPath = `${pathname}?${searchParams.toString()}`; const [cancellationReason, setCancellationReason] = useState(""); const { t } = useLocale(); const router = useRouter(); @@ -44,6 +41,7 @@ export default function CancelBooking(props: Props) { } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + return ( <> {error && ( @@ -100,7 +98,8 @@ export default function CancelBooking(props: Props) { }); if (res.status >= 200 && res.status < 300) { - router.replace(asPath); + // tested by apps/web/playwright/booking-pages.e2e.ts + router.refresh(); } else { setLoading(false); setError( diff --git a/apps/web/components/eventtype/EventSetupTab.tsx b/apps/web/components/eventtype/EventSetupTab.tsx index a5a386a2d3..754f060868 100644 --- a/apps/web/components/eventtype/EventSetupTab.tsx +++ b/apps/web/components/eventtype/EventSetupTab.tsx @@ -1,27 +1,22 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { isValidPhoneNumber } from "libphonenumber-js"; +import { ErrorMessage } from "@hookform/error-message"; import { Trans } from "next-i18next"; import Link from "next/link"; import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]"; import { useEffect, useState } from "react"; -import { Controller, useForm, useFormContext } from "react-hook-form"; +import { Controller, useFormContext, useFieldArray } from "react-hook-form"; import type { MultiValue } from "react-select"; -import { z } from "zod"; import type { EventLocationType } from "@calcom/app-store/locations"; -import { getEventLocationType, MeetLocationType, LocationType } from "@calcom/app-store/locations"; +import { getEventLocationType, LocationType, MeetLocationType } from "@calcom/app-store/locations"; import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager"; import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; -import { classNames } from "@calcom/lib"; import { CAL_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import invertLogoOnDark from "@calcom/lib/invertLogoOnDark"; import { md } from "@calcom/lib/markdownIt"; import { slugify } from "@calcom/lib/slugify"; import turndown from "@calcom/lib/turndownService"; import { - Button, Label, Select, SettingsToggle, @@ -30,11 +25,16 @@ import { Editor, SkeletonContainer, SkeletonText, + Input, + PhoneInput, + Button, + showToast, } from "@calcom/ui"; -import { Edit2, Check, X, Plus } from "@calcom/ui/components/icon"; +import { Plus, X, Check } from "@calcom/ui/components/icon"; +import { CornerDownRight } from "@calcom/ui/components/icon"; -import { EditLocationDialog } from "@components/dialog/EditLocationDialog"; -import type { SingleValueLocationOption, LocationOption } from "@components/ui/form/LocationSelect"; +import CheckboxField from "@components/ui/form/CheckboxField"; +import type { SingleValueLocationOption } from "@components/ui/form/LocationSelect"; import LocationSelect from "@components/ui/form/LocationSelect"; const getLocationFromType = ( @@ -114,9 +114,6 @@ export const EventSetupTab = ( const { t } = useLocale(); const formMethods = useFormContext(); const { eventType, team, destinationCalendar } = props; - const [showLocationModal, setShowLocationModal] = useState(false); - const [editingLocationType, setEditingLocationType] = useState(""); - const [selectedLocation, setSelectedLocation] = useState(undefined); const [multipleDuration, setMultipleDuration] = useState(eventType.metadata?.multipleDuration); const orgBranding = useOrgBranding(); const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled"); @@ -150,83 +147,6 @@ export const EventSetupTab = ( selectedMultipleDuration.find((opt) => opt.value === eventType.length) ?? null ); - const openLocationModal = (type: EventLocationType["type"], address = "") => { - const option = getLocationFromType(type, locationOptions); - if (option && option.value === LocationType.InPerson) { - const inPersonOption = { - ...option, - address, - }; - setSelectedLocation(inPersonOption); - } else { - setSelectedLocation(option); - } - setShowLocationModal(true); - }; - - const removeLocation = (selectedLocation: (typeof eventType.locations)[number]) => { - formMethods.setValue( - "locations", - formMethods.getValues("locations").filter((location) => { - if (location.type === LocationType.InPerson) { - return location.address !== selectedLocation.address; - } - return location.type !== selectedLocation.type; - }), - { shouldValidate: true } - ); - }; - - const saveLocation = (newLocationType: EventLocationType["type"], details = {}) => { - const locationType = editingLocationType !== "" ? editingLocationType : newLocationType; - const existingIdx = formMethods.getValues("locations").findIndex((loc) => locationType === loc.type); - if (existingIdx !== -1) { - const copy = formMethods.getValues("locations"); - if (editingLocationType !== "") { - copy[existingIdx] = { - ...details, - type: newLocationType, - }; - } - - formMethods.setValue("locations", [ - ...copy, - ...(newLocationType === LocationType.InPerson && editingLocationType === "" - ? [{ ...details, type: newLocationType }] - : []), - ]); - } else { - formMethods.setValue( - "locations", - formMethods.getValues("locations").concat({ type: newLocationType, ...details }) - ); - } - - setEditingLocationType(""); - setShowLocationModal(false); - }; - - const locationFormSchema = z.object({ - locationType: z.string(), - locationAddress: z.string().optional(), - displayLocationPublicly: z.boolean().optional(), - locationPhoneNumber: z - .string() - .refine((val) => isValidPhoneNumber(val)) - .optional(), - locationLink: z.string().url().optional(), // URL validates as new URL() - which requires HTTPS:// In the input field - }); - - const locationFormMethods = useForm<{ - locationType: EventLocationType["type"]; - 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), - }); - const { isChildrenManagedEventType, isManagedEventType, shouldLockIndicator, shouldLockDisableProps } = useLockedFieldsManager( eventType, @@ -236,6 +156,15 @@ export const EventSetupTab = ( const Locations = () => { const { t } = useLocale(); + const { + fields: locationFields, + append, + remove, + update: updateLocationField, + } = useFieldArray({ + control: formMethods.control, + name: "locations", + }); const [animationRef] = useAutoAnimate(); @@ -254,131 +183,266 @@ export const EventSetupTab = ( const { locationDetails, locationAvailable } = getLocationInfo(props); + const LocationInput = (props: { + eventLocationType: EventLocationType; + defaultValue?: string; + index: number; + }) => { + const { eventLocationType, index, ...remainingProps } = props; + + if (eventLocationType?.organizerInputType === "text") { + const { defaultValue, ...rest } = remainingProps; + + return ( + { + return ( + <> + + + + ); + }} + /> + ); + } else if (eventLocationType?.organizerInputType === "phone") { + const { defaultValue, ...rest } = remainingProps; + + return ( + { + return ( + <> + + + + ); + }} + /> + ); + } + return null; + }; + + const [showEmptyLocationSelect, setShowEmptyLocationSelect] = useState(false); + const [selectedNewOption, setSelectedNewOption] = useState(null); + return (
- {validLocations.length === 0 && ( -
- { - if (e?.value) { - const newLocationType = e.value; - const eventLocationType = getEventLocationType(newLocationType); - if (!eventLocationType) { - return; - } - locationFormMethods.setValue("locationType", newLocationType); - if (eventLocationType.organizerInputType) { - openLocationModal(newLocationType); - } else { - saveLocation(newLocationType); - } +
    + {locationFields.map((field, index) => { + const eventLocationType = getEventLocationType(field.type); + const defaultLocation = formMethods + .getValues("locations") + ?.find((location: { type: EventLocationType["type"]; address?: string }) => { + if (location.type === LocationType.InPerson) { + return location.type === eventLocationType?.type && location.address === field?.address; + } else { + return location.type === eventLocationType?.type; } - }} - /> -
- )} - {validLocations.length > 0 && ( -
    - {validLocations.map((location, index) => { - const eventLocationType = getEventLocationType(location.type); - if (!eventLocationType) { - return null; - } + }); - const eventLabel = - location[eventLocationType.defaultValueVariable] || t(eventLocationType.label); - return ( -
  • -
    -
    - {`${eventLocationType.label} - {`${eventLabel} ${ - location.teamName ? `(${location.teamName})` : "" - }`} + const option = getLocationFromType(field.type, locationOptions); + + return ( +
  • +
    + { + if (e?.value) { + const newLocationType = e.value; + const eventLocationType = getEventLocationType(newLocationType); + if (!eventLocationType) { + return; + } + const canAddLocation = + eventLocationType.organizerInputType || + !validLocations.find((location) => location.type === newLocationType); + + if (canAddLocation) { + updateLocationField(index, { type: newLocationType }); + } else { + updateLocationField(index, { type: field.type }); + showToast(t("location_already_exists"), "warning"); + } + } + }} + /> + +
    + + {eventLocationType?.organizerInputType && ( +
    +
    +
    + +
    +
    + +
    +
    +
    + { + const fieldValues = formMethods.getValues().locations[index]; + updateLocationField(index, { + ...fieldValues, + displayLocationPublicly: e.target.checked, + }); }} - aria-label={t("edit")} - className="hover:text-emphasis text-subtle mr-1 p-1"> - - - + informationIconText={t("display_location_info_badge")} + />
    -
  • - ); - })} - {validLocations.some( - (location) => - location.type === MeetLocationType && destinationCalendar?.integration !== "google_calendar" - ) && ( -
    - - -

    - The “Add to calendar” for this event type needs to be a Google Calendar for Meet to work. - Change it{" "} - - here. - {" "} -

    -
    -
    - )} - {isChildrenManagedEventType && !locationAvailable && locationDetails && ( -

    - {t("app_not_connected", { appName: locationDetails.name })}{" "} - - {t("connect_now")} - -

    - )} - {validLocations.length > 0 && !isManagedEventType && !isChildrenManagedEventType && ( -
  • - + )}
  • - )} -
- )} + ); + })} + {(validLocations.length === 0 || showEmptyLocationSelect) && ( +
+ { + if (e?.value) { + const newLocationType = e.value; + const eventLocationType = getEventLocationType(newLocationType); + if (!eventLocationType) { + return; + } + + const canAppendLocation = + eventLocationType.organizerInputType || + !validLocations.find((location) => location.type === newLocationType); + + if (canAppendLocation) { + append({ type: newLocationType }); + setSelectedNewOption(e); + } else { + showToast(t("location_already_exists"), "warning"); + setSelectedNewOption(null); + } + } + }} + /> +
+ )} + {validLocations.some( + (location) => + location.type === MeetLocationType && destinationCalendar?.integration !== "google_calendar" + ) && ( +
+
+ +
+ +

+ The “Add to calendar” for this event type needs to be a Google Calendar for Meet to work. + Change it{" "} + + here. + {" "} +

+
+
+ )} + {isChildrenManagedEventType && !locationAvailable && locationDetails && ( +

+ {t("app_not_connected", { appName: locationDetails.name })}{" "} + + {t("connect_now")} + +

+ )} + {validLocations.length > 0 && !isManagedEventType && !isChildrenManagedEventType && ( +
  • + +
  • + )} + +

    + + Can't find the right video app? Visit our + + App Store + + . + +

    ); }; @@ -542,33 +606,6 @@ export const EventSetupTab = ( />
    - - {/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */} - ); diff --git a/apps/web/components/getting-started/steps-views/UserProfile.tsx b/apps/web/components/getting-started/steps-views/UserProfile.tsx index f197ff4461..79f4fd9076 100644 --- a/apps/web/components/getting-started/steps-views/UserProfile.tsx +++ b/apps/web/components/getting-started/steps-views/UserProfile.tsx @@ -3,12 +3,13 @@ import type { FormEvent } from "react"; import { useRef, useState } from "react"; import { useForm } from "react-hook-form"; -import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar"; +import OrganizationMemberAvatar from "@calcom/features/ee/organizations/components/OrganizationMemberAvatar"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { md } from "@calcom/lib/markdownIt"; import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import turndown from "@calcom/lib/turndownService"; import { trpc } from "@calcom/trpc/react"; +import type { Ensure } from "@calcom/types/utils"; import { Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui"; import { ArrowRight } from "@calcom/ui/components/icon"; @@ -96,16 +97,19 @@ const UserProfile = () => { }, ]; + const organization = + user.organization && user.organization.id + ? { + ...(user.organization as Ensure), + slug: user.organization.slug || null, + requestedSlug: user.organization.metadata?.requestedSlug || null, + } + : null; return (
    {user && ( - + )} , "inviteToken">; type MembersType = TeamType["members"]; -type MemberType = Pick & { safeBio: string | null }; +type MemberType = Pick & { + safeBio: string | null; + orgOrigin: string; +}; const Member = ({ member, teamName }: { member: MemberType; teamName: string | null }) => { const routerQuery = useRouterQuery(); @@ -20,9 +24,11 @@ const Member = ({ member, teamName }: { member: MemberType; teamName: string | n const { slug: _slug, orgSlug: _orgSlug, user: _user, ...queryParamsToForward } = routerQuery; return ( - +
    - +

    {member.name}

    diff --git a/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx b/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx index dbef95668d..fb33a0f899 100644 --- a/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx +++ b/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx @@ -222,9 +222,9 @@ const PremiumTextfield = (props: ICustomUsernameProps) => { onChange={(event) => { event.preventDefault(); // Reset payment status - const _searchParams = new URLSearchParams(searchParams); + const _searchParams = new URLSearchParams(searchParams ?? undefined); _searchParams.delete("paymentStatus"); - if (searchParams.toString() !== _searchParams.toString()) { + if (searchParams?.toString() !== _searchParams.toString()) { router.replace(`${pathname}?${_searchParams.toString()}`); } setInputUsernameValue(event.target.value); diff --git a/apps/web/components/ui/avatar/UserAvatar.tsx b/apps/web/components/ui/avatar/UserAvatar.tsx new file mode 100644 index 0000000000..63fa676676 --- /dev/null +++ b/apps/web/components/ui/avatar/UserAvatar.tsx @@ -0,0 +1,19 @@ +import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; +import type { User } from "@calcom/prisma/client"; +import { Avatar } from "@calcom/ui"; + +type UserAvatarProps = Omit, "alt" | "imageSrc"> & { + user: Pick; + /** + * Useful when allowing the user to upload their own avatar and showing the avatar before it's uploaded + */ + previewSrc?: string | null; +}; + +/** + * It is aware of the user's organization to correctly show the avatar from the correct URL + */ +export function UserAvatar(props: UserAvatarProps) { + const { user, previewSrc, ...rest } = props; + return ; +} diff --git a/apps/web/components/ui/avatar/UserAvatarGroup.tsx b/apps/web/components/ui/avatar/UserAvatarGroup.tsx new file mode 100644 index 0000000000..ad3909641e --- /dev/null +++ b/apps/web/components/ui/avatar/UserAvatarGroup.tsx @@ -0,0 +1,20 @@ +import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; +import type { User } from "@calcom/prisma/client"; +import { AvatarGroup } from "@calcom/ui"; + +type UserAvatarProps = Omit, "items"> & { + users: Pick[]; +}; +export function UserAvatarGroup(props: UserAvatarProps) { + const { users, ...rest } = props; + return ( + ({ + alt: user.name || "", + title: user.name || "", + image: getUserAvatarUrl(user), + }))} + /> + ); +} diff --git a/apps/web/components/ui/avatar/UserAvatarGroupWithOrg.tsx b/apps/web/components/ui/avatar/UserAvatarGroupWithOrg.tsx new file mode 100644 index 0000000000..9de57a0b57 --- /dev/null +++ b/apps/web/components/ui/avatar/UserAvatarGroupWithOrg.tsx @@ -0,0 +1,30 @@ +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; +import type { Team, User } from "@calcom/prisma/client"; +import { AvatarGroup } from "@calcom/ui"; + +type UserAvatarProps = Omit, "items"> & { + users: Pick[]; + organization: Pick; +}; + +export function UserAvatarGroupWithOrg(props: UserAvatarProps) { + const { users, organization, ...rest } = props; + const items = [ + { + image: `${WEBAPP_URL}/team/${organization.slug}/avatar.png`, + alt: organization.name || undefined, + title: organization.name, + }, + ].concat( + users.map((user) => { + return { + image: getUserAvatarUrl(user), + alt: user.name || undefined, + title: user.name || user.username || "", + }; + }) + ); + users.unshift(); + return ; +} diff --git a/apps/web/components/ui/form/CheckboxField.tsx b/apps/web/components/ui/form/CheckboxField.tsx index 222cbd7731..8298fbb5b5 100644 --- a/apps/web/components/ui/form/CheckboxField.tsx +++ b/apps/web/components/ui/form/CheckboxField.tsx @@ -52,7 +52,7 @@ const CheckboxField = forwardRef( className="text-primary-600 focus:ring-primary-500 border-default bg-default h-4 w-4 rounded" />
    - {description} + {description} )} {informationIconText && } diff --git a/apps/web/lib/buildNonce.test.ts b/apps/web/lib/buildNonce.test.ts new file mode 100644 index 0000000000..46c7f6c26e --- /dev/null +++ b/apps/web/lib/buildNonce.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest"; + +import { buildNonce } from "./buildNonce"; + +describe("buildNonce", () => { + it("should return an empty string for an empty array", () => { + const nonce = buildNonce(new Uint8Array()); + + expect(nonce).toEqual(""); + expect(atob(nonce).length).toEqual(0); + }); + + it("should return a base64 string for values from 0 to 63", () => { + const array = Array(22) + .fill(0) + .map((_, i) => i); + const nonce = buildNonce(new Uint8Array(array)); + + expect(nonce.length).toEqual(24); + expect(nonce).toEqual("ABCDEFGHIJKLMNOPQRSTQQ=="); + + expect(atob(nonce).length).toEqual(16); + }); + + it("should return a base64 string for values from 64 to 127", () => { + const array = Array(22) + .fill(0) + .map((_, i) => i + 64); + const nonce = buildNonce(new Uint8Array(array)); + + expect(nonce.length).toEqual(24); + expect(nonce).toEqual("ABCDEFGHIJKLMNOPQRSTQQ=="); + + expect(atob(nonce).length).toEqual(16); + }); + + it("should return a base64 string for values from 128 to 191", () => { + const array = Array(22) + .fill(0) + .map((_, i) => i + 128); + const nonce = buildNonce(new Uint8Array(array)); + + expect(nonce.length).toEqual(24); + expect(nonce).toEqual("ABCDEFGHIJKLMNOPQRSTQQ=="); + + expect(atob(nonce).length).toEqual(16); + }); + + it("should return a base64 string for values from 192 to 255", () => { + const array = Array(22) + .fill(0) + .map((_, i) => i + 192); + const nonce = buildNonce(new Uint8Array(array)); + + expect(nonce.length).toEqual(24); + expect(nonce).toEqual("ABCDEFGHIJKLMNOPQRSTQQ=="); + + expect(atob(nonce).length).toEqual(16); + }); + + it("should return a base64 string for values from 0 to 42", () => { + const array = Array(22) + .fill(0) + .map((_, i) => 2 * i); + const nonce = buildNonce(new Uint8Array(array)); + + expect(nonce.length).toEqual(24); + expect(nonce).toEqual("ACEGIKMOQSUWYacegikmgg=="); + + expect(atob(nonce).length).toEqual(16); + }); + + it("should return a base64 string for 0 values", () => { + const array = Array(22) + .fill(0) + .map(() => 0); + const nonce = buildNonce(new Uint8Array(array)); + + expect(nonce.length).toEqual(24); + expect(nonce).toEqual("AAAAAAAAAAAAAAAAAAAAAA=="); + + expect(atob(nonce).length).toEqual(16); + }); + + it("should return a base64 string for 0xFF values", () => { + const array = Array(22) + .fill(0) + .map(() => 0xff); + const nonce = buildNonce(new Uint8Array(array)); + + expect(nonce.length).toEqual(24); + expect(nonce).toEqual("////////////////////ww=="); + + expect(atob(nonce).length).toEqual(16); + }); +}); diff --git a/apps/web/lib/buildNonce.ts b/apps/web/lib/buildNonce.ts new file mode 100644 index 0000000000..211371dbc7 --- /dev/null +++ b/apps/web/lib/buildNonce.ts @@ -0,0 +1,46 @@ +const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/* +The buildNonce array allows a randomly generated 22-unsigned-byte array +and returns a 24-ASCII character string that mimics a base64-string. +*/ + +export const buildNonce = (uint8array: Uint8Array): string => { + // the random uint8array should contain 22 bytes + // 22 bytes mimic the base64-encoded 16 bytes + // base64 encodes 6 bits (log2(64)) with 8 bits (64 allowed characters) + // thus ceil(16*8/6) gives us 22 bytes + if (uint8array.length != 22) { + return ""; + } + + // for each random byte, we take: + // a) only the last 6 bits (so we map them to the base64 alphabet) + // b) for the last byte, we are interested in two bits + // explaination: + // 16*8 bits = 128 bits of information (order: left->right) + // 22*6 bits = 132 bits (order: left->right) + // thus the last byte has 4 redundant (least-significant, right-most) bits + // it leaves the last byte with 2 bits of information before the redundant bits + // so the bitmask is 0x110000 (2 bits of information, 4 redundant bits) + const bytes = uint8array.map((value, i) => { + if (i < 20) { + return value & 0b111111; + } + + return value & 0b110000; + }); + + const nonceCharacters: string[] = []; + + bytes.forEach((value) => { + nonceCharacters.push(BASE64_ALPHABET.charAt(value)); + }); + + // base64-encoded strings can be padded with 1 or 2 `=` + // since 22 % 4 = 2, we pad with two `=` + nonceCharacters.push("=="); + + // the end result has 22 information and 2 padding ASCII characters = 24 ASCII characters + return nonceCharacters.join(""); +}; diff --git a/apps/web/lib/csp.ts b/apps/web/lib/csp.ts index 830ad7ffff..257f0d2dc7 100644 --- a/apps/web/lib/csp.ts +++ b/apps/web/lib/csp.ts @@ -1,10 +1,11 @@ -import crypto from "crypto"; import type { IncomingMessage, OutgoingMessage } from "http"; import { z } from "zod"; import { IS_PRODUCTION } from "@calcom/lib/constants"; import { WEBAPP_URL } from "@calcom/lib/constants"; +import { buildNonce } from "@lib/buildNonce"; + function getCspPolicy(nonce: string) { //TODO: Do we need to explicitly define it in turbo.json const CSP_POLICY = process.env.CSP_POLICY; @@ -59,7 +60,7 @@ export function csp(req: IncomingMessage | null, res: OutgoingMessage | null) { } const CSP_POLICY = process.env.CSP_POLICY; const cspEnabledForInstance = CSP_POLICY; - const nonce = crypto.randomBytes(16).toString("base64"); + const nonce = buildNonce(crypto.getRandomValues(new Uint8Array(22))); const parsedUrl = new URL(req.url, "http://base_url"); const cspEnabledForPage = cspEnabledForInstance && isPagePathRequest(parsedUrl); diff --git a/apps/web/lib/hooks/useIsBookingPage.ts b/apps/web/lib/hooks/useIsBookingPage.ts index 1e231e3f40..3f890bcedc 100644 --- a/apps/web/lib/hooks/useIsBookingPage.ts +++ b/apps/web/lib/hooks/useIsBookingPage.ts @@ -1,12 +1,12 @@ import { usePathname, useSearchParams } from "next/navigation"; -export default function useIsBookingPage() { +export default function useIsBookingPage(): boolean { const pathname = usePathname(); const isBookingPage = ["/booking/", "/cancel", "/reschedule"].some((route) => pathname?.startsWith(route)); const searchParams = useSearchParams(); - const userParam = searchParams.get("user"); - const teamParam = searchParams.get("team"); + const userParam = Boolean(searchParams?.get("user")); + const teamParam = Boolean(searchParams?.get("team")); - return !!(isBookingPage || userParam || teamParam); + return isBookingPage || userParam || teamParam; } diff --git a/apps/web/lib/hooks/useRouterQuery.ts b/apps/web/lib/hooks/useRouterQuery.ts index 56b321c1fa..3bd40e57b3 100644 --- a/apps/web/lib/hooks/useRouterQuery.ts +++ b/apps/web/lib/hooks/useRouterQuery.ts @@ -1,17 +1,21 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useCallback } from "react"; export default function useRouterQuery(name: T) { const searchParams = useSearchParams(); const pathname = usePathname(); const router = useRouter(); - const setQuery = (newValue: string | number | null | undefined) => { - const _searchParams = new URLSearchParams(searchParams); - _searchParams.set(name, newValue as string); - router.replace(`${pathname}?${_searchParams.toString()}`); - }; + const setQuery = useCallback( + (newValue: string | number | null | undefined) => { + const _searchParams = new URLSearchParams(searchParams ?? undefined); + _searchParams.set(name, newValue as string); + router.replace(`${pathname}?${_searchParams.toString()}`); + }, + [name, pathname, router, searchParams] + ); - return { [name]: searchParams.get(name), setQuery } as { + return { [name]: searchParams?.get(name), setQuery } as { [K in T]: string | undefined; } & { setQuery: typeof setQuery }; } diff --git a/apps/web/package.json b/apps/web/package.json index 245026e66e..829f47e542 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@calcom/web", - "version": "3.4.2", + "version": "3.4.3", "private": true, "scripts": { "analyze": "ANALYZE=true next build", diff --git a/apps/web/pages/404.tsx b/apps/web/pages/404.tsx index 6871c19630..fc23e64fd1 100644 --- a/apps/web/pages/404.tsx +++ b/apps/web/pages/404.tsx @@ -51,8 +51,8 @@ export default function Custom404() { const [url, setUrl] = useState(`${WEBSITE_URL}/signup`); useEffect(() => { const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(window.location.host); - const [routerUsername] = pathname?.replace("%20", "-").split(/[?#]/); - if (!isValidOrgDomain || !currentOrgDomain) { + const [routerUsername] = pathname?.replace("%20", "-").split(/[?#]/) ?? []; + if (routerUsername && (!isValidOrgDomain || !currentOrgDomain)) { const splitPath = routerUsername.split("/"); if (splitPath[1] === "team" && splitPath.length === 3) { // Accessing a non-existent team @@ -66,13 +66,12 @@ export default function Custom404() { setUrl(`${WEBSITE_URL}/signup?username=${routerUsername.replace("/", "")}`); } } else { - setUsername(currentOrgDomain); + setUsername(currentOrgDomain ?? ""); setCurrentPageType(pageType.ORG); setUrl( - `${WEBSITE_URL}/signup?callbackUrl=settings/organizations/new%3Fslug%3D${currentOrgDomain.replace( - "/", - "" - )}` + `${WEBSITE_URL}/signup?callbackUrl=settings/organizations/new%3Fslug%3D${ + currentOrgDomain?.replace("/", "") ?? "" + }` ); } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/apps/web/pages/[user].tsx b/apps/web/pages/[user].tsx index 1392af4cfa..1e927a4539 100644 --- a/apps/web/pages/[user].tsx +++ b/apps/web/pages/[user].tsx @@ -11,7 +11,7 @@ import { useEmbedStyles, useIsEmbed, } from "@calcom/embed-core/embed-iframe"; -import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar"; +import OrganizationMemberAvatar from "@calcom/features/ee/organizations/components/OrganizationMemberAvatar"; import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains"; import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components"; @@ -25,7 +25,7 @@ import { stripMarkdown } from "@calcom/lib/stripMarkdown"; import prisma from "@calcom/prisma"; import { RedirectType, type EventType, type User } from "@calcom/prisma/client"; import { baseEventTypeSelect } from "@calcom/prisma/selects"; -import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; +import { EventTypeMetaDataSchema, teamMetadataSchema } from "@calcom/prisma/zod-utils"; import { HeadSeo, UnpublishedEntity } from "@calcom/ui"; import { Verified, ArrowRight } from "@calcom/ui/components/icon"; @@ -99,11 +99,22 @@ export function UserPage(props: InferGetServerSidePropsType
    -

    {profile.name} @@ -226,8 +237,13 @@ export type UserPageProps = { theme: string | null; brandColor: string; darkBrandColor: string; - organizationSlug: string | null; + organization: { + requestedSlug: string | null; + slug: string | null; + id: number | null; + }; allowSEOIndexing: boolean; + username: string | null; }; users: Pick[]; themeBasis: string | null; @@ -286,6 +302,7 @@ export const getServerSideProps: GetServerSideProps = async (cont select: { slug: true, name: true, + metadata: true, }, }, theme: true, @@ -313,6 +330,10 @@ export const getServerSideProps: GetServerSideProps = async (cont const users = usersWithoutAvatar.map((user) => ({ ...user, + organization: { + ...user.organization, + metadata: user.organization?.metadata ? teamMetadataSchema.parse(user.organization.metadata) : null, + }, avatar: `/${user.username}/avatar.png`, })); @@ -344,8 +365,13 @@ export const getServerSideProps: GetServerSideProps = async (cont theme: user.theme, brandColor: user.brandColor, darkBrandColor: user.darkBrandColor, - organizationSlug: user.organization?.slug ?? null, allowSEOIndexing: user.allowSEOIndexing ?? true, + username: user.username, + organization: { + id: user.organizationId, + slug: user.organization?.slug ?? null, + requestedSlug: user.organization?.metadata?.requestedSlug ?? null, + }, }; const eventTypesWithHidden = await getEventTypesWithHiddenFromDB(user.id); diff --git a/apps/web/pages/api/user/avatar.ts b/apps/web/pages/api/user/avatar.ts index fcf0ce7d09..6f6cabeaf7 100644 --- a/apps/web/pages/api/user/avatar.ts +++ b/apps/web/pages/api/user/avatar.ts @@ -1,15 +1,23 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { z } from "zod"; -import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { + orgDomainConfig, + whereClauseForOrgWithSlugOrRequestedSlug, +} from "@calcom/features/ee/organizations/lib/orgDomains"; import { AVATAR_FALLBACK } from "@calcom/lib/constants"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; +import logger from "@calcom/lib/logger"; import prisma from "@calcom/prisma"; +const log = logger.getSubLogger({ prefix: ["team/[slug]"] }); const querySchema = z .object({ username: z.string(), teamname: z.string(), + /** + * Passed when we want to fetch avatar of a particular organization + */ orgSlug: z.string(), /** * Allow fetching avatar of a particular organization @@ -30,11 +38,11 @@ async function getIdentityData(req: NextApiRequest) { id: orgId, } : org - ? getSlugOrRequestedSlug(org) + ? whereClauseForOrgWithSlugOrRequestedSlug(org) : null; if (username) { - let user = await prisma.user.findFirst({ + const user = await prisma.user.findFirst({ where: { username, organization: orgQuery, @@ -42,27 +50,6 @@ async function getIdentityData(req: NextApiRequest) { select: { avatar: true, email: true }, }); - /** - * TEMPORARY CODE STARTS - TO BE REMOVED after mono-user schema is implemented - * Try the non-org user temporarily to support users part of a team but not part of the organization - * This is needed because of a situation where we migrate a user and the team to ORG but not all the users in the team to the ORG. - * Eventually, all users will be migrated to the ORG but this is when user by user migration happens initially. - */ - // No user found in the org, try the non-org user that might be part of the team that's part of an org - if (!user && orgQuery) { - // The only side effect this code could have is that it could serve the avatar of a non-org member from the org domain but as long as the username isn't taken by an org member. - user = await prisma.user.findFirst({ - where: { - username, - organization: null, - }, - select: { avatar: true, email: true }, - }); - } - /** - * TEMPORARY CODE ENDS - */ - return { name: username, email: user?.email, @@ -79,6 +66,7 @@ async function getIdentityData(req: NextApiRequest) { }, select: { logo: true }, }); + return { org, name: teamname, @@ -86,15 +74,25 @@ async function getIdentityData(req: NextApiRequest) { avatar: getPlaceholderAvatar(team?.logo, teamname), }; } + if (orgSlug) { - const org = await prisma.team.findFirst({ - where: getSlugOrRequestedSlug(orgSlug), + const orgs = await prisma.team.findMany({ + where: { + ...whereClauseForOrgWithSlugOrRequestedSlug(orgSlug), + }, select: { slug: true, logo: true, name: true, }, }); + + if (orgs.length > 1) { + // This should never happen, but instead of throwing error, we are just logging to be able to observe when it happens. + log.error("More than one organization found for slug", orgSlug); + } + + const org = orgs[0]; return { org: org?.slug, name: org?.name, diff --git a/apps/web/pages/auth/login.tsx b/apps/web/pages/auth/login.tsx index d6b21a118f..780286c47a 100644 --- a/apps/web/pages/auth/login.tsx +++ b/apps/web/pages/auth/login.tsx @@ -83,7 +83,7 @@ inferSSRProps & WithNonceProps<{}>) { const telemetry = useTelemetry(); - let callbackUrl = searchParams.get("callbackUrl") || ""; + let callbackUrl = searchParams?.get("callbackUrl") || ""; if (/"\//.test(callbackUrl)) callbackUrl = callbackUrl.substring(1); diff --git a/apps/web/pages/auth/oauth2/authorize.tsx b/apps/web/pages/auth/oauth2/authorize.tsx index 6b2c276aac..e34635540c 100644 --- a/apps/web/pages/auth/oauth2/authorize.tsx +++ b/apps/web/pages/auth/oauth2/authorize.tsx @@ -22,7 +22,7 @@ export default function Authorize() { const state = searchParams?.get("state") as string; const scope = searchParams?.get("scope") as string; - const queryString = searchParams.toString(); + const queryString = searchParams?.toString(); const [selectedAccount, setSelectedAccount] = useState<{ value: string; label: string } | null>(); const scopes = scope ? scope.toString().split(",") : []; diff --git a/apps/web/pages/auth/setup/index.tsx b/apps/web/pages/auth/setup/index.tsx index 1fe903613f..4badbd1804 100644 --- a/apps/web/pages/auth/setup/index.tsx +++ b/apps/web/pages/auth/setup/index.tsx @@ -24,7 +24,7 @@ function useSetStep() { const searchParams = useSearchParams(); const pathname = usePathname(); const setStep = (newStep = 1) => { - const _searchParams = new URLSearchParams(searchParams); + const _searchParams = new URLSearchParams(searchParams ?? undefined); _searchParams.set("step", newStep.toString()); router.replace(`${pathname}?${_searchParams.toString()}`); }; diff --git a/apps/web/pages/auth/verify.tsx b/apps/web/pages/auth/verify.tsx index 8f6193d5ef..d0ce633d2f 100644 --- a/apps/web/pages/auth/verify.tsx +++ b/apps/web/pages/auth/verify.tsx @@ -164,7 +164,7 @@ export default function Verify() { e.preventDefault(); setSecondsLeft(30); // Update query params with t:timestamp, shallow: true doesn't re-render the page - const _searchParams = new URLSearchParams(searchParams.toString()); + const _searchParams = new URLSearchParams(searchParams?.toString()); _searchParams.set("t", `${Date.now()}`); router.replace(`${pathname}?${_searchParams.toString()}`); return await sendVerificationLogin(customer.email, customer.username); diff --git a/apps/web/pages/booking/[uid].tsx b/apps/web/pages/booking/[uid].tsx index af7b779dc8..44eb293a1a 100644 --- a/apps/web/pages/booking/[uid].tsx +++ b/apps/web/pages/booking/[uid].tsx @@ -155,7 +155,7 @@ export default function Success(props: SuccessProps) { const [calculatedDuration, setCalculatedDuration] = useState(undefined); const { requiresLoginToUpdate } = props; function setIsCancellationMode(value: boolean) { - const _searchParams = new URLSearchParams(searchParams); + const _searchParams = new URLSearchParams(searchParams ?? undefined); if (value) { _searchParams.set("cancel", "true"); diff --git a/apps/web/pages/event-types/[type]/index.tsx b/apps/web/pages/event-types/[type]/index.tsx index 1c0df98e0a..3c944f399e 100644 --- a/apps/web/pages/event-types/[type]/index.tsx +++ b/apps/web/pages/event-types/[type]/index.tsx @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-empty-function */ import { useAutoAnimate } from "@formkit/auto-animate/react"; import { zodResolver } from "@hookform/resolvers/zod"; +import { isValidPhoneNumber } from "libphonenumber-js"; import type { GetServerSidePropsContext } from "next"; import dynamic from "next/dynamic"; import { useEffect, useMemo, useState } from "react"; @@ -299,6 +300,28 @@ const EventTypePage = (props: EventTypeSetupProps) => { length: z.union([z.string().transform((val) => +val), z.number()]).optional(), offsetStart: z.union([z.string().transform((val) => +val), z.number()]).optional(), bookingFields: eventTypeBookingFields, + locations: z + .array( + z + .object({ + type: z.string(), + address: z.string().optional(), + link: z.string().url().optional(), + phone: z + .string() + .refine((val) => isValidPhoneNumber(val)) + .optional(), + hostPhoneNumber: z + .string() + .refine((val) => isValidPhoneNumber(val)) + .optional(), + displayLocationPublicly: z.boolean().optional(), + credentialId: z.number().optional(), + teamName: z.string().optional(), + }) + .passthrough() + ) + .optional(), }) // TODO: Add schema for other fields later. .passthrough() diff --git a/apps/web/pages/event-types/index.tsx b/apps/web/pages/event-types/index.tsx index 27d825aa70..bd87521314 100644 --- a/apps/web/pages/event-types/index.tsx +++ b/apps/web/pages/event-types/index.tsx @@ -298,7 +298,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL // inject selection data into url for correct router history const openDuplicateModal = (eventType: EventType, group: EventTypeGroup) => { - const newSearchParams = new URLSearchParams(searchParams); + const newSearchParams = new URLSearchParams(searchParams ?? undefined); function setParamsIfDefined(key: string, value: string | number | boolean | null | undefined) { if (value) newSearchParams.set(key, value.toString()); if (value === null) newSearchParams.delete(key); diff --git a/apps/web/pages/settings/my-account/profile.tsx b/apps/web/pages/settings/my-account/profile.tsx index 6b57135292..bfe82cdc27 100644 --- a/apps/web/pages/settings/my-account/profile.tsx +++ b/apps/web/pages/settings/my-account/profile.tsx @@ -6,7 +6,7 @@ import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; -import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar"; +import OrganizationMemberAvatar from "@calcom/features/ee/organizations/components/OrganizationMemberAvatar"; import SectionBottomActions from "@calcom/features/settings/SectionBottomActions"; import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants"; @@ -19,6 +19,7 @@ import type { TRPCClientErrorLike } from "@calcom/trpc/client"; import { trpc } from "@calcom/trpc/react"; import type { RouterOutputs } from "@calcom/trpc/react"; import type { AppRouter } from "@calcom/trpc/server/routers/_app"; +import type { Ensure } from "@calcom/types/utils"; import { Alert, Button, @@ -251,6 +252,7 @@ const ProfileView = () => { isLoading={updateProfileMutation.isLoading} isFallbackImg={checkIfItFallbackImage(fetchedImgSrc)} userAvatar={user.avatar} + user={user} userOrganization={user.organization} onSubmit={(values) => { if (values.email !== user.email && isCALIdentityProvider) { @@ -396,6 +398,7 @@ const ProfileForm = ({ isLoading = false, isFallbackImg, userAvatar, + user, userOrganization, }: { defaultValues: FormValues; @@ -404,6 +407,7 @@ const ProfileForm = ({ isLoading: boolean; isFallbackImg: boolean; userAvatar: string; + user: RouterOutputs["viewer"]["me"]; userOrganization: RouterOutputs["viewer"]["me"]["organization"]; }) => { const { t } = useLocale(); @@ -443,13 +447,21 @@ const ProfileForm = ({ name="avatar" render={({ field: { value } }) => { const showRemoveAvatarButton = !isFallbackImg || (value && userAvatar !== value); + const organization = + userOrganization && userOrganization.id + ? { + ...(userOrganization as Ensure), + slug: userOrganization.slug || null, + requestedSlug: userOrganization.metadata?.requestedSlug || null, + } + : null; return ( <> -

    {t("profile_picture")}

    diff --git a/apps/web/pages/signup.tsx b/apps/web/pages/signup.tsx index 8aceea51ba..21f3459f1f 100644 --- a/apps/web/pages/signup.tsx +++ b/apps/web/pages/signup.tsx @@ -8,7 +8,7 @@ import { FormProvider, useForm } from "react-hook-form"; import { z } from "zod"; import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername"; -import { getOrgFullDomain } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml"; import { useFlagMap } from "@calcom/features/flags/context/provider"; import { getFeatureFlagMap } from "@calcom/features/flags/server/utils"; @@ -159,7 +159,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoA
    - ({ - alt: user.name || "", - title: user.name || "", - image: `/${user.username}/avatar.png` || "", - }))} + users={type.users} />
    @@ -149,17 +146,11 @@ function TeamPage({

    - mem.subteams?.includes(ch.slug) && mem.accepted) - .map((member) => ({ - alt: member.name || "", - image: `/${member.username}/avatar.png`, - title: member.name || "", - }))} + users={team.members.filter((mem) => mem.subteams?.includes(ch.slug) && mem.accepted)} /> @@ -373,7 +364,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => subteams: member.subteams, username: member.username, accepted: member.accepted, + organizationId: member.organizationId, safeBio: markdownToSafeHTML(member.bio || ""), + orgOrigin: getOrgFullOrigin(member.organization?.slug || ""), }; }) : []; diff --git a/apps/web/playwright/app-list-card.e2e.ts b/apps/web/playwright/app-list-card.e2e.ts new file mode 100644 index 0000000000..780bf759af --- /dev/null +++ b/apps/web/playwright/app-list-card.e2e.ts @@ -0,0 +1,14 @@ +import { test } from "./lib/fixtures"; + +test.describe("AppListCard", async () => { + test("should remove the highlight from the URL", async ({ page, users }) => { + const user = await users.create({}); + await user.apiLogin(); + + await page.goto("/apps/installed/conferencing?hl=daily-video"); + + await page.waitForLoadState(); + + await page.waitForURL("/apps/installed/conferencing"); + }); +}); diff --git a/apps/web/playwright/booking-pages.e2e.ts b/apps/web/playwright/booking-pages.e2e.ts index 87ac1dcf51..63071b8f64 100644 --- a/apps/web/playwright/booking-pages.e2e.ts +++ b/apps/web/playwright/booking-pages.e2e.ts @@ -53,7 +53,7 @@ test.describe("free user", () => { // book same time spot again await bookTimeSlot(page); - await expect(page.locator("[data-testid=booking-fail]")).toBeVisible({ timeout: 1000 }); + await page.locator("[data-testid=booking-fail]").waitFor({ state: "visible" }); }); }); diff --git a/apps/web/playwright/booking/longTextQuestion.e2e.ts b/apps/web/playwright/booking/longTextQuestion.e2e.ts new file mode 100644 index 0000000000..3f7818bddd --- /dev/null +++ b/apps/web/playwright/booking/longTextQuestion.e2e.ts @@ -0,0 +1,483 @@ +import { loginUser } from "../fixtures/regularBookings"; +import { test } from "../lib/fixtures"; + +test.describe("Booking With Long Text Question and Each Other Question", () => { + const bookingOptions = { hasPlaceholder: true, isRequired: true }; + + test.beforeEach(async ({ page, users }) => { + await loginUser(users); + await page.goto("/event-types"); + }); + + test("Long Text and Address required", async ({ bookingPage }) => { + await bookingPage.goToEventType("30 min"); + await bookingPage.goToTab("event_advanced_tab_title"); + await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test"); + await bookingPage.addQuestion("address", "address-test", "address test", true, "address test"); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "textarea", + fillText: "Test Long Text question and Address question (both required)", + secondQuestion: "address", + options: bookingOptions, + }); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); + }); + + test("Long Text required and Address not required", async ({ bookingPage }) => { + await bookingPage.goToEventType("30 min"); + await bookingPage.goToTab("event_advanced_tab_title"); + await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test"); + await bookingPage.addQuestion("address", "address-test", "address test", false, "address test"); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "textarea", + fillText: "Test Long Text question and Address question (only Long Text required)", + secondQuestion: "address", + options: { ...bookingOptions, isRequired: false }, + }); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); + }); + + test.describe("Booking With Long Text Question and Checkbox Group Question", () => { + test("Long Text and Checkbox Group required", async ({ bookingPage }) => { + await bookingPage.goToEventType("30 min"); + await bookingPage.goToTab("event_advanced_tab_title"); + await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test"); + await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", true); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "textarea", + fillText: "Test Long Text question and Checkbox Group question (both required)", + secondQuestion: "checkbox", + options: bookingOptions, + }); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); + }); + + test("Long Text required and Checkbox Group not required", async ({ bookingPage }) => { + await bookingPage.goToEventType("30 min"); + await bookingPage.goToTab("event_advanced_tab_title"); + await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test"); + await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", false); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "textarea", + fillText: "Test Long Text question and Checkbox Group question (only Long Text required)", + secondQuestion: "checkbox", + options: { ...bookingOptions, isRequired: false }, + }); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); + }); + }); + + test.describe("Booking With Long Text Question and checkbox Question", () => { + test("Long Text and checkbox required", async ({ bookingPage }) => { + await bookingPage.goToEventType("30 min"); + await bookingPage.goToTab("event_advanced_tab_title"); + await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test"); + await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", true); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "textarea", + fillText: "Test Long Text question and Checkbox question (only Long Text required)", + secondQuestion: "boolean", + options: bookingOptions, + }); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); + }); + + test("Long Text required and checkbox not required", async ({ bookingPage }) => { + await bookingPage.goToEventType("30 min"); + await bookingPage.goToTab("event_advanced_tab_title"); + await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test"); + await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", false); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "textarea", + fillText: "Test Long Text question and Checkbox question (only Long Text required)", + secondQuestion: "boolean", + options: { ...bookingOptions, isRequired: false }, + }); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); + }); + }); + + test.describe("Booking With Long Text Question and Multiple email Question", () => { + const bookingOptions = { hasPlaceholder: true, isRequired: true }; + test("Long Text and Multiple email required", async ({ bookingPage }) => { + await bookingPage.goToEventType("30 min"); + await bookingPage.goToTab("event_advanced_tab_title"); + await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test"); + await bookingPage.addQuestion( + "multiemail", + "multiemail-test", + "multiemail test", + true, + "multiemail test" + ); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "textarea", + fillText: "Test Long Text question and Multiple email question (both required)", + secondQuestion: "multiemail", + options: bookingOptions, + }); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); + }); + + test("Long Text required and Multiple email not required", async ({ bookingPage }) => { + await bookingPage.goToEventType("30 min"); + await bookingPage.goToTab("event_advanced_tab_title"); + await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test"); + await bookingPage.addQuestion( + "multiemail", + "multiemail-test", + "multiemail test", + false, + "multiemail test" + ); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "textarea", + fillText: "Test Long Text question and Multiple email question (only Long Text required)", + secondQuestion: "multiemail", + options: { hasPlaceholder: true, isRequired: false }, + }); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); + }); + }); + + test.describe("Booking With Long Text Question and multiselect Question", () => { + test("Long Text and multiselect text required", async ({ bookingPage }) => { + await bookingPage.goToEventType("30 min"); + await bookingPage.goToTab("event_advanced_tab_title"); + await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test"); + await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", true); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "textarea", + fillText: "Test Long Text question and multiselect question (both required)", + secondQuestion: "multiselect", + options: bookingOptions, + }); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); + }); + + test("Long Text required and multiselect text not required", async ({ bookingPage }) => { + await bookingPage.goToEventType("30 min"); + await bookingPage.goToTab("event_advanced_tab_title"); + await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test"); + await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", false); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "textarea", + fillText: "Test Long Text question and multiselect question (only long text required)", + secondQuestion: "multiselect", + options: { hasPlaceholder: false, isRequired: false }, + }); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); + }); + }); + + test.describe("Booking With Long Text Question and Number Question", () => { + test("Long Text and Number required", async ({ bookingPage }) => { + await bookingPage.goToEventType("30 min"); + await bookingPage.goToTab("event_advanced_tab_title"); + await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test"); + await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", true); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "textarea", + fillText: "Test Long Text question and Number question (both required)", + secondQuestion: "multiselect", + options: bookingOptions, + }); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); + }); + }); + + test("Long Text required and Number not required", async ({ bookingPage }) => { + await bookingPage.goToEventType("30 min"); + await bookingPage.goToTab("event_advanced_tab_title"); + await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test"); + await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", false); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "textarea", + fillText: "Test Long Text question and Number question (only Long Textß required)", + secondQuestion: "multiselect", + options: { hasPlaceholder: false, isRequired: false }, + }); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); + }); + + test.describe("Booking With Long Text Question and Phone Question", () => { + test("Long Text and Phone required", async ({ bookingPage }) => { + await bookingPage.goToEventType("30 min"); + await bookingPage.goToTab("event_advanced_tab_title"); + await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test"); + await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "textarea", + fillText: "Test Long Text question and Phone question (both required)", + secondQuestion: "phone", + options: bookingOptions, + }); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); + }); + + test("Long Text required and Phone not required", async ({ bookingPage }) => { + await bookingPage.goToEventType("30 min"); + await bookingPage.goToTab("event_advanced_tab_title"); + await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test"); + await bookingPage.addQuestion("phone", "phone-test", "phone test", false, "phone test"); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "textarea", + fillText: "Test Long Text question and Phone question (only Long Text required)", + secondQuestion: "phone", + options: { hasPlaceholder: false, isRequired: false }, + }); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); + }); + }); + + test.describe("Booking With Long Text Question and Radio group Question", () => { + test("Long Text and Radio group required", async ({ bookingPage }) => { + await bookingPage.goToEventType("30 min"); + await bookingPage.goToTab("event_advanced_tab_title"); + await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test"); + await bookingPage.addQuestion("radio", "radio-test", "radio test", true); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "textarea", + fillText: "Test Long Text question and Radio Group question (both required)", + secondQuestion: "radio", + options: bookingOptions, + }); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); + }); + + test("Long Text required and Radio group not required", async ({ bookingPage }) => { + await bookingPage.goToEventType("30 min"); + await bookingPage.goToTab("event_advanced_tab_title"); + await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test"); + await bookingPage.addQuestion("radio", "radio-test", "radio test", false); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "textarea", + fillText: "Test Long Text question and Radio Group question (only Long Text required)", + secondQuestion: "radio", + options: { hasPlaceholder: false, isRequired: false }, + }); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); + }); + }); + + test.describe("Booking With Long Text Question and select Question", () => { + test("Long Text and select required", async ({ bookingPage }) => { + await bookingPage.goToEventType("30 min"); + await bookingPage.goToTab("event_advanced_tab_title"); + await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test"); + await bookingPage.addQuestion("select", "select-test", "select test", true); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "textarea", + fillText: "Test Long Text question and Select question (both required)", + secondQuestion: "select", + options: bookingOptions, + }); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); + }); + + test("Long Text required and select not required", async ({ bookingPage }) => { + await bookingPage.goToEventType("30 min"); + await bookingPage.goToTab("event_advanced_tab_title"); + await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test"); + await bookingPage.addQuestion("select", "select-test", "select test", false); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "textarea", + fillText: "Test Long Text question and Select question (only Long Text required)", + secondQuestion: "select", + options: { hasPlaceholder: false, isRequired: false }, + }); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); + }); + }); + + test.describe("Booking With Long Text Question and Short text question", () => { + const bookingOptions = { hasPlaceholder: true, isRequired: true }; + test("Long Text and Short text required", async ({ bookingPage }) => { + await bookingPage.goToEventType("30 min"); + await bookingPage.goToTab("event_advanced_tab_title"); + await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test"); + await bookingPage.addQuestion("text", "text-test", "text test", true, "text test"); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "textarea", + fillText: "Test Long Text question and Text question (both required)", + secondQuestion: "text", + options: bookingOptions, + }); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); + }); + + test("Long Text required and Short text not required", async ({ bookingPage }) => { + await bookingPage.goToEventType("30 min"); + await bookingPage.goToTab("event_advanced_tab_title"); + await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test"); + await bookingPage.addQuestion("text", "text-test", "text test", false, "text test"); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "textarea", + fillText: "Test Long Text question and Text question (only Long Text required)", + secondQuestion: "text", + options: { hasPlaceholder: false, isRequired: false }, + }); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); + }); + }); +}); diff --git a/apps/web/playwright/booking/phoneQuestion.e2e.ts b/apps/web/playwright/booking/phoneQuestion.e2e.ts index f8236c34ff..481b489cbc 100644 --- a/apps/web/playwright/booking/phoneQuestion.e2e.ts +++ b/apps/web/playwright/booking/phoneQuestion.e2e.ts @@ -26,10 +26,13 @@ test.describe("Booking With Phone Question and Each Other Question", () => { secondQuestion: "address", options: bookingOptions, }); - await bookingPage.cancelAndRescheduleBooking(eventTypePage); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); }); - test("Phone and Address not required", async ({ bookingPage }) => { + test("Phone required and Address not required", async ({ bookingPage }) => { await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); await bookingPage.addQuestion("address", "address-test", "address test", false, "address test"); await bookingPage.updateEventType(); @@ -43,7 +46,10 @@ test.describe("Booking With Phone Question and Each Other Question", () => { secondQuestion: "address", options: { ...bookingOptions, isRequired: false }, }); - await bookingPage.cancelAndRescheduleBooking(eventTypePage); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); }); test.describe("Booking With Phone Question and checkbox group Question", () => { @@ -62,10 +68,13 @@ test.describe("Booking With Phone Question and Each Other Question", () => { secondQuestion: "checkbox", options: bookingOptions, }); - await bookingPage.cancelAndRescheduleBooking(eventTypePage); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); }); - test("Phone and checkbox group not required", async ({ bookingPage }) => { + test("Phone required and checkbox group not required", async ({ bookingPage }) => { await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", false); await bookingPage.updateEventType(); @@ -79,7 +88,10 @@ test.describe("Booking With Phone Question and Each Other Question", () => { secondQuestion: "checkbox", options: { ...bookingOptions, isRequired: false }, }); - await bookingPage.cancelAndRescheduleBooking(eventTypePage); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); }); }); @@ -98,9 +110,12 @@ test.describe("Booking With Phone Question and Each Other Question", () => { secondQuestion: "boolean", options: bookingOptions, }); - await bookingPage.cancelAndRescheduleBooking(eventTypePage); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); }); - test("Phone and checkbox not required", async ({ bookingPage }) => { + test("Phone required and checkbox not required", async ({ bookingPage }) => { await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", false); await bookingPage.updateEventType(); @@ -114,7 +129,10 @@ test.describe("Booking With Phone Question and Each Other Question", () => { secondQuestion: "boolean", options: { ...bookingOptions, isRequired: false }, }); - await bookingPage.cancelAndRescheduleBooking(eventTypePage); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); }); }); @@ -133,10 +151,13 @@ test.describe("Booking With Phone Question and Each Other Question", () => { secondQuestion: "textarea", options: bookingOptions, }); - await bookingPage.cancelAndRescheduleBooking(eventTypePage); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); }); - test("Phone and Long text not required", async ({ bookingPage }) => { + test("Phone required and Long text not required", async ({ bookingPage }) => { await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", false, "textarea test"); await bookingPage.updateEventType(); @@ -150,7 +171,10 @@ test.describe("Booking With Phone Question and Each Other Question", () => { secondQuestion: "textarea", options: { ...bookingOptions, isRequired: false }, }); - await bookingPage.cancelAndRescheduleBooking(eventTypePage); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); }); }); @@ -176,10 +200,13 @@ test.describe("Booking With Phone Question and Each Other Question", () => { secondQuestion: "multiemail", options: bookingOptions, }); - await bookingPage.cancelAndRescheduleBooking(eventTypePage); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); }); - test("Phone and Multi email not required", async ({ bookingPage }) => { + test("Phone required and Multi email not required", async ({ bookingPage }) => { await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); await bookingPage.addQuestion( "multiemail", @@ -199,7 +226,10 @@ test.describe("Booking With Phone Question and Each Other Question", () => { secondQuestion: "multiemail", options: { ...bookingOptions, isRequired: false }, }); - await bookingPage.cancelAndRescheduleBooking(eventTypePage); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); }); }); @@ -218,10 +248,13 @@ test.describe("Booking With Phone Question and Each Other Question", () => { secondQuestion: "multiselect", options: bookingOptions, }); - await bookingPage.cancelAndRescheduleBooking(eventTypePage); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); }); - test("Phone and multiselect text not required", async ({ bookingPage }) => { + test("Phone required and multiselect text not required", async ({ bookingPage }) => { await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", false); await bookingPage.updateEventType(); @@ -235,7 +268,10 @@ test.describe("Booking With Phone Question and Each Other Question", () => { secondQuestion: "multiselect", options: { ...bookingOptions, isRequired: false }, }); - await bookingPage.cancelAndRescheduleBooking(eventTypePage); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); }); }); @@ -254,10 +290,13 @@ test.describe("Booking With Phone Question and Each Other Question", () => { secondQuestion: "number", options: bookingOptions, }); - await bookingPage.cancelAndRescheduleBooking(eventTypePage); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); }); - test("Phone and Number not required", async ({ bookingPage }) => { + test("Phone required and Number not required", async ({ bookingPage }) => { await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); await bookingPage.addQuestion("number", "number-test", "number test", false, "number test"); await bookingPage.updateEventType(); @@ -271,7 +310,10 @@ test.describe("Booking With Phone Question and Each Other Question", () => { secondQuestion: "number", options: { ...bookingOptions, isRequired: false }, }); - await bookingPage.cancelAndRescheduleBooking(eventTypePage); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); }); }); @@ -290,10 +332,13 @@ test.describe("Booking With Phone Question and Each Other Question", () => { secondQuestion: "radio", options: bookingOptions, }); - await bookingPage.cancelAndRescheduleBooking(eventTypePage); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); }); - test("Phone and Radio group not required", async ({ bookingPage }) => { + test("Phone required and Radio group not required", async ({ bookingPage }) => { await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); await bookingPage.addQuestion("radio", "radio-test", "radio test", false); await bookingPage.updateEventType(); @@ -307,7 +352,10 @@ test.describe("Booking With Phone Question and Each Other Question", () => { secondQuestion: "radio", options: { ...bookingOptions, isRequired: false }, }); - await bookingPage.cancelAndRescheduleBooking(eventTypePage); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); }); }); @@ -326,10 +374,13 @@ test.describe("Booking With Phone Question and Each Other Question", () => { secondQuestion: "select", options: bookingOptions, }); - await bookingPage.cancelAndRescheduleBooking(eventTypePage); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); }); - test("Phone and select not required", async ({ bookingPage }) => { + test("Phone required and select not required", async ({ bookingPage }) => { await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); await bookingPage.addQuestion("select", "select-test", "select test", false, "select test"); await bookingPage.updateEventType(); @@ -343,7 +394,10 @@ test.describe("Booking With Phone Question and Each Other Question", () => { secondQuestion: "select", options: { ...bookingOptions, isRequired: false }, }); - await bookingPage.cancelAndRescheduleBooking(eventTypePage); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); }); }); @@ -363,10 +417,13 @@ test.describe("Booking With Phone Question and Each Other Question", () => { secondQuestion: "text", options: bookingOptions, }); - await bookingPage.cancelAndRescheduleBooking(eventTypePage); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); }); - test("Phone and Short text not required", async ({ bookingPage }) => { + test("Phone required and Short text not required", async ({ bookingPage }) => { await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); await bookingPage.addQuestion("text", "text-test", "text test", false, "text test"); await bookingPage.updateEventType(); @@ -380,7 +437,10 @@ test.describe("Booking With Phone Question and Each Other Question", () => { secondQuestion: "text", options: { ...bookingOptions, isRequired: false }, }); - await bookingPage.cancelAndRescheduleBooking(eventTypePage); + await bookingPage.rescheduleBooking(eventTypePage); + await bookingPage.assertBookingRescheduled(eventTypePage); + await bookingPage.cancelBooking(eventTypePage); + await bookingPage.assertBookingCanceled(eventTypePage); }); }); }); diff --git a/apps/web/playwright/change-username.e2e.ts b/apps/web/playwright/change-username.e2e.ts index b2f611714f..46bf03e778 100644 --- a/apps/web/playwright/change-username.e2e.ts +++ b/apps/web/playwright/change-username.e2e.ts @@ -43,9 +43,40 @@ test.describe("Change username on settings", () => { id: user.id, }, }); + expect(newUpdatedUser.username).toBe("demousernamex"); }); + test("User can change username to include periods(or dots)", async ({ page, users, prisma }) => { + const user = await users.create(); + + await user.apiLogin(); + // Try to go homepage + await page.goto("/settings/my-account/profile"); + // Change username from normal to normal + const usernameInput = page.locator("[data-testid=username-input]"); + // User can change username to include dots(or periods) + await usernameInput.fill("demo.username"); + await page.click("[data-testid=update-username-btn]"); + await Promise.all([ + page.click("[data-testid=save-username]"), + page.getByTestId("toast-success").waitFor(), + ]); + await page.waitForLoadState("networkidle"); + + const updatedUser = await prisma.user.findUniqueOrThrow({ + where: { + id: user.id, + }, + }); + + expect(updatedUser.username).toBe("demo.username"); + + // Check if user avatar can be accessed and response headers contain 'image/' in the content type + const response = await page.goto("/demo.username/avatar.png"); + expect(response?.headers()?.["content-type"]).toContain("image/"); + }); + test("User can update to PREMIUM username", async ({ page, users }, testInfo) => { // eslint-disable-next-line playwright/no-skipped-test test.skip(!IS_STRIPE_ENABLED, "It should only run if Stripe is installed"); diff --git a/apps/web/playwright/dynamic-booking-pages.e2e.ts b/apps/web/playwright/dynamic-booking-pages.e2e.ts index eddb68be20..f41fe4c91b 100644 --- a/apps/web/playwright/dynamic-booking-pages.e2e.ts +++ b/apps/web/playwright/dynamic-booking-pages.e2e.ts @@ -13,7 +13,7 @@ test("dynamic booking", async ({ page, users }) => { const pro = await users.create(); await pro.apiLogin(); - const free = await users.create({ username: "free" }); + const free = await users.create({ username: "free.example" }); await page.goto(`/${pro.username}+${free.username}`); await test.step("book an event first day in next month", async () => { diff --git a/apps/web/playwright/event-types.e2e.ts b/apps/web/playwright/event-types.e2e.ts index a5af946dda..70e7e18b88 100644 --- a/apps/web/playwright/event-types.e2e.ts +++ b/apps/web/playwright/event-types.e2e.ts @@ -115,23 +115,13 @@ test.describe("Event Types tests", () => { const locationData = ["location 1", "location 2", "location 3"]; - const fillLocation = async (inputText: string) => { - await page.locator("#location-select").click(); - await page.locator("text=In Person (Organizer Address)").click(); - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(1000); - await page.locator('input[name="locationAddress"]').fill(inputText); - await page.locator("[data-testid=display-location]").check(); - await page.locator("[data-testid=update-location]").click(); - }; - - await fillLocation(locationData[0]); + await fillLocation(page, locationData[0], 0); await page.locator("[data-testid=add-location]").click(); - await fillLocation(locationData[1]); + await fillLocation(page, locationData[1], 1); await page.locator("[data-testid=add-location]").click(); - await fillLocation(locationData[2]); + await fillLocation(page, locationData[2], 2); await page.locator("[data-testid=update-eventtype]").click(); @@ -177,6 +167,93 @@ test.describe("Event Types tests", () => { await expect(page.locator("[data-testid=success-page]")).toBeVisible(); await expect(page.locator("text=+19199999999")).toBeVisible(); }); + + test("Can add Organzer Phone Number location and book with it", async ({ page }) => { + await gotoFirstEventType(page); + + await page.locator("#location-select").click(); + await page.locator(`text="Organizer Phone Number"`).click(); + const locationInputName = "locations[0].hostPhoneNumber"; + await page.locator(`input[name="${locationInputName}"]`).waitFor(); + await page.locator(`input[name="${locationInputName}"]`).fill("9199999999"); + + await saveEventType(page); + await gotoBookingPage(page); + await selectFirstAvailableTimeSlotNextMonth(page); + + await bookTimeSlot(page); + + await expect(page.locator("[data-testid=success-page]")).toBeVisible(); + await expect(page.locator("text=+19199999999")).toBeVisible(); + }); + + test("Can add Cal video location and book with it", async ({ page }) => { + await gotoFirstEventType(page); + + await page.locator("#location-select").click(); + await page.locator(`text="Cal Video (Global)"`).click(); + + await saveEventType(page); + await page.getByTestId("toast-success").waitFor(); + await gotoBookingPage(page); + await selectFirstAvailableTimeSlotNextMonth(page); + + await bookTimeSlot(page); + + await expect(page.locator("[data-testid=success-page]")).toBeVisible(); + await expect(page.locator("[data-testid=where] ")).toContainText("Cal Video"); + }); + + test("Can add Link Meeting as location and book with it", async ({ page }) => { + await gotoFirstEventType(page); + + await page.locator("#location-select").click(); + await page.locator(`text="Link meeting"`).click(); + + const locationInputName = `locations[0].link`; + + const testUrl = "https://cal.ai/"; + await page.locator(`input[name="${locationInputName}"]`).fill(testUrl); + + await saveEventType(page); + await page.getByTestId("toast-success").waitFor(); + await gotoBookingPage(page); + await selectFirstAvailableTimeSlotNextMonth(page); + + await bookTimeSlot(page); + + await expect(page.locator("[data-testid=success-page]")).toBeVisible(); + const linkElement = await page.locator("[data-testid=where] > a"); + expect(await linkElement.getAttribute("href")).toBe(testUrl); + }); + + test("Can remove location from multiple locations that are saved", async ({ page }) => { + await gotoFirstEventType(page); + + // Add Attendee Phone Number location + await selectAttendeePhoneNumber(page); + + // Add Cal Video location + await addAnotherLocation(page, "Cal Video (Global)"); + + await saveEventType(page); + await page.waitForLoadState("networkidle"); + + // Remove Attendee Phone Number Location + const removeButtomId = "delete-locations.0.type"; + await page.getByTestId(removeButtomId).click(); + + await saveEventType(page); + await page.waitForLoadState("networkidle"); + + await gotoBookingPage(page); + await selectFirstAvailableTimeSlotNextMonth(page); + + await bookTimeSlot(page); + + await expect(page.locator("[data-testid=success-page]")).toBeVisible(); + await expect(page.locator("[data-testid=where]")).toHaveText(/Cal Video/); + }); }); }); }); @@ -205,3 +282,26 @@ async function gotoBookingPage(page: Page) { await page.goto(previewLink ?? ""); } + +/** + * Adds n+1 location to the event type + */ +async function addAnotherLocation(page: Page, locationOptionText: string) { + await page.locator("[data-testid=add-location]").click(); + // When adding another location, the dropdown opens automatically. So, we don't need to open it here. + // + await page.locator(`text="${locationOptionText}"`).click(); +} + +const fillLocation = async (page: Page, inputText: string, index: number) => { + // Except the first location, dropdown automatically opens when adding another location + if (index == 0) { + await page.locator("#location-select").last().click(); + } + await page.locator("text=In Person (Organizer Address)").last().click(); + + const locationInputName = `locations[${index}].address`; + await page.locator(`input[name="${locationInputName}"]`).waitFor(); + await page.locator(`input[name="locations[${index}].address"]`).fill(inputText); + await page.locator("[data-testid=display-location]").last().check(); +}; diff --git a/apps/web/playwright/fixtures/regularBookings.ts b/apps/web/playwright/fixtures/regularBookings.ts index 447debd83a..3ad4c0e7d3 100644 --- a/apps/web/playwright/fixtures/regularBookings.ts +++ b/apps/web/playwright/fixtures/regularBookings.ts @@ -174,18 +174,17 @@ export function createBookingPageFixture(page: Page) { await page.getByPlaceholder(reschedulePlaceholderText).fill("Test reschedule"); await page.getByTestId("confirm-reschedule-button").click(); }, - verifyReschedulingSuccess: async () => { - await expect(page.getByText(scheduleSuccessfullyText)).toBeVisible(); - }, - cancelBookingWithReason: async () => { + + cancelBookingWithReason: async (page: Page) => { await page.getByTestId("cancel").click(); await page.getByTestId("cancel_reason").fill("Test cancel"); await page.getByTestId("confirm_cancel").click(); }, - verifyBookingCancellation: async () => { + assertBookingCanceled: async (page: Page) => { await expect(page.getByTestId("cancelled-headline")).toBeVisible(); }, - cancelAndRescheduleBooking: async (eventTypePage: Page) => { + + rescheduleBooking: async (eventTypePage: Page) => { await eventTypePage.getByText("Reschedule").click(); while (await eventTypePage.getByRole("button", { name: "View next" }).isVisible()) { await eventTypePage.getByRole("button", { name: "View next" }).click(); @@ -194,7 +193,13 @@ export function createBookingPageFixture(page: Page) { await eventTypePage.getByPlaceholder(reschedulePlaceholderText).click(); await eventTypePage.getByPlaceholder(reschedulePlaceholderText).fill("Test reschedule"); await eventTypePage.getByTestId("confirm-reschedule-button").click(); - await expect(eventTypePage.getByText(scheduleSuccessfullyText)).toBeVisible(); + }, + + assertBookingRescheduled: async (page: Page) => { + await expect(page.getByText(scheduleSuccessfullyText)).toBeVisible(); + }, + + cancelBooking: async (eventTypePage: Page) => { await eventTypePage.getByTestId("cancel").click(); await eventTypePage.getByTestId("cancel_reason").fill("Test cancel"); await eventTypePage.getByTestId("confirm_cancel").click(); diff --git a/apps/web/playwright/lib/testUtils.ts b/apps/web/playwright/lib/testUtils.ts index f401dca0f9..b9cf3850d6 100644 --- a/apps/web/playwright/lib/testUtils.ts +++ b/apps/web/playwright/lib/testUtils.ts @@ -1,5 +1,6 @@ import type { Frame, Page } from "@playwright/test"; import { expect } from "@playwright/test"; +import EventEmitter from "events"; import type { IncomingMessage, ServerResponse } from "http"; import { createServer } from "http"; // eslint-disable-next-line no-restricted-imports @@ -35,7 +36,27 @@ export function createHttpServer(opts: { requestHandler?: RequestHandler } = {}) res.end(); }, } = opts; + const eventEmitter = new EventEmitter(); const requestList: Request[] = []; + + const waitForRequestCount = (count: number) => + new Promise((resolve) => { + if (requestList.length === count) { + resolve(); + return; + } + + const pushHandler = () => { + if (requestList.length !== count) { + return; + } + eventEmitter.off("push", pushHandler); + resolve(); + }; + + eventEmitter.on("push", pushHandler); + }); + const server = createServer((req, res) => { const buffer: unknown[] = []; @@ -49,6 +70,7 @@ export function createHttpServer(opts: { requestHandler?: RequestHandler } = {}) _req.body = json; requestList.push(_req); + eventEmitter.emit("push"); requestHandler({ req: _req, res }); }); }); @@ -58,34 +80,16 @@ export function createHttpServer(opts: { requestHandler?: RequestHandler } = {}) // eslint-disable-next-line @typescript-eslint/no-explicit-any const port: number = (server.address() as any).port; const url = `http://localhost:${port}`; + return { port, close: () => server.close(), requestList, url, + waitForRequestCount, }; } -/** - * When in need to wait for any period of time you can use waitFor, to wait for your expectations to pass. - */ -export async function waitFor(fn: () => Promise | unknown, opts: { timeout?: number } = {}) { - let finished = false; - const timeout = opts.timeout ?? 5000; // 5s - const timeStart = Date.now(); - while (!finished) { - try { - await fn(); - finished = true; - } catch { - if (Date.now() - timeStart >= timeout) { - throw new Error("waitFor timed out"); - } - await new Promise((resolve) => setTimeout(resolve, 0)); - } - } -} - export async function selectFirstAvailableTimeSlotNextMonth(page: Page | Frame) { // Let current month dates fully render. await page.click('[data-testid="incrementMonth"]'); diff --git a/apps/web/playwright/manage-booking-questions.e2e.ts b/apps/web/playwright/manage-booking-questions.e2e.ts index 4091acfa1a..9f0d4762ae 100644 --- a/apps/web/playwright/manage-booking-questions.e2e.ts +++ b/apps/web/playwright/manage-booking-questions.e2e.ts @@ -8,7 +8,7 @@ import { WebhookTriggerEvents } from "@calcom/prisma/enums"; import type { CalendarEvent } from "@calcom/types/Calendar"; import { test } from "./lib/fixtures"; -import { createHttpServer, waitFor, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils"; +import { createHttpServer, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils"; async function getLabelText(field: Locator) { return await field.locator("label").first().locator("span").first().innerText(); @@ -215,13 +215,7 @@ test.describe("Manage Booking Questions", () => { async function runTestStepsCommonForTeamAndUserEventType( page: Page, context: PlaywrightTestArgs["context"], - webhookReceiver: { - port: number; - close: () => import("http").Server; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - requestList: (import("http").IncomingMessage & { body?: any })[]; - url: string; - } + webhookReceiver: Awaited> ) { await page.click('[href$="tabName=advanced"]'); @@ -311,12 +305,11 @@ async function runTestStepsCommonForTeamAndUserEventType( await page.locator('[data-testid="field-response"][data-fob-field="how-are-you"]').innerText() ).toBe("I am great!"); - await waitFor(() => { - expect(webhookReceiver.requestList.length).toBe(1); - }); + await webhookReceiver.waitForRequestCount(1); const [request] = webhookReceiver.requestList; + // @ts-expect-error body is unknown const payload = request.body.payload; expect(payload.responses).toMatchObject({ @@ -667,9 +660,7 @@ async function expectWebhookToBeCalled( }; } ) { - await waitFor(() => { - expect(webhookReceiver.requestList.length).toBe(1); - }); + await webhookReceiver.waitForRequestCount(1); const [request] = webhookReceiver.requestList; const body = request.body; diff --git a/apps/web/playwright/webhook.e2e.ts b/apps/web/playwright/webhook.e2e.ts index d5e1d5b512..074ffbfd5c 100644 --- a/apps/web/playwright/webhook.e2e.ts +++ b/apps/web/playwright/webhook.e2e.ts @@ -10,7 +10,6 @@ import { bookOptinEvent, createHttpServer, selectFirstAvailableTimeSlotNextMonth, - waitFor, gotoRoutingLink, createUserWithSeatedEventAndAttendees, } from "./lib/testUtils"; @@ -78,10 +77,7 @@ test.describe("BOOKING_CREATED", async () => { await page.fill('[name="email"]', "test@example.com"); await page.press('[name="email"]', "Enter"); - // --- check that webhook was called - await waitFor(() => { - expect(webhookReceiver.requestList.length).toBe(1); - }); + await webhookReceiver.waitForRequestCount(1); const [request] = webhookReceiver.requestList; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -209,10 +205,8 @@ test.describe("BOOKING_REJECTED", async () => { await page.click('[data-testid="rejection-confirm"]'); await page.waitForResponse((response) => response.url().includes("/api/trpc/bookings/confirm")); - // --- check that webhook was called - await waitFor(() => { - expect(webhookReceiver.requestList.length).toBe(1); - }); + await webhookReceiver.waitForRequestCount(1); + const [request] = webhookReceiver.requestList; // eslint-disable-next-line @typescript-eslint/no-explicit-any const body = request.body as any; @@ -332,9 +326,8 @@ test.describe("BOOKING_REQUESTED", async () => { // --- check that webhook was called - await waitFor(() => { - expect(webhookReceiver.requestList.length).toBe(1); - }); + await webhookReceiver.waitForRequestCount(1); + const [request] = webhookReceiver.requestList; // eslint-disable-next-line @typescript-eslint/no-explicit-any const body = request.body as any; @@ -442,9 +435,7 @@ test.describe("BOOKING_RESCHEDULED", async () => { expect(newBooking).not.toBeNull(); // --- check that webhook was called - await waitFor(() => { - expect(webhookReceiver.requestList.length).toBe(1); - }); + await webhookReceiver.waitForRequestCount(1); const [request] = webhookReceiver.requestList; @@ -520,9 +511,7 @@ test.describe("BOOKING_RESCHEDULED", async () => { expect(newBooking).not.toBeNull(); // --- check that webhook was called - await waitFor(() => { - expect(webhookReceiver.requestList.length).toBe(1); - }); + await webhookReceiver.waitForRequestCount(1); const [firstRequest] = webhookReceiver.requestList; @@ -541,9 +530,7 @@ test.describe("BOOKING_RESCHEDULED", async () => { await expect(page).toHaveURL(/.*booking/); - await waitFor(() => { - expect(webhookReceiver.requestList.length).toBe(2); - }); + await webhookReceiver.waitForRequestCount(2); const [_, secondRequest] = webhookReceiver.requestList; @@ -597,9 +584,8 @@ test.describe("FORM_SUBMITTED", async () => { await page.fill(`[data-testid="form-field-${fieldName}"]`, "John Doe"); page.click('button[type="submit"]'); - await waitFor(() => { - expect(webhookReceiver.requestList.length).toBe(1); - }); + await webhookReceiver.waitForRequestCount(1); + const [request] = webhookReceiver.requestList; // eslint-disable-next-line @typescript-eslint/no-explicit-any const body = request.body as any; @@ -656,9 +642,9 @@ test.describe("FORM_SUBMITTED", async () => { const fieldName = "name"; await page.fill(`[data-testid="form-field-${fieldName}"]`, "John Doe"); page.click('button[type="submit"]'); - await waitFor(() => { - expect(webhookReceiver.requestList.length).toBe(1); - }); + + await webhookReceiver.waitForRequestCount(1); + const [request] = webhookReceiver.requestList; // eslint-disable-next-line @typescript-eslint/no-explicit-any const body = request.body as any; diff --git a/apps/web/public/static/locales/ar/vital.json b/apps/web/public/static/locales/ar/vital.json deleted file mode 100644 index 75c0a57f52..0000000000 --- a/apps/web/public/static/locales/ar/vital.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "connected_vital_app": "متصل باستخدام", - "vital_app_sleep_automation": "أتمتة إعادة الجدولة بناءً على بيانات نومك", - "vital_app_automation_description": "يمكنك تحديد معلمات مختلفة لتشغيل إعادة الجدولة بناءً على مقاييس النوم الخاصة بك.", - "vital_app_parameter": "المعلمات", - "vital_app_trigger": "Trigger عندما يساوي أو يكون أقل من", - "vital_app_save_button": "حفظ التكوين", - "vital_app_total_label": "المجموع (المجموع = نوم حركة العين السريعة + النوم الخفيف + النوم العميق)", - "vital_app_duration_label": "المدة (المدة= نهاية وقت النوم - بداية وقت النوم)", - "vital_app_hours": "ساعة", - "vital_app_save_success": "تم حفظ Vital Configurations بنجاح", - "vital_app_save_error": "حدث خطأ أثناء حفظ Vital Configurations الخاصة بك" -} diff --git a/apps/web/public/static/locales/cs/vital.json b/apps/web/public/static/locales/cs/vital.json deleted file mode 100644 index 7d94769411..0000000000 --- a/apps/web/public/static/locales/cs/vital.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "connected_vital_app": "Propojeno s aplikací", - "vital_app_sleep_automation": "Automatizace přeplánování spánku", - "vital_app_automation_description": "Na základě měření délky spánku můžete zvolit různé parametry, které spustí přeplánování.", - "vital_app_parameter": "Parametr", - "vital_app_trigger": "Spustit při hodnotě menší nebo rovné", - "vital_app_save_button": "Uložit konfiguraci", - "vital_app_total_label": "Celková doba (celková doba = REM + lehký spánek + hluboký spánek)", - "vital_app_duration_label": "Délka (délka = konec uložení ke spánku − začátek uložení ke spánku)", - "vital_app_hours": "h", - "vital_app_save_success": "Uložení konfigurace aplikace Vital se zdařilo", - "vital_app_save_error": "Při ukládání konfigurace aplikace Vital se vyskytla chyba" -} diff --git a/apps/web/public/static/locales/de/vital.json b/apps/web/public/static/locales/de/vital.json deleted file mode 100644 index 02f8804983..0000000000 --- a/apps/web/public/static/locales/de/vital.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "connected_vital_app": "Verbunden mit", - "vital_app_sleep_automation": "Schlaf Terminumbuchunsautomatisierung", - "vital_app_automation_description": "Sie können verschiedene Parameter auswählen, um die Umbuchung basierend auf Ihren Schlafmetriken auszulösen.", - "vital_app_parameter": "Parameter", - "vital_app_trigger": "Auslöser kleiner oder gleich", - "vital_app_save_button": "Einstellungen speichern", - "vital_app_total_label": "Gesamt (Gesamt= rem + leichter Schlaf + tiefer Schlaf)", - "vital_app_duration_label": "Dauer (Dauer = Schlafzeitende - Schlafzeit start)", - "vital_app_hours": "Stunden", - "vital_app_save_success": "Erfolgreich Ihre Vital-Konfigurationen wurden Erfolgreich gespeichert", - "vital_app_save_error": "Ein Fehler ist aufgetreten beim Speichern Ihrer Vital Einstellungen" - } diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 7a1b81c44c..7a78643903 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1605,6 +1605,7 @@ "options": "Options", "enter_option": "Enter Option {{index}}", "add_an_option": "Add an option", + "location_already_exists": "This Location already exists. Please select a new location", "radio": "Radio", "google_meet_warning": "In order to use Google Meet you must set your destination calendar to a Google Calendar", "individual": "Individual", diff --git a/apps/web/public/static/locales/es/vital.json b/apps/web/public/static/locales/es/vital.json deleted file mode 100644 index 64c6324058..0000000000 --- a/apps/web/public/static/locales/es/vital.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "connected_vital_app": "Conectado con", - "vital_app_sleep_automation": "Automatización de reprogramación del sueño", - "vital_app_automation_description": "Puede seleccionar diferentes parámetros para activar la reprogramación automática del sueño.", - "vital_app_parameter": "Parámetro", - "vital_app_trigger": "Activar cuando sea igual o menor que", - "vital_app_save_button": "Guardar configuración", - "vital_app_total_label": "Total (total = rem + sueño ligero + sueño profundo)", - "vital_app_duration_label": "Duración (duración = horario que se levanta de la cama - horario que se acuesta en la cama)", - "vital_app_hours": "horas", - "vital_app_save_success": "Guardado exitoso de sus configuraciones de Vital App ", - "vital_app_save_error": "Ocurrió un error al intentar guardar sus configuraciones de Vital App " -} diff --git a/apps/web/public/static/locales/eu/common.json b/apps/web/public/static/locales/eu/common.json index 01e610bfd4..1c248ba252 100644 --- a/apps/web/public/static/locales/eu/common.json +++ b/apps/web/public/static/locales/eu/common.json @@ -17,6 +17,7 @@ "verify_email_banner_body": "Egiaztatu zure email helbidea mezuak eta egutegiko eguneratzeak ahalik eta hobekien jasoko dituzula bermatzeko", "verify_email_email_header": "Egiaztatu zure email helbidea", "verify_email_email_button": "Egiaztatu emaila", + "copy_somewhere_safe": "Gorde API gako hau toki seguru batean. Ezingo duzu berriro ikusi.", "verify_email_email_body": "Mesedez, egiaztatu zure email helbidea beheko botoia sakatuz.", "verify_email_by_code_email_body": "Mesedez, egiaztatu zure email helbidea beheko kodea erabiliz.", "verify_email_email_link_text": "Hemen duzu esteka, botoiak sakatzea gustuko ez baduzu:", @@ -45,17 +46,25 @@ "invite_team_notifcation_badge": "Gon.", "your_event_has_been_scheduled": "Zure gertaera programatu da", "your_event_has_been_scheduled_recurring": "Zure gertaera errepikaria programatu da", + "accept_our_license": "Onartu gure lizentzia <1>NEXT_PUBLIC_LICENSE_CONSENT .env aldagaia aldatuz honakora: '{{agree}}'.", + "remove_banner_instructions": "Baner hau ezabatzeko, mesedez zabaldu zure .env fitxategia eta aldatu <1>NEXT_PUBLIC_LICENSE_CONSENT aldagaia honakora: '{{agree}}'.", "error_message": "Errore-mezua honakoa ian da: '{{errorMessage}}'", "refund_failed_subject": "Itzulketak huts egin du: {{name}} - {{date}} - {{eventType}}", "refund_failed": "Huts egin du itzulketak {{eventType}} gertaerarako, {{userName}}(r)ekin {{date}}(e)an.", "a_refund_failed": "Itzulketa batek huts egin du", "awaiting_payment_subject": "Ordainketaren zain: {{title}} {{date}}(e)an", + "meeting_awaiting_payment": "Zure bilera ordainketa zain dago", "help": "Laguntza", "price": "Prezioa", "paid": "Ordainduta", "refunded": "Itzulita", "payment": "Ordainketa", + "missing_card_fields": "Txartelaren eremuak falta dira", "pay_now": "Ordaindu orain", + "terms_summary": "Terminoen laburpena", + "open_env": "Ireki .env eta onartu gure Lizentzia", + "env_changed": "Nire .env aldatu dut", + "accept_license": "Onart Lizentzia", "still_waiting_for_approval": "Gertaera bat onarpenaren zain dago", "event_is_still_waiting": "Gertaera-eskaera oraindik zain dago: {{attendeeName}} - {{date}} - {{eventType}}", "no_more_results": "Emaitza gehiagorik ez", @@ -82,12 +91,36 @@ "event_has_been_rescheduled": "Eguneratuta - Zure gertaeraren programazioa aldatu egin da", "request_reschedule_subtitle": "{{organizer}}(e)k erreserba bertan behera utzi du eta beste denbora-tarte bat hautatzeko eskatu dizu.", "request_reschedule_title_organizer": "Beste denbora-tarte bat hautatzeko eskatu diozu {{attendee}}(r)i", + "request_reschedule_subtitle_organizer": "Erreserba bertan behera utzi duzu eta {{attendee}}(e)k erreserba-ordu berri bat hautatu beharko du.", + "rescheduled_event_type_subject": "Berrantolatzeko eskaera bidalita: {{eventType}} {{name}}(r)ekin {{date}}(e)an", "hi_user_name": "Kaixo {{name}}", "ics_event_title": "{{eventType}} {{name}}(r)ekin", + "new_event_subject": "Gertaera berria: {{attendeeName}} - {{date}} - {{eventType}}", "notes": "Oharrak", "manage_my_bookings": "Kudeatu nire erreserbak", + "need_to_make_a_change": "Aldaketaren bat egin behar duzu?", + "new_event_scheduled": "Gertaera berri bat programatu da.", + "new_event_scheduled_recurring": "Gertaera errepikari berri bat programatu da.", + "invitee_email": "Gonbidatuaren emaila", + "invitee_timezone": "Gonbidatuaren ordu-eremua", + "event_type": "Gertaera mota", + "enter_meeting": "Sartu bilerara", + "video_call_provider": "Bideodeiaren hornitzailea", + "meeting_id": "Bileraren IDa", + "meeting_password": "Bileraren pasahitza", + "meeting_url": "Bilerarako URLa", + "meeting_request_rejected": "Zure bilera eskaera ez da onartu", "rejected_event_type_with_organizer": "Errefusatua: {{eventType}} {{organizer}}(r)ekin {{date}}(e)an", "hi": "Kaixo", + "join_team": "Batu taldera", + "manage_this_team": "Kudeatu talde hau", + "team_info": "Taldearen informazioa", + "you_have_been_invited": "{{teamName}} taldera batzeko gonbidatua izan zara", + "hidden_team_member_title": "Ezkutuan zaude talde honetan", + "hidden_team_owner_message": "Pro kontu bat behar duzu taldeak erabiltzeko, ezkutuan geratuko zara bitartean.", + "team_upgrade_banner_description": "Ez duzu taldea konfiguratzen bukatu. Zure \"{{teamName}}\" taldea bertsio-berritu behar duzu.", + "upgrade_banner_action": "Bertsio-berritu hemen", + "team_upgraded_successfully": "Zure taldea zuzen bertsio-berritu da!", "use_link_to_reset_password": "Erabili beheko esteka pasahitza berrezartzeko", "hey_there": "Kaixo,", "forgot_your_password_calcom": "Pasahitza ahaztu duzu? - {{appName}}", @@ -122,32 +155,612 @@ "rejected": "Baztertua", "unconfirmed": "Baieztatu gabea", "guests": "Gonbidatuak", + "guest": "Gonbidatua", + "404_the_user": "Erabiltzaile izena", + "username": "Erabiltzaile izena", + "is_still_available": "orandik eskuragarri dago.", + "documentation": "Dokumentazioa", + "blog": "Bloga", + "404_claim_entity_user": "Erreklamatu zure erabiltzaile-izena eta programatu gertaerak", + "popular_pages": "Orrialde ospetsuak", + "register_now": "Izena eman orain", + "register": "Izena eman", + "page_doesnt_exist": "Orrialde hau ez dago.", + "check_spelling_mistakes_or_go_back": "Egiaztatu akats ortografikorik ez dagoela edo joan aurreko orrira.", + "404_page_not_found": "404: Orrialde hau ezin izan da aurkitu.", + "booker_event_not_found": "Ezin izan dugu aurkitu erreserbatu nahi izan duzun gertaera.", + "getting_started": "Nola hasi", + "15min_meeting": "15 minutuko bilera", + "30min_meeting": "30 minutuko bilera", + "secret": "Sekretua", + "leave_blank_to_remove_secret": "Zuri utzi sekretua ezabatzeko", + "secret_meeting": "Bilera sekretua", + "already_have_an_account": "Baduzu kontua dagoeneko?", "create_account": "Sortu kontua", "confirm_password": "Baieztatu pasahitza", + "reset_your_password": "Ezarri zure pasahitz berria zure email helbidera bidalitako argibideak jarraituz.", + "create_your_account": "Sortu zure kontua", + "sign_up": "Izena eman", + "youve_been_logged_out": "Saioa amaitu duzu", + "hope_to_see_you_soon": "Laster ikusiko zaitugula espero dugu!", + "logged_out": "Saioa amaituta", + "no_account_exists": "Ez dago konturik email helbide horrekin bat datorrenik.", + "create_an_account": "Sortu kontu bat", + "dont_have_an_account": "Ez duzu konturik?", + "sign_in_account": "Hasi saioa zure kontuan", + "sign_in": "Hasi saioa", + "go_back_login": "Itzuli saio-hasiera orrialdera", + "request_password_reset": "Bidali berrezartzeko emaila", + "send_invite": "Bidali gonbidapena", + "forgot_password": "Pasahitza ahaztu duzu?", + "forgot": "Ahaztuta?", + "done": "Eginda", + "all_done": "Dena eginda!", + "all": "Guztia", + "yours": "Zure kontua", + "finish": "Amaitu", + "organization_general_description": "Kudeatu zure taldearen hizkuntza eta ordu-eremuko ezarpenak", + "few_sentences_about_yourself": "Esaldi gutxi batzuk zeuri buruz. Zure orrialde pertsonalean agertuko dira.", + "nearly_there": "Ia bukatuta!", + "set_availability": "Ezarri zein ordutan zauden libre", + "continue_without_calendar": "Jarraitu egutegirik gabe", + "connect_your_calendar": "Konektatu zure egutegia", + "connect_your_video_app": "Konektatu zure bideo-aplikazioak", + "set_up_later": "Konfiguratu geroago", + "current_time": "Uneko ordua", + "details": "Xehetasunak", + "welcome": "Ongi etorri", + "welcome_back": "Ongi etorri", + "welcome_to_calcom": "Ongi etorri {{appName}}(e)ra", + "connect": "Konektatu", + "try_for_free": "Proba ezazu doan", "create_booking_link_with_calcom": "Sor ezazu zeure erreserba-esteka {{appName}}(e)kin", + "who": "Nor(k)", + "what": "Zer", + "when": "Noiz", + "where": "Non", + "add_to_calendar": "Gehitu egutegira", + "add_events_to": "Gehitu gertaerak hona:", + "add_another_calendar": "Gehitu beste egutegi bat", + "other": "Besterik", "user_needs_to_confirm_or_reject_booking": "{{user}}(e)k erreserba baieztatu edo errefusatu behar du oraindik.", + "meeting_is_scheduled": "Bilera hau programatuta dago", + "meeting_is_scheduled_recurring": "Gertaera errepikariak programatuta daude", "booking_submitted": "Zure erreserba bidali da", + "booking_submitted_recurring": "Zure bilera errepikaria bidali da", "booking_confirmed": "Zure erreserba baieztatu da", + "booking_confirmed_recurring": "Zure bilera errepikaria baieztatu da", + "reset_password": "Berrezarri pasahitza", + "change_your_password": "Aldatu zure pasahitza", + "show_password": "Erakutsi pasahitza", + "hide_password": "Ezkutatu pasahitza", + "try_again": "Saiatu berriro", + "whoops": "Hara", + "login": "Saioa hasi", + "success": "Arrakasta", + "failed": "Huts egin du", + "password_has_been_reset_login": "Zure pasahitza berrezarri da. Orain saioa has dezakezu sortu berri duzun pasahitzarekin.", + "layout": "Diseinua", + "bookerlayout_default_title": "Lehenetsitako ikuspegia", + "bookerlayout_user_settings_title": "Erreserbatarako diseinua", + "bookerlayout_month_view": "Hilabetea", + "bookerlayout_week_view": "Astero", "bookerlayout_column_view": "Zutabea", + "bookerlayout_error_min_one_enabled": "Gutxienez ikuspegi bat gaituta egotea behar da.", + "bookerlayout_error_default_not_enabled": "Lehenetsitako ikuspegi bezala hautatu duzun diseinua ez dago gaitutako diseinuen artean.", + "bookerlayout_error_unknown_layout": "Hautatu duzun diseinua ez da baliozko diseinu bat.", + "sunday_time_error": "Ordu baliogabea igandean", + "monday_time_error": "Ordu baliogabea astelehenean", + "tuesday_time_error": "Ordu baliogabea asteartean", + "wednesday_time_error": "Ordu baliogabea asteazkenean", + "thursday_time_error": "Ordu baliogabea ostegunean", + "friday_time_error": "Ordu baliogabea ostiralean", + "saturday_time_error": "Ordu baliogabea larunbatean", + "error_end_time_before_start_time": "Amaiera-orduak ezin du hasiera-ordua baino lehenago izan", + "error_end_time_next_day": "Amaiera-denborak ezin du 24 ordu baino gehiago izan", "back_to_bookings": "Itzuli erreserbatara", + "cancelled": "Bertan behera", "really_cancel_booking": "Benetan bertan behera utzi nahi duzu zure erreserba?", "cannot_cancel_booking": "Ezin duzu erreserba hau bertan behera utzi", "booking_already_accepted_rejected": "Erreserba hau onartu edo errefusatu da dagoeneko", + "go_back_home": "Itzuli hasierara", + "or_go_back_home": "Edo itzuli hasierara", + "no_meeting_found": "Ez da bilerarik aurkitu", + "no_meeting_found_description": "Bilera hau ez dago. Jarri harremanetan bileraren jabearekin eguneratutako esteka lortzeko.", "bookings": "Erreserbak", + "booking_not_found": "Erreserba ez da aurkitu", "past_bookings": "Zure iraganeko erreserbak agertuko dira hemen.", "unconfirmed_bookings": "Zure baieztatu gabeko erreserbak agertuko dira hemen.", "unconfirmed_bookings_tooltip": "Baieztatu gabeko erreserbak", + "start_time": "Hasiera-ordua", + "end_time": "Amaiera-orduan", + "buffer_time": "Tarteko denbora", + "before_event": "Gertaeraren aurretik", + "after_event": "Gertaeraren ondoren", + "event_buffer_default": "Tarteko denborarik ez", + "buffer": "Tarteko denbora", + "your_day_starts_at": "Zure eguna hasten den ordua:", + "your_day_ends_at": "Zure eguna amaitzen den ordua:", + "change_available_times": "Aldatu libre zauden orduan", + "change_your_available_times": "Aldatu libre zauden orduak", + "change_start_end": "Aldatu zure egunaren hasiera- eta amaiera-orduak", + "change_start_end_buffer": "Ezarri zure egunaren hasiera- eta amaiera-ordua eta gutxieneko denbora tarte bat zure bileren artean.", + "current_start_date": "Unean, zure eguna honako orduan hasteko ezarrita dago:", + "start_end_changed_successfully": "Zure egunaren hasiera- eta amaiera-orduak egoki aldatu dira.", + "light": "Argia", + "dark": "Iluna", + "email": "Emaila", + "email_placeholder": "izena@adibidea.eus", + "full_name": "Izen osoa", + "booking_cancelled": "Erreserba bertan behera", "booking_rescheduled": "Erreserbaren programazioa aldatuta", "booking_created": "Erreserba sortuta", + "booking_rejected": "Erreserba ez onartua", + "booking_requested": "Erreserba eskatua", + "meeting_ended": "Bilera amaitu da", + "form_submitted": "Galdetegia bidali da", + "uh_oh": "Ai ama!", + "no_event_types_have_been_setup": "Erabiltzaile honek ez du gertaera-motarik konfiguratu oraindik.", + "edit_logo": "Editatu logoa", + "upload_a_logo": "Kargatu logo bat", + "upload_logo": "Kargatu logoa", + "remove_logo": "Ezabatu logoa", + "enable": "Gaitu", + "code": "Kodea", + "code_is_incorrect": "Kodea ez da zuzena.", + "add_time_availability": "Gehitu denbora-tarte berri bat", + "security": "Segurtasuna", + "manage_account_security": "Kudeatu zure kontuaren segurtasuna.", + "password": "Pasahitza", + "password_updated_successfully": "Pasahitza egoki eguneratu da", + "password_has_been_changed": "Zure pasahitza egoki aldatu da.", + "error_changing_password": "Errorea pasahitza aldatzean", + "session_timeout_change_error": "Errorea saioaren konfigurazioa eguneratzerakoan", + "something_went_wrong": "Zerbait gaizki joan da.", + "something_doesnt_look_right": "Zerbaitek ez du itxura onik?", + "please_try_again": "Saia zaitez berriro, mesedez.", + "super_secure_new_password": "Zure pasahitz berri super segurua", + "new_password": "Pasahitz berria", + "your_old_password": "Zure pasahitz zaharra", + "current_password": "Uneko pasahitza", + "change_password": "Aldatu pasahitza", + "change_secret": "Aldatu sekretua", + "new_password_matches_old_password": "Pasahitz berria zure pasahitz zaharrarekin bat dator. Aukeratu ezazu pasahitz ezberdin bat, mesedez.", + "current_incorrect_password": "Uneko pasahitza ez da zuzena", + "password_hint_caplow": "Maiuskulak eta minuskulak nahasian", + "password_hint_min": "Gutxienez 7 karaktereko luzera", + "password_hint_admin_min": "Gutxienez 15 karaktereko luzera", + "password_hint_num": "Gutxienez zenbaki bat", + "max_limit_allowed_hint": "{{limit}} karaktere edo gutxiagoko luzera izan behar du", + "invalid_password_hint": "Pasahitzak gutxienez {{passwordLength}} karaktereko luzera behar du gutxienez zenbaki bat eta letra maiuskula zein minuskulak nahasten dituela", + "incorrect_password": "Pasahitza ez da zuzena.", + "incorrect_email_password": "Emaila edo pasahitza ez dira zuzenak.", + "am_pm": "am/pm", + "january": "Urtarrila", + "february": "Otsaila", + "march": "Martxoa", + "april": "Apirila", + "may": "Maiatza", + "june": "Ekaina", + "july": "Uztaila", + "august": "Abuztua", + "september": "Iraila", + "october": "Urria", + "november": "Azaroa", + "december": "Abendua", + "monday": "Astelehena", + "tuesday": "Asteartea", + "wednesday": "Asteazkena", + "thursday": "Osteguna", + "friday": "Ostirala", + "saturday": "Larunbata", + "sunday": "Igandea", + "all_booked_today": "Dena erreserbatuta.", + "additional_guests": "Gehitu gonbidatuak", + "your_name": "Zure izena", + "your_full_name": "Zure izen osoa", + "no_name": "Izenik ez", + "enter_number_between_range": "Mesedez sartu 1 eta {{maxOccurences}} arteko zenbaki bat", + "email_address": "Email helbidea", + "enter_valid_email": "Mesedez, adierazi baliozko email helbide bat", + "location": "Kokapena", + "address": "Helbidea", + "enter_address": "Sartu helbidea", + "in_person_attendee_address": "Aurrez aurre (partaidearen helbidean)", + "yes": "bai", + "no": "ez", + "additional_notes": "Ohar gehigarriak", + "booking_fail": "Ezin izan da bilera erreserbatu.", + "reschedule_fail": "Ezin izan da bilera berrantolatu.", + "in_person_meeting": "Aurrez aurreko bilera", + "in_person": "Aurrez aurre (antolatzailearen helbidean)", + "phone_number": "Telefono zenbakia", + "attendee_phone_number": "Partaidearen telefono zenbakia", + "organizer_phone_number": "Antolatzailearen telefono zenbakia", + "enter_phone_number": "Sartu telefono zenbakia", + "reschedule": "Berrantolatu", + "or": "EDO", + "go_back": "Atzera", + "email_or_username": "Emaila edo erabiltzaile izena", + "send_invite_email": "Bidali gonbidapen-email bat", + "role": "Eginkizuna", + "edit_role": "Editatu eginkizuna", + "edit_team": "Editatu taldea", + "reject": "Baztertu", + "reject_all": "Baztertu guztiak", + "accept": "Onartu", + "profile": "Profila", + "my_team_url": "Nire taldearen URLa", + "my_teams": "Nire taldeak", + "team_name": "Taldearen izena", + "your_team_name": "Zure taldearen izena", + "team_updated_successfully": "Taldea egoki eguneratu da", + "your_team_updated_successfully": "Zure taldea egoki eguneratu da.", + "your_org_updated_successfully": "Zure erakundea egoki eguneratu da.", + "about": "Honi buruz", + "team_description": "Esaldi gutxi batzuk zure taldeari buruz. Zure taldearen orrialdean agertuko dira.", + "org_description": "Esaldi gutxi batzuk zure erakundeari buruz. Zure erakundearen orrialdean agertuko dira.", + "members": "Kideak", + "organization_members": "Erakundeko kideak", + "member": "Kidea", + "number_member_one": "{{count}} kide", + "danger_zone": "Arrisku gunea", + "account_deletion_cannot_be_undone": "Kontuz. Kontuak ezabatzea ezin da desegin.", + "back": "Atzera", + "cancel_event": "Bertan behera utzi gertaera", + "continue": "Jarraitu", + "confirm": "Baieztatu", + "confirm_all": "Baieztatu guztiak", + "confirm_remove_member": "Bai, ezabatu kidea", + "remove_member": "Ezabatu kidea", + "manage_your_team": "Kudeatu zure taldea", + "no_teams": "Oraindik ez daukazu talderik.", + "submit": "Bidali", + "delete": "Ezabatu", + "update": "Eguneratu", + "save": "Gorde", + "pending": "Egiteke", + "open_options": "Ireki aukerak", + "copy_link": "Kopiatu gertaerarako esteka", + "share": "Partekatu", + "copy_link_team": "Kopiatu talderako esteka", + "leave_team": "Utzi taldea", + "confirm_leave_team": "Bai, utzi taldea", + "leave_team_confirmation_message": "Ziur al zaude talde hau utzi nahi duzula? Ezingo duzu erreserbarik egin taldea erabiliz hemendik aurrera.", + "preview": "Aurreikusi", + "link_copied": "Esteka kopiatuta!", + "private_link_copied": "Esteka pribatua kopiatuta!", + "link_shared": "Esteka partekatuta!", + "title": "Izenburua", + "description": "Deskribapena", + "preview_team": "Aurreikusi taldea", + "duration": "Iraupena", + "available_durations": "Iraupen aukerak", + "default_duration": "Lehenetsitako iraupena", + "minutes": "minutu", + "username_placeholder": "erabiltzaile izena", + "count_members_one": "kide {{count}}", + "count_members_other": "{{count}} kide", + "url": "URLa", + "hidden": "Ezkutuan", + "readonly": "Irakurtzeko bakarrik", + "one_time_link": "Aldi bakarreko esteka", + "upload_avatar": "Kargatu abatarra", + "language": "Hizkuntza", + "timezone": "Ordu-eremua", + "first_day_of_week": "Asteko lehen eguna", + "plus_more": "{{count}} gehiago", + "create_team": "Sortu taldea", + "name": "Izena", + "create_new_team_description": "Sortu talde berri bat erabiltzaileekin elkarlanean aritzeko.", + "create_new_team": "Sortu talde berri bat", + "open_invitations": "Gonbidapen irekiak", + "new_team": "Talde berria", + "create_first_team_and_invite_others": "Sortu zure lehen taldea eta gonbidatu beste erabiltzaileak elkarlanean aritzera.", + "create_team_to_get_started": "Sortu talde bat hasteko", + "teams": "Taldeak", + "team": "Taldea", + "organization": "Erakundea", + "change_email_tip": "Saioa itxi eta berriro hasi beharko duzu aldaketa ikusi ahal izateko.", + "little_something_about": "Kontatu zuri buruzko zerbait.", + "profile_updated_successfully": "Profila egoki eguneratu da", + "your_user_profile_updated_successfully": "Zure erabiltzaile profila egoki eguneratu da.", + "enabled": "Gaituta", + "disabled": "Ezgaituta", + "disable": "Ezgaitu", + "billing": "Fakturazioa", + "manage_your_billing_info": "Kudeatu zure fakturaziorako informazioa eta amaitu zure harpidetza.", + "logo": "Logoa", + "error": "Errorea", + "team_logo": "Taldearen logoa", + "add_location": "Gehitu kokapena", + "attendees": "Partaideak", + "add_attendees": "Gehitu partaideak", + "label": "Etiketa", + "type": "Mota", + "edit": "Editatu", + "disable_notes": "Ezkutatu oharrak egutegian", + "recurring_event": "Gertaera errepikaria", + "disable_guests": "Ezgaitu gonbidatuak", + "private_link": "Sortu esteka pribatua", + "enable_private_url": "Gaitu URL pribatua", + "private_link_label": "Esteka pribatua", + "private_link_hint": "Zure esteka pribatua birsortu egingo da erabilera bakoitzaren ondoren", + "copy_private_link": "Kopiatu esteka pribatua", + "invitees_can_schedule": "Gobnidatuek programatu dezakete", + "set_address_place": "Ezarri helbide edo toki bat", + "set_link_meeting": "Ezarri esteka bat bilerarako", + "you_need_to_add_a_name": "Izena gehitu behar duzu", + "hide_event_type": "Ezkutatu gertaera mota", + "edit_location": "Editatu kokapena", + "quick_chat": "Elkarrizketa azkarra", + "add_new_event_type": "Gehitu gertaera mota berri bat", + "length": "Luzera", + "delete_event_type": "Ezabatu gertaera mota?", + "confirm_delete_event_type": "Bai, ezabatu", + "delete_account": "Ezabatu kontua", + "confirm_delete_account": "Bai, ezabatu kontua", + "settings": "Ezarpenak", + "event_type_moved_successfully": "Gertaera mota zuzen mugitu da", + "next_step_text": "Hurrengo pausoa", + "next_step": "Saltatu pausoa", + "prev_step": "Aurreko pausoa", + "install": "Instalatu", + "installed": "Instalatua", + "disconnect": "Deskonektatu", + "automation": "Automatizazioa", + "connect_additional_calendar": "Konektatu egutegi bat gehiago", + "calendar_updated_successfully": "Egutegia zuzen eguneratu da", + "calendar": "Egutegia", + "payments": "Ordainketak", + "not_installed": "Instalatu gabe", + "error_password_mismatch": "Pasahitzak ez datoz bat.", + "error_required_field": "Eremu hau nahitaezkoa da.", + "status": "Egoera", + "signin_with_google": "Hasi saioa Googlerekin", + "signin_with_saml": "Hasi saioa SAMLrekin", + "signin_with_saml_oidc": "Hasi saioa SAML/OIDCrekin", + "import": "Inportatu", + "import_from": "Inportatu hemendik:", + "featured_categories": "Nabarmendutako kategoriak", + "popular_categories": "Kategoria ospetsuak", + "most_popular": "Ospetsuenak", + "permissions": "Baimenak", + "terms_and_privacy": "Baldintzak eta pribatutasuna", + "subscribe": "Harpidetu", + "buy": "Erosi", + "categories": "Kategoriak", + "pricing": "Prezioak", + "learn_more": "Gehiago ikasi", + "privacy_policy": "Pribatutasun politika", + "terms_of_service": "Erabilera-baldintzak", + "remove": "Ezabatu", + "add": "Gehitu", + "installed_other": "{{count}} instalatuta", + "next_steps": "Hurrengo pausoak", + "error_404": "404 errorea", + "default": "Lehenetsitakoa", + "set_to_default": "Ezarri lehenetsitako gisa", + "new_schedule_btn": "Programazio berria", + "add_new_schedule": "Gehitu programazio berria", + "add_new_calendar": "Gehitu egutegi berria", + "delete_schedule": "Ezabatu programazioa", + "default_schedule_name": "Lanorduak", + "example_name": "Mikel Biteri", + "time_format": "Ordu-formatua", + "12_hour": "12 ordu", + "24_hour": "24 ordu", + "12_hour_short": "12o", + "24_hour_short": "24o", + "redirect_success_booking": "Birbideratu erreserbatzean ", + "create": "Sortu", + "copy_to_clipboard": "Kopiatu arbelera", + "copy": "Kopiatu", + "request_reschedule_booking": "Eskatu zure erreserba berrantolatzeko", + "reason_for_reschedule": "Berrantolatzeko arrazoia", + "book_a_new_time": "Erreserbatu momentu berri bat", + "reschedule_request_sent": "Berrantolatzeko eskaera bidalita", + "reschedule_modal_description": "Honek programatutako bilera bertan behera utziko du, programatzaileari jakinaraziko dio eta momentu berri bat hautatzeko eskatu.", + "reason_for_reschedule_request": "Berrantolatzea eskatzeko arrazoia", + "send_reschedule_request": "Berrantolatzeko eskatu ", "edit_booking": "Aldatu erreserba", "reschedule_booking": "Aldatu erreserbaren programazioa", + "former_time": "Lehengo ordua", + "confirmation_page_gif": "Gehitu GIF bat zure baieztapen-orrialdera", + "search": "Bilatu", + "make_team_private": "Bihurtu taldea pribatua", "location_changed_event_type_subject": "Kokapena aldatu da: {{eventType}} {{name}}(r)ekin {{date}}(e)an", + "current_location": "Uneko kokapena", + "new_location": "Kokapen berria", + "session": "Saioa", + "session_description": "Kontrolatu zure kontuaren saioa", + "no_location": "Ez dago kokapenik definituta", + "set_location": "Ezarri kokapena", + "update_location": "Eguneratu kokapena", + "location_updated": "Kokapena eguneratuta", + "email_validation_error": "Honek ez du email helbide baten itxurarik", + "copy_code": "Kopiatu kodea", + "code_copied": "Kodea kopiatuta!", + "calendar_url": "Egutegiaren URLa", + "set_your_phone_number": "Ezarri telefono zenbaki bat bilerarako", + "display_location_label": "Erakutsi erreserba orrialdean", + "display_location_info_badge": "Kokapena ikusgarri egongo da erreserba baieztatu aurretik", + "add_gif": "Gehitu GIF bat", + "search_giphy": "Bilatu Giphyn", + "add_link_from_giphy": "Gehitu Giphyko esteka bat", + "add_gif_to_confirmation": "Zure baieztapen-orrialdera GIF bat gehitzen", + "find_gif_spice_confirmation": "Aurkitu GIF bat zure baieztapen-orrialdea alaitzeko", + "resources": "Baliabideak", + "support_documentation": "Laguntzako dokumentazioa", + "developer_documentation": "Garatzaileentzako dokumentazioa", + "get_in_touch": "Jar zaitez harremanetan", "booking_details": "Erreserbaren xehetasunak", + "or_lowercase": "edo", + "go_to": "Joan hona: ", + "event_location": "Gertaeraren kokapena", + "reschedule_optional": "Berrantolatzeko arrazoia (aukerakoa)", + "reschedule_placeholder": "Jakinarazi besteei zergatik behar duzun berrantolatzea", + "event_cancelled": "Gertaera hau bertan behera geratu da", + "emailed_information_about_cancelled_event": "Email bat bidali diegu guztiei jakinaren gainean egon daitezen.", + "meeting_url_in_confirmation_email": "Bileraren URLa baieztapen emailean dago", + "url_start_with_https": "URLak http:// edo https:// hasi behar du", + "number_provided": "Telefono zenbakia emango da", + "before_event_trigger": "gertaera hasi aurretik", + "event_cancelled_trigger": "gertaera bertan behera geratzen denean", + "new_event_trigger": "gertaera berri bat erreserbatzen denean", + "email_host_action": "bidali emaila anfitrioiari", + "email_attendee_action": "bidali emaila partaideei", + "sms_attendee_action": "Bidali SMSa partaideari", + "sms_number_action": "bidali SMSa zenbaki jakin batera", + "whatsapp_number_action": "bidali Whatsapp mezua zenbaki jakin batera", + "whatsapp_attendee_action": "bidali Whatsapp mezua partaideari", + "reschedule_event_trigger": "gertaera berrantolatzen denean", + "day_timeUnit": "egun", + "hour_timeUnit": "ordu", + "minute_timeUnit": "minutu", + "current": "Unekoa", + "confirm_username_change_dialog_title": "Baieztatu erabiltzaile-izenaren aldaketa", + "requires_confirmation": "Baieztapena behar du", + "always_requires_confirmation": "Beti", + "email_body": "Emailaren gorputza", + "text_message": "Testu mezua", + "choose_template": "Hautatu txantiloi bat", + "reminder": "Gogorarazlea", + "rescheduled": "Berrantolatua", + "completed": "Osatuta", "reminder_email": "Gogorarazpena: {{eventType}} {{name}}(r)ekin {{date}}(e)an", + "minute_one": "minutu {{count}}", + "minute_other": "{{count}} minutu", + "hour_one": "ordu {{count}}", + "hour_other": "{{count}} ordu", + "attendee_name": "Partaidearen izena", + "scheduler_full_name": "Erreserba egiten duen pertsonaren izen osoa", + "no_active_event_types": "Ez dago gertaera mota aktiborik", "new_seat_subject": "{{name}} parte-hartzaile berria {{eventType}}(e)n {{date}}(e)an", + "new_seat_title": "Norbaitek bere burua gehitu du gertaera batera", + "variable": "Aldagaia", + "event_name_variable": "Gertaeraren izena", + "attendee_name_variable": "Partaidea", + "event_date_variable": "Gertaeraren data", + "event_time_variable": "Gertaeraren ordua", + "timezone_variable": "Ordu-eremua", + "location_variable": "Kokapena", + "additional_notes_variable": "Ohar gehigarriak", + "organizer_name_variable": "Antolatzailearen izena", + "invalid_number": "Telefono zenbaki baliogabea", + "navigate": "Nabigatu", + "open": "Ireki", + "close": "Itxi", + "upgrade": "Bertsio-berritu", + "upgrade_to_access_recordings_title": "Bertsio-berritu grabaketetarako sarbidea izateko", + "show_eventtype_on_profile": "Erakutsi profilean", + "new_username": "Erabiltzaile izen berria", + "current_username": "Uneko erabiltzaile izena", + "example_1": "1. adibidea", + "example_2": "2. adibidea", + "company_size": "Enpresaren tamaina", + "what_help_needed": "Zerekin behar duzu laguntza?", + "notification_sent": "Jakinarazpena bidalita", + "event_advanced_tab_title": "Aurreratua", + "do_this": "Egin honakoa", + "turn_off": "Itzali", + "turn_on": "Piztu", + "settings_updated_successfully": "Ezarpenak egoki eguneratu dira", + "error_updating_settings": "Errorea gertatu da ezarpenak eguneratzerakoan", + "bio_hint": "Esaldi gutxi batzuk zeuri buruz. Zure orrialde pertsonalean agertuko dira.", + "user_has_no_bio": "Erabiltzaile honek ez du bio bat gehitu oraindik.", + "bio": "Bio", + "delete_account_modal_title": "Ezabatu kontua", + "delete_my_account": "Ezabatu nire kontua", + "start_of_week": "Astearen hasiera", + "recordings_title": "Grabaketak", + "recording": "Grabaketa", + "happy_scheduling": "Programazio zoriontsua", + "select_calendars": "Hautatu zein egutegitan egiaztatu nahi duzun talkarik ote dagoen, erreserba bikoitzak saihesteko.", + "check_for_conflicts": "Egiaztatu talkak", + "view_recordings": "Grabaketak ikusi", + "adding_events_to": "Gertaerak hona gehitzen:", + "pro": "Pro", + "profile_picture": "Profileko irudia", + "upload": "Kargatu", + "add_profile_photo": "Gehitu profileko argazkia", + "web3": "Web3", + "old_password": "Pasahitz zaharra", + "secure_password": "Zure pasahitz berri super segurua", + "error_updating_password": "Errorea pasahitza eguneratzean", + "today": "gaur", + "appearance": "Itxura", + "my_account": "Nire kontua", + "general": "Orokorra", + "calendars": "Egutegiak", + "invoices": "Fakturak", + "users": "Erabiltzaileak", + "user": "Erabiltzailea", + "users_description": "Hemen erabiltzaile guztien zerrenda aurkituko duzu", + "add_variable": "Gehitu aldagaia", + "message_template": "Mezuen txantiloia", + "email_subject": "Emailaren gaia", + "event_name_info": "Gertaeraren motaren izena", + "event_date_info": "Gertaeraren data", + "event_time_info": "Gertaeraren hasiera-ordua", + "location_info": "Gertaeraren kokapena", + "additional_notes_info": "Erreserbaren ohar gehigarriak", + "organizer_name_info": "Antolatzailearen izena", + "download_responses": "Deskargatu erantzunak", + "download": "Deskargatu", + "download_recording": "Deskargatu grabaketa", + "create_your_first_form": "Sortu zure lehen galdetegia", + "profile_team_description": "Kudeatu zure talde-profilaren ezarpenak", + "profile_org_description": "Kudeatu zure erakunde-profilaren ezarpenak", + "members_team_description": "Talde honetan diren erabiltzaileak", + "organization_description": "Kudeatu zure erakundeko administrari eta kideak", + "team_url": "Taldearen URLa", + "team_members": "Taldekideak", + "more": "Gehiago", + "workflow_example_1": "Bidali SMS gogorarazlea gertaera hasi baino 24 ordu lehenago partaideari", + "workflow_example_4": "Bidali email gogorarazlea gertaerak hasi baino ordubete lehenago partaideari", + "edit_form_later_subtitle": "Geroago editatu ahal izango duzu.", + "connect_calendar_later": "Geroago konektatuko dut nire egutegia", "booking_appearance": "Erreserba-orriaren itxura", + "add_a_team": "Gehitu taldea", + "password_updated": "Pasahitza eguneratuta!", + "pending_payment": "Ordainketa egiteke", + "pending_invites": "Zain dauden gonbidapenak", + "no_calendar_installed": "Ez dago egutegirik instalatuta", + "no_calendar_installed_description": "Oraindik ez duzu zure egutegietako bat ere konektatu", + "add_a_calendar": "Gehitu egutegia", + "change_email_hint": "Saioa itxi eta berriro hasi beharko duzu edozein aldaketa ikusi ahal izateko", + "confirm_password_change_email": "Mesedez, baieztatu zure pasahitza, email helbidea aldatu aurretik", + "seats": "eserleku", "limit_booking_frequency": "Mugatu erreserbatzeko maiztasuna", + "calendar_connection_fail": "Egutegia konektatzeak huts egin du", + "booking_confirmation_success": "Erreserba egoki baieztatu da", + "booking_rejection_success": "Erreserba baztertzea zuzen egin da", "booking_tentative": "Erreserba hau behin-behinekoa da", + "booking_accept_intent": "Iepa, onartu egin nahi dut", + "we_wont_show_again": "Ez dugu hau berriro erakutsiko", + "couldnt_update_timezone": "Ezin izan dugu ordu-eremua eguneratu", + "updated_timezone_to": "Ordu-eremua eguneratua honakora: {{formattedCurrentTz}}", + "update_timezone": "Eguneratu ordu-eremua", + "update_timezone_question": "Eguneratu ordu-eremua?", + "dont_update": "Ez eguneratu", + "email_address_action": "bidali emaila email helbide jakin batera", + "after_event_trigger": "gertaera bukatu ondoren", + "how_long_after": "Gertaera bukatzen denetik zenbat denborara?", + "add_calendar": "Gehitu egutegia", "limit_future_bookings": "Mugatu etorkizuneko erreserbak", + "no_event_types": "Ez dago gertaera motarik ezarrita", + "no_event_types_description": "{{name}}(e)k ez du erreserbatu dezakezun gertaera motarik ezarri.", + "error_creating_team": "Errorea taldea sortzean", + "you": "Zu", + "resend_email": "Berbidali emaila", + "member_already_invited": "Kidea gonbidatua izan da lehendik ere", + "enter_email_or_username": "Sartu email edo erabiltzaile izen bat", + "team_name_taken": "Izen hau dagoeneko hartua dago", + "must_enter_team_name": "Taldearentzat izen bat behar da", + "fill_this_field": "Mesedez, bete ezazu eremu hau", + "options": "Aukerak", + "add_an_option": "Gehitu aukera bat", + "radio": "Irratia", "all_bookings_filter_label": "Erreserba guztiak" } diff --git a/apps/web/public/static/locales/fr/common.json b/apps/web/public/static/locales/fr/common.json index b11e9ef8b4..9d7cb5be31 100644 --- a/apps/web/public/static/locales/fr/common.json +++ b/apps/web/public/static/locales/fr/common.json @@ -268,6 +268,7 @@ "set_availability": "Définissez vos disponibilités", "availability_settings": "Paramètres de disponibilité", "continue_without_calendar": "Continuer sans calendrier", + "continue_with": "Continuer avec {{appName}}", "connect_your_calendar": "Connectez votre calendrier", "connect_your_video_app": "Connectez vos applications vidéo", "connect_your_video_app_instructions": "Connectez vos applications vidéo pour les utiliser sur vos types d'événements.", @@ -599,6 +600,7 @@ "hide_book_a_team_member": "Masquer le bouton Réserver un membre d'équipe", "hide_book_a_team_member_description": "Masquez le bouton Réserver un membre d'équipe de vos pages publiques.", "danger_zone": "Zone de danger", + "account_deletion_cannot_be_undone": "Attention, la suppression de compte est irréversible.", "back": "Retour", "cancel": "Annuler", "cancel_all_remaining": "Annuler tous les événements restants", @@ -688,6 +690,7 @@ "people": "Personnes", "your_email": "Votre adresse e-mail", "change_avatar": "Changer d'avatar", + "upload_avatar": "Télécharger un avatar", "language": "Langue", "timezone": "Fuseau horaire", "first_day_of_week": "Premier jour de la semaine", @@ -778,6 +781,7 @@ "disable_guests": "Désactiver les invités", "disable_guests_description": "Désactivez l'ajout d'invités supplémentaires lors de la réservation.", "private_link": "Générer un lien privé", + "enable_private_url": "Rendre le lien privé", "private_link_label": "Lien privé", "private_link_hint": "Votre lien privé sera régénéré après chaque utilisation", "copy_private_link": "Copier le lien privé", @@ -1276,6 +1280,7 @@ "personal_cal_url": "Mon lien {{appName}} personnel", "bio_hint": "Quelques mots à propos de vous. Ces informations apparaîtront sur votre page publique.", "user_has_no_bio": "Cet utilisateur n'a pas encore ajouté de description.", + "bio": "Bio", "delete_account_modal_title": "Supprimer le compte", "confirm_delete_account_modal": "Voulez-vous vraiment supprimer votre compte {{appName}} ?", "delete_my_account": "Supprimer mon compte", @@ -1879,6 +1884,7 @@ "edit_invite_link": "Modifier les paramètres du lien", "invite_link_copied": "Lien d'invitation copié", "invite_link_deleted": "Lien d'invitation supprimé", + "api_key_deleted": "Clé API supprimée", "invite_link_updated": "Paramètres de lien d'invitation enregistrés", "link_expires_after": "Les liens ont été définis pour expirer après...", "one_day": "1 jour", @@ -2051,5 +2057,18 @@ "no_members_found": "Aucun membre trouvé", "event_setup_length_error": "Configuration de l'événement : la durée doit être d'au moins 1 minute.", "availability_schedules": "Horaires de disponibilité", + "unauthorized": "Non autorisé", + "select_account_team": "Sélectionner un compte ou une équipe", + "access_event_type": "Lire, modifier, supprimer vos types d'événements", + "access_availability": "Lire, modifier, supprimer vos disponibilités", + "access_bookings": "Lire, modifier, supprimer vos réservations", + "allow_client_to_do": "Autoriser {{clientName}} à faire cela ?", + "allow": "Autoriser", + "edit_users_availability": "Modifier la disponibilité de l'utilisateur : {{username}}", + "resend_invitation": "Renvoyer l'invitation", + "invitation_resent": "L'invitation a été renvoyée.", + "add_client": "Ajouter un client", + "add_new_client": "Ajouter un nouveau client", + "as_csv": "au format CSV", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Ajoutez vos nouvelles chaînes ci-dessus ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/public/static/locales/fr/vital.json b/apps/web/public/static/locales/fr/vital.json deleted file mode 100644 index 13df196a1b..0000000000 --- a/apps/web/public/static/locales/fr/vital.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "connected_vital_app": "Connecté avec", - "vital_app_sleep_automation": "Automatisation de la reprogrammation du sommeil", - "vital_app_automation_description": "Vous pouvez sélectionner différents paramètres pour déclencher la reprogrammation en fonction de vos paramètres de sommeil.", - "vital_app_parameter": "Paramètre", - "vital_app_trigger": "Trigger inférieur ou égal à", - "vital_app_save_button": "Enregistrer la configuration", - "vital_app_total_label": "Total (total = rem + sommeil léger + sommeil profond)", - "vital_app_duration_label": "Durée (durée = heure de fin du coucher - début du sommeil)", - "vital_app_hours": "heures", - "vital_app_save_success": "Enregistrement de vos configurations Vital réussi", - "vital_app_save_error": "Une erreur est survenu lors de l'enregistrement de vos configurations Vital" -} diff --git a/apps/web/public/static/locales/he/vital.json b/apps/web/public/static/locales/he/vital.json deleted file mode 100644 index 5fd291eb48..0000000000 --- a/apps/web/public/static/locales/he/vital.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "connected_vital_app": "מחובר/ת דרך", - "vital_app_sleep_automation": "תזמון מחדש באופן אוטומטי על סמך נתוני השינה שלך", - "vital_app_automation_description": "ניתן לבחור פרמטרים שונים כדי להפעיל את קביעת המועד מחדש על סמך מדדי השינה שלך.", - "vital_app_parameter": "פרמטר", - "vital_app_trigger": "להפעיל כאשר הערך הוא פחות מ- או שווה ל-", - "vital_app_save_button": "שמירת התצורה", - "vital_app_total_label": "סה\"כ (סה\"כ = שנת REM + שינה קלה + שינה עמוקה)", - "vital_app_duration_label": "משך זמן (משך זמן = שעת סיום זמן שינה פחות שעת תחילת זמן שינה)", - "vital_app_hours": "שעות", - "vital_app_save_success": "שמירת ה-Vital Configurations שלך בוצעה בהצלחה", - "vital_app_save_error": "אירעה שגיאה במהלך שמירת ה-Vital Configurations שלך" -} diff --git a/apps/web/public/static/locales/hu/vital.json b/apps/web/public/static/locales/hu/vital.json deleted file mode 100644 index 0967ef424b..0000000000 --- a/apps/web/public/static/locales/hu/vital.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/apps/web/public/static/locales/it/vital.json b/apps/web/public/static/locales/it/vital.json deleted file mode 100644 index e5a21b7bbb..0000000000 --- a/apps/web/public/static/locales/it/vital.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "connected_vital_app": "Connesso con", - "vital_app_sleep_automation": "Automazione della riprogrammazione in base ai dati del tuo sonno", - "vital_app_automation_description": "Puoi scegliere vari parametri per attivare la riprogrammazione in base ai parametri del tuo sonno.", - "vital_app_parameter": "Parametro", - "vital_app_trigger": "Attiva se inferiore o uguale a", - "vital_app_save_button": "Salva configurazione", - "vital_app_total_label": "Totale (totale = REM + sonno leggero + sonno profondo)", - "vital_app_duration_label": "Durata (durata = fine del sonno - inizio del sonno)", - "vital_app_hours": "ore", - "vital_app_save_success": "Configurazioni di Vital salvate correttamente", - "vital_app_save_error": "Si è verificato un errore durante il salvataggio delle configurazioni di Vital" -} diff --git a/apps/web/public/static/locales/ja/vital.json b/apps/web/public/static/locales/ja/vital.json deleted file mode 100644 index 9c281a1ada..0000000000 --- a/apps/web/public/static/locales/ja/vital.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "connected_vital_app": "接続先", - "vital_app_sleep_automation": "Sleeping リスケジュールの自動設定", - "vital_app_automation_description": "リスケジュールのトリガーとなるパラメータは、スリーピングメトリクスに基づいてさまざまに選択できます。", - "vital_app_parameter": "パラメータ", - "vital_app_trigger": "以下でトリガー", - "vital_app_save_button": "設定を保存", - "vital_app_total_label": "合計(合計 = レム + 軽い睡眠 + 深い睡眠)", - "vital_app_duration_label": "持続時間(持続時間 = 起床時間 - 就寝時間)", - "vital_app_hours": "時間", - "vital_app_save_success": "Vital 設定の保存に成功しました", - "vital_app_save_error": "Vital 設定の保存中にエラーが発生しました" -} diff --git a/apps/web/public/static/locales/km/common.json b/apps/web/public/static/locales/km/common.json new file mode 100644 index 0000000000..01b355718a --- /dev/null +++ b/apps/web/public/static/locales/km/common.json @@ -0,0 +1,255 @@ +{ + "identity_provider": "អ្នកផ្តល់អត្តសញ្ញាណ", + "trial_days_left": "អ្នកនៅសល់ $t(day, {\"count\": {{days}} }) ទៀតក្នុងការសាកល្បង PRO របស់អ្នក។", + "day_one": "{{count}} ថ្ងៃ", + "day_other": "{{count}} ថ្ងៃ", + "second_one": "{{count}} នាទី", + "second_other": "{{count}} នាទី", + "upgrade_now": "ធ្វើបច្ចុប្បន្នភាពឥឡូវនេះ", + "accept_invitation": "ទទួលយកការអញ្ជើញ", + "calcom_explained": "{{appName}} ផ្តល់ហេដ្ឋារចនាសម្ព័ន្ធកំណត់ពេលសម្រាប់មនុស្សគ្រប់គ្នា។", + "calcom_explained_new_user": "បញ្ចប់ការកំណត់របស់អ្នក {{appName}} គណនី! អ្នកនៅសល់តែប៉ុន្មានជំហានទៀតប៉ុណ្ណោះ ក្នុងការដោះស្រាយបញ្ហាការកំណត់កាលវិភាគរបស់អ្នក។", + "have_any_questions": "អ្នកមានសំណួរឬ? យើងនៅទីនេះដើម្បីជួយ។", + "reset_password_subject": "{{appName}}: កំណត់ការណែនាំពាក្យសម្ងាត់ឡើងវិញ", + "verify_email_subject": "{{appName}}: ផ្ទៀងផ្ទាត់គណនីរបស់អ្នក។", + "check_your_email": "ពិនិត្យអ៊ីមែលរបស់អ្នក។", + "verify_email_page_body": "យើងបានផ្ញើអ៊ីមែលទៅ {{email}}។ វាមានសារៈសំខាន់ណាស់ក្នុងការផ្ទៀងផ្ទាត់អាសយដ្ឋានអ៊ីមែលរបស់អ្នក ដើម្បីធានានូវលទ្ធភាពចែកចាយអ៊ីមែល និងប្រតិទិនដ៏ល្អបំផុតពី {{appName}}.", + "verify_email_banner_body": "ផ្ទៀងផ្ទាត់អាសយដ្ឋានអ៊ីមែលរបស់អ្នក ដើម្បីធានានូវលទ្ធភាពចែកចាយអ៊ីមែល និងប្រតិទិនដ៏ល្អបំផុត", + "verify_email_email_header": "ផ្ទៀងផ្ទាត់អាសយដ្ឋានអ៊ីមែលរបស់អ្នក។", + "verify_email_email_button": "ផ្ទៀងផ្ទាត់អ៊ីមែល", + "copy_somewhere_safe": "រក្សាទុក API key នេះនៅកន្លែងណាដែលមានសុវត្ថិភាព។ អ្នកនឹងមិនអាចមើលវាម្តងទៀតបានទេ។", + "verify_email_email_body": "សូមផ្ទៀងផ្ទាត់អ៊ីមែលរបស់អ្នកដោយចុចប៊ូតុងខាងក្រោម។", + "verify_email_by_code_email_body": "សូមផ្ទៀងផ្ទាត់អាសយដ្ឋានអ៊ីមែលរបស់អ្នកដោយប្រើលេខកូដខាងក្រោម។", + "verify_email_email_link_text": "នេះជាតំណភ្ជាប់ក្នុងករណីដែលអ្នកមិនចូលចិត្តចុចប៊ូតុង៖", + "email_verification_code": "សូម​បញ្ចូល​កូដ", + "email_verification_code_placeholder": "បញ្ចូលលេខកូដផ្ទៀងផ្ទាត់ដែលបានផ្ញើទៅអ៊ីមែលរបស់អ្នក។", + "incorrect_email_verification_code": "លេខកូដផ្ទៀងផ្ទាត់មិនត្រឹមត្រូវទេ។", + "email_sent": "អ៊ីមែលត្រូវបានផ្ញើដោយជោគជ័យ", + "email_not_sent": "កំហុសបានកើតឡើងនៅពេលផ្ញើអ៊ីមែល", + "event_declined_subject": "បានបដិសេធ៖ {{title}} នៅ {{date}}", + "event_cancelled_subject": "បានលុបចោល៖ {{title}} នៅ {{date}}", + "event_request_declined": "សំណើព្រឹត្តិការណ៍របស់អ្នកត្រូវបានបដិសេធ", + "event_request_declined_recurring": "សំណើព្រឹត្តិការណ៍កើតឡើងដដែលៗរបស់អ្នកត្រូវបានបដិសេធ", + "event_request_cancelled": "ព្រឹត្តិការណ៍ដែលបានគ្រោងទុករបស់អ្នកត្រូវបានលុបចោល", + "organizer": "អ្នករៀបចំ", + "need_to_reschedule_or_cancel": "ត្រូវ​ការ​កំណត់​ពេល​វេលា​ឡើងវិញ​ឬ​បោះបង់?", + "no_options_available": "មិនមានជម្រើសទេ។", + "cancellation_reason": "ហេតុផលសម្រាប់ការលុបចោល (មិនចាំបាច់)", + "cancellation_reason_placeholder": "ហេតុអ្វីបានជាអ្នកលុបចោល?", + "rejection_reason": "ហេតុផលសម្រាប់ការបដិសេធ", + "rejection_reason_title": "បដិសេធសំណើកក់?", + "rejection_reason_description": "តើអ្នកប្រាកដថាចង់បដិសេធការកក់នេះទេ? យើង​នឹង​ឲ្យ​អ្នក​ដែល​ព្យាយាម​កក់​នោះ​ដឹង។ អ្នកអាចផ្តល់ហេតុផលខាងក្រោម។", + "rejection_confirmation": "បដិសេធការកក់", + "manage_this_event": "គ្រប់គ្រងព្រឹត្តិការណ៍នេះ។", + "invite_team_member": "អញ្ជើញសមាជិកក្រុម", + "invite_team_individual_segment": "អញ្ជើញជាបុគ្គល", + "invite_team_bulk_segment": "ការនាំចូលច្រើន", + "your_event_has_been_scheduled": "ព្រឹត្តិការណ៍របស់អ្នកត្រូវបានកំណត់ពេល", + "your_event_has_been_scheduled_recurring": "ព្រឹត្តិការណ៍កើតឡើងដដែលៗរបស់អ្នកត្រូវបានកំណត់ពេល", + "accept_our_license": "ទទួលយកអាជ្ញាប័ណ្ណរបស់យើងដោយការផ្លាស់ប្តូរ .env អថេរ <1>NEXT_PUBLIC_LICENSE_CONSENT ទៅ '{{agree}}'.", + "remove_banner_instructions": "ដើម្បីលុបបដានេះ សូមបើកឯកសារ .env របស់អ្នក ហើយផ្លាស់ប្តូរ <1>NEXT_PUBLIC_LICENSE_CONSENT អថេរទៅ '{{agree}}'.", + "error_message": "សារកំហុសគឺ៖ '{{errorMessage}}'", + "refund_failed_subject": "ការសងប្រាក់វិញបានបរាជ័យ៖ {{name}} - {{date}} - {{eventType}}", + "refund_failed": "ការសងប្រាក់វិញសម្រាប់ព្រឹត្តិការណ៍ {{eventType}} សម្រាប់ {{userName}} នៅថ្ងៃ {{date}} បរាជ័យ។", + "check_with_provider_and_user": "សូមពិនិត្យជាមួយអ្នកផ្តល់សេវាទូទាត់របស់អ្នក និង {{user}} របៀបដោះស្រាយនេះ។", + "a_refund_failed": "ការសងប្រាក់វិញបានបរាជ័យ", + "awaiting_payment_subject": "កំពុងរង់ចាំការទូទាត់៖ {{title}} នៅថ្ងៃ {{date}}", + "meeting_awaiting_payment": "ការប្រជុំរបស់អ្នកកំពុងរង់ចាំការបង់ប្រាក់", + "help": "ជំនួយ", + "price": "តម្លៃ", + "paid": "បង់ប្រាក់", + "refunded": "សងប្រាក់វិញ។", + "payment": "ការទូទាត់", + "missing_card_fields": "ប្រអប់ កាត ត្រូវតែបំពេញ", + "pay_now": "បង់ប្រាក់ឥឡូវនេះ", + "codebase_has_to_stay_opensource": "មូលដ្ឋានកូដត្រូវតែរក្សាប្រភពបើកចំហ ទោះបីជាវាត្រូវបានកែប្រែឬអត់ក៏ដោយ។", + "cannot_repackage_codebase": "អ្នកមិនអាចវេចខ្ចប់ឡើងវិញ ឬលក់មូលដ្ឋានកូដបានទេ។", + "acquire_license": "ទទួលបានអាជ្ញាប័ណ្ណពាណិជ្ជកម្ម ដើម្បីលុបលក្ខខណ្ឌទាំងនេះដោយការផ្ញើអ៊ីមែល", + "terms_summary": "សេចក្តីសង្ខេបនៃលក្ខខណ្ឌ", + "open_env": "បើកឯកសារ .env ហើយយល់ព្រមនឹងអាជ្ញាប័ណ្ណរបស់យើង។", + "env_changed": "ខ្ញុំបានផ្លាស់ប្តូរឯកសារ .env របស់ខ្ញុំ", + "accept_license": "ទទួលយកអាជ្ញាប័ណ្ណ", + "still_waiting_for_approval": "ព្រឹត្តិការណ៍មួយនៅតែរង់ចាំការយល់ព្រម", + "event_is_still_waiting": "សំណើព្រឹត្តិការណ៍កំពុងរង់ចាំ៖ {{attendeeName}} - {{date}} - {{eventType}}", + "no_more_results": "មិនមានលទ្ធផលទៀតទេ", + "no_results": "គ្មាន​លទ្ធផល", + "load_more_results": "ផ្ទុកលទ្ធផលបន្ថែមទៀត", + "integration_meeting_id": "{{integrationName}} លេខសម្គាល់ការប្រជុំ៖ {{meetingId}}", + "confirmed_event_type_subject": "បញ្ជាក់៖ {{eventType}} ជាមួយ {{name}} នៅ {{date}}", + "new_event_request": "សំណើព្រឹត្តិការណ៍ថ្មី៖ {{attendeeName}} - {{date}} - {{eventType}}", + "confirm_or_reject_request": "បញ្ជាក់ ឬបដិសេធសំណើ", + "check_bookings_page_to_confirm_or_reject": "ពិនិត្យមើលទំព័រកក់របស់អ្នក ដើម្បីបញ្ជាក់ ឬបដិសេធការកក់។", + "event_awaiting_approval": "ព្រឹត្តិការណ៍មួយកំពុងរង់ចាំការយល់ព្រមរបស់អ្នក។", + "event_awaiting_approval_recurring": "ព្រឹត្តិការណ៍កើតឡើងដដែលៗកំពុងរង់ចាំការយល់ព្រមរបស់អ្នក។", + "someone_requested_an_event": "មាននរណាម្នាក់បានស្នើសុំរៀបចំព្រឹត្តិការណ៍មួយនៅលើប្រតិទិនរបស់អ្នក។", + "someone_requested_password_reset": "មាននរណាម្នាក់បានស្នើសុំតំណដើម្បីផ្លាស់ប្តូរពាក្យសម្ងាត់របស់អ្នក។", + "password_reset_email_sent": "ប្រសិនបើអ៊ីមែលនេះមាននៅក្នុងប្រព័ន្ធរបស់យើង អ្នកគួរតែទទួលបានអ៊ីមែលកំណត់ឡើងវិញ។", + "password_reset_instructions": "ប្រសិនបើអ្នកមិនបានស្នើសុំវាទេ អ្នកអាចមិនអើពើអ៊ីមែលនេះដោយសុវត្ថិភាព ហើយពាក្យសម្ងាត់របស់អ្នកនឹងមិនត្រូវបានផ្លាស់ប្តូរទេ។", + "event_awaiting_approval_subject": "កំពុងរង់ចាំការអនុម័ត៖ {{title}} នៅថ្ងៃ {{date}}", + "event_still_awaiting_approval": "ព្រឹត្តិការណ៍មួយកំពុងរង់ចាំការយល់ព្រមរបស់អ្នក។", + "booking_submitted_subject": "បានដាក់ស្នើការកក់ទុក៖ {{title}} នៅថ្ងៃ {{date}}", + "download_recording_subject": "ទាញយកការថត៖ {{title}} នៅថ្ងៃ {{date}}", + "download_your_recording": "ទាញយកការថតរបស់អ្នក។", + "your_meeting_has_been_booked": "ការប្រជុំរបស់អ្នកត្រូវបានកក់ទុក", + "event_type_has_been_rescheduled_on_time_date": "{{title}} របស់អ្នកត្រូវបានកំណត់ពេលវេលាឡើងវិញ ទៅថ្ងៃ {{date}}.", + "event_has_been_rescheduled": "បានធ្វើបច្ចុប្បន្នភាព - ព្រឹត្តិការណ៍របស់អ្នកត្រូវបានកំណត់ពេលឡើងវិញ", + "request_reschedule_subtitle": "{{organizer}} បានលុបចោលការកក់ ហើយស្នើឱ្យអ្នកជ្រើសរើសពេលផ្សេងទៀត។", + "request_reschedule_title_organizer": "អ្នកបានស្នើសុំ {{attendee}} ដើម្បីរៀបចំកាលវិភាគឡើងវិញ", + "request_reschedule_subtitle_organizer": "អ្នកបានលុបចោលការកក់ហើយ {{attendee}} គួរតែជ្រើសរើសពេលវេលាកក់ថ្មីជាមួយអ្នក។", + "rescheduled_event_type_subject": "សំណើសម្រាប់កាលវិភាគត្រូវបានផ្ញើឡើងវិញ៖ {{eventType}} ជាមួយ {{name}} នៅថ្ងៃ {{date}}", + "requested_to_reschedule_subject_attendee": "ត្រូវតែកាលវិភាគឡើងវិញ: សូមកក់ពេលវេលាថ្មីសម្រាប់ {{eventType}} ជាមួយ {{name}}", + "hi_user_name": "សួស្តី {{name}}", + "ics_event_title": "{{eventType}} ជាមួយ {{name}}", + "new_event_subject": "ព្រឹត្តិការណ៍ថ្មី៖ {{attendeeName}} - {{date}} - {{eventType}}", + "join_by_entrypoint": "ចូលរួមដោយ {{entryPoint}}", + "notes": "កំណត់ចំណាំ", + "manage_my_bookings": "គ្រប់គ្រងការកក់របស់ខ្ញុំ", + "need_to_make_a_change": "ត្រូវ​ការ​ផ្លាស់​ប្តូ​រ​?", + "new_event_scheduled": "ព្រឹត្តិការណ៍ថ្មីមួយត្រូវបានកំណត់ពេល។", + "new_event_scheduled_recurring": "ព្រឹត្តិការណ៍កើតឡើងម្តងទៀតត្រូវបានកំណត់ពេល។", + "invitee_email": "អ៊ីមែលអញ្ជើញ", + "invitee_timezone": "តំបន់ពេលវេលាអញ្ជើញ", + "time_left": "ពេលវេលានៅសល់", + "event_type": "ប្រភេទព្រឹត្តិការណ៍", + "enter_meeting": "ចូលប្រជុំ", + "video_call_provider": "អ្នកផ្តល់សេវាហៅជាវីដេអូ", + "meeting_id": "លេខសម្គាល់ការប្រជុំ", + "meeting_password": "ពាក្យសម្ងាត់ការប្រជុំ", + "meeting_url": "URL ការប្រជុំ", + "meeting_request_rejected": "សំណើប្រជុំរបស់អ្នកត្រូវបានបដិសេធ", + "rejected_event_type_with_organizer": "ច្រានចោល៖ {{eventType}} ជាមួយ {{organizer}} នៅថ្ងៃ {{date}}", + "hi": "សួស្តី", + "join_team": "ចូលរួមក្រុម", + "manage_this_team": "គ្រប់គ្រងក្រុមនេះ", + "team_info": "ព័ត៌មានក្រុម", + "request_another_invitation_email": "ប្រសិនបើអ្នកមិនចង់ប្រើ {{toEmail}} ជា {{appName}} របស់អ្នក អ៊ីម៉ែលឬ គណនី {{appName}} មានរួចហើយ, សូមស្នើសុំការអញ្ជើញមួយផ្សេងទៀតទៅកាន់អ៊ីមែលនោះ។", + "you_have_been_invited": "អ្នកត្រូវបានអញ្ជើញឱ្យចូលរួមក្រុម {{teamName}}", + "user_invited_you": "{{user}} បានអញ្ជើញអ្នកឱ្យចូលរួម {{entity}} {{team}} នៅលើ {{appName}}", + "hidden_team_member_title": "អ្នកត្រូវបានលាក់នៅក្នុងក្រុមនេះ។", + "hidden_team_member_message": "កៅអីរបស់អ្នកមិនត្រូវបានបង់ទេ ទាំង Upgrade ទៅ PRO ឬអនុញ្ញាតឱ្យម្ចាស់ក្រុមដឹងថាពួកគេអាចបង់ប្រាក់សម្រាប់កៅអីរបស់អ្នក។", + "hidden_team_owner_message": "អ្នក​ត្រូវ​ការ​គណនី​គាំទ្រ​ដើម្បី​ប្រើ​ក្រុម អ្នក​ត្រូវ​បាន​លាក់​រហូត​ដល់​អ្នក Upgrade", + "link_expires": "p.s. វាផុតកំណត់នៅក្នុង {{expiresIn}} ម៉ោង", + "upgrade_to_per_seat": "Upgrade ទៅ Per-Seat", + "seat_options_doesnt_support_confirmation": "ជម្រើសកៅអីមិនគាំទ្រតម្រូវការបញ្ជាក់ទេ។", + "team_upgrade_seats_details": "ក្នុងចំណោមសមាជិក {{memberCount}} នាក់នៅក្នុងក្រុមរបស់អ្នក។, {{unpaidCount}} កន្លែងអង្គុយមិនបង់ប្រាក់ទេ។ នៅ ${{seatPrice}}/ខែក្នុងមួយកៅអី តម្លៃសរុបប៉ាន់ស្មាននៃសមាជិកភាពរបស់អ្នកគឺ ${{totalCost}}/ខែ.", + "team_upgrade_banner_description": "អ្នកមិនទាន់បានបញ្ចប់ការរៀបចំក្រុមរបស់អ្នកទេ។ ក្រុមរបស់អ្នក \"{{teamName}}\" ត្រូវការធ្វើឱ្យប្រសើរឡើង។", + "upgrade_banner_action": "Upgrade ទីនេះ", + "team_upgraded_successfully": "ក្រុមរបស់អ្នកត្រូវបាន upgrade ដោយជោគជ័យ!", + "org_upgrade_banner_description": "សូមអរគុណសម្រាប់ការសាកល្បងគម្រោង Organization របស់យើង។ យើងកត់សំគាល់ Organization របស់អ្នក \"{{teamName}}\" ត្រូវការធ្វើឱ្យប្រសើរឡើង។", + "org_upgraded_successfully": "Organization របស់អ្នកត្រូវបានដំឡើងកំណែដោយជោគជ័យ!", + "use_link_to_reset_password": "ប្រើតំណខាងក្រោមដើម្បីកំណត់ពាក្យសម្ងាត់របស់អ្នកឡើងវិញ", + "hey_there": "ហេ!", + "forgot_your_password_calcom": "ភ្លេចពាក្យសម្ងាត់? - {{appName}}", + "delete_webhook_confirmation_message": "តើអ្នកប្រាកដថាចង់លុប webhook នេះទេ? អ្នកនឹងលែងទទួលបានទិន្នន័យប្រជុំ {{appName}} តាម URL ដែលបានបញ្ជាក់, ក្នុងពេលវេលាជាក់ស្តែង នៅពេលដែលព្រឹត្តិការណ៍មួយត្រូវបានកំណត់ពេល ឬលុបចោល។", + "confirm_delete_webhook": "បាទ/ចាស លុប webhook", + "edit_webhook": "កែសម្រួល Webhook", + "delete_webhook": "លុប Webhook", + "webhook_status": "ស្ថានភាព Webhook", + "webhook_enabled": "Webhook បានបើក", + "webhook_disabled": "Webhook បានបិទ", + "webhook_response": "ការឆ្លើយតប Webhook", + "webhook_test": "តេស្ត Webhook", + "manage_your_webhook": "គ្រប់គ្រង webhook របស់អ្នក។", + "webhook_created_successfully": "Webhook បានបង្កើតដោយជោគជ័យ!", + "webhook_updated_successfully": "Webhook បានធ្វើបច្ចុប្បន្នភាពដោយជោគជ័យ!", + "webhook_removed_successfully": "Webhook ត្រូវបានដកចេញដោយជោគជ័យ!", + "payload_template": "គំរូនៃការផ្ទុក", + "dismiss": "ច្រានចោល", + "no_data_yet": "មិនមានទិន្នន័យនៅឡើយទេ", + "ping_test": "ការធ្វើតេស្តភីង(Ping)", + "add_to_homescreen": "បន្ថែមកម្មវិធីនេះទៅអេក្រង់ដើមរបស់អ្នកសម្រាប់ការចូលប្រើកាន់តែលឿន និងបទពិសោធន៍ប្រសើរឡើង។", + "upcoming": "នាពេលខាងមុខ", + "recurring": "កើតឡើងម្តងទៀត", + "past": "អតីតកាល", + "choose_a_file": "ជ្រើសរើសឯកសារ...", + "upload_image": "បង្ហោះរូបភាព", + "upload_target": "បង្ហោះ {{target}}", + "no_target": "គ្មាន {{target}}", + "slide_zoom_drag_instructions": "អូសដើម្បីពង្រីក អូសដើម្បីដាក់ទីតាំងឡើងវិញ", + "view_notifications": "មើលការជូនដំណឹង", + "view_public_page": "មើលទំព័រសាធារណៈ", + "copy_public_page_link": "ចម្លងតំណទំព័រសាធារណៈ", + "sign_out": "ចាកចេញ", + "add_another": "បន្ថែមមួយទៀត", + "install_another": "ដំឡើងមួយផ្សេងទៀត", + "until": "រហូតដល់", + "powered_by": "ដំណើរការដោយ", + "unavailable": "មិន​មាន", + "set_work_schedule": "កំណត់កាលវិភាគការងាររបស់អ្នក។", + "change_bookings_availability": "ផ្លាស់ប្តូរនៅពេលដែលអ្នកមានសម្រាប់ការកក់", + "select": "ជ្រើសរើស...", + "2fa_confirm_current_password": "បញ្ជាក់ពាក្យសម្ងាត់បច្ចុប្បន្នរបស់អ្នក ដើម្បីចាប់ផ្តើម។", + "2fa_scan_image_or_use_code": "ស្កេនរូបភាពខាងក្រោមដោយប្រើកម្មវិធីផ្ទៀងផ្ទាត់(Authenticator App) ឬបញ្ចូលលេខកូដអត្ថបទដោយដៃជំនួសវិញ។", + "text": "អត្ថបទ", + "multiline_text": "អត្ថបទច្រើនបន្ទាត់", + "number": "ចំនួន", + "checkbox": "ប្រអប់ធីក", + "is_required": "គឺ​តំរូវ​អោយ​មាន", + "required": "ទាមទារ", + "optional": "ស្រេចចិត្ត", + "input_type": "ប្រភេទបញ្ចូល", + "rejected": "បដិសេធ", + "unconfirmed": "មិន​បាន​បញ្ជាក់", + "guests": "ភ្ញៀវ", + "guest": "ភ្ញៀវ", + "email": "អ៊ីមែល", + "full_name": "ឈ្មោះ​ពេញ", + "january": "មករា", + "february": "កុម្ភៈ", + "march": "មីនា", + "april": "មេសា", + "may": "ឧសភា", + "june": "មិថុនា", + "july": "កក្កដា", + "august": "សីហា", + "september": "កញ្ញា", + "october": "តុលា", + "november": "វិច្ឆិកា", + "december": "ធ្នូ", + "monday": "ច័ន្ទ", + "tuesday": "អង្គារ", + "wednesday": "ពុធ", + "thursday": "ព្រហស្បតិ៍", + "friday": "សុក្រ", + "saturday": "សៅរ៍", + "sunday": "អាទិត្យ", + "submit": "ដាក់ស្នើ", + "delete": "លុប", + "update": "ធ្វើបច្ចុប្បន្នភាព", + "save": "រក្សាទុក", + "pending": "កំពុងរង់ចាំ", + "share": "ចែករំលែក", + "event_types_page_title": "ប្រភេទព្រឹត្តិការណ៍", + "event_types_page_subtitle": "បង្កើតព្រឹត្តិការណ៍ដើម្បីចែករំលែកសម្រាប់មនុស្សដើម្បីកក់នៅលើប្រតិទិនរបស់អ្នក។", + "new": "ថ្មី", + "new_event_type_btn": "ប្រភេទព្រឹត្តិការណ៍ថ្មី", + "new_event_type_heading": "បង្កើតប្រភេទព្រឹត្តិការណ៍ដំបូងរបស់អ្នក", + "new_event_type_description": "ប្រភេទព្រឹត្តិការណ៍អនុញ្ញាតឱ្យអ្នកចែករំលែកតំណដែលបង្ហាញពេលវេលាដែលមាននៅលើប្រតិទិនរបស់អ្នក និងអនុញ្ញាតឱ្យអ្នកផ្សេងធ្វើការកក់ជាមួយអ្នក។", + "event_type_created_successfully": "ប្រភេទព្រឹត្តិការណ៍ {{eventTypeTitle}} ត្រូវបានបង្កើតដោយជោគជ័យ", + "event_type_updated_successfully": "ប្រភេទព្រឹត្តិការណ៍ {{eventTypeTitle}} បានធ្វើបច្ចុប្បន្នភាពដោយជោគជ័យ", + "event_type_deleted_successfully": "ប្រភេទព្រឹត្តិការណ៍ត្រូវបានលុបដោយជោគជ័យ", + "hours": "ម៉ោង", + "your_email": "អ៊ីមែល​របស់​អ្នក", + "change_avatar": "ផ្លាស់ប្តូរ Avatar", + "upload_avatar": "បង្ហោះ Avatar", + "language": "ភាសា", + "timezone": "ល្វែងម៉ោង", + "first_day_of_week": "ថ្ងៃដំបូងនៃសប្តាហ៍", + "repeats_up_to_one": "ធ្វើម្តងទៀតរហូតដល់ {{count}} ដង", + "repeats_up_to_other": "ធ្វើម្តងទៀតរហូតដល់ {{count}} ដង", + "event_remaining_one": "{{count}} ព្រឹត្តិការណ៍ដែលនៅសល់", + "event_remaining_other": "{{count}} ព្រឹត្តិការណ៍ដែលនៅសល់", + "repeats_every": "ធ្វើម្តងទៀតរៀងរាល់", + "time_format": "ទម្រង់ពេលវេលា", + "timeformat_profile_hint": "នេះគឺជាការកំណត់ខាងក្នុង ហើយនឹងមិនប៉ះពាល់ដល់របៀបដែលពេលវេលាត្រូវបានបង្ហាញនៅលើទំព័រកក់សាធារណៈសម្រាប់អ្នក ឬនរណាម្នាក់ដែលកក់អ្នក។", + "start_of_week": "ការចាប់ផ្តើមនៃសប្តាហ៍", + "today": "ថ្ងៃនេះ", + "appearance": "រូបរាង", + "my_account": "គណនី​របស់ខ្ញុំ", + "general": "ទូទៅ", + "calendars": "ប្រតិទិន", + "invoices": "វិក្កយបត្រ", + "users": "អ្នកប្រើប្រាស់", + "user": "អ្នកប្រើប្រាស់", + "general_description": "គ្រប់គ្រងការកំណត់សម្រាប់ភាសា និងល្វែងម៉ោងរបស់អ្នក។" +} diff --git a/apps/web/public/static/locales/en/vital.json b/apps/web/public/static/locales/km/vital.json similarity index 91% rename from apps/web/public/static/locales/en/vital.json rename to apps/web/public/static/locales/km/vital.json index a08a9058b2..cdcadc6d4d 100644 --- a/apps/web/public/static/locales/en/vital.json +++ b/apps/web/public/static/locales/km/vital.json @@ -1,5 +1,5 @@ { - "connected_vital_app": "Connected with", + "connected_vital_app": "ភ្ជាប់ជាមួយ", "vital_app_sleep_automation": "Sleeping reschedule automation", "vital_app_automation_description": "You can select different parameters to trigger the reschedule based on your sleeping metrics.", "vital_app_parameter": "Parameter", diff --git a/apps/web/public/static/locales/ko/vital.json b/apps/web/public/static/locales/ko/vital.json deleted file mode 100644 index b70b7081d9..0000000000 --- a/apps/web/public/static/locales/ko/vital.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "connected_vital_app": "연결된 대상", - "vital_app_sleep_automation": "수면 재예약 자동화", - "vital_app_automation_description": "수면 지표에 따라 다른 매개변수를 선택하여 일정을 변경할 수 있습니다.", - "vital_app_parameter": "매개변수", - "vital_app_trigger": "같거나 미만인 범위에서 트리거", - "vital_app_save_button": "구성 저장", - "vital_app_total_label": "총계(총계 = 렘 + 가벼운 수면 + 깊은 수면)", - "vital_app_duration_label": "지속 시간(지속 시간 = 취침 시간 끝 - 취침 시간 시작)", - "vital_app_hours": "시간", - "vital_app_save_success": "주요 구성을 저장함", - "vital_app_save_error": "주요 구성을 저장하는 중 오류가 발생했습니다" -} diff --git a/apps/web/public/static/locales/nl/vital.json b/apps/web/public/static/locales/nl/vital.json deleted file mode 100644 index 71d61357cf..0000000000 --- a/apps/web/public/static/locales/nl/vital.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "connected_vital_app": "Verbonden met", - "vital_app_sleep_automation": "Automatisering opnieuw plannen van slaap", - "vital_app_automation_description": "U kunt verschillende parameters selecteren om het opnieuw plannen te activeren, op basis van uw slaapmetriek.", - "vital_app_parameter": "Parameter", - "vital_app_trigger": "Activatie bij minder dan of gelijk aan", - "vital_app_save_button": "Configuratie opslaan", - "vital_app_total_label": "Totaal (totaal = remslaap + lichte slaap + diepe slaap)", - "vital_app_duration_label": "Duur (duur = einde bedtijd - start bedtijd)", - "vital_app_hours": "uur", - "vital_app_save_success": "Uw Vital-configuraties zijn opgeslagen", - "vital_app_save_error": "Er is een fout opgetreden bij het opslaan van uw Vital-configuraties" -} diff --git a/apps/web/public/static/locales/pl/vital.json b/apps/web/public/static/locales/pl/vital.json deleted file mode 100644 index 82e5829319..0000000000 --- a/apps/web/public/static/locales/pl/vital.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "connected_vital_app": "Połączono z", - "vital_app_sleep_automation": "Automatyczna zmiana harmonogramu snu", - "vital_app_automation_description": "Możesz wybrać różne parametry, które spowodują zmianę harmonogramu w zależności od jakości Twojego snu.", - "vital_app_parameter": "Parametr", - "vital_app_trigger": "Wyzwalaj przy wartości mniejszej lub równej", - "vital_app_save_button": "Zapisz konfigurację", - "vital_app_total_label": "Łącznie (łącznie = REM + płytki sen + głęboki sen)", - "vital_app_duration_label": "Czas trwania (czas trwania = koniec snu - początek snu)", - "vital_app_hours": "godz.", - "vital_app_save_success": "Pomyślnie zapisano Twoje Vital Configurations", - "vital_app_save_error": "Podczas zapisywania ustawień parametrów życiowych wystąpił błąd" -} diff --git a/apps/web/public/static/locales/pt-BR/vital.json b/apps/web/public/static/locales/pt-BR/vital.json deleted file mode 100644 index 2c36117865..0000000000 --- a/apps/web/public/static/locales/pt-BR/vital.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "connected_vital_app": "Conectou-se com", - "vital_app_sleep_automation": "Automação de reagendamento de sono", - "vital_app_automation_description": "Você pode selecionar parâmetros diferentes para acionar o reagendamento conforme suas métricas de sono.", - "vital_app_parameter": "Parâmetro", - "vital_app_trigger": "Aciona quando for menor ou igual a", - "vital_app_save_button": "Salvar configuração", - "vital_app_total_label": "Total (total = rem + sono leve + sono profundo)", - "vital_app_duration_label": "Duração (duração = fim do horário de dormir - início do horário de dormir)", - "vital_app_hours": "horas", - "vital_app_save_success": "Sucesso ao salvar suas configurações do Vital", - "vital_app_save_error": "Ocorreu um erro ao salvar suas configurações do Vital" -} diff --git a/apps/web/public/static/locales/pt/vital.json b/apps/web/public/static/locales/pt/vital.json deleted file mode 100644 index dfcc798f5b..0000000000 --- a/apps/web/public/static/locales/pt/vital.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "connected_vital_app": "Ligado a", - "vital_app_sleep_automation": "Reagendar automaticamente durante o sono", - "vital_app_automation_description": "Pode seleccionar diferentes parâmetros para accionar o reagendamento com base nas suas métricas de sono.", - "vital_app_parameter": "Parâmetro", - "vital_app_trigger": "Executar abaixo ou igual a", - "vital_app_save_button": "Guardar configuração", - "vital_app_total_label": "Total (total = rem + sono leve + sono profundo)", - "vital_app_duration_label": "Duração (duração = fim do sono - início do sono)", - "vital_app_hours": "horas", - "vital_app_save_success": "As suas configurações vitais foram guardadas com sucesso", - "vital_app_save_error": "Ocorreu um erro ao guardar as suas configurações vitais" -} diff --git a/apps/web/public/static/locales/ro/vital.json b/apps/web/public/static/locales/ro/vital.json deleted file mode 100644 index 3b22229949..0000000000 --- a/apps/web/public/static/locales/ro/vital.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "connected_vital_app": "Conectat cu", - "vital_app_sleep_automation": "Automatizare reprogramare somn", - "vital_app_automation_description": "Puteți selecta diferiți parametri pentru a declanșa reprogramarea în funcție de valorile dvs. legate de somn.", - "vital_app_parameter": "Parametru", - "vital_app_trigger": "Declanșare la valori de sau mai mici de", - "vital_app_save_button": "Salvare configurație", - "vital_app_total_label": "Total (total = rem + somn ușor + somn profund)", - "vital_app_duration_label": "Durată (durata = finalul orei de somn - începutul orei de somn)", - "vital_app_hours": "ore", - "vital_app_save_success": "Configurațiile dvs. pentru semnele vitale au fost salvate cu succes", - "vital_app_save_error": "A intervenit o eroare la salvarea configurațiilor dvs. pentru semnele vitale" -} diff --git a/apps/web/public/static/locales/ru/vital.json b/apps/web/public/static/locales/ru/vital.json deleted file mode 100644 index c7c04b11cf..0000000000 --- a/apps/web/public/static/locales/ru/vital.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "connected_vital_app": "Соединено с аккаунтом", - "vital_app_sleep_automation": "Автоматический перенос с учетом режима сна", - "vital_app_automation_description": "Вы можете настроить параметры переноса событий с учетом ваших показателей сна.", - "vital_app_parameter": "Параметр", - "vital_app_trigger": "Запускать, если меньше или равно", - "vital_app_save_button": "Сохранить конфигурацию", - "vital_app_total_label": "Всего (всего = быстрый сон + легкий сон + глубокий сон)", - "vital_app_duration_label": "Продолжительность (продолжительность = время окончания сна – время начала сна)", - "vital_app_hours": "ч.", - "vital_app_save_success": "Конфигурации Vital сохранены", - "vital_app_save_error": "Ошибка при сохранении конфигураций Vital" -} diff --git a/apps/web/public/static/locales/sr/vital.json b/apps/web/public/static/locales/sr/vital.json deleted file mode 100644 index 5bec384765..0000000000 --- a/apps/web/public/static/locales/sr/vital.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "connected_vital_app": "Povezan sa", - "vital_app_sleep_automation": "Automatizacija promene termina na osnovu parametara spavanja", - "vital_app_automation_description": "Možete izabrati različite parametre koji će aktivirati promenu termina na osnovu vaših parametara spavanja.", - "vital_app_parameter": "Parametar", - "vital_app_trigger": "Aktiviraj kada je jednako ili manje od", - "vital_app_save_button": "Sačuvaj konfiguraciju", - "vital_app_total_label": "Ukupno (ukupno = REM + lagani san + dubok san)", - "vital_app_duration_label": "Trajanje (trajanje = kraj spavanja - početak spavanja)", - "vital_app_hours": "sati", - "vital_app_save_success": "Vaše konfiguracije vitalnih znakova su uspešno sačuvane", - "vital_app_save_error": "Došlo je do greške pri čuvanju vaših konfiguracija vitalnih znakova" -} diff --git a/apps/web/public/static/locales/sv/vital.json b/apps/web/public/static/locales/sv/vital.json deleted file mode 100644 index 4c3cd550fe..0000000000 --- a/apps/web/public/static/locales/sv/vital.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "connected_vital_app": "Ansluten till", - "vital_app_sleep_automation": "Automatisering för att schemalägga sömn på nytt", - "vital_app_automation_description": "Du kan välja olika parametrar för att utlösa tidsplanen baserat på dina sömnmått.", - "vital_app_parameter": "Parameter", - "vital_app_trigger": "Utlösare under eller lika med", - "vital_app_save_button": "Spara konfiguration", - "vital_app_total_label": "Totalt (totalt = rem + lätt sömn + djupsömn)", - "vital_app_duration_label": "Varaktighet (varaktighet = sängdags slut - sängdags start)", - "vital_app_hours": "timmar", - "vital_app_save_success": "Dina grundläggande konfigurationer sparades", - "vital_app_save_error": "Det gick inte att spara dina grundläggande konfigurationer" -} \ No newline at end of file diff --git a/apps/web/public/static/locales/tr/vital.json b/apps/web/public/static/locales/tr/vital.json deleted file mode 100644 index 8fd47532e3..0000000000 --- a/apps/web/public/static/locales/tr/vital.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "connected_vital_app": "Şununla bağlan:", - "vital_app_sleep_automation": "Uykuyu yeniden programlama otomasyonu", - "vital_app_automation_description": "Uyku değerlerinize göre yeniden programlamayı tetiklemek için farklı parametreler seçebilirsiniz.", - "vital_app_parameter": "Parametre", - "vital_app_trigger": "Şu değerin altında veya eşit olduğunda tetikle:", - "vital_app_save_button": "Yapılandırmayı kaydet", - "vital_app_total_label": "Toplam (toplam = REM + hafif uyku + derin uyku)", - "vital_app_duration_label": "Süre (süre = uyku saatinin bitişi - uyku saatinin başlangıcı)", - "vital_app_hours": "saat", - "vital_app_save_success": "Önemli Yapılandırmalarınız başarıyla kaydedildi", - "vital_app_save_error": "Önemli Yapılandırmalarınız kaydedilirken bir hata oluştu" -} diff --git a/apps/web/public/static/locales/uk/vital.json b/apps/web/public/static/locales/uk/vital.json deleted file mode 100644 index 50b7d556cf..0000000000 --- a/apps/web/public/static/locales/uk/vital.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "connected_vital_app": "Підключено до", - "vital_app_sleep_automation": "Автоматизація переналаштування розкладу", - "vital_app_automation_description": "Ви можете вибирати різні параметри, щоб виконувати переналаштування розкладу на основі показників сну.", - "vital_app_parameter": "Параметр", - "vital_app_trigger": "Ініціювати, якщо показник не перевищує", - "vital_app_save_button": "Зберегти конфігурацію", - "vital_app_total_label": "Усього (усього = швидкий сон + поверхневий сон + глибокий сон)", - "vital_app_duration_label": "Тривалість (тривалість = час, коли ви встали з ліжка - час, коли ви лягли в ліжко)", - "vital_app_hours": "год", - "vital_app_save_success": "Ваші показники Vital Configurations збережено", - "vital_app_save_error": "Сталася помилка під час збереження ваших показників Vital Configurations" -} diff --git a/apps/web/public/static/locales/vi/vital.json b/apps/web/public/static/locales/vi/vital.json deleted file mode 100644 index 60fc345ebf..0000000000 --- a/apps/web/public/static/locales/vi/vital.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "connected_vital_app": "Đã kết nối với", - "vital_app_sleep_automation": "Tự động sắp lại lịch ngủ", - "vital_app_automation_description": "Bạn có thể chọn những tham số khác nhau để kích hoạt sắp lịch lại dựa trên số liệu thống kê giấc ngủ.", - "vital_app_parameter": "Tham số", - "vital_app_trigger": "Trigger ở mức dưới hoặc bằng", - "vital_app_save_button": "Lưu cấu hình", - "vital_app_total_label": "Tổng (tổng = giấc ngủ rem + ngủ nông + ngủ sâu)", - "vital_app_duration_label": "Thời lượng (thời lượng = giờ dậy - giờ bắt đầu ngủ)", - "vital_app_hours": "giờ", - "vital_app_save_success": "Lưu thành công cấu hình Vital của bạn", - "vital_app_save_error": "Có lỗi xảy ra khi lưu cấu hình Vital" -} diff --git a/apps/web/public/static/locales/zh-CN/vital.json b/apps/web/public/static/locales/zh-CN/vital.json deleted file mode 100644 index 124ac24a84..0000000000 --- a/apps/web/public/static/locales/zh-CN/vital.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "connected_vital_app": "已连接到", - "vital_app_sleep_automation": "睡眠重新安排自动化", - "vital_app_automation_description": "您可以选择不同的参数以根据您的睡眠指标触发重新安排。", - "vital_app_parameter": "参数", - "vital_app_trigger": "低于或等于时触发", - "vital_app_save_button": "保存配置", - "vital_app_total_label": "总合(总合 = 快速眼动睡眠 + 轻度睡眠 + 深度睡眠)", - "vital_app_duration_label": "时长(时长 = 就寝结束时间 - 就寝开始时间)", - "vital_app_hours": "小时", - "vital_app_save_success": "成功保存您的 Vital 配置", - "vital_app_save_error": "保存您的 Vital 配置时出错" -} diff --git a/apps/web/public/static/locales/zh-TW/vital.json b/apps/web/public/static/locales/zh-TW/vital.json deleted file mode 100644 index e134a03e3f..0000000000 --- a/apps/web/public/static/locales/zh-TW/vital.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "connected_vital_app": "已連至", - "vital_app_sleep_automation": "睡眠重新預定自動化", - "vital_app_automation_description": "您可以選取不同的參數,以根據您的睡眠指標來觸發重新預定。", - "vital_app_parameter": "參數", - "vital_app_trigger": "低於下列值或相等時觸發", - "vital_app_save_button": "儲存設定", - "vital_app_total_label": "總計 (總計 = 快速動眼期 + 淺層睡眠 + 深層睡眠)", - "vital_app_duration_label": "時間長度 (時間長度 = 起床時間 - 上床時間)", - "vital_app_hours": "小時", - "vital_app_save_success": "成功儲存您的 Vital 設定", - "vital_app_save_error": "儲存您的 Vital 設定時發生錯誤" -} diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index f8ddc1c735..5f95c6afdd 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -635,7 +635,7 @@ export const TestData = { example: { name: "Example", email: "example@example.com", - username: "example", + username: "example.username", defaultScheduleId: 1, timeZone: Timezones["+5:30"], }, diff --git a/packages/app-store/alby/components/AlbyPaymentComponent.tsx b/packages/app-store/alby/components/AlbyPaymentComponent.tsx index a4977b64cd..34599a9800 100644 --- a/packages/app-store/alby/components/AlbyPaymentComponent.tsx +++ b/packages/app-store/alby/components/AlbyPaymentComponent.tsx @@ -134,7 +134,15 @@ function PaymentChecker(props: PaymentCheckerProps) { const bookingSuccessRedirect = useBookingSuccessRedirect(); const utils = trpc.useContext(); const { t } = useLocale(); + useEffect(() => { + if (searchParams === null) { + return; + } + + // use closure to ensure non-nullability + const sp = searchParams; + const interval = setInterval(() => { (async () => { if (props.booking.status === "ACCEPTED") { @@ -153,7 +161,7 @@ function PaymentChecker(props: PaymentCheckerProps) { location: string; } = { uid: props.booking.uid, - email: searchParams.get("email"), + email: sp.get("email"), location: t("web_conferencing_details_to_follow"), }; @@ -165,6 +173,7 @@ function PaymentChecker(props: PaymentCheckerProps) { } })(); }, 1000); + return () => clearInterval(interval); }, [ bookingSuccessRedirect, @@ -178,5 +187,6 @@ function PaymentChecker(props: PaymentCheckerProps) { t, utils.viewer.bookings, ]); + return null; } diff --git a/packages/app-store/alby/pages/setup/index.tsx b/packages/app-store/alby/pages/setup/index.tsx index 9017af73ec..fdd8403b03 100644 --- a/packages/app-store/alby/pages/setup/index.tsx +++ b/packages/app-store/alby/pages/setup/index.tsx @@ -30,15 +30,20 @@ export default function AlbySetup(props: IAlbySetupProps) { function AlbySetupCallback() { const [error, setError] = useState(null); - const params = useSearchParams(); + const searchParams = useSearchParams(); + useEffect(() => { + if (!searchParams) { + return; + } + if (!window.opener) { setError("Something went wrong. Opener not available. Please contact support@getalby.com"); return; } - const code = params.get("code"); - const error = params.get("error"); + const code = searchParams?.get("code"); + const error = searchParams?.get("error"); if (!code) { setError("declined"); @@ -54,7 +59,7 @@ function AlbySetupCallback() { payload: { code }, }); window.close(); - }, []); + }, [searchParams]); return (
    diff --git a/packages/app-store/routing-forms/components/FormActions.tsx b/packages/app-store/routing-forms/components/FormActions.tsx index 17a2932d1c..6c96a221eb 100644 --- a/packages/app-store/routing-forms/components/FormActions.tsx +++ b/packages/app-store/routing-forms/components/FormActions.tsx @@ -48,7 +48,7 @@ export const useOpenModal = () => { const pathname = usePathname(); const searchParams = useSearchParams(); const openModal = (option: z.infer) => { - const newQuery = new URLSearchParams(searchParams); + const newQuery = new URLSearchParams(searchParams ?? undefined); newQuery.set("dialog", "new-form"); Object.keys(option).forEach((key) => { newQuery.set(key, option[key as keyof typeof option] || ""); diff --git a/packages/app-store/routing-forms/pages/routing-link/[...appPages].tsx b/packages/app-store/routing-forms/pages/routing-link/[...appPages].tsx index 8ff57e60ea..18b400a72d 100644 --- a/packages/app-store/routing-forms/pages/routing-link/[...appPages].tsx +++ b/packages/app-store/routing-forms/pages/routing-link/[...appPages].tsx @@ -301,7 +301,7 @@ const usePrefilledResponse = (form: Props["form"]) => { // Prefill the form from query params form.fields?.forEach((field) => { - const valuesFromQuery = searchParams?.getAll(getFieldIdentifier(field)).filter(Boolean); + const valuesFromQuery = searchParams?.getAll(getFieldIdentifier(field)).filter(Boolean) ?? []; // We only want to keep arrays if the field is a multi-select const value = valuesFromQuery.length > 1 ? valuesFromQuery : valuesFromQuery[0]; diff --git a/packages/app-store/vital/components/AppConfiguration.tsx b/packages/app-store/vital/components/AppConfiguration.tsx index fde7cdd3b0..6e20585f1f 100644 --- a/packages/app-store/vital/components/AppConfiguration.tsx +++ b/packages/app-store/vital/components/AppConfiguration.tsx @@ -1,5 +1,4 @@ import { useEffect, useState, useMemo } from "react"; -import { useTranslation } from "react-i18next"; import { Button, Select, showToast } from "@calcom/ui"; @@ -34,15 +33,14 @@ const saveSettings = async ({ }; const AppConfiguration = (props: IAppConfigurationProps) => { - const { t } = useTranslation(); const [credentialId] = props.credentialIds; const options = useMemo( () => [ - { label: t("vital_app_total_label", { ns: "vital" }), value: "total" }, - { label: t("vital_app_duration_label", { ns: "vital" }), value: "duration" }, + { label: "Total (total = rem + light sleep + deep sleep)", value: "total" }, + { label: "Duration (duration = bedtime end - bedtime start)", value: "duration" }, ], - [t] + [] ); const [selectedParam, setSelectedParam] = useState<{ label: string; value: string }>(options[0]); @@ -92,21 +90,21 @@ const AppConfiguration = (props: IAppConfigurationProps) => { return (

    - - {t("connected_vital_app", { ns: "vital" })} Vital App: {connected ? "Yes" : "No"} - + Connected with Vital App: {connected ? "Yes" : "No"}


    - {t("vital_app_sleep_automation", { ns: "vital" })} + Sleeping reschedule automation +

    +

    + You can select different parameters to trigger the reschedule based on your sleeping metrics.

    -

    {t("vital_app_automation_description", { ns: "vital" })}

    @@ -125,7 +123,7 @@ const AppConfiguration = (props: IAppConfigurationProps) => {
    @@ -142,7 +140,7 @@ const AppConfiguration = (props: IAppConfigurationProps) => { className="pr-12shadow-sm border-default mt-1 block w-full rounded-sm border py-2 pl-6 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm" />

    - {t("vital_app_hours", { ns: "vital" })} + hours

    @@ -154,9 +152,9 @@ const AppConfiguration = (props: IAppConfigurationProps) => { try { setSaveLoading(true); await saveSettings({ parameter: selectedParam, sleepValue: sleepValue }); - showToast(t("vital_app_save_success"), "success"); + showToast("Success saving your Vital Configurations", "success"); } catch (error) { - showToast(t("vital_app_save_error"), "error"); + showToast("An error ocurred saving your Vital Configurations", "error"); setSaveLoading(false); } setTouchedForm(false); @@ -164,7 +162,7 @@ const AppConfiguration = (props: IAppConfigurationProps) => { }} loading={saveLoading} disabled={disabledSaveButton}> - {t("vital_app_save_button", { ns: "vital" })} + Save configuration
    diff --git a/packages/config/next-i18next.config.js b/packages/config/next-i18next.config.js index 4bbcbbc082..ad617bbb82 100644 --- a/packages/config/next-i18next.config.js +++ b/packages/config/next-i18next.config.js @@ -31,6 +31,7 @@ const config = { "vi", "zh-CN", "zh-TW", + "km", ], }, fallbackLng: { diff --git a/packages/features/apps/AdminAppsList.tsx b/packages/features/apps/AdminAppsList.tsx index 5991687276..5c96da95bd 100644 --- a/packages/features/apps/AdminAppsList.tsx +++ b/packages/features/apps/AdminAppsList.tsx @@ -268,7 +268,7 @@ interface EditModalState extends Pick { const AdminAppsListContainer = () => { const searchParams = useSearchParams(); const { t } = useLocale(); - const category = searchParams.get("category") || AppCategories.calendar; + const category = searchParams?.get("category") || AppCategories.calendar; const { data: apps, isLoading } = trpc.viewer.appsRouter.listLocal.useQuery( { category }, diff --git a/packages/features/auth/lib/next-auth-options.ts b/packages/features/auth/lib/next-auth-options.ts index 6e21b15b96..97cc306065 100644 --- a/packages/features/auth/lib/next-auth-options.ts +++ b/packages/features/auth/lib/next-auth-options.ts @@ -8,7 +8,7 @@ import GoogleProvider from "next-auth/providers/google"; import checkLicense from "@calcom/features/ee/common/server/checkLicense"; import ImpersonationProvider from "@calcom/features/ee/impersonation/lib/ImpersonationProvider"; -import { getOrgFullDomain, subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { getOrgFullOrigin, subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains"; import { clientSecretVerifier, hostedCal, isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml"; import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; @@ -471,7 +471,7 @@ export const AUTH_OPTIONS: AuthOptions = { id: organization.id, name: organization.name, slug: organization.slug ?? parsedOrgMetadata?.requestedSlug ?? "", - fullDomain: getOrgFullDomain(organization.slug ?? parsedOrgMetadata?.requestedSlug ?? ""), + fullDomain: getOrgFullOrigin(organization.slug ?? parsedOrgMetadata?.requestedSlug ?? ""), domainSuffix: subdomainSuffix(), } : undefined, diff --git a/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContainer.tsx b/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContainer.tsx index a4e52447f5..1e7e479bca 100644 --- a/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContainer.tsx +++ b/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContainer.tsx @@ -38,7 +38,7 @@ function OverlayCalendarSwitch({ enabled }: OverlayCalendarSwitchProps) { // Toggle query param for overlay calendar const toggleOverlayCalendarQueryParam = useCallback( (state: boolean) => { - const current = new URLSearchParams(Array.from(searchParams.entries())); + const current = new URLSearchParams(Array.from(searchParams?.entries() ?? [])); if (state) { current.set("overlayCalendar", "true"); localStorage.setItem("overlayCalendarSwitchDefault", "true"); @@ -121,7 +121,7 @@ export function OverlayCalendarContainer() { const { data: session, status: sessionStatus } = useSession(); const setOverlayBusyDates = useOverlayCalendarStore((state) => state.setOverlayBusyDates); const switchEnabled = - searchParams.get("overlayCalendar") === "true" || + searchParams?.get("overlayCalendar") === "true" || localStorage.getItem("overlayCalendarSwitchDefault") === "true"; const selectedDate = useBookerStore((state) => state.selectedDate); diff --git a/packages/features/bookings/Booker/utils/event.ts b/packages/features/bookings/Booker/utils/event.ts index 519861837f..b32440197c 100644 --- a/packages/features/bookings/Booker/utils/event.ts +++ b/packages/features/bookings/Booker/utils/event.ts @@ -62,7 +62,7 @@ export const useScheduleForEvent = ({ shallow ); const searchParams = useSearchParams(); - const rescheduleUid = searchParams.get("rescheduleUid"); + const rescheduleUid = searchParams?.get("rescheduleUid"); const pathname = usePathname(); @@ -78,6 +78,6 @@ export const useScheduleForEvent = ({ rescheduleUid, month: monthFromStore ?? month, duration: durationFromStore ?? duration, - isTeamEvent: pathname.indexOf("/team/") !== -1 || isTeam, + isTeamEvent: pathname?.indexOf("/team/") !== -1 || isTeam, }); }; diff --git a/packages/features/bookings/components/event-meta/Members.tsx b/packages/features/bookings/components/event-meta/Members.tsx index 85d8f22d2c..29686e8f1f 100644 --- a/packages/features/bookings/components/event-meta/Members.tsx +++ b/packages/features/bookings/components/event-meta/Members.tsx @@ -1,9 +1,6 @@ -import { usePathname } from "next/navigation"; - -import { getOrgFullDomain } from "@calcom/features/ee/organizations/lib/orgDomains"; -import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants"; import { SchedulingType } from "@calcom/prisma/enums"; -import { AvatarGroup } from "@calcom/ui"; +import { UserAvatarGroup } from "@calcom/web/components/ui/avatar/UserAvatarGroup"; +import { UserAvatarGroupWithOrg } from "@calcom/web/components/ui/avatar/UserAvatarGroupWithOrg"; import type { PublicEvent } from "../../types"; @@ -18,17 +15,7 @@ export interface EventMembersProps { entity: PublicEvent["entity"]; } -type Avatar = { - title: string; - image: string | undefined; - alt: string | undefined; - href: string | undefined; -}; - -type AvatarWithRequiredImage = Avatar & { image: string }; - export const EventMembers = ({ schedulingType, users, profile, entity }: EventMembersProps) => { - const pathname = usePathname(); const showMembers = schedulingType !== SchedulingType.ROUND_ROBIN; const shownUsers = showMembers ? users : []; @@ -38,40 +25,22 @@ export const EventMembers = ({ schedulingType, users, profile, entity }: EventMe !users.length || (profile.name !== users[0].name && schedulingType === SchedulingType.COLLECTIVE); - const avatars: Avatar[] = shownUsers.map((user) => ({ - title: `${user.name || user.username}`, - image: "image" in user ? `${user.image}` : `/${user.username}/avatar.png`, - alt: user.name || undefined, - href: `/${user.username}`, - })); - - // Add organization avatar - if (entity.orgSlug) { - avatars.unshift({ - title: `${entity.name}`, - image: `${WEBAPP_URL}/team/${entity.orgSlug}/avatar.png`, - alt: entity.name || undefined, - href: getOrgFullDomain(entity.orgSlug), - }); - } - - // Add profile later since we don't want to force creating an avatar for this if it doesn't exist. - avatars.unshift({ - title: `${profile.name || profile.username}`, - image: "logo" in profile && profile.logo ? `${profile.logo}` : undefined, - alt: profile.name || undefined, - href: profile.username - ? `${CAL_URL}${pathname.indexOf("/team/") !== -1 ? "/team" : ""}/${profile.username}` - : undefined, - }); - - const uniqueAvatars = avatars - .filter((item): item is AvatarWithRequiredImage => !!item.image) - .filter((item, index, self) => self.findIndex((t) => t.image === item.image) === index); - return ( <> - + {entity.orgSlug ? ( + + ) : ( + + )} +

    {showOnlyProfileName ? profile.name diff --git a/packages/features/calendars/DatePicker.tsx b/packages/features/calendars/DatePicker.tsx index e046aadcea..2bea7d04fa 100644 --- a/packages/features/calendars/DatePicker.tsx +++ b/packages/features/calendars/DatePicker.tsx @@ -5,6 +5,7 @@ import type { Dayjs } from "@calcom/dayjs"; import dayjs from "@calcom/dayjs"; import { useEmbedStyles } from "@calcom/embed-core/embed-iframe"; import { useBookerStore } from "@calcom/features/bookings/Booker/store"; +import { getAvailableDatesInMonth } from "@calcom/features/calendars/lib/getAvailableDatesInMonth"; import classNames from "@calcom/lib/classNames"; import { daysInMonth, yyyymmdd } from "@calcom/lib/date-fns"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -23,9 +24,9 @@ export type DatePickerProps = { /** which date or dates are currently selected (not tracked from here) */ selected?: Dayjs | Dayjs[] | null; /** defaults to current date. */ - minDate?: Dayjs; + minDate?: Date; /** Furthest date selectable in the future, default = UNLIMITED */ - maxDate?: Dayjs; + maxDate?: Date; /** locale, any IETF language tag, e.g. "hu-HU" - defaults to Browser settings */ locale: string; /** Defaults to [], which dates are not bookable. Array of valid dates like: ["2022-04-23", "2022-04-24"] */ @@ -102,7 +103,7 @@ const NoAvailabilityOverlay = ({ }; const Days = ({ - minDate = dayjs.utc(), + minDate, excludedDates = [], browsingDate, weekStart, @@ -121,30 +122,12 @@ const Days = ({ }) => { // Create placeholder elements for empty days in first week const weekdayOfFirst = browsingDate.date(1).day(); - const currentDate = minDate.utcOffset(browsingDate.utcOffset()); - const availableDates = (includedDates: string[] | undefined) => { - const dates = []; - const lastDateOfMonth = browsingDate.date(daysInMonth(browsingDate)); - for ( - let date = currentDate; - date.isBefore(lastDateOfMonth) || date.isSame(lastDateOfMonth, "day"); - date = date.add(1, "day") - ) { - // even if availableDates is given, filter out the passed included dates - if (includedDates && !includedDates.includes(yyyymmdd(date))) { - continue; - } - dates.push(yyyymmdd(date)); - } - return dates; - }; - const utcBrowsingDateWithOffset = browsingDate.utc().add(browsingDate.utcOffset(), "minute"); - const utcCurrentDateWithOffset = currentDate.utc().add(browsingDate.utcOffset(), "minute"); - - const includedDates = utcCurrentDateWithOffset.isSame(utcBrowsingDateWithOffset, "month") - ? availableDates(props.includedDates) - : props.includedDates; + const includedDates = getAvailableDatesInMonth({ + browsingDate: browsingDate.toDate(), + minDate, + includedDates: props.includedDates, + }); const days: (Dayjs | null)[] = Array((weekdayOfFirst - weekStart + 7) % 7).fill(null); for (let day = 1, dayCount = daysInMonth(browsingDate); day <= dayCount; day++) { diff --git a/packages/features/calendars/lib/getAvailableDatesInMonth.test.ts b/packages/features/calendars/lib/getAvailableDatesInMonth.test.ts new file mode 100644 index 0000000000..10e8fdc147 --- /dev/null +++ b/packages/features/calendars/lib/getAvailableDatesInMonth.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "vitest"; + +import { getAvailableDatesInMonth } from "@calcom/features/calendars/lib/getAvailableDatesInMonth"; +import { daysInMonth, yyyymmdd } from "@calcom/lib/date-fns"; + +describe("Test Suite: Date Picker", () => { + describe("Calculates the available dates left in the month", () => { + // *) Use right amount of days in given month. (28, 30, 31) + test("it returns the right amount of days in a given month", () => { + const currentDate = new Date(); + const nextMonthDate = new Date(Date.UTC(currentDate.getFullYear(), currentDate.getMonth() + 1)); + + const result = getAvailableDatesInMonth({ + browsingDate: nextMonthDate, + }); + + expect(result).toHaveLength(daysInMonth(nextMonthDate)); + }); + // *) Dates in the past are not available. + test("it doesn't return dates that already passed", () => { + const currentDate = new Date(); + const result = getAvailableDatesInMonth({ + browsingDate: currentDate, + }); + + expect(result).toHaveLength(daysInMonth(currentDate) - currentDate.getDate() + 1); + }); + // *) Intersect with included dates. + test("it intersects with given included dates", () => { + const currentDate = new Date(); + const result = getAvailableDatesInMonth({ + browsingDate: currentDate, + includedDates: [yyyymmdd(currentDate)], + }); + + expect(result).toHaveLength(1); + }); + }); +}); diff --git a/packages/features/calendars/lib/getAvailableDatesInMonth.ts b/packages/features/calendars/lib/getAvailableDatesInMonth.ts new file mode 100644 index 0000000000..8fbace876b --- /dev/null +++ b/packages/features/calendars/lib/getAvailableDatesInMonth.ts @@ -0,0 +1,32 @@ +import { daysInMonth, yyyymmdd } from "@calcom/lib/date-fns"; + +// calculate the available dates in the month: +// *) Intersect with included dates. +// *) Dates in the past are not available. +// *) Use right amount of days in given month. (28, 30, 31) +export function getAvailableDatesInMonth({ + browsingDate, // pass as UTC + minDate = new Date(), + includedDates, +}: { + browsingDate: Date; + minDate?: Date; + includedDates?: string[]; +}) { + const dates = []; + const lastDateOfMonth = new Date( + Date.UTC(browsingDate.getFullYear(), browsingDate.getMonth(), daysInMonth(browsingDate)) + ); + for ( + let date = browsingDate > minDate ? browsingDate : minDate; + date <= lastDateOfMonth; + date = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate() + 1)) + ) { + // intersect included dates + if (includedDates && !includedDates.includes(yyyymmdd(date))) { + continue; + } + dates.push(yyyymmdd(date)); + } + return dates; +} diff --git a/packages/features/ee/organizations/components/OrganizationAvatar.tsx b/packages/features/ee/organizations/components/OrganizationAvatar.tsx deleted file mode 100644 index 85a361a291..0000000000 --- a/packages/features/ee/organizations/components/OrganizationAvatar.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import classNames from "@calcom/lib/classNames"; -import { Avatar } from "@calcom/ui"; -import type { AvatarProps } from "@calcom/ui"; - -type OrganizationAvatarProps = AvatarProps & { - organizationSlug: string | null | undefined; -}; - -const OrganizationAvatar = ({ size, imageSrc, alt, organizationSlug, ...rest }: OrganizationAvatarProps) => { - return ( - - {alt} -

    - ) : null - } - /> - ); -}; - -export default OrganizationAvatar; diff --git a/packages/features/ee/organizations/components/OrganizationMemberAvatar.tsx b/packages/features/ee/organizations/components/OrganizationMemberAvatar.tsx new file mode 100644 index 0000000000..7c898776c7 --- /dev/null +++ b/packages/features/ee/organizations/components/OrganizationMemberAvatar.tsx @@ -0,0 +1,47 @@ +import classNames from "@calcom/lib/classNames"; +import { getOrgAvatarUrl } from "@calcom/lib/getAvatarUrl"; +// import { Avatar } from "@calcom/ui"; +import { UserAvatar } from "@calcom/web/components/ui/avatar/UserAvatar"; + +type OrganizationMemberAvatarProps = React.ComponentProps & { + organization: { + id: number; + slug: string | null; + requestedSlug: string | null; + } | null; +}; + +/** + * Shows the user's avatar along with a small organization's avatar + */ +const OrganizationMemberAvatar = ({ + size, + user, + organization, + previewSrc, + ...rest +}: OrganizationMemberAvatarProps) => { + return ( + + {user.username +
    + ) : null + } + {...rest} + /> + ); +}; + +export default OrganizationMemberAvatar; diff --git a/packages/features/ee/organizations/lib/orgDomains.ts b/packages/features/ee/organizations/lib/orgDomains.ts index 68f6425fad..8c55dd5929 100644 --- a/packages/features/ee/organizations/lib/orgDomains.ts +++ b/packages/features/ee/organizations/lib/orgDomains.ts @@ -1,6 +1,7 @@ import type { Prisma } from "@prisma/client"; import { ALLOWED_HOSTNAMES, RESERVED_SUBDOMAINS, WEBAPP_URL } from "@calcom/lib/constants"; +import logger from "@calcom/lib/logger"; import slugify from "@calcom/lib/slugify"; /** @@ -18,6 +19,12 @@ export function getOrgSlug(hostname: string) { const testHostname = `${url.hostname}${url.port ? `:${url.port}` : ""}`; return testHostname.endsWith(`.${ahn}`); }); + logger.debug(`getOrgSlug: ${hostname} ${currentHostname}`, { + ALLOWED_HOSTNAMES, + WEBAPP_URL, + currentHostname, + hostname, + }); if (currentHostname) { // Define which is the current domain/subdomain const slug = hostname.replace(`.${currentHostname}` ?? "", ""); @@ -29,6 +36,7 @@ export function getOrgSlug(hostname: string) { export function orgDomainConfig(hostname: string, fallback?: string | string[]) { const currentOrgDomain = getOrgSlug(hostname); const isValidOrgDomain = currentOrgDomain !== null && !RESERVED_SUBDOMAINS.includes(currentOrgDomain); + logger.debug(`orgDomainConfig: ${hostname} ${currentOrgDomain} ${isValidOrgDomain}`); if (isValidOrgDomain || !fallback) { return { currentOrgDomain: isValidOrgDomain ? currentOrgDomain : null, @@ -48,10 +56,14 @@ export function subdomainSuffix() { return urlSplit.length === 3 ? urlSplit.slice(1).join(".") : urlSplit.join("."); } -export function getOrgFullDomain(slug: string, options: { protocol: boolean } = { protocol: true }) { +export function getOrgFullOrigin(slug: string, options: { protocol: boolean } = { protocol: true }) { + if (!slug) return WEBAPP_URL; return `${options.protocol ? `${new URL(WEBAPP_URL).protocol}//` : ""}${slug}.${subdomainSuffix()}`; } +/** + * @deprecated You most probably intend to query for an organization only, use `whereClauseForOrgWithSlugOrRequestedSlug` instead which will only return the organization and not a team accidentally. + */ export function getSlugOrRequestedSlug(slug: string) { const slugifiedValue = slugify(slug); return { @@ -67,6 +79,26 @@ export function getSlugOrRequestedSlug(slug: string) { } satisfies Prisma.TeamWhereInput; } +export function whereClauseForOrgWithSlugOrRequestedSlug(slug: string) { + const slugifiedValue = slugify(slug); + + return { + OR: [ + { slug: slugifiedValue }, + { + metadata: { + path: ["requestedSlug"], + equals: slug, + }, + }, + ], + metadata: { + path: ["isOrganization"], + equals: true, + }, + } satisfies Prisma.TeamWhereInput; +} + export function userOrgQuery(hostname: string, fallback?: string | string[]) { const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(hostname, fallback); return isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null; diff --git a/packages/features/ee/organizations/pages/settings/other-team-members-view.tsx b/packages/features/ee/organizations/pages/settings/other-team-members-view.tsx index 0e026e8127..6575ddda81 100644 --- a/packages/features/ee/organizations/pages/settings/other-team-members-view.tsx +++ b/packages/features/ee/organizations/pages/settings/other-team-members-view.tsx @@ -58,7 +58,7 @@ const MembersView = () => { const { t, i18n } = useLocale(); const router = useRouter(); const searchParams = useSearchParams(); - const teamId = Number(searchParams.get("id")); + const teamId = Number(searchParams?.get("id")); const session = useSession(); const utils = trpc.useContext(); const [offset, setOffset] = useState(1); @@ -74,6 +74,7 @@ const MembersView = () => { const { data: team, isLoading: isTeamLoading } = trpc.viewer.organizations.getOtherTeam.useQuery( { teamId }, { + enabled: !Number.isNaN(teamId), onError: () => { router.push("/settings"); }, @@ -86,13 +87,14 @@ const MembersView = () => { distinctUser: true, }, { - enabled: searchParams !== null, + enabled: !Number.isNaN(teamId), } ); const { data: membersFetch, isLoading: isLoadingMembers } = trpc.viewer.organizations.listOtherTeamMembers.useQuery( { teamId, limit, offset: (offset - 1) * limit }, { + enabled: !Number.isNaN(teamId), onError: () => { router.push("/settings"); }, @@ -101,12 +103,8 @@ const MembersView = () => { useEffect(() => { if (membersFetch) { - if (membersFetch.length < limit) { - setLoadMore(false); - } else { - setLoadMore(true); - } - setMembers(members.concat(membersFetch)); + setLoadMore(membersFetch.length >= limit); + setMembers((m) => m.concat(membersFetch)); } }, [membersFetch]); diff --git a/packages/features/ee/organizations/pages/settings/other-team-profile-view.tsx b/packages/features/ee/organizations/pages/settings/other-team-profile-view.tsx index 1d97111bbc..1b04688418 100644 --- a/packages/features/ee/organizations/pages/settings/other-team-profile-view.tsx +++ b/packages/features/ee/organizations/pages/settings/other-team-profile-view.tsx @@ -77,11 +77,11 @@ const OtherTeamProfileView = () => { resolver: zodResolver(teamProfileFormSchema), }); const searchParams = useSearchParams(); - const teamId = Number(searchParams.get("id")); + const teamId = Number(searchParams?.get("id")); const { data: team, isLoading } = trpc.viewer.organizations.getOtherTeam.useQuery( { teamId: teamId }, { - enabled: !!teamId, + enabled: !Number.isNaN(teamId), onError: () => { router.push("/settings"); }, diff --git a/packages/features/ee/payments/components/Payment.tsx b/packages/features/ee/payments/components/Payment.tsx index be4f3a5e69..4311e9036c 100644 --- a/packages/features/ee/payments/components/Payment.tsx +++ b/packages/features/ee/payments/components/Payment.tsx @@ -81,12 +81,17 @@ const PaymentForm = (props: Props) => { const handleSubmit = async (ev: SyntheticEvent) => { ev.preventDefault(); - if (!stripe || !elements) return; + if (!stripe || !elements || searchParams === null) { + return; + } + setState({ status: "processing" }); let payload; const params: { - [k: string]: any; + uid: string; + email: string | null; + location?: string; } = { uid: props.booking.uid, email: searchParams.get("email"), diff --git a/packages/features/ee/teams/components/MemberListItem.tsx b/packages/features/ee/teams/components/MemberListItem.tsx index e460d52e46..1bfaa68b60 100644 --- a/packages/features/ee/teams/components/MemberListItem.tsx +++ b/packages/features/ee/teams/components/MemberListItem.tsx @@ -11,7 +11,6 @@ import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery"; import { - Avatar, Button, ButtonGroup, ConfirmationDialogContent, @@ -29,6 +28,7 @@ import { Tooltip, } from "@calcom/ui"; import { ExternalLink, MoreHorizontal, Edit2, Lock, UserX } from "@calcom/ui/components/icon"; +import { UserAvatar } from "@calcom/web/components/ui/avatar/UserAvatar"; import MemberChangeRoleModal from "./MemberChangeRoleModal"; import TeamAvailabilityModal from "./TeamAvailabilityModal"; @@ -141,13 +141,7 @@ export default function MemberListItem(props: Props) {
    - - +
    {name} diff --git a/packages/features/ee/teams/pages/team-profile-view.tsx b/packages/features/ee/teams/pages/team-profile-view.tsx index d9cd6ab6e2..69179974d0 100644 --- a/packages/features/ee/teams/pages/team-profile-view.tsx +++ b/packages/features/ee/teams/pages/team-profile-view.tsx @@ -8,7 +8,7 @@ import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; -import { getOrgFullDomain } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -235,7 +235,7 @@ const ProfileView = () => { value={value} addOnLeading={ team.parent && orgBranding - ? `${getOrgFullDomain(orgBranding?.slug, { protocol: false })}/` + ? `${getOrgFullOrigin(orgBranding?.slug, { protocol: false })}/` : `${WEBAPP_URL}/team/` } onChange={(e) => { diff --git a/packages/features/ee/users/pages/users-add-view.tsx b/packages/features/ee/users/pages/users-add-view.tsx index 052e546443..30ffc93ae0 100644 --- a/packages/features/ee/users/pages/users-add-view.tsx +++ b/packages/features/ee/users/pages/users-add-view.tsx @@ -17,7 +17,10 @@ const UsersAddView = () => { onSuccess: async () => { showToast("User added successfully", "success"); await utils.viewer.users.list.invalidate(); - router.replace(pathname?.replace("/add", "")); + + if (pathname !== null) { + router.replace(pathname.replace("/add", "")); + } }, onError: (err) => { console.error(err.message); diff --git a/packages/features/ee/users/server/trpc-router.ts b/packages/features/ee/users/server/trpc-router.ts index fd4fec9115..d0789d81da 100644 --- a/packages/features/ee/users/server/trpc-router.ts +++ b/packages/features/ee/users/server/trpc-router.ts @@ -33,6 +33,9 @@ const userBodySchema = User.pick({ avatar: true, }); +/** + * @deprecated in favour of @calcom/lib/getAvatarUrl + */ /** This helps to prevent reaching the 4MB payload limit by avoiding base64 and instead passing the avatar url */ export function getAvatarUrlFromUser(user: { avatar: string | null; diff --git a/packages/features/embed/Embed.tsx b/packages/features/embed/Embed.tsx index afebad31f0..69df6baaf6 100644 --- a/packages/features/embed/Embed.tsx +++ b/packages/features/embed/Embed.tsx @@ -242,7 +242,7 @@ const EmailEmbed = ({ eventType, username }: { eventType?: EventType; username: {selectedDate ? (
    {selectedDate ? ( -
    +
    [state.month, state.selectedDatesAndTimes], shallow ); - const eventId = searchParams.get("eventId"); + const eventId = searchParams?.get("eventId"); + const parsedEventId = parseInt(eventId ?? "", 10); const calLink = decodeURIComponent(embedUrl); const { data: eventTypeData } = trpc.viewer.eventTypes.get.useQuery( - { id: parseInt(eventId as string) }, - { enabled: !!eventId && embedType === "email", refetchOnWindowFocus: false } + { id: parsedEventId }, + { enabled: !Number.isNaN(parsedEventId) && embedType === "email", refetchOnWindowFocus: false } ); const s = (href: string) => { const _searchParams = new URLSearchParams(searchParams); const [a, b] = href.split("="); _searchParams.set(a, b); - return `${pathname?.split("?")[0]}?${_searchParams.toString()}`; + return `${pathname?.split("?")[0] ?? ""}?${_searchParams.toString()}`; }; const parsedTabs = tabs.map((t) => ({ ...t, href: s(t.href) })); const embedCodeRefs: Record<(typeof tabs)[0]["name"], RefObject> = {}; diff --git a/packages/features/eventtypes/components/ChildrenEventTypeSelect.tsx b/packages/features/eventtypes/components/ChildrenEventTypeSelect.tsx index d6cc851900..328644da30 100644 --- a/packages/features/eventtypes/components/ChildrenEventTypeSelect.tsx +++ b/packages/features/eventtypes/components/ChildrenEventTypeSelect.tsx @@ -2,7 +2,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; import type { Props } from "react-select"; import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; -import { getOrgFullDomain } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; import { classNames } from "@calcom/lib"; import { CAL_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -63,7 +63,7 @@ export const ChildrenEventTypeSelect = ({ ()({ brandColor: true, darkBrandColor: true, theme: true, + organizationId: true, metadata: true, }, }, @@ -93,6 +94,7 @@ const publicEventSelect = Prisma.validator()({ metadata: true, brandColor: true, darkBrandColor: true, + organizationId: true, organization: { select: { name: true, @@ -130,6 +132,7 @@ export const getPublicEvent = async ( brandColor: true, darkBrandColor: true, theme: true, + organizationId: true, organization: { select: { slug: true, @@ -291,23 +294,24 @@ function getUsersFromEvent(event: Event) { if (!owner) { return null; } - const { username, name, weekStart } = owner; - return [{ username, name, weekStart }]; + const { username, name, weekStart, organizationId } = owner; + return [{ username, name, weekStart, organizationId }]; } async function getOwnerFromUsersArray(prisma: PrismaClient, eventTypeId: number) { const { users } = await prisma.eventType.findUniqueOrThrow({ where: { id: eventTypeId }, - select: { users: { select: { username: true, name: true, weekStart: true } } }, + select: { users: { select: { username: true, name: true, weekStart: true, organizationId: true } } }, }); if (!users.length) return null; return [users[0]]; } -function mapHostsToUsers(host: { user: Pick }) { +function mapHostsToUsers(host: { user: Pick }) { return { username: host.user.username, name: host.user.name, weekStart: host.user.weekStart, + organizationId: host.user.organizationId, }; } diff --git a/packages/features/insights/context/FiltersProvider.tsx b/packages/features/insights/context/FiltersProvider.tsx index dcebb2a777..2eec6e91b3 100644 --- a/packages/features/insights/context/FiltersProvider.tsx +++ b/packages/features/insights/context/FiltersProvider.tsx @@ -8,21 +8,22 @@ import { trpc } from "@calcom/trpc"; import type { FilterContextType } from "./provider"; import { FilterProvider } from "./provider"; +const querySchema = z.object({ + startTime: z.string().nullable(), + endTime: z.string().nullable(), + teamId: z.coerce.number().nullable(), + userId: z.coerce.number().nullable(), + memberUserId: z.coerce.number().nullable(), + eventTypeId: z.coerce.number().nullable(), + filter: z.enum(["event-type", "user"]).nullable(), +}); + export function FiltersProvider({ children }: { children: React.ReactNode }) { // searchParams to get initial values from query params const utils = trpc.useContext(); const searchParams = useSearchParams(); const router = useRouter(); const pathname = usePathname(); - const querySchema = z.object({ - startTime: z.string().nullable(), - endTime: z.string().nullable(), - teamId: z.coerce.number().nullable(), - userId: z.coerce.number().nullable(), - memberUserId: z.coerce.number().nullable(), - eventTypeId: z.coerce.number().nullable(), - filter: z.enum(["event-type", "user"]).nullable(), - }); let startTimeParsed, endTimeParsed, @@ -33,13 +34,13 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) { memberUserIdParsed; const safe = querySchema.safeParse({ - startTime: searchParams.get("startTime"), - endTime: searchParams.get("endTime"), - teamId: searchParams.get("teamId"), - userId: searchParams.get("userId"), - eventTypeId: searchParams.get("eventTypeId"), - filter: searchParams.get("filter"), - memberUserId: searchParams.get("memberUserId"), + startTime: searchParams?.get("startTime") ?? null, + endTime: searchParams?.get("endTime") ?? null, + teamId: searchParams?.get("teamId") ?? null, + userId: searchParams?.get("userId") ?? null, + eventTypeId: searchParams?.get("eventTypeId") ?? null, + filter: searchParams?.get("filter") ?? null, + memberUserId: searchParams?.get("memberUserId") ?? null, }); if (!safe.success) { @@ -119,7 +120,7 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) { initialConfig, } = newConfigFilters; const [startTime, endTime] = dateRange || [null, null]; - const newSearchParams = new URLSearchParams(searchParams.toString()); + const newSearchParams = new URLSearchParams(searchParams?.toString() ?? undefined); function setParamsIfDefined(key: string, value: string | number | boolean | null | undefined) { if (value !== undefined && value !== null) newSearchParams.set(key, value.toString()); } diff --git a/packages/features/shell/Shell.tsx b/packages/features/shell/Shell.tsx index 22eb8685cd..0975f4d055 100644 --- a/packages/features/shell/Shell.tsx +++ b/packages/features/shell/Shell.tsx @@ -12,7 +12,7 @@ import { useIsEmbed } from "@calcom/embed-core/embed-iframe"; import UnconfirmedBookingBadge from "@calcom/features/bookings/UnconfirmedBookingBadge"; import ImpersonatingBanner from "@calcom/features/ee/impersonation/components/ImpersonatingBanner"; import { OrgUpgradeBanner } from "@calcom/features/ee/organizations/components/OrgUpgradeBanner"; -import { getOrgFullDomain } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; import HelpMenuItem from "@calcom/features/ee/support/components/HelpMenuItem"; import { TeamsUpgradeBanner } from "@calcom/features/ee/teams/components"; import { useFlagMap } from "@calcom/features/flags/context/provider"; @@ -509,7 +509,7 @@ export type NavigationItemType = { }: { item: Pick; isChild?: boolean; - pathname: string; + pathname: string | null; }) => boolean; }; @@ -527,7 +527,7 @@ const navigation: NavigationItemType[] = [ href: "/bookings/upcoming", icon: Calendar, badge: , - isCurrent: ({ pathname }) => pathname?.startsWith("/bookings"), + isCurrent: ({ pathname }) => pathname?.startsWith("/bookings") ?? false, }, { name: "availability", @@ -547,7 +547,7 @@ const navigation: NavigationItemType[] = [ icon: Grid, isCurrent: ({ pathname: path, item }) => { // During Server rendering path is /v2/apps but on client it becomes /apps(weird..) - return path?.startsWith(item.href) && !path?.includes("routing-forms/"); + return (path?.startsWith(item.href) ?? false) && !(path?.includes("routing-forms/") ?? false); }, child: [ { @@ -556,7 +556,9 @@ const navigation: NavigationItemType[] = [ isCurrent: ({ pathname: path, item }) => { // During Server rendering path is /v2/apps but on client it becomes /apps(weird..) return ( - path?.startsWith(item.href) && !path?.includes("routing-forms/") && !path?.includes("/installed") + (path?.startsWith(item.href) ?? false) && + !(path?.includes("routing-forms/") ?? false) && + !(path?.includes("/installed") ?? false) ); }, }, @@ -564,7 +566,8 @@ const navigation: NavigationItemType[] = [ name: "installed_apps", href: "/apps/installed/calendar", isCurrent: ({ pathname: path }) => - path?.startsWith("/apps/installed/") || path?.startsWith("/v2/apps/installed/"), + (path?.startsWith("/apps/installed/") ?? false) || + (path?.startsWith("/v2/apps/installed/") ?? false), }, ], }, @@ -577,7 +580,7 @@ const navigation: NavigationItemType[] = [ name: "Routing Forms", href: "/apps/routing-forms/forms", icon: FileText, - isCurrent: ({ pathname }) => pathname?.startsWith("/apps/routing-forms/"), + isCurrent: ({ pathname }) => pathname?.startsWith("/apps/routing-forms/") ?? false, }, { name: "workflows", @@ -631,7 +634,7 @@ function useShouldDisplayNavigationItem(item: NavigationItemType) { } const defaultIsCurrent: NavigationItemType["isCurrent"] = ({ isChild, item, pathname }) => { - return isChild ? item.href === pathname : item.href ? pathname?.startsWith(item.href) : false; + return isChild ? item.href === pathname : item.href ? pathname?.startsWith(item.href) ?? false : false; }; const NavigationItem: React.FC<{ @@ -794,7 +797,7 @@ function SideBar({ bannersHeight, user }: SideBarProps) { const publicPageUrl = useMemo(() => { if (!user?.org?.id) return `${process.env.NEXT_PUBLIC_WEBSITE_URL}/${user?.username}`; - const publicPageUrl = orgBranding?.slug ? getOrgFullDomain(orgBranding.slug) : ""; + const publicPageUrl = orgBranding?.slug ? getOrgFullOrigin(orgBranding.slug) : ""; return publicPageUrl; }, [orgBranding?.slug, user?.username, user?.org?.id]); diff --git a/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx b/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx index bdedad5d1c..d1e5b103b9 100644 --- a/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx +++ b/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx @@ -8,7 +8,8 @@ import type { DateRange } from "@calcom/lib/date-ranges"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { MembershipRole } from "@calcom/prisma/enums"; import { trpc } from "@calcom/trpc"; -import { Avatar, Button, ButtonGroup, DataTable } from "@calcom/ui"; +import { Button, ButtonGroup, DataTable } from "@calcom/ui"; +import { UserAvatar } from "@calcom/web/components/ui/avatar/UserAvatar"; import { UpgradeTip } from "../../tips/UpgradeTip"; import { TBContext, createTimezoneBuddyStore } from "../store"; @@ -18,6 +19,8 @@ import { TimeDial } from "./TimeDial"; export interface SliderUser { id: number; username: string | null; + name: string | null; + organizationId: number; email: string; timeZone: string; role: MembershipRole; @@ -78,10 +81,17 @@ export function AvailabilitySliderTable() { accessorFn: (data) => data.email, header: "Member", cell: ({ row }) => { - const { username, email, timeZone } = row.original; + const { username, email, timeZone, name, organizationId } = row.original; return (
    - +
    {username || "No username"} diff --git a/packages/features/webhooks/pages/webhook-edit-view.tsx b/packages/features/webhooks/pages/webhook-edit-view.tsx index 7b3c62a857..e44ffeb5e6 100644 --- a/packages/features/webhooks/pages/webhook-edit-view.tsx +++ b/packages/features/webhooks/pages/webhook-edit-view.tsx @@ -12,7 +12,7 @@ import { subscriberUrlReserved } from "../lib/subscriberUrlReserved"; const EditWebhook = () => { const searchParams = useSearchParams(); - const id = searchParams.get("id"); + const id = searchParams?.get("id"); if (!id) return ; diff --git a/packages/lib/bookingSuccessRedirect.ts b/packages/lib/bookingSuccessRedirect.ts index 303f4fca4e..b1e3c6a8a0 100644 --- a/packages/lib/bookingSuccessRedirect.ts +++ b/packages/lib/bookingSuccessRedirect.ts @@ -60,7 +60,7 @@ export const useBookingSuccessRedirect = () => { ...query, ...bookingExtraParams, }, - searchParams, + searchParams: searchParams ?? undefined, }); window.parent.location.href = `${url.toString()}?${newSearchParams.toString()}`; return; diff --git a/packages/lib/checkRateLimitAndThrowError.ts b/packages/lib/checkRateLimitAndThrowError.ts index 88c43f0230..d510a16181 100644 --- a/packages/lib/checkRateLimitAndThrowError.ts +++ b/packages/lib/checkRateLimitAndThrowError.ts @@ -9,7 +9,7 @@ export async function checkRateLimitAndThrowError({ }: RateLimitHelper) { const { remaining, reset } = await rateLimiter()({ rateLimitingType, identifier }); - if (remaining < 0) { + if (remaining < 1) { const convertToSeconds = (ms: number) => Math.floor(ms / 1000); const secondsToWait = convertToSeconds(reset - Date.now()); throw new TRPCError({ diff --git a/packages/lib/getAvatarUrl.ts b/packages/lib/getAvatarUrl.ts new file mode 100644 index 0000000000..2c971be827 --- /dev/null +++ b/packages/lib/getAvatarUrl.ts @@ -0,0 +1,24 @@ +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { AVATAR_FALLBACK } from "@calcom/lib/constants"; +import type { User, Team } from "@calcom/prisma/client"; + +/** + * Gives an organization aware avatar url for a user + * It ensures that the wrong avatar isn't fetched by ensuring that organizationId is always passed + */ +export const getUserAvatarUrl = (user: Pick) => { + if (!user.username) return AVATAR_FALLBACK; + // avatar.png automatically redirects to fallback avatar if user doesn't have one + return `${WEBAPP_URL}/${user.username}/avatar.png${ + user.organizationId ? `?orgId=${user.organizationId}` : "" + }`; +}; + +export const getOrgAvatarUrl = (org: { + id: Team["id"]; + slug: Team["slug"]; + requestedSlug: string | null; +}) => { + const slug = org.slug ?? org.requestedSlug; + return `${WEBAPP_URL}/org/${slug}/avatar.png`; +}; diff --git a/packages/lib/getEventTypeById.ts b/packages/lib/getEventTypeById.ts index 3f96aa9e36..7637446d80 100644 --- a/packages/lib/getEventTypeById.ts +++ b/packages/lib/getEventTypeById.ts @@ -4,7 +4,7 @@ import { getLocationGroupedOptions } from "@calcom/app-store/server"; import type { StripeData } from "@calcom/app-store/stripepayment/lib/server"; import { getEventTypeAppData } from "@calcom/app-store/utils"; import type { LocationObject } from "@calcom/core/location"; -import { getOrgFullDomain } from "@calcom/ee/organizations/lib/orgDomains"; +import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains"; import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields"; import { parseBookingLimit, parseDurationLimit, parseRecurringEvent } from "@calcom/lib"; import { CAL_URL } from "@calcom/lib/constants"; @@ -298,7 +298,7 @@ export default async function getEventTypeById({ const eventTypeUsers: ((typeof eventType.users)[number] & { avatar: string })[] = eventType.users.map( (user) => ({ ...user, - avatar: `${eventType.team?.parent?.slug ? getOrgFullDomain(eventType.team?.parent?.slug) : CAL_URL}/${ + avatar: `${eventType.team?.parent?.slug ? getOrgFullOrigin(eventType.team?.parent?.slug) : CAL_URL}/${ user.username }/avatar.png`, }) @@ -348,7 +348,7 @@ export default async function getEventTypeById({ ...member.user, avatar: `${ eventTypeObject.team?.parent?.slug - ? getOrgFullDomain(eventTypeObject.team?.parent?.slug) + ? getOrgFullOrigin(eventTypeObject.team?.parent?.slug) : CAL_URL }/${member.user.username}/avatar.png`, }; diff --git a/packages/lib/server/getBrand.ts b/packages/lib/server/getBrand.ts index 98ac5d4b3e..ad4066e9f1 100644 --- a/packages/lib/server/getBrand.ts +++ b/packages/lib/server/getBrand.ts @@ -1,4 +1,4 @@ -import { subdomainSuffix, getOrgFullDomain } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { subdomainSuffix, getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; import { prisma } from "@calcom/prisma"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; @@ -19,7 +19,7 @@ export const getBrand = async (orgId: number | null) => { }); const metadata = teamMetadataSchema.parse(org?.metadata); const slug = (org?.slug || metadata?.requestedSlug) as string; - const fullDomain = getOrgFullDomain(slug); + const fullDomain = getOrgFullOrigin(slug); const domainSuffix = subdomainSuffix(); return { diff --git a/packages/lib/server/queries/teams/index.ts b/packages/lib/server/queries/teams/index.ts index 2d1fe4189b..62e2411618 100644 --- a/packages/lib/server/queries/teams/index.ts +++ b/packages/lib/server/queries/teams/index.ts @@ -1,7 +1,7 @@ import { Prisma } from "@prisma/client"; import { getAppFromSlug } from "@calcom/app-store/utils"; -import { getSlugOrRequestedSlug } from "@calcom/ee/organizations/lib/orgDomains"; +import { getOrgFullOrigin, getSlugOrRequestedSlug } from "@calcom/ee/organizations/lib/orgDomains"; import prisma, { baseEventTypeSelect } from "@calcom/prisma"; import { SchedulingType } from "@calcom/prisma/enums"; import { EventTypeMetaDataSchema, teamMetadataSchema } from "@calcom/prisma/zod-utils"; @@ -30,6 +30,12 @@ export async function getTeamWithMembers(args: { name: true, id: true, bio: true, + organizationId: true, + organization: { + select: { + slug: true, + }, + }, teams: { select: { team: { @@ -163,6 +169,7 @@ export async function getTeamWithMembers(args: { ? obj.user.teams.filter((obj) => obj.team.slug !== orgSlug).map((obj) => obj.team.slug) : null, avatar: `${WEBAPP_URL}/${obj.user.username}/avatar.png`, + orgOrigin: getOrgFullOrigin(obj.user.organization?.slug || ""), connectedApps: !isTeamView ? credentials?.map((cred) => { const appSlug = cred.app?.slug; diff --git a/packages/lib/slugify.test.ts b/packages/lib/slugify.test.ts index 0cf9760303..634e147c84 100644 --- a/packages/lib/slugify.test.ts +++ b/packages/lib/slugify.test.ts @@ -30,6 +30,21 @@ describe("slugify", () => { expect(slugify("$hello-there_")).toEqual("hello-there"); }); + it("should keep periods as is except the start and end", () => { + expect(slugify("hello.there")).toEqual("hello.there"); + expect(slugify("h.e.l.l.o.t.h.e.r.e")).toEqual("h.e.l.l.o.t.h.e.r.e"); + }); + it("should remove consecutive periods", () => { + expect(slugify("hello...there")).toEqual("hello.there"); + expect(slugify("hello....there")).toEqual("hello.there"); + expect(slugify("hello..there")).toEqual("hello.there"); + }); + it("should remove periods from start and end", () => { + expect(slugify(".hello.there")).toEqual("hello.there"); + expect(slugify(".hello.there.")).toEqual("hello.there"); + expect(slugify("hellothere.")).toEqual("hellothere"); + }); + // This is failing, if we want to fix it, one approach is as used in getValidRhfFieldName it.skip("should remove unicode and emoji characters", () => { expect(slugify("Hello 📚🕯️®️ There")).toEqual("hello---------there"); diff --git a/packages/lib/slugify.ts b/packages/lib/slugify.ts index e43d8d57e3..71cd8a7ac8 100644 --- a/packages/lib/slugify.ts +++ b/packages/lib/slugify.ts @@ -7,11 +7,13 @@ export const slugify = (str: string, forDisplayingInput?: boolean) => { .trim() // Remove whitespace from both sides .normalize("NFD") // Normalize to decomposed form for handling accents .replace(/\p{Diacritic}/gu, "") // Remove any diacritics (accents) from characters - .replace(/[^\p{L}\p{N}\p{Zs}\p{Emoji}]+/gu, "-") // Replace any non-alphanumeric characters (including Unicode) with a dash + .replace(/[^.\p{L}\p{N}\p{Zs}\p{Emoji}]+/gu, "-") // Replace any non-alphanumeric characters (including Unicode and except "." period) with a dash .replace(/[\s_#]+/g, "-") // Replace whitespace, # and underscores with a single dash - .replace(/^-+/, ""); // Remove dashes from start + .replace(/^-+/, "") // Remove dashes from start + .replace(/\.{2,}/g, ".") // Replace consecutive periods with a single period + .replace(/^\.+/, ""); // Remove periods from the start - return forDisplayingInput ? s : s.replace(/-+$/, ""); // Remove dashes from end + return forDisplayingInput ? s : s.replace(/-+$/, "").replace(/\.*$/, ""); // Remove dashes and period from end }; export default slugify; diff --git a/packages/trpc/server/routers/loggedInViewer/me.handler.ts b/packages/trpc/server/routers/loggedInViewer/me.handler.ts index 8463fc4058..3b53cfa0c6 100644 --- a/packages/trpc/server/routers/loggedInViewer/me.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/me.handler.ts @@ -1,5 +1,4 @@ -import { getOrgFullDomain } from "@calcom/ee/organizations/lib/orgDomains"; -import { WEBAPP_URL } from "@calcom/lib/constants"; +import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; type MeOptions = { @@ -25,9 +24,7 @@ export const meHandler = async ({ ctx }: MeOptions) => { locale: user.locale, timeFormat: user.timeFormat, timeZone: user.timeZone, - avatar: `${user.organization?.slug ? getOrgFullDomain(user.organization.slug) : WEBAPP_URL}/${ - user.username - }/avatar.png`, + avatar: getUserAvatarUrl(user), createdDate: user.createdDate, trialEndsAt: user.trialEndsAt, defaultScheduleId: user.defaultScheduleId, diff --git a/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts b/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts index 3213573854..96f20707a1 100644 --- a/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts +++ b/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts @@ -41,7 +41,9 @@ async function getTeamMembers({ user: { select: { id: true, + organizationId: true, username: true, + name: true, email: true, timeZone: true, defaultScheduleId: true, @@ -63,6 +65,8 @@ async function buildMember(member: Member, dateFrom: Dayjs, dateTo: Dayjs) { if (!member.user.defaultScheduleId) { return { id: member.user.id, + organizationId: member.user.organizationId, + name: member.user.name, username: member.user.username, email: member.user.email, timeZone: member.user.timeZone, @@ -89,6 +93,8 @@ async function buildMember(member: Member, dateFrom: Dayjs, dateTo: Dayjs) { id: member.user.id, username: member.user.username, email: member.user.email, + organizationId: member.user.organizationId, + name: member.user.name, timeZone, role: member.role, defaultScheduleId: member.user.defaultScheduleId ?? -1, diff --git a/packages/ui/components/button/Button.tsx b/packages/ui/components/button/Button.tsx index 40f327f1cd..95a8cbc1bb 100644 --- a/packages/ui/components/button/Button.tsx +++ b/packages/ui/components/button/Button.tsx @@ -94,7 +94,7 @@ export const buttonClasses = cva( { variant: "icon", size: "base", - className: "min-h-[36px] min-w-[36px] !p-2", + className: "min-h-[36px] min-w-[36px] !p-2 hover:border-default", }, { variant: "icon",