2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View File

@@ -0,0 +1,98 @@
import Link from "next/link";
import type { TDependencyData } from "@calcom/app-store/_appRegistry";
import { InstallAppButtonWithoutPlanCheck } from "@calcom/app-store/components";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { App } from "@calcom/types/App";
import { Badge, Button, Icon } from "@calcom/ui";
interface IAppConnectionItem {
title: string;
description?: string;
logo: string;
type: App["type"];
installed?: boolean;
isDefault?: boolean;
defaultInstall?: boolean;
slug?: string;
dependencyData?: TDependencyData;
}
const AppConnectionItem = (props: IAppConnectionItem) => {
const { title, logo, type, installed, isDefault, defaultInstall, slug } = props;
const { t } = useLocale();
const setDefaultConferencingApp = trpc.viewer.appsRouter.setDefaultConferencingApp.useMutation();
const dependency = props.dependencyData?.find((data) => !data.installed);
return (
<div className="flex flex-row items-center justify-between p-5">
<div className="flex items-center space-x-3">
<img src={logo} alt={title} className="h-8 w-8" />
<p className="text-sm font-bold">{title}</p>
{isDefault && <Badge variant="green">{t("default")}</Badge>}
</div>
<InstallAppButtonWithoutPlanCheck
type={type}
options={{
onSuccess: () => {
if (defaultInstall && slug) {
setDefaultConferencingApp.mutate({ slug });
}
},
}}
render={(buttonProps) => (
<Button
{...buttonProps}
color="secondary"
disabled={installed || !!dependency}
type="button"
loading={buttonProps?.isPending}
tooltip={
dependency ? (
<div className="items-start space-x-2.5">
<div className="flex items-start">
<div>
<Icon name="circle-alert" className="mr-2 mt-1 font-semibold" />
</div>
<div>
<span className="text-xs font-semibold">
{t("this_app_requires_connected_account", {
appName: title,
dependencyName: dependency.name,
})}
</span>
<div>
<div>
<>
<Link
href={`${WEBAPP_URL}/getting-started/connected-calendar`}
className="flex items-center text-xs underline">
<span className="mr-1">
{t("connect_app", { dependencyName: dependency.name })}
</span>
<Icon name="arrow-right" className="inline-block h-3 w-3" />
</Link>
</>
</div>
</div>
</div>
</div>
</div>
) : undefined
}
onClick={(event) => {
// Save cookie key to return url step
document.cookie = `return-to=${window.location.href};path=/;max-age=3600;SameSite=Lax`;
buttonProps && buttonProps.onClick && buttonProps?.onClick(event);
}}>
{installed ? t("installed") : t("connect")}
</Button>
)}
/>
</div>
);
};
export { AppConnectionItem };

View File

@@ -0,0 +1,72 @@
import { CalendarSwitch } from "@calcom/features/calendars/CalendarSwitch";
interface IConnectedCalendarItem {
name: string;
logo: string;
externalId?: string;
integrationType: string;
calendars?: {
primary: true | null;
isSelected: boolean;
credentialId: number;
name?: string | undefined;
readOnly?: boolean | undefined;
userId?: number | undefined;
integration?: string | undefined;
externalId: string;
}[];
}
const ConnectedCalendarItem = (prop: IConnectedCalendarItem) => {
const { name, logo, externalId, calendars, integrationType } = prop;
return (
<>
<div className="flex flex-row items-center p-4">
<img src={logo} alt={name} className="m-1 h-8 w-8" />
<div className="mx-4">
<p className="font-sans text-sm font-bold leading-5">
{name}
{/* Temporarily removed till we use it on another place */}
{/* <span className="mx-1 rounded-[4px] bg-success py-[2px] px-[6px] font-sans text-xs font-medium text-green-600">
{t("default")}
</span> */}
</p>
<div className="fle-row flex">
<span
title={externalId}
className="max-w-44 text-subtle mt-1 overflow-hidden text-ellipsis whitespace-nowrap font-sans text-sm">
{externalId}{" "}
</span>
</div>
</div>
{/* Temporarily removed */}
{/* <Button
color="minimal"
type="button"
className="ml-auto flex rounded-md border border-subtle py-[10x] px-4 font-sans text-sm">
{t("edit")}
</Button> */}
</div>
<div className="border-subtle h-[1px] w-full border-b" />
<div>
<ul className="p-4">
{calendars?.map((calendar, i) => (
<CalendarSwitch
credentialId={calendar.credentialId}
key={calendar.externalId}
externalId={calendar.externalId}
title={calendar.name || "Nameless Calendar"}
name={calendar.name || "Nameless Calendar"}
type={integrationType}
isChecked={calendar.isSelected}
isLastItemInList={i === calendars.length - 1}
/>
))}
</ul>
</div>
</>
);
};
export { ConnectedCalendarItem };

View File

@@ -0,0 +1,37 @@
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterInputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
interface ICreateEventsOnCalendarSelectProps {
calendar?: RouterInputs["viewer"]["setDestinationCalendar"] | null;
}
const CreateEventsOnCalendarSelect = (props: ICreateEventsOnCalendarSelectProps) => {
const { calendar } = props;
const { t } = useLocale();
const mutation = trpc.viewer.setDestinationCalendar.useMutation();
return (
<>
<div className="mt-6 flex flex-row">
<div className="w-full">
<label htmlFor="createEventsOn" className="text-default flex text-sm font-medium">
{t("create_events_on")}
</label>
<div className="mt-2">
<DestinationCalendarSelector
value={calendar ? calendar.externalId : undefined}
onChange={(calendar) => {
mutation.mutate(calendar);
}}
hidePlaceholder
/>
</div>
</div>
</div>
</>
);
};
export { CreateEventsOnCalendarSelect };

View File

@@ -0,0 +1,17 @@
import { SkeletonAvatar, SkeletonText, SkeletonButton } from "@calcom/ui";
export function StepConnectionLoader() {
return (
<ul className="bg-default divide-subtle border-subtle divide-y rounded-md border p-0 dark:bg-black">
{Array.from({ length: 4 }).map((_item, index) => {
return (
<li className="flex w-full flex-row justify-center border-b-0 py-6" key={index}>
<SkeletonAvatar className="mx-6 h-8 w-8 px-4" />
<SkeletonText className="ml-1 mr-4 mt-3 h-5 w-full" />
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
</li>
);
})}
</ul>
);
}

View File

@@ -0,0 +1,96 @@
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Icon, List } from "@calcom/ui";
import { AppConnectionItem } from "../components/AppConnectionItem";
import { ConnectedCalendarItem } from "../components/ConnectedCalendarItem";
import { CreateEventsOnCalendarSelect } from "../components/CreateEventsOnCalendarSelect";
import { StepConnectionLoader } from "../components/StepConnectionLoader";
interface IConnectCalendarsProps {
nextStep: () => void;
}
const ConnectedCalendars = (props: IConnectCalendarsProps) => {
const { nextStep } = props;
const queryConnectedCalendars = trpc.viewer.connectedCalendars.useQuery({ onboarding: true });
const { t } = useLocale();
const queryIntegrations = trpc.viewer.integrations.useQuery({
variant: "calendar",
onlyInstalled: false,
sortByMostPopular: true,
});
const firstCalendar = queryConnectedCalendars.data?.connectedCalendars.find(
(item) => item.calendars && item.calendars?.length > 0
);
const disabledNextButton = firstCalendar === undefined;
const destinationCalendar = queryConnectedCalendars.data?.destinationCalendar;
return (
<>
{/* Already connected calendars */}
{!queryConnectedCalendars.isPending &&
firstCalendar &&
firstCalendar.integration &&
firstCalendar.integration.title &&
firstCalendar.integration.logo && (
<>
<List className="bg-default border-subtle rounded-md border p-0 dark:bg-black ">
<ConnectedCalendarItem
key={firstCalendar.integration.title}
name={firstCalendar.integration.title}
logo={firstCalendar.integration.logo}
externalId={
firstCalendar && firstCalendar.calendars && firstCalendar.calendars.length > 0
? firstCalendar.calendars[0].externalId
: ""
}
calendars={firstCalendar.calendars}
integrationType={firstCalendar.integration.type}
/>
</List>
{/* Create event on selected calendar */}
<CreateEventsOnCalendarSelect calendar={destinationCalendar} />
<p className="text-subtle mt-4 text-sm">{t("connect_calendars_from_app_store")}</p>
</>
)}
{/* Connect calendars list */}
{firstCalendar === undefined && queryIntegrations.data && queryIntegrations.data.items.length > 0 && (
<List className="bg-default divide-subtle border-subtle mx-1 divide-y rounded-md border p-0 dark:bg-black sm:mx-0">
{queryIntegrations.data &&
queryIntegrations.data.items.map((item) => (
<li key={item.title}>
{item.title && item.logo && (
<AppConnectionItem
type={item.type}
title={item.title}
description={item.description}
logo={item.logo}
/>
)}
</li>
))}
</List>
)}
{queryIntegrations.isPending && <StepConnectionLoader />}
<button
type="button"
data-testid="save-calendar-button"
className={classNames(
"text-inverted bg-inverted border-inverted mt-8 flex w-full flex-row justify-center rounded-md border p-2 text-center text-sm",
disabledNextButton ? "cursor-not-allowed opacity-20" : ""
)}
onClick={() => nextStep()}
disabled={disabledNextButton}>
{firstCalendar ? `${t("continue")}` : `${t("next_step_text")}`}
<Icon name="arrow-right" className="ml-2 h-4 w-4 self-center" aria-hidden="true" />
</button>
</>
);
};
export { ConnectedCalendars };

View File

@@ -0,0 +1,79 @@
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { userMetadata } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { Icon, List } from "@calcom/ui";
import { AppConnectionItem } from "../components/AppConnectionItem";
import { StepConnectionLoader } from "../components/StepConnectionLoader";
interface ConnectedAppStepProps {
nextStep: () => void;
}
const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
const { nextStep } = props;
const { data: queryConnectedVideoApps, isPending } = trpc.viewer.integrations.useQuery({
variant: "conferencing",
onlyInstalled: false,
sortByMostPopular: true,
});
const { data } = useMeQuery();
const { t } = useLocale();
const metadata = userMetadata.parse(data?.metadata);
const hasAnyInstalledVideoApps = queryConnectedVideoApps?.items.some(
(item) => item.userCredentialIds.length > 0
);
const defaultConferencingApp = metadata?.defaultConferencingApp?.appSlug;
return (
<>
{!isPending && (
<List className="bg-default border-subtle divide-subtle scroll-bar mx-1 max-h-[45vh] divide-y !overflow-y-scroll rounded-md border p-0 sm:mx-0">
{queryConnectedVideoApps?.items &&
queryConnectedVideoApps?.items.map((item) => {
if (item.slug === "daily-video") return null; // we dont want to show daily here as it is installed by default
return (
<li key={item.name}>
{item.name && item.logo && (
<AppConnectionItem
type={item.type}
title={item.name}
isDefault={item.slug === defaultConferencingApp}
description={item.description}
dependencyData={item.dependencyData}
logo={item.logo}
slug={item.slug}
installed={item.userCredentialIds.length > 0}
defaultInstall={
!defaultConferencingApp && item.appData?.location?.linkType === "dynamic"
}
/>
)}
</li>
);
})}
</List>
)}
{isPending && <StepConnectionLoader />}
<button
type="button"
data-testid="save-video-button"
className={classNames(
"text-inverted border-inverted bg-inverted mt-8 flex w-full flex-row justify-center rounded-md border p-2 text-center text-sm",
!hasAnyInstalledVideoApps ? "cursor-not-allowed opacity-20" : ""
)}
disabled={!hasAnyInstalledVideoApps}
onClick={() => nextStep()}>
{t("next_step_text")}
<Icon name="arrow-right" className="ml-2 h-4 w-4 self-center" aria-hidden="true" />
</button>
</>
);
};
export { ConnectedVideoStep };

View File

@@ -0,0 +1,88 @@
import { useForm } from "react-hook-form";
import { Schedule } from "@calcom/features/schedules";
import { DEFAULT_SCHEDULE } from "@calcom/lib/availability";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { TRPCClientErrorLike } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
import { Button, Form, Icon } from "@calcom/ui";
interface ISetupAvailabilityProps {
nextStep: () => void;
defaultScheduleId?: number | null;
}
const SetupAvailability = (props: ISetupAvailabilityProps) => {
const { defaultScheduleId } = props;
const { t } = useLocale();
const { nextStep } = props;
const scheduleId = defaultScheduleId === null ? undefined : defaultScheduleId;
const queryAvailability = trpc.viewer.availability.schedule.get.useQuery(
{ scheduleId: defaultScheduleId ?? undefined },
{
enabled: !!scheduleId,
}
);
const availabilityForm = useForm({
defaultValues: {
schedule: queryAvailability?.data?.availability || DEFAULT_SCHEDULE,
},
});
const mutationOptions = {
onError: (error: TRPCClientErrorLike<AppRouter>) => {
throw new Error(error.message);
},
onSuccess: () => {
nextStep();
},
};
const createSchedule = trpc.viewer.availability.schedule.create.useMutation(mutationOptions);
const updateSchedule = trpc.viewer.availability.schedule.update.useMutation(mutationOptions);
return (
<Form
form={availabilityForm}
handleSubmit={async (values) => {
try {
if (defaultScheduleId) {
await updateSchedule.mutate({
scheduleId: defaultScheduleId,
name: t("default_schedule_name"),
...values,
});
} else {
await createSchedule.mutate({
name: t("default_schedule_name"),
...values,
});
}
} catch (error) {
if (error instanceof Error) {
// setError(error);
// @TODO: log error
}
}
}}>
<div className="bg-default dark:text-inverted text-emphasis border-subtle w-full rounded-md border dark:bg-opacity-5">
<Schedule control={availabilityForm.control} name="schedule" weekStart={1} />
</div>
<div>
<Button
data-testid="save-availability"
type="submit"
className="mt-2 w-full justify-center p-2 text-sm sm:mt-8"
loading={availabilityForm.formState.isSubmitting}
disabled={availabilityForm.formState.isSubmitting}>
{t("next_step_text")} <Icon name="arrow-right" className="ml-2 h-4 w-4 self-center" />
</Button>
</div>
</Form>
);
};
export { SetupAvailability };

View File

@@ -0,0 +1,158 @@
import { useRouter } from "next/navigation";
import type { FormEvent } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
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 { Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui";
import { UserAvatar } from "@calcom/ui";
type FormData = {
bio: string;
};
const UserProfile = () => {
const [user] = trpc.viewer.me.useSuspenseQuery();
const { t } = useLocale();
const avatarRef = useRef<HTMLInputElement>(null);
const { setValue, handleSubmit, getValues } = useForm<FormData>({
defaultValues: { bio: user?.bio || "" },
});
const { data: eventTypes } = trpc.viewer.eventTypes.list.useQuery();
const [imageSrc, setImageSrc] = useState<string>(user?.avatar || "");
const utils = trpc.useUtils();
const router = useRouter();
const createEventType = trpc.viewer.eventTypes.create.useMutation();
const telemetry = useTelemetry();
const [firstRender, setFirstRender] = useState(true);
const mutation = trpc.viewer.updateProfile.useMutation({
onSuccess: async (_data, context) => {
if (context.avatarUrl) {
showToast(t("your_user_profile_updated_successfully"), "success");
await utils.viewer.me.refetch();
} else
try {
if (eventTypes?.length === 0) {
await Promise.all(
DEFAULT_EVENT_TYPES.map(async (event) => {
return createEventType.mutate(event);
})
);
}
} catch (error) {
console.error(error);
}
await utils.viewer.me.refetch();
const redirectUrl = localStorage.getItem("onBoardingRedirect");
localStorage.removeItem("onBoardingRedirect");
redirectUrl ? router.push(redirectUrl) : router.push("/");
},
onError: () => {
showToast(t("problem_saving_user_profile"), "error");
},
});
const onSubmit = handleSubmit((data: { bio: string }) => {
const { bio } = data;
telemetry.event(telemetryEventTypes.onboardingFinished);
mutation.mutate({
bio,
completedOnboarding: true,
});
});
async function updateProfileHandler(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const enteredAvatar = avatarRef.current?.value;
mutation.mutate({
avatarUrl: enteredAvatar,
});
}
const DEFAULT_EVENT_TYPES = [
{
title: t("15min_meeting"),
slug: "15min",
length: 15,
},
{
title: t("30min_meeting"),
slug: "30min",
length: 30,
},
{
title: t("secret_meeting"),
slug: "secret",
length: 15,
hidden: true,
},
];
return (
<form onSubmit={onSubmit}>
<div className="flex flex-row items-center justify-start rtl:justify-end">
{user && <UserAvatar size="lg" user={user} previewSrc={imageSrc} />}
<input
ref={avatarRef}
type="hidden"
name="avatar"
id="avatar"
placeholder="URL"
className="border-default focus:ring-empthasis mt-1 block w-full rounded-sm border px-3 py-2 text-sm focus:border-gray-800 focus:outline-none"
defaultValue={imageSrc}
/>
<div className="flex items-center px-4">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("add_profile_photo")}
handleAvatarChange={(newAvatar) => {
if (avatarRef.current) {
avatarRef.current.value = newAvatar;
}
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value"
)?.set;
nativeInputValueSetter?.call(avatarRef.current, newAvatar);
const ev2 = new Event("input", { bubbles: true });
avatarRef.current?.dispatchEvent(ev2);
updateProfileHandler(ev2 as unknown as FormEvent<HTMLFormElement>);
setImageSrc(newAvatar);
}}
imageSrc={imageSrc}
/>
</div>
</div>
<fieldset className="mt-8">
<Label className="text-default mb-2 block text-sm font-medium">{t("about")}</Label>
<Editor
getText={() => md.render(getValues("bio") || user?.bio || "")}
setText={(value: string) => setValue("bio", turndown(value))}
excludedToolbarItems={["blockType"]}
firstRender={firstRender}
setFirstRender={setFirstRender}
/>
<p className="text-default mt-2 font-sans text-sm font-normal">{t("few_sentences_about_yourself")}</p>
</fieldset>
<Button
loading={mutation.isPending}
EndIcon="arrow-right"
type="submit"
className="mt-8 w-full items-center justify-center">
{t("finish")}
</Button>
</form>
);
};
export default UserProfile;

View File

@@ -0,0 +1,124 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { useTimePreferences } from "@calcom/features/bookings/lib";
import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { trpc } from "@calcom/trpc/react";
import { Button, TimezoneSelect, Icon, Input } from "@calcom/ui";
import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
interface IUserSettingsProps {
nextStep: () => void;
hideUsername?: boolean;
}
const UserSettings = (props: IUserSettingsProps) => {
const { nextStep } = props;
const [user] = trpc.viewer.me.useSuspenseQuery();
const { t } = useLocale();
const { setTimezone: setSelectedTimeZone, timezone: selectedTimeZone } = useTimePreferences();
const telemetry = useTelemetry();
const userSettingsSchema = z.object({
name: z
.string()
.min(1)
.max(FULL_NAME_LENGTH_MAX_LIMIT, {
message: t("max_limit_allowed_hint", { limit: FULL_NAME_LENGTH_MAX_LIMIT }),
}),
});
const {
register,
handleSubmit,
formState: { errors },
} = useForm<z.infer<typeof userSettingsSchema>>({
defaultValues: {
name: user?.name || "",
},
reValidateMode: "onChange",
resolver: zodResolver(userSettingsSchema),
});
useEffect(() => {
telemetry.event(telemetryEventTypes.onboardingStarted);
}, [telemetry]);
const utils = trpc.useUtils();
const onSuccess = async () => {
await utils.viewer.me.invalidate();
nextStep();
};
const mutation = trpc.viewer.updateProfile.useMutation({
onSuccess: onSuccess,
});
const onSubmit = handleSubmit((data) => {
mutation.mutate({
name: data.name,
timeZone: selectedTimeZone,
});
});
return (
<form onSubmit={onSubmit}>
<div className="space-y-6">
{/* Username textfield: when not coming from signup */}
{!props.hideUsername && <UsernameAvailabilityField />}
{/* Full name textfield */}
<div className="w-full">
<label htmlFor="name" className="text-default mb-2 block text-sm font-medium">
{t("full_name")}
</label>
<Input
{...register("name", {
required: true,
})}
id="name"
name="name"
type="text"
autoComplete="off"
autoCorrect="off"
/>
{errors.name && (
<p data-testid="required" className="py-2 text-xs text-red-500">
{errors.name.message}
</p>
)}
</div>
{/* Timezone select field */}
<div className="w-full">
<label htmlFor="timeZone" className="text-default block text-sm font-medium">
{t("timezone")}
</label>
<TimezoneSelect
id="timeZone"
value={selectedTimeZone}
onChange={({ value }) => setSelectedTimeZone(value)}
className="mt-2 w-full rounded-md text-sm"
/>
<p className="text-subtle mt-3 flex flex-row font-sans text-xs leading-tight">
{t("current_time")} {dayjs().tz(selectedTimeZone).format("LT").toString().toLowerCase()}
</p>
</div>
</div>
<Button
type="submit"
className="mt-8 flex w-full flex-row justify-center"
loading={mutation.isPending}
disabled={mutation.isPending}>
{t("next_step_text")}
<Icon name="arrow-right" className="ml-2 h-4 w-4 self-center" aria-hidden="true" />
</Button>
</form>
);
};
export { UserSettings };