Merge commit '9250b91bb0d5a66ccf2cf42311ac9999c79f6a84' into teste2e-allQuestions

teste2e-allQuestions
gitstart-calcom 2023-10-24 14:30:33 +00:00
commit 9a17ffbdc3
116 changed files with 2505 additions and 961 deletions

View File

@ -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

View File

@ -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 <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address.`,
html: `Thanks for your interest in Cal.ai! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address and then install Cal.ai here: <a href="https://go.cal.com/ai" target="_blank">go.cal.com/ai</a>.`,
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,

View File

@ -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()

View File

@ -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:

View File

@ -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:

View File

@ -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 (
<div className={classNames(highlight && "dark:bg-muted bg-yellow-100")}>

View File

@ -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);

View File

@ -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<string>("");
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(

View File

@ -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<FormValues>();
const { eventType, team, destinationCalendar } = props;
const [showLocationModal, setShowLocationModal] = useState(false);
const [editingLocationType, setEditingLocationType] = useState<string>("");
const [selectedLocation, setSelectedLocation] = useState<LocationOption | undefined>(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<HTMLUListElement>();
@ -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 (
<Controller
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
control={formMethods.control}
defaultValue={defaultValue}
render={({ field: { onChange, value } }) => {
return (
<>
<Input
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
type="text"
required
onChange={onChange}
value={value}
className="my-0"
{...rest}
/>
<ErrorMessage
errors={formMethods.formState.errors.locations?.[index]}
name={eventLocationType.defaultValueVariable}
className="text-error my-1 text-sm"
as="div"
/>
</>
);
}}
/>
);
} else if (eventLocationType?.organizerInputType === "phone") {
const { defaultValue, ...rest } = remainingProps;
return (
<Controller
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
control={formMethods.control}
defaultValue={defaultValue}
render={({ field: { onChange, value } }) => {
return (
<>
<PhoneInput
required
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
value={value}
onChange={onChange}
{...rest}
/>
<ErrorMessage
errors={formMethods.formState.errors.locations?.[index]}
name={eventLocationType.defaultValueVariable}
className="text-error my-1 text-sm"
as="div"
/>
</>
);
}}
/>
);
}
return null;
};
const [showEmptyLocationSelect, setShowEmptyLocationSelect] = useState(false);
const [selectedNewOption, setSelectedNewOption] = useState<SingleValueLocationOption | null>(null);
return (
<div className="w-full">
{validLocations.length === 0 && (
<div className="flex">
<LocationSelect
placeholder={t("select")}
options={locationOptions}
isDisabled={shouldLockDisableProps("locations").disabled}
defaultValue={defaultValue}
isSearchable={false}
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
menuPlacement="auto"
onChange={(e: SingleValueLocationOption) => {
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);
}
<ul ref={animationRef} className="space-y-2">
{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;
}
}}
/>
</div>
)}
{validLocations.length > 0 && (
<ul ref={animationRef}>
{validLocations.map((location, index) => {
const eventLocationType = getEventLocationType(location.type);
if (!eventLocationType) {
return null;
}
});
const eventLabel =
location[eventLocationType.defaultValueVariable] || t(eventLocationType.label);
return (
<li
key={`${location.type}${index}`}
className="border-default text-default mb-2 h-9 rounded-md border px-2 py-1.5 hover:cursor-pointer">
<div className="flex items-center justify-between">
<div className="flex items-center">
<img
src={eventLocationType.iconUrl}
className={classNames(
"h-4 w-4",
classNames(invertLogoOnDark(eventLocationType.iconUrl))
)}
alt={`${eventLocationType.label} logo`}
/>
<span className="ms-1 line-clamp-1 text-sm">{`${eventLabel} ${
location.teamName ? `(${location.teamName})` : ""
}`}</span>
const option = getLocationFromType(field.type, locationOptions);
return (
<li key={field.id}>
<div className="flex w-full items-center">
<LocationSelect
name={`locations[${index}].type`}
placeholder={t("select")}
options={locationOptions}
isDisabled={shouldLockDisableProps("locations").disabled}
defaultValue={option}
isSearchable={false}
className="block min-w-0 flex-1 rounded-sm text-sm"
menuPlacement="auto"
onChange={(e: SingleValueLocationOption) => {
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");
}
}
}}
/>
<button
data-testid={`delete-locations.${index}.type`}
className="min-h-9 block h-9 px-2"
type="button"
onClick={() => remove(index)}
aria-label={t("remove")}>
<div className="h-4 w-4">
<X className="border-l-1 hover:text-emphasis text-subtle h-4 w-4" />
</div>
<div className="flex">
<button
type="button"
onClick={() => {
locationFormMethods.setValue("locationType", location.type);
locationFormMethods.unregister("locationLink");
if (location.type === LocationType.InPerson) {
locationFormMethods.setValue("locationAddress", location.address);
} else {
locationFormMethods.unregister("locationAddress");
</button>
</div>
{eventLocationType?.organizerInputType && (
<div className="mt-2 space-y-2">
<div className="flex gap-2">
<div className="flex items-center justify-center">
<CornerDownRight className="h-4 w-4" />
</div>
<div className="w-full">
<LocationInput
defaultValue={
defaultLocation
? defaultLocation[eventLocationType.defaultValueVariable]
: undefined
}
locationFormMethods.unregister("locationPhoneNumber");
setEditingLocationType(location.type);
openLocationModal(location.type, location.address);
eventLocationType={eventLocationType}
index={index}
/>
</div>
</div>
<div className="ml-6">
<CheckboxField
data-testid="display-location"
defaultChecked={defaultLocation?.displayLocationPublicly}
description={t("display_location_label")}
onChange={(e) => {
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">
<Edit2 className="h-4 w-4" />
</button>
<button type="button" onClick={() => removeLocation(location)} aria-label={t("remove")}>
<X className="border-l-1 hover:text-emphasis text-subtle h-6 w-6 pl-1 " />
</button>
informationIconText={t("display_location_info_badge")}
/>
</div>
</div>
</li>
);
})}
{validLocations.some(
(location) =>
location.type === MeetLocationType && destinationCalendar?.integration !== "google_calendar"
) && (
<div className="text-default flex text-sm">
<Check className="mr-1.5 mt-0.5 h-2 w-2.5" />
<Trans i18nKey="event_type_requres_google_cal">
<p>
The Add to calendar for this event type needs to be a Google Calendar for Meet to work.
Change it{" "}
<Link
href={`${CAL_URL}/event-types/${eventType.id}?tabName=advanced`}
className="underline">
here.
</Link>{" "}
</p>
</Trans>
</div>
)}
{isChildrenManagedEventType && !locationAvailable && locationDetails && (
<p className="pl-1 text-sm leading-none text-red-600">
{t("app_not_connected", { appName: locationDetails.name })}{" "}
<a className="underline" href={`${CAL_URL}/apps/${locationDetails.slug}`}>
{t("connect_now")}
</a>
</p>
)}
{validLocations.length > 0 && !isManagedEventType && !isChildrenManagedEventType && (
<li>
<Button
data-testid="add-location"
StartIcon={Plus}
color="minimal"
onClick={() => setShowLocationModal(true)}>
{t("add_location")}
</Button>
)}
</li>
)}
</ul>
)}
);
})}
{(validLocations.length === 0 || showEmptyLocationSelect) && (
<div className="flex">
<LocationSelect
defaultMenuIsOpen={showEmptyLocationSelect}
autoFocus
placeholder={t("select")}
options={locationOptions}
value={selectedNewOption}
isDisabled={shouldLockDisableProps("locations").disabled}
defaultValue={defaultValue}
isSearchable={false}
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
menuPlacement="auto"
onChange={(e: SingleValueLocationOption) => {
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);
}
}
}}
/>
</div>
)}
{validLocations.some(
(location) =>
location.type === MeetLocationType && destinationCalendar?.integration !== "google_calendar"
) && (
<div className="text-default flex items-center text-sm">
<div className="mr-1.5 h-3 w-3">
<Check className="h-3 w-3" />
</div>
<Trans i18nKey="event_type_requres_google_cal">
<p>
The Add to calendar for this event type needs to be a Google Calendar for Meet to work.
Change it{" "}
<Link
href={`${CAL_URL}/event-types/${eventType.id}?tabName=advanced`}
className="underline">
here.
</Link>{" "}
</p>
</Trans>
</div>
)}
{isChildrenManagedEventType && !locationAvailable && locationDetails && (
<p className="pl-1 text-sm leading-none text-red-600">
{t("app_not_connected", { appName: locationDetails.name })}{" "}
<a className="underline" href={`${CAL_URL}/apps/${locationDetails.slug}`}>
{t("connect_now")}
</a>
</p>
)}
{validLocations.length > 0 && !isManagedEventType && !isChildrenManagedEventType && (
<li>
<Button
data-testid="add-location"
StartIcon={Plus}
color="minimal"
onClick={() => setShowEmptyLocationSelect(true)}>
{t("add_location")}
</Button>
</li>
)}
</ul>
<p className="text-default mt-2 text-sm">
<Trans i18nKey="cant_find_the_right_video_app_visit_our_app_store">
Can&apos;t find the right video app? Visit our
<Link className="cursor-pointer text-blue-500 underline" href="/apps/categories/video">
App Store
</Link>
.
</Trans>
</p>
</div>
);
};
@ -542,33 +606,6 @@ export const EventSetupTab = (
/>
</div>
</div>
{/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */}
<EditLocationDialog
isOpenDialog={showLocationModal}
setShowLocationModal={setShowLocationModal}
saveLocation={saveLocation}
defaultValues={formMethods.getValues("locations")}
selection={
selectedLocation
? selectedLocation.address
? {
value: selectedLocation.value,
label: t(selectedLocation.label),
icon: selectedLocation.icon,
address: selectedLocation.address,
}
: {
value: selectedLocation.value,
label: t(selectedLocation.label),
icon: selectedLocation.icon,
}
: undefined
}
setSelectedLocation={setSelectedLocation}
setEditingLocationType={setEditingLocationType}
teamId={eventType.team?.id}
/>
</div>
</div>
);

View File

@ -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<typeof user.organization, "id">),
slug: user.organization.slug || null,
requestedSlug: user.organization.metadata?.requestedSlug || null,
}
: null;
return (
<form onSubmit={onSubmit}>
<div className="flex flex-row items-center justify-start rtl:justify-end">
{user && (
<OrganizationAvatar
alt={user.username || "user avatar"}
size="lg"
imageSrc={imageSrc}
organizationSlug={user.organization?.slug}
/>
<OrganizationMemberAvatar size="lg" user={user} previewSrc={imageSrc} organization={organization} />
)}
<input
ref={avatarRef}

View File

@ -5,11 +5,15 @@ import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { md } from "@calcom/lib/markdownIt";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import type { TeamWithMembers } from "@calcom/lib/server/queries/teams";
import { Avatar } from "@calcom/ui";
import { UserAvatar } from "@components/ui/avatar/UserAvatar";
type TeamType = Omit<NonNullable<TeamWithMembers>, "inviteToken">;
type MembersType = TeamType["members"];
type MemberType = Pick<MembersType[number], "id" | "name" | "bio" | "username"> & { safeBio: string | null };
type MemberType = Pick<MembersType[number], "id" | "name" | "bio" | "username" | "organizationId"> & {
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 (
<Link key={member.id} href={{ pathname: `/${member.username}`, query: queryParamsToForward }}>
<Link
key={member.id}
href={{ pathname: `${member.orgOrigin}/${member.username}`, query: queryParamsToForward }}>
<div className="sm:min-w-80 sm:max-w-80 bg-default hover:bg-muted border-subtle group flex min-h-full flex-col space-y-2 rounded-md border p-4 hover:cursor-pointer">
<Avatar size="md" alt={member.name || ""} imageSrc={`/${member.username}/avatar.png`} />
<UserAvatar size="md" user={member} />
<section className="mt-2 line-clamp-4 w-full space-y-1">
<p className="text-default font-medium">{member.name}</p>
<div className="text-subtle line-clamp-3 overflow-ellipsis text-sm font-normal">

View File

@ -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);

View File

@ -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<React.ComponentProps<typeof Avatar>, "alt" | "imageSrc"> & {
user: Pick<User, "organizationId" | "name" | "username">;
/**
* 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 <Avatar {...rest} alt={user.name || ""} imageSrc={previewSrc ?? getUserAvatarUrl(user)} />;
}

View File

@ -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<React.ComponentProps<typeof AvatarGroup>, "items"> & {
users: Pick<User, "organizationId" | "name" | "username">[];
};
export function UserAvatarGroup(props: UserAvatarProps) {
const { users, ...rest } = props;
return (
<AvatarGroup
{...rest}
items={users.map((user) => ({
alt: user.name || "",
title: user.name || "",
image: getUserAvatarUrl(user),
}))}
/>
);
}

View File

@ -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<React.ComponentProps<typeof AvatarGroup>, "items"> & {
users: Pick<User, "organizationId" | "name" | "username">[];
organization: Pick<Team, "slug" | "name">;
};
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 <AvatarGroup {...rest} items={items} />;
}

View File

@ -52,7 +52,7 @@ const CheckboxField = forwardRef<HTMLInputElement, Props>(
className="text-primary-600 focus:ring-primary-500 border-default bg-default h-4 w-4 rounded"
/>
</div>
<span className="ms-3 text-sm">{description}</span>
<span className="ms-2 text-sm">{description}</span>
</>
)}
{informationIconText && <InfoBadge content={informationIconText} />}

View File

@ -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);
});
});

View File

@ -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("");
};

View File

@ -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);

View File

@ -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;
}

View File

@ -1,17 +1,21 @@
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback } from "react";
export default function useRouterQuery<T extends string>(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 };
}

View File

@ -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

View File

@ -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<typeof getServerSide
"max-w-3xl px-4 py-24"
)}>
<div className="mb-8 text-center">
<OrganizationAvatar
imageSrc={profile.image}
<OrganizationMemberAvatar
size="xl"
alt={profile.name}
organizationSlug={profile.organizationSlug}
user={{
organizationId: profile.organization?.id,
name: profile.name,
username: profile.username,
}}
organization={
profile.organization?.id
? {
id: profile.organization.id,
slug: profile.organization.slug,
requestedSlug: null,
}
: null
}
/>
<h1 className="font-cal text-emphasis mb-1 text-3xl" data-testid="name-title">
{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<User, "away" | "name" | "username" | "bio" | "verified">[];
themeBasis: string | null;
@ -286,6 +302,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
select: {
slug: true,
name: true,
metadata: true,
},
},
theme: true,
@ -313,6 +330,10 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = 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<UserPageProps> = 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);

View File

@ -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,

View File

@ -83,7 +83,7 @@ inferSSRProps<typeof _getServerSideProps> & WithNonceProps<{}>) {
const telemetry = useTelemetry();
let callbackUrl = searchParams.get("callbackUrl") || "";
let callbackUrl = searchParams?.get("callbackUrl") || "";
if (/"\//.test(callbackUrl)) callbackUrl = callbackUrl.substring(1);

View File

@ -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(",") : [];

View File

@ -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()}`);
};

View File

@ -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);

View File

@ -155,7 +155,7 @@ export default function Success(props: SuccessProps) {
const [calculatedDuration, setCalculatedDuration] = useState<number | undefined>(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");

View File

@ -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()

View File

@ -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);

View File

@ -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<typeof user.organization, "id">),
slug: userOrganization.slug || null,
requestedSlug: userOrganization.metadata?.requestedSlug || null,
}
: null;
return (
<>
<OrganizationAvatar
alt={formMethods.getValues("username")}
imageSrc={value}
<OrganizationMemberAvatar
previewSrc={value}
size="lg"
organizationSlug={userOrganization.slug}
user={user}
organization={organization}
/>
<div className="ms-4">
<h2 className="mb-2 text-sm font-medium">{t("profile_picture")}</h2>

View File

@ -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
<TextField
addOnLeading={
orgSlug
? `${getOrgFullDomain(orgSlug, { protocol: true })}/`
? `${getOrgFullOrigin(orgSlug, { protocol: true })}/`
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/`
}
{...register("username")}

View File

@ -11,7 +11,7 @@ import { usePathname } from "next/navigation";
import { useEffect } from "react";
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { orgDomainConfig, getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import EventTypeDescription from "@calcom/features/eventtypes/components/EventTypeDescription";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
import { WEBAPP_URL } from "@calcom/lib/constants";
@ -27,7 +27,7 @@ import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calco
import prisma from "@calcom/prisma";
import { RedirectType } from "@calcom/prisma/client";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { Avatar, AvatarGroup, Button, HeadSeo, UnpublishedEntity } from "@calcom/ui";
import { Avatar, Button, HeadSeo, UnpublishedEntity } from "@calcom/ui";
import { ArrowRight } from "@calcom/ui/components/icon";
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
@ -35,6 +35,7 @@ import type { inferSSRProps } from "@lib/types/inferSSRProps";
import PageWrapper from "@components/PageWrapper";
import Team from "@components/team/screens/Team";
import { UserAvatarGroup } from "@components/ui/avatar/UserAvatarGroup";
import { ssrInit } from "@server/lib/ssr";
@ -111,15 +112,11 @@ function TeamPage({
<EventTypeDescription className="text-sm" eventType={type} />
</div>
<div className="mt-1 self-center">
<AvatarGroup
<UserAvatarGroup
truncateAfter={4}
className="flex flex-shrink-0"
size="sm"
items={type.users.map((user) => ({
alt: user.name || "",
title: user.name || "",
image: `/${user.username}/avatar.png` || "",
}))}
users={type.users}
/>
</div>
</Link>
@ -149,17 +146,11 @@ function TeamPage({
</span>
</div>
</div>
<AvatarGroup
<UserAvatarGroup
className="mr-6"
size="sm"
truncateAfter={4}
items={team.members
.filter((mem) => 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)}
/>
</Link>
</li>
@ -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 || ""),
};
})
: [];

View File

@ -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");
});
});

View File

@ -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" });
});
});

View File

@ -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);
});
});
});

View File

@ -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);
});
});
});

View File

@ -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");

View File

@ -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 () => {

View File

@ -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();
};

View File

@ -175,18 +175,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();
@ -195,7 +194,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();

View File

@ -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<void>((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> | 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"]');

View File

@ -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<ReturnType<typeof addWebhook>>
) {
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;

View File

@ -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;

View File

@ -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 الخاصة بك"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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",

View File

@ -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 "
}

View File

@ -315,6 +315,7 @@
"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.",
@ -331,6 +332,35 @@
"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",
@ -352,15 +382,385 @@
"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"
}

View File

@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -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"
}

View File

@ -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 שלך"
}

View File

@ -1 +0,0 @@
{}

View File

@ -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"
}

View File

@ -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 設定の保存中にエラーが発生しました"
}

View File

@ -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</1> ទៅ '{{agree}}'.",
"remove_banner_instructions": "ដើម្បីលុបបដានេះ សូមបើកឯកសារ .env របស់អ្នក ហើយផ្លាស់ប្តូរ <1>NEXT_PUBLIC_LICENSE_CONSENT</1> អថេរទៅ '{{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": "គ្រប់គ្រងការកំណត់សម្រាប់ភាសា និងល្វែងម៉ោងរបស់អ្នក។"
}

View File

@ -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",

View File

@ -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": "주요 구성을 저장하는 중 오류가 발생했습니다"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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 配置时出错"
}

View File

@ -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 設定時發生錯誤"
}

View File

@ -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"],
},

View File

@ -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;
}

View File

@ -30,15 +30,20 @@ export default function AlbySetup(props: IAlbySetupProps) {
function AlbySetupCallback() {
const [error, setError] = useState<string | null>(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 (
<div>

View File

@ -48,7 +48,7 @@ export const useOpenModal = () => {
const pathname = usePathname();
const searchParams = useSearchParams();
const openModal = (option: z.infer<typeof newFormModalQuerySchema>) => {
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] || "");

View File

@ -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];

View File

@ -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 (
<div className="flex-col items-start p-3 text-sm">
<p>
<strong>
{t("connected_vital_app", { ns: "vital" })} Vital App: {connected ? "Yes" : "No"}
</strong>
<strong>Connected with Vital App: {connected ? "Yes" : "No"}</strong>
</p>
<br />
<p>
<strong>{t("vital_app_sleep_automation", { ns: "vital" })}</strong>
<strong>Sleeping reschedule automation</strong>
</p>
<p className="mt-1">
You can select different parameters to trigger the reschedule based on your sleeping metrics.
</p>
<p className="mt-1">{t("vital_app_automation_description", { ns: "vital" })}</p>
<div className="w-100 mt-2">
<div className="block sm:flex">
<div className="min-w-24 mb-4 mt-5 sm:mb-0">
<label htmlFor="description" className="text-sm font-bold">
{t("vital_app_parameter", { ns: "vital" })}
Parameter
</label>
</div>
<div className="w-120 mt-2.5">
@ -125,7 +123,7 @@ const AppConfiguration = (props: IAppConfigurationProps) => {
<div className="w-full">
<div className="min-w-24 mb-4 mt-3">
<label htmlFor="value" className="text-sm font-bold">
{t("vital_app_trigger", { ns: "vital" })}
Trigger at below or equal than
</label>
</div>
<div className="mx-2 mt-0 inline-flex w-24 items-baseline">
@ -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"
/>
<p className="ml-2">
<strong>{t("vital_app_hours", { ns: "vital" })}</strong>
<strong>hours</strong>
</p>
</div>
</div>
@ -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
</Button>
</div>
</div>

View File

@ -31,6 +31,7 @@ const config = {
"vi",
"zh-CN",
"zh-TW",
"km",
],
},
fallbackLng: {

View File

@ -268,7 +268,7 @@ interface EditModalState extends Pick<App, "keys"> {
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 },

View File

@ -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,

View File

@ -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);

View File

@ -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,
});
};

View File

@ -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 (
<>
<AvatarGroup size="sm" className="border-muted" items={uniqueAvatars} />
{entity.orgSlug ? (
<UserAvatarGroupWithOrg
size="sm"
className="border-muted"
organization={{
slug: entity.orgSlug,
name: entity.name || "",
}}
users={shownUsers}
/>
) : (
<UserAvatarGroup size="sm" className="border-muted" users={shownUsers} />
)}
<p className="text-subtle text-sm font-semibold">
{showOnlyProfileName
? profile.name

View File

@ -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++) {

View File

@ -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);
});
});
});

View File

@ -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;
}

View File

@ -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 (
<Avatar
data-testid="organization-avatar"
size={size}
imageSrc={imageSrc}
alt={alt}
indicator={
organizationSlug ? (
<div
className={classNames("absolute bottom-0 right-0 z-10", size === "lg" ? "h-6 w-6" : "h-10 w-10")}>
<img
src={`/org/${organizationSlug}/avatar.png`}
alt={alt}
className="flex h-full items-center justify-center rounded-full"
/>
</div>
) : null
}
/>
);
};
export default OrganizationAvatar;

View File

@ -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<typeof UserAvatar> & {
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 (
<UserAvatar
data-testid="organization-avatar"
size={size}
user={user}
previewSrc={previewSrc}
indicator={
organization ? (
<div
className={classNames("absolute bottom-0 right-0 z-10", size === "lg" ? "h-6 w-6" : "h-10 w-10")}>
<img
src={getOrgAvatarUrl(organization)}
alt={user.username || ""}
className="flex h-full items-center justify-center rounded-full"
/>
</div>
) : null
}
{...rest}
/>
);
};
export default OrganizationMemberAvatar;

View File

@ -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;

View File

@ -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<number>(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]);

View File

@ -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");
},

View File

@ -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"),

View File

@ -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) {
<div className="my-4 flex justify-between">
<div className="flex w-full flex-col justify-between truncate sm:flex-row">
<div className="flex">
<Avatar
size="sm"
imageSrc={`${bookerUrl}/${props.member.username}/avatar.png`}
alt={name || ""}
className="h-10 w-10 rounded-full"
/>
<UserAvatar size="sm" user={props.member} className="h-10 w-10 rounded-full" />
<div className="ms-3 inline-block">
<div className="mb-1 flex">
<span className="text-default mr-2 text-sm font-bold leading-4">{name}</span>

View File

@ -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) => {

View File

@ -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);

View File

@ -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;

View File

@ -520,18 +520,19 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
(state) => [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<HTMLTextAreaElement>> = {};

View File

@ -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 = ({
<Avatar
size="mdLg"
className="overflow-visible"
imageSrc={`${orgBranding ? getOrgFullDomain(orgBranding.slug) : CAL_URL}/${
imageSrc={`${orgBranding ? getOrgFullOrigin(orgBranding.slug) : CAL_URL}/${
children.owner.username
}/avatar.png`}
alt={children.owner.name || children.owner.email || ""}

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