Merge commit '9250b91bb0d5a66ccf2cf42311ac9999c79f6a84' into teste2e-allQuestions
commit
9a17ffbdc3
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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")}>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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'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>
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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)} />;
|
||||
}
|
|
@ -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),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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} />;
|
||||
}
|
|
@ -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} />}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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("");
|
||||
};
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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(",") : [];
|
||||
|
|
|
@ -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()}`);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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")}
|
||||
|
|
|
@ -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 || ""),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
});
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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"]');
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 الخاصة بك"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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 "
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
|
@ -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 שלך"
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
{}
|
|
@ -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"
|
||||
}
|
|
@ -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 設定の保存中にエラーが発生しました"
|
||||
}
|
|
@ -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": "គ្រប់គ្រងការកំណត់សម្រាប់ភាសា និងល្វែងម៉ោងរបស់អ្នក។"
|
||||
}
|
|
@ -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",
|
|
@ -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": "주요 구성을 저장하는 중 오류가 발생했습니다"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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 配置时出错"
|
||||
}
|
|
@ -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 設定時發生錯誤"
|
||||
}
|
|
@ -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"],
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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] || "");
|
||||
|
|
|
@ -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];
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -31,6 +31,7 @@ const config = {
|
|||
"vi",
|
||||
"zh-CN",
|
||||
"zh-TW",
|
||||
"km",
|
||||
],
|
||||
},
|
||||
fallbackLng: {
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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++) {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
|
|
|
@ -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]);
|
||||
|
||||
|
|
|
@ -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");
|
||||
},
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>> = {};
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue