Feature/new onboarding page (#3377)
* [WIP] New design and components for onboarding page * saving work in progress * new fonts * [WIP] new onboarding page, initial page, components * WIP calendar connect * WIP availability new design * WIP onboarding page * WIP onboarding, working new availability form * WIP AvailabilitySchedule componente v2 * WIP availability with defaultSchedule * User profile view * Relocate new onboarding/getting-started page components * Steps test for onboarding v2 * Remove logs and unused code * remove any as types * Adding translations * Fixes translation text and css for step 4 * Deprecation note for old-getting-started * Added defaul events and refetch user query when finishing getting-started * Fix button text translation * Undo schedule v1 changes * Fix calendar switches state * Add cookie to save return-to when connecting calendar * Change useTranslation for useLocale instead * Change test to work with data-testid instead of hardcoded plain text due to translation * Fix skeleton containers for calendars * Style fixes * fix styles to match v2 * Fix styles and props types to match v2 design * Bugfix/router and console errors (#4206) * The loading={boolean} parameter is required, so this must be <Button /> * Fixes duplicate key error * Use zod and router.query.step directly to power state machine * use ul>li & divide for borders * Update apps/web/components/getting-started/steps-views/ConnectCalendars.tsx Co-authored-by: alannnc <alannnc@gmail.com> * Linting * Deprecation notices and type fixes * Update CreateEventsOnCalendarSelect.tsx * Type fixes Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: Alex van Andel <me@alexvanandel.com> Co-authored-by: zomars <zomars@me.com>pull/4222/head
parent
7f2db86a83
commit
13c2dc24dc
|
@ -207,7 +207,7 @@ Be sure to set the environment variable `NEXTAUTH_URL` to the correct value. If
|
|||
yarn test-e2e
|
||||
|
||||
# To open last HTML report run:
|
||||
yarn playwright show-report test-results/reports/playwright-html-report
|
||||
yarn playwright show-report test-results/reports/playwright-html-report
|
||||
```
|
||||
|
||||
### Upgrading from earlier versions
|
||||
|
|
|
@ -107,9 +107,10 @@ const DestinationCalendarSelector = ({
|
|||
control: (defaultStyles) => {
|
||||
return {
|
||||
...defaultStyles,
|
||||
borderRadius: "2px",
|
||||
borderRadius: "6px",
|
||||
"@media only screen and (min-width: 640px)": {
|
||||
...(defaultStyles["@media only screen and (min-width: 640px)"] as object),
|
||||
width: "100%",
|
||||
},
|
||||
};
|
||||
},
|
||||
|
@ -135,6 +136,9 @@ const DestinationCalendarSelector = ({
|
|||
}}
|
||||
isLoading={isLoading}
|
||||
value={selectedOption}
|
||||
components={{
|
||||
IndicatorSeparator: () => null,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
/**
|
||||
* @deprecated
|
||||
* use Component in "/packages/features/schedules/components/Schedule";
|
||||
**/
|
||||
import classNames from "classnames";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
|
||||
|
@ -192,6 +196,10 @@ const CopyTimes = ({ disabled, onApply }: { disabled: number[]; onApply: (select
|
|||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* use Component in "/packages/features/schedules/components/Schedule";
|
||||
**/
|
||||
export const DayRanges = ({
|
||||
name,
|
||||
defaultValue = [defaultDayRange],
|
||||
|
@ -324,6 +332,10 @@ const ScheduleBlock = ({ name, day, weekday }: ScheduleBlockProps) => {
|
|||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* use Component in "/packages/features/schedules/components/Schedule";
|
||||
**/
|
||||
const Schedule = ({ name }: { name: string }) => {
|
||||
const { i18n } = useLocale();
|
||||
return (
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* @deprecated modifications to this file should be v2 only
|
||||
* Use `/packages/ui/modules/availability/ScheduleListItem.tsx` instead
|
||||
* Use `/packages/features/schedules/components/ScheduleListItem.tsx` instead
|
||||
*/
|
||||
import Link from "next/link";
|
||||
import { Fragment } from "react";
|
||||
|
@ -13,6 +13,10 @@ import { Button } from "@calcom/ui";
|
|||
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@calcom/ui/Dropdown";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
|
||||
/**
|
||||
* @deprecated modifications to this file should be v2 only
|
||||
* Use `/packages/features/schedules/components/ScheduleListItem.tsx` instead
|
||||
*/
|
||||
export function ScheduleListItem({
|
||||
schedule,
|
||||
deleteFunction,
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
import { InstallAppButtonWithoutPlanCheck } from "@calcom/app-store/components";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { App } from "@calcom/types/App";
|
||||
import Button from "@calcom/ui/v2/core/Button";
|
||||
|
||||
interface ICalendarItem {
|
||||
title: string;
|
||||
description?: string;
|
||||
imageSrc: string;
|
||||
type: App["type"];
|
||||
}
|
||||
|
||||
const CalendarItem = (props: ICalendarItem) => {
|
||||
const { title, imageSrc, type } = props;
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div className="flex flex-row items-center p-5">
|
||||
<img src={imageSrc} alt={title} className="h-8 w-8" />
|
||||
<p className="mx-3 text-sm font-bold">{title}</p>
|
||||
|
||||
<InstallAppButtonWithoutPlanCheck
|
||||
type={type}
|
||||
render={(buttonProps) => (
|
||||
<Button
|
||||
{...buttonProps}
|
||||
color="secondary"
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
// Save cookie key to return url step
|
||||
document.cookie = `return-to=${window.location.href};path=/;max-age=3600`;
|
||||
buttonProps && buttonProps.onClick && buttonProps?.onClick(event);
|
||||
}}
|
||||
className="ml-auto rounded-md border border-gray-200 py-[10px] px-4 text-sm font-bold">
|
||||
{t("connect")}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { CalendarItem };
|
|
@ -0,0 +1,83 @@
|
|||
import { useMutation } from "react-query";
|
||||
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Switch } from "@calcom/ui/v2";
|
||||
|
||||
interface ICalendarSwitchProps {
|
||||
title: string;
|
||||
externalId: string;
|
||||
type: string;
|
||||
isChecked: boolean;
|
||||
name: string;
|
||||
}
|
||||
const CalendarSwitch = (props: ICalendarSwitchProps) => {
|
||||
const { title, externalId, type, isChecked, name } = props;
|
||||
const utils = trpc.useContext();
|
||||
const mutation = useMutation<
|
||||
unknown,
|
||||
unknown,
|
||||
{
|
||||
isOn: boolean;
|
||||
}
|
||||
>(
|
||||
async ({ isOn }) => {
|
||||
const body = {
|
||||
integration: type,
|
||||
externalId: externalId,
|
||||
};
|
||||
|
||||
if (isOn) {
|
||||
const res = await fetch("/api/availability/calendar", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Something went wrong");
|
||||
}
|
||||
} else {
|
||||
const res = await fetch("/api/availability/calendar", {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Something went wrong");
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
async onSettled() {
|
||||
await utils.invalidateQueries(["viewer.integrations"]);
|
||||
},
|
||||
onError() {
|
||||
showToast(`Something went wrong when toggling "${title}""`, "error");
|
||||
},
|
||||
}
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex px-2 py-1">
|
||||
<Switch
|
||||
id={externalId}
|
||||
defaultChecked={isChecked}
|
||||
onCheckedChange={(isOn: boolean) => {
|
||||
mutation.mutate({ isOn });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<label className="text-sm" htmlFor={externalId}>
|
||||
{name}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { CalendarSwitch };
|
|
@ -0,0 +1,70 @@
|
|||
import { DotsHorizontalIcon } from "@heroicons/react/solid";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import { CalendarSwitch } from "./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;
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row items-center p-4">
|
||||
<img src={logo} alt={name} className="h-8 w-8" />
|
||||
<div className="mx-4">
|
||||
<p className="text-sm font-bold">{name}</p>
|
||||
<div className="fle-row flex">
|
||||
<span
|
||||
title={externalId}
|
||||
className="max-w-44 overflow-hidden text-ellipsis whitespace-nowrap text-sm text-gray-500">
|
||||
{externalId}{" "}
|
||||
</span>
|
||||
<span className="mx-1 rounded-md bg-green-100 py-[2px] px-[6px] text-xs text-green-600">
|
||||
{t("default")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto flex h-9 w-9 rounded-md border border-gray-200 text-sm font-bold">
|
||||
<DotsHorizontalIcon className="m-auto h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-[1px] w-full border-b border-gray-200" />
|
||||
<div>
|
||||
<ul className="space-y-1 p-3">
|
||||
{calendars?.map((calendar) => (
|
||||
<CalendarSwitch
|
||||
key={calendar.externalId}
|
||||
externalId={calendar.externalId}
|
||||
title={calendar.name || "Nameless Calendar"}
|
||||
name={calendar.name || "Nameless Calendar"}
|
||||
type={integrationType}
|
||||
isChecked={calendar.isSelected}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { ConnectedCalendarItem };
|
|
@ -0,0 +1,37 @@
|
|||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { inferMutationInput, trpc } from "@calcom/trpc/react";
|
||||
|
||||
import DestinationCalendarSelector from "@components/DestinationCalendarSelector";
|
||||
|
||||
interface ICreateEventsOnCalendarSelectProps {
|
||||
calendar?: inferMutationInput<"viewer.setDestinationCalendar"> | null;
|
||||
}
|
||||
|
||||
const CreateEventsOnCalendarSelect = (props: ICreateEventsOnCalendarSelectProps) => {
|
||||
const { calendar } = props;
|
||||
const { t } = useLocale();
|
||||
const mutation = trpc.useMutation(["viewer.setDestinationCalendar"]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-6 flex flex-row">
|
||||
<div className="w-full">
|
||||
<label htmlFor="createEventsOn" className="flex text-sm font-medium text-neutral-700">
|
||||
{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 };
|
|
@ -0,0 +1,9 @@
|
|||
const StepCard: React.FC<{ children: React.ReactNode }> = (props) => {
|
||||
return (
|
||||
<div className="mt-11 rounded-md border border-gray-200 bg-white p-0 dark:bg-black sm:p-8">
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { StepCard };
|
|
@ -0,0 +1,37 @@
|
|||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
interface ISteps {
|
||||
maxSteps: number;
|
||||
currentStep: number;
|
||||
navigateToStep: (step: number) => void;
|
||||
}
|
||||
|
||||
const Steps = (props: ISteps) => {
|
||||
const { maxSteps, currentStep, navigateToStep } = props;
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div className="space-y-2 pt-4">
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-white">
|
||||
{t("current_step_of_total", { currentStep: currentStep + 1, maxSteps })}
|
||||
</p>
|
||||
<div className="flex w-full space-x-2 rtl:space-x-reverse">
|
||||
{new Array(maxSteps).fill(0).map((_s, index) => {
|
||||
return index <= currentStep ? (
|
||||
<div
|
||||
key={`step-${index}`}
|
||||
onClick={() => navigateToStep(index)}
|
||||
className={classNames(
|
||||
"h-1 w-1/4 bg-black dark:bg-white",
|
||||
index < currentStep ? "cursor-pointer" : ""
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div key={`step-${index}`} className="h-1 w-1/4 bg-black bg-opacity-25" />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export { Steps };
|
|
@ -0,0 +1,107 @@
|
|||
import { ArrowRightIcon } from "@heroicons/react/solid";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { List } from "@calcom/ui/List";
|
||||
import { SkeletonAvatar, SkeletonText, SkeletonButton } from "@calcom/ui/v2";
|
||||
|
||||
import { CalendarItem } from "../components/CalendarItem";
|
||||
import { ConnectedCalendarItem } from "../components/ConnectedCalendarItem";
|
||||
import { CreateEventsOnCalendarSelect } from "../components/CreateEventsOnCalendarSelect";
|
||||
|
||||
interface IConnectCalendarsProps {
|
||||
nextStep: () => void;
|
||||
}
|
||||
|
||||
const ConnectedCalendars = (props: IConnectCalendarsProps) => {
|
||||
const { nextStep } = props;
|
||||
const queryConnectedCalendars = trpc.useQuery(["viewer.connectedCalendars"]);
|
||||
const { t } = useLocale();
|
||||
const queryIntegrations = trpc.useQuery([
|
||||
"viewer.integrations",
|
||||
{ variant: "calendar", onlyInstalled: false },
|
||||
]);
|
||||
|
||||
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 */}
|
||||
{firstCalendar &&
|
||||
firstCalendar.integration &&
|
||||
firstCalendar.integration.title &&
|
||||
firstCalendar.integration.imageSrc && (
|
||||
<>
|
||||
<List className="rounded-md border border-gray-200 bg-white p-0 dark:bg-black">
|
||||
<ConnectedCalendarItem
|
||||
key={firstCalendar.integration.title}
|
||||
name={firstCalendar.integration.title}
|
||||
logo={firstCalendar.integration.imageSrc}
|
||||
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="mt-7 text-sm text-gray-500">{t("connect_calendars_from_app_store")}</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Connect calendars list */}
|
||||
{firstCalendar === undefined && queryIntegrations.data && queryIntegrations.data.items.length > 0 && (
|
||||
<List className="divide-y divide-gray-200 rounded-md border border-gray-200 bg-white p-0 dark:bg-black">
|
||||
{queryIntegrations.data &&
|
||||
queryIntegrations.data.items.map((item) => (
|
||||
<li key={item.title}>
|
||||
{item.title && item.imageSrc && (
|
||||
<CalendarItem
|
||||
type={item.type}
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
imageSrc={item.imageSrc}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
{queryConnectedCalendars.isLoading && (
|
||||
<ul className="divide-y divide-gray-200 rounded-md border border-gray-200 bg-white p-0 dark:bg-black">
|
||||
{[0, 0, 0, 0].map((_item, index) => {
|
||||
return (
|
||||
<li className="flex w-full flex-row justify-center border-b-0 py-6" key={index}>
|
||||
<SkeletonAvatar width="8" height="8" className="mx-6 px-4" />
|
||||
<SkeletonText width="full" height="5" className="ml-1 mr-4 mt-3" />
|
||||
<SkeletonButton height="8" width="20" className="mr-6 rounded-md p-5" />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="save-calendar-button"
|
||||
className={classNames(
|
||||
"mt-8 flex w-full flex-row justify-center rounded-md border border-black bg-black p-2 text-center text-sm text-white",
|
||||
disabledNextButton ? "cursor-not-allowed opacity-20" : ""
|
||||
)}
|
||||
onClick={() => nextStep()}
|
||||
disabled={disabledNextButton}>
|
||||
{firstCalendar ? `${t("continue")}` : `${t("next_step_text")}`}
|
||||
<ArrowRightIcon className="ml-2 h-4 w-4 self-center" aria-hidden="true" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { ConnectedCalendars };
|
|
@ -0,0 +1,93 @@
|
|||
import { ArrowRightIcon } from "@heroicons/react/solid";
|
||||
import { useRouter } from "next/router";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { Schedule } from "@calcom/features/schedules";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc, TRPCClientErrorLike } from "@calcom/trpc/react";
|
||||
import { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
import { Form } from "@calcom/ui/form/fields";
|
||||
import { Button } from "@calcom/ui/v2";
|
||||
|
||||
import { DEFAULT_SCHEDULE } from "@lib/availability";
|
||||
import type { Schedule as ScheduleType } from "@lib/types/schedule";
|
||||
|
||||
interface ISetupAvailabilityProps {
|
||||
nextStep: () => void;
|
||||
defaultScheduleId?: number | null;
|
||||
defaultAvailability?: { schedule?: TimeRanges[][] };
|
||||
}
|
||||
|
||||
interface ScheduleFormValues {
|
||||
schedule: ScheduleType;
|
||||
}
|
||||
|
||||
const SetupAvailability = (props: ISetupAvailabilityProps) => {
|
||||
const { defaultScheduleId } = props;
|
||||
|
||||
const { t } = useLocale();
|
||||
const { nextStep } = props;
|
||||
|
||||
const router = useRouter();
|
||||
let queryAvailability;
|
||||
if (defaultScheduleId) {
|
||||
queryAvailability = trpc.useQuery(["viewer.availability.schedule", { scheduleId: defaultScheduleId }], {
|
||||
enabled: router.isReady,
|
||||
});
|
||||
}
|
||||
|
||||
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.useMutation("viewer.availability.schedule.create", mutationOptions);
|
||||
const updateSchedule = trpc.useMutation("viewer.availability.schedule.update", mutationOptions);
|
||||
return (
|
||||
<Form<ScheduleFormValues>
|
||||
className="w-full bg-white text-black dark:bg-opacity-5 dark:text-white"
|
||||
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
|
||||
}
|
||||
}
|
||||
}}>
|
||||
<Schedule />
|
||||
|
||||
<div>
|
||||
<Button
|
||||
data-testid="save-availability"
|
||||
type="submit"
|
||||
className="my-6 w-full justify-center p-2 text-sm"
|
||||
disabled={availabilityForm.formState.isSubmitting}>
|
||||
{t("next_step_text")} <ArrowRightIcon className="ml-2 h-4 w-4 self-center" />
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export { SetupAvailability };
|
|
@ -0,0 +1,163 @@
|
|||
import { ArrowRightIcon } from "@heroicons/react/solid";
|
||||
import { useRouter } from "next/router";
|
||||
import { FormEvent, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { User } from "@calcom/prisma/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, Input } from "@calcom/ui/v2";
|
||||
|
||||
import { AvatarSSR } from "@components/ui/AvatarSSR";
|
||||
import ImageUploader from "@components/v2/settings/ImageUploader";
|
||||
|
||||
interface IUserProfile {
|
||||
user?: User;
|
||||
}
|
||||
|
||||
type FormData = {
|
||||
bio: string;
|
||||
};
|
||||
|
||||
const UserProfile = (props: IUserProfile) => {
|
||||
const { user } = props;
|
||||
const { t } = useLocale();
|
||||
const avatarRef = useRef<HTMLInputElement>(null!);
|
||||
const bioRef = useRef<HTMLInputElement>(null);
|
||||
const {
|
||||
register,
|
||||
setValue,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({ defaultValues: { bio: user?.bio || "" } });
|
||||
const { data: eventTypes } = trpc.useQuery(["viewer.eventTypes.list"]);
|
||||
const [imageSrc, setImageSrc] = useState<string>(user?.avatar || "");
|
||||
const utils = trpc.useContext();
|
||||
const router = useRouter();
|
||||
const createEventType = trpc.useMutation("viewer.eventTypes.create");
|
||||
const onSuccess = async () => {
|
||||
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.refetchQueries(["viewer.me"]);
|
||||
router.push("/");
|
||||
};
|
||||
const mutation = trpc.useMutation("viewer.updateProfile", {
|
||||
onSuccess: onSuccess,
|
||||
});
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
const { bio } = data;
|
||||
|
||||
mutation.mutate({
|
||||
bio,
|
||||
completedOnboarding: true,
|
||||
});
|
||||
});
|
||||
|
||||
async function updateProfileHandler(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const enteredAvatar = avatarRef.current.value;
|
||||
mutation.mutate({
|
||||
avatar: 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} className="p-4 sm:p-0">
|
||||
<p className="font-cal text-sm">{t("profile_picture")}</p>
|
||||
<div className="mt-4 flex flex-row items-center justify-start rtl:justify-end">
|
||||
{user && <AvatarSSR user={user} alt="Profile picture" className="h-16 w-16" />}
|
||||
<input
|
||||
ref={avatarRef}
|
||||
type="hidden"
|
||||
name="avatar"
|
||||
id="avatar"
|
||||
placeholder="URL"
|
||||
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 text-sm focus:border-neutral-800 focus:outline-none focus:ring-neutral-800"
|
||||
defaultValue={imageSrc}
|
||||
/>
|
||||
<div className="flex items-center px-4">
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="avatar-upload"
|
||||
buttonMsg={t("upload")}
|
||||
handleAvatarChange={(newAvatar) => {
|
||||
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 htmlFor="bio" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
{t("about")}
|
||||
</label>
|
||||
<Input
|
||||
{...register("bio", { required: true })}
|
||||
ref={bioRef}
|
||||
type="text"
|
||||
name="bio"
|
||||
id="bio"
|
||||
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm"
|
||||
defaultValue={user?.bio || undefined}
|
||||
onChange={(event) => {
|
||||
setValue("bio", event.target.value);
|
||||
}}
|
||||
/>
|
||||
{errors.bio && (
|
||||
<p data-testid="required" className="text-xs italic text-red-500">
|
||||
{t("required")}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-sm font-normal text-gray-600 dark:text-white">
|
||||
{t("few_sentences_about_yourself")}
|
||||
</p>
|
||||
</fieldset>
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-8 flex w-full flex-row justify-center rounded-md border border-black bg-black p-2 text-center text-sm text-white">
|
||||
{t("finish")}
|
||||
<ArrowRightIcon className="ml-2 h-4 w-4 self-center" aria-hidden="true" />
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserProfile;
|
|
@ -0,0 +1,116 @@
|
|||
import { ArrowRightIcon } from "@heroicons/react/outline";
|
||||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { User } from "@calcom/prisma/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import TimezoneSelect from "@calcom/ui/form/TimezoneSelect";
|
||||
import { Button } from "@calcom/ui/v2";
|
||||
|
||||
import { UsernameAvailability } from "@components/ui/UsernameAvailability";
|
||||
|
||||
interface IUserSettingsProps {
|
||||
user: User;
|
||||
nextStep: () => void;
|
||||
}
|
||||
|
||||
type FormData = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
const UserSettings = (props: IUserSettingsProps) => {
|
||||
const { user, nextStep } = props;
|
||||
const { t } = useLocale();
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState(user.timeZone ?? dayjs.tz.guess());
|
||||
const { register, handleSubmit, formState } = useForm<FormData>({
|
||||
defaultValues: {
|
||||
name: user?.name || undefined,
|
||||
},
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
const { errors } = formState;
|
||||
const defaultOptions = { required: true, maxLength: 255 };
|
||||
|
||||
const utils = trpc.useContext();
|
||||
const onSuccess = async () => {
|
||||
await utils.invalidateQueries(["viewer.me"]);
|
||||
nextStep();
|
||||
};
|
||||
const mutation = trpc.useMutation("viewer.updateProfile", {
|
||||
onSuccess: onSuccess,
|
||||
});
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
mutation.mutate({
|
||||
name: data.name,
|
||||
timeZone: selectedTimeZone,
|
||||
});
|
||||
});
|
||||
const [currentUsername, setCurrentUsername] = useState(user.username || undefined);
|
||||
const [inputUsernameValue, setInputUsernameValue] = useState(currentUsername);
|
||||
const usernameRef = useRef<HTMLInputElement>(null!);
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="space-y-4">
|
||||
{/* Username textfield */}
|
||||
<UsernameAvailability
|
||||
currentUsername={currentUsername}
|
||||
setCurrentUsername={setCurrentUsername}
|
||||
inputUsernameValue={inputUsernameValue}
|
||||
usernameRef={usernameRef}
|
||||
setInputUsernameValue={setInputUsernameValue}
|
||||
user={user}
|
||||
/>
|
||||
|
||||
{/* Full name textfield */}
|
||||
<div className="w-full">
|
||||
<label htmlFor="name" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
{t("full_name")}
|
||||
</label>
|
||||
<input
|
||||
{...register("name", defaultOptions)}
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className="w-full rounded-md border border-gray-300 text-sm"
|
||||
/>
|
||||
{errors.name && (
|
||||
<p data-testid="required" className="text-xs italic text-red-500">
|
||||
{t("required")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Timezone select field */}
|
||||
<div className="w-full">
|
||||
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
|
||||
{t("timezone")}
|
||||
</label>
|
||||
|
||||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
value={selectedTimeZone}
|
||||
onChange={({ value }) => setSelectedTimeZone(value)}
|
||||
className="mt-2 w-full rounded-md text-sm"
|
||||
/>
|
||||
|
||||
<p className="mt-3 flex flex-row text-xs leading-tight text-gray-500 dark:text-white">
|
||||
{t("current_time")} {dayjs().tz(selectedTimeZone).format("LT").toString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-8 flex w-full flex-row justify-center"
|
||||
disabled={mutation.isLoading}>
|
||||
{t("next_step_text")}
|
||||
<ArrowRightIcon className="ml-2 h-4 w-4 self-center" aria-hidden="true" />
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export { UserSettings };
|
|
@ -178,16 +178,16 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyItems: "center" }}>
|
||||
<Label htmlFor="username">{t("username")}</Label>
|
||||
</div>
|
||||
<div className="mt-1 flex rounded-md">
|
||||
<div className="mt-2 flex rounded-md">
|
||||
<span
|
||||
className={classNames(
|
||||
"inline-flex items-center rounded-l-sm border border-gray-300 bg-gray-50 px-3 text-sm text-gray-500"
|
||||
"inline-flex items-center rounded-l-md border border-r-0 border-gray-300 bg-gray-50 px-3 text-sm text-gray-500"
|
||||
)}>
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}/
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", "")}/
|
||||
</span>
|
||||
<div style={{ position: "relative", width: "100%" }}>
|
||||
<Input
|
||||
|
@ -197,7 +197,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
|||
autoCapitalize="none"
|
||||
autoCorrect="none"
|
||||
className={classNames(
|
||||
"mt-0 rounded-l-none",
|
||||
"mt-0 rounded-md rounded-l-none",
|
||||
markAsError
|
||||
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
|
||||
: ""
|
||||
|
@ -317,7 +317,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
|||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -103,16 +103,16 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div>
|
||||
<Label htmlFor="username">{t("username")}</Label>
|
||||
</div>
|
||||
<div className="mt-1 flex rounded-md">
|
||||
<div className="mt-2 flex rounded-md">
|
||||
<span
|
||||
className={classNames(
|
||||
"inline-flex items-center rounded-l-sm border border-gray-300 bg-gray-50 px-3 text-sm text-gray-500"
|
||||
"inline-flex items-center rounded-l-md border border-r-0 border-gray-300 bg-gray-50 px-3 text-sm text-gray-500"
|
||||
)}>
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}/
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", "")}/
|
||||
</span>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
|
@ -122,7 +122,7 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
|||
autoCapitalize="none"
|
||||
autoCorrect="none"
|
||||
className={classNames(
|
||||
"mt-0 rounded-l-none",
|
||||
"mt-0 rounded-md rounded-l-none",
|
||||
markAsError
|
||||
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
|
||||
: ""
|
||||
|
@ -200,7 +200,7 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
|||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -80,7 +80,7 @@ function Select<
|
|||
<ReactSelect
|
||||
theme={(theme) => ({
|
||||
...theme,
|
||||
borderRadius: 2,
|
||||
borderRadius: 6,
|
||||
colors: {
|
||||
...theme.colors,
|
||||
...(hasDarkTheme
|
||||
|
|
|
@ -4,6 +4,7 @@ import { InstallAppButton } from "@calcom/app-store/components";
|
|||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { App } from "@calcom/types/App";
|
||||
import { AppGetServerSidePropsContext } from "@calcom/types/AppGetServerSideProps";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import EmptyScreen from "@calcom/ui/EmptyScreen";
|
||||
|
@ -139,6 +140,30 @@ const IntegrationsContainer = ({ variant, className = "" }: IntegrationsContaine
|
|||
);
|
||||
};
|
||||
|
||||
// Server side rendering
|
||||
export async function getServerSideProps(ctx: AppGetServerSidePropsContext) {
|
||||
// get return-to cookie and redirect if needed
|
||||
const { cookies } = ctx.req;
|
||||
if (cookies && cookies["return-to"]) {
|
||||
const returnTo = cookies["return-to"];
|
||||
if (returnTo) {
|
||||
ctx.res.setHeader(
|
||||
"Set-Cookie",
|
||||
"returnToGettingStarted=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"
|
||||
);
|
||||
return {
|
||||
redirect: {
|
||||
destination: `${returnTo}`,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
props: {},
|
||||
};
|
||||
}
|
||||
|
||||
export default function IntegrationsPage() {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.useQuery(["viewer.integrations", { onlyInstalled: true }]);
|
||||
|
|
|
@ -26,6 +26,10 @@ import { HttpError } from "@lib/core/http/error";
|
|||
import Schedule from "@components/availability/Schedule";
|
||||
import EditableHeading from "@components/ui/EditableHeading";
|
||||
|
||||
/**
|
||||
* @deprecated modifications to this file should be v2 only
|
||||
* Use `/apps/web/pages/v2/availability/[schedule].tsx` instead
|
||||
*/
|
||||
export function AvailabilityForm(props: inferQueryOutput<"viewer.availability.schedule">) {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
|
|
@ -16,6 +16,10 @@ import { NewScheduleButton } from "@components/availability/NewScheduleButton";
|
|||
import { ScheduleListItem } from "@components/availability/ScheduleListItem";
|
||||
import SkeletonLoader from "@components/availability/SkeletonLoader";
|
||||
|
||||
/**
|
||||
* @deprecated modifications to this file should be v2 only
|
||||
* Use `/apps/web/pages/v2/availability/index.tsx` instead
|
||||
*/
|
||||
export function AvailabilityList({ schedules }: inferQueryOutput<"viewer.availability.list">) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
/**
|
||||
* @deprecated modifications to this file should be v2 only
|
||||
* Use `/apps/web/pages/v2/availability/troubleshoot.tsx` instead
|
||||
*/
|
||||
import { useState } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
|
@ -11,6 +15,10 @@ import Loader from "@components/Loader";
|
|||
|
||||
type User = inferQueryOutput<"viewer.me">;
|
||||
|
||||
/**
|
||||
* @deprecated modifications to this file should be v2 only
|
||||
* Use `/apps/web/pages/v2/availability/troubleshoot.tsx` instead
|
||||
*/
|
||||
const AvailabilityView = ({ user }: { user: User }) => {
|
||||
const { t } = useLocale();
|
||||
const [selectedDate, setSelectedDate] = useState(dayjs());
|
||||
|
|
|
@ -811,7 +811,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
<label
|
||||
htmlFor="createEventsOn"
|
||||
className="flex text-sm font-medium text-neutral-700">
|
||||
{t("create_events_on")}
|
||||
{t("create_events_on")}:
|
||||
</label>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
|
|
|
@ -0,0 +1,190 @@
|
|||
import { GetServerSidePropsContext } from "next";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getSession } from "@calcom/lib/auth";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { User } from "@calcom/prisma/client";
|
||||
|
||||
import prisma from "@lib/prisma";
|
||||
|
||||
import { StepCard } from "@components/getting-started/components/StepCard";
|
||||
import { Steps } from "@components/getting-started/components/Steps";
|
||||
import { ConnectedCalendars } from "@components/getting-started/steps-views/ConnectCalendars";
|
||||
import { SetupAvailability } from "@components/getting-started/steps-views/SetupAvailability";
|
||||
import UserProfile from "@components/getting-started/steps-views/UserProfile";
|
||||
import { UserSettings } from "@components/getting-started/steps-views/UserSettings";
|
||||
|
||||
interface IOnboardingPageProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
const INITIAL_STEP = "user-settings";
|
||||
const steps = ["user-settings", "connected-calendar", "setup-availability", "user-profile"] as const;
|
||||
|
||||
const stepTransform = (step: typeof steps[number]) => {
|
||||
const stepIndex = steps.indexOf(step);
|
||||
if (stepIndex > -1) {
|
||||
return steps[stepIndex];
|
||||
}
|
||||
return INITIAL_STEP;
|
||||
};
|
||||
|
||||
const stepRouteSchema = z.object({
|
||||
step: z.array(z.enum(steps)).default([INITIAL_STEP]),
|
||||
});
|
||||
|
||||
const OnboardingPage = (props: IOnboardingPageProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { user } = props;
|
||||
const { t } = useLocale();
|
||||
|
||||
const result = stepRouteSchema.safeParse(router.query);
|
||||
const currentStep = result.success ? result.data.step[0] : INITIAL_STEP;
|
||||
|
||||
const headers = [
|
||||
{
|
||||
title: `${t("welcome_to_calcom")}!`,
|
||||
subtitle: [`${t("we_just_need_basic_info")}`],
|
||||
skipText: `${t("skip")}`,
|
||||
},
|
||||
{
|
||||
title: `${t("connect_your_calendar")}`,
|
||||
subtitle: [`${t("connect_your_calendar_instructions")}`],
|
||||
skipText: `${t("do_this_later")}`,
|
||||
},
|
||||
{
|
||||
title: `${t("set_availability")}`,
|
||||
subtitle: [
|
||||
`${t("set_availability_getting_started_subtitle_1")}`,
|
||||
`${t("set_availability_getting_started_subtitle_2")}`,
|
||||
],
|
||||
skipText: `${t("do_this_later")}`,
|
||||
},
|
||||
{
|
||||
title: `${t("nearly_there")}`,
|
||||
subtitle: [`${t("nearly_there_instructions")}`],
|
||||
},
|
||||
];
|
||||
|
||||
const goToIndex = (index: number) => {
|
||||
const newStep = steps[index];
|
||||
router.push(
|
||||
{
|
||||
pathname: `/getting-started/${stepTransform(newStep)}`,
|
||||
},
|
||||
undefined
|
||||
);
|
||||
};
|
||||
|
||||
const currentStepIndex = steps.indexOf(currentStep);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="dark:bg-brand dark:text-brand-contrast min-h-screen text-black"
|
||||
data-testid="onboarding"
|
||||
key={router.asPath}>
|
||||
<Head>
|
||||
<title>Cal.com - {t("getting_started")}</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<div className="mx-auto px-4 py-24">
|
||||
<div className="relative">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-[600px]">
|
||||
<div className="mx-auto sm:max-w-lg">
|
||||
<header>
|
||||
<p className="font-cal mb-2 text-[28px] leading-7 tracking-wider">
|
||||
{headers[currentStepIndex]?.title || "Undefined title"}
|
||||
</p>
|
||||
|
||||
{headers[currentStepIndex]?.subtitle.map((subtitle, index) => (
|
||||
<p className="text-sm font-normal text-gray-500" key={index}>
|
||||
{subtitle}
|
||||
</p>
|
||||
))}
|
||||
</header>
|
||||
<Steps maxSteps={steps.length} currentStep={currentStepIndex} navigateToStep={goToIndex} />
|
||||
</div>
|
||||
<StepCard>
|
||||
{currentStep === "user-settings" && <UserSettings user={user} nextStep={() => goToIndex(1)} />}
|
||||
|
||||
{currentStep === "connected-calendar" && <ConnectedCalendars nextStep={() => goToIndex(2)} />}
|
||||
|
||||
{currentStep === "setup-availability" && (
|
||||
<SetupAvailability nextStep={() => goToIndex(3)} defaultScheduleId={user.defaultScheduleId} />
|
||||
)}
|
||||
|
||||
{currentStep === "user-profile" && <UserProfile user={user} />}
|
||||
</StepCard>
|
||||
{headers[currentStepIndex]?.skipText && (
|
||||
<div className="flex w-full flex-row justify-center">
|
||||
<a
|
||||
data-testid="skip-step"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
goToIndex(currentStepIndex + 1);
|
||||
}}
|
||||
className="mt-24 cursor-pointer px-4 py-2 text-sm">
|
||||
{headers[currentStepIndex]?.skipText}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const crypto = await import("crypto");
|
||||
const session = await getSession(context);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return { redirect: { permanent: false, destination: "/auth/login" } };
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: session.user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
email: true,
|
||||
bio: true,
|
||||
avatar: true,
|
||||
timeZone: true,
|
||||
weekStart: true,
|
||||
hideBranding: true,
|
||||
theme: true,
|
||||
plan: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
metadata: true,
|
||||
timeFormat: true,
|
||||
allowDynamicBooking: true,
|
||||
defaultScheduleId: true,
|
||||
completedOnboarding: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User from session not found");
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
user: {
|
||||
...user,
|
||||
emailMd5: crypto.createHash("md5").update(user.email).digest("hex"),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default OnboardingPage;
|
|
@ -1,3 +1,4 @@
|
|||
// @@DEPRECATED, use new getting-started.tsx instead
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import classnames from "classnames";
|
||||
|
@ -43,9 +44,9 @@ import { TRPCClientErrorLike } from "@trpc/client";
|
|||
// Embed isn't applicable to onboarding, so ignore the rule
|
||||
/* eslint-disable @calcom/eslint/avoid-web-storage */
|
||||
|
||||
type ScheduleFormValues = {
|
||||
export interface ScheduleFormValues {
|
||||
schedule: ScheduleType;
|
||||
};
|
||||
}
|
||||
|
||||
let mutationComplete: ((err: Error | null) => void) | null;
|
||||
|
|
@ -4,14 +4,15 @@ import { useState } from "react";
|
|||
import { Controller, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { DEFAULT_SCHEDULE, availabilityAsString } from "@calcom/lib/availability";
|
||||
import { Schedule } from "@calcom/features/schedules";
|
||||
import { availabilityAsString, DEFAULT_SCHEDULE } from "@calcom/lib/availability";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||
import { BadgeCheckIcon } from "@calcom/ui/Icon";
|
||||
import Shell from "@calcom/ui/Shell";
|
||||
import TimezoneSelect from "@calcom/ui/form/TimezoneSelect";
|
||||
import { Button, Switch, Schedule, Form, TextField, showToast } from "@calcom/ui/v2";
|
||||
import { Button, Form, showToast, Switch, TextField } from "@calcom/ui/v2";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
|
@ -62,9 +63,9 @@ export function AvailabilityForm(props: inferQueryOutput<"viewer.availability.sc
|
|||
}}
|
||||
className="-mx-5 flex flex-col sm:mx-0 xl:flex-row">
|
||||
<div className="flex-1">
|
||||
<div className="divide-y rounded-md border border-gray-200 bg-white px-4 py-5 sm:p-6">
|
||||
<div className="rounded-md border border-gray-200 bg-white px-4 py-5 sm:p-6">
|
||||
<h3 className="mb-5 text-base font-medium leading-6 text-gray-900">{t("change_start_end")}</h3>
|
||||
<Schedule name="schedule" />
|
||||
<Schedule />
|
||||
</div>
|
||||
<div className="space-x-2 pt-4 text-right sm:pt-2">
|
||||
<Button color="secondary" href="/availability" tabIndex={-1}>
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import Shell from "@calcom/ui/Shell";
|
||||
import { NewScheduleButton, EmptyScreen, showToast } from "@calcom/ui/v2";
|
||||
import { ScheduleListItem } from "@calcom/ui/v2/modules/availability/ScheduleListItem";
|
||||
import { EmptyScreen, showToast } from "@calcom/ui/v2";
|
||||
|
||||
import { withQuery } from "@lib/QueryCell";
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
|
|
|
@ -237,7 +237,7 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
|||
};
|
||||
};
|
||||
|
||||
type CustomUserOptsKeys = "username" | "password" | "plan" | "completedOnboarding" | "locale";
|
||||
type CustomUserOptsKeys = "username" | "password" | "plan" | "completedOnboarding" | "locale" | "name";
|
||||
type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & { timeZone?: TimeZoneEnum };
|
||||
|
||||
// creates the actual user in the db.
|
||||
|
@ -251,7 +251,7 @@ const createUser = async (
|
|||
}-${Date.now()}`;
|
||||
return {
|
||||
username: uname,
|
||||
name: (opts?.username ?? opts?.plan ?? UserPlan.PRO).toUpperCase(),
|
||||
name: opts?.name === undefined ? (opts?.plan ?? UserPlan.PRO).toUpperCase() : opts?.name,
|
||||
plan: opts?.plan ?? UserPlan.PRO,
|
||||
email: `${uname}@example.com`,
|
||||
password: await hashPassword(uname),
|
||||
|
|
|
@ -1,51 +1,159 @@
|
|||
import { expect } from "@playwright/test";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
import { UserPlan } from "@prisma/client";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
test.describe("Onboarding", () => {
|
||||
test.beforeEach(async ({ users }) => {
|
||||
const onboardingUser = await users.create({ completedOnboarding: false });
|
||||
await onboardingUser.login();
|
||||
});
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test("redirects to /getting-started after login", async ({ page }) => {
|
||||
await page.goto("/event-types");
|
||||
await page.waitForNavigation({
|
||||
url(url) {
|
||||
return url.pathname === "/getting-started";
|
||||
},
|
||||
test.describe("Onboarding", () => {
|
||||
test.describe("Onboarding v2", () => {
|
||||
test("test onboarding v2 new user first step", async ({ page, users }) => {
|
||||
const user = await users.create({ plan: UserPlan.TRIAL, completedOnboarding: false, name: "new user" });
|
||||
await user.login();
|
||||
await page.goto("/getting-started");
|
||||
|
||||
// First step
|
||||
await page.waitForSelector("text=Welcome to Cal.com");
|
||||
const usernameInput = await page.locator("input[name=username]");
|
||||
await usernameInput.fill("new user onboarding");
|
||||
|
||||
const nameInput = await page.locator("input[name=name]");
|
||||
await nameInput.fill("new user 2");
|
||||
|
||||
const timezoneSelect = await page.locator("input[role=combobox]");
|
||||
await timezoneSelect.click();
|
||||
const timezoneOption = await page.locator("text=Eastern Time");
|
||||
await timezoneOption.click();
|
||||
|
||||
const nextButtonUserProfile = await page.locator("button[type=submit]");
|
||||
await nextButtonUserProfile.click();
|
||||
|
||||
await expect(page).toHaveURL(/.*connected-calendar/);
|
||||
|
||||
const userComplete = await user.self();
|
||||
expect(userComplete.name).toBe("new user 2");
|
||||
});
|
||||
|
||||
test("test onboarding v2 new user second step", async ({ page, users }) => {
|
||||
const user = await users.create({ plan: UserPlan.TRIAL, completedOnboarding: false, name: "new user" });
|
||||
await user.login();
|
||||
await page.goto("/getting-started/connected-calendar");
|
||||
|
||||
// Second step
|
||||
|
||||
const nextButtonCalendar = await page.locator("button[data-testid=save-calendar-button]");
|
||||
const isDisabled = await nextButtonCalendar.isDisabled();
|
||||
await expect(isDisabled).toBe(true);
|
||||
|
||||
const skipStepButton = await page.locator("a[data-testid=skip-step]");
|
||||
await skipStepButton.click();
|
||||
await expect(page).toHaveURL(/.*setup-availability/);
|
||||
// @TODO: make sure calendar UL list has at least 1 item
|
||||
});
|
||||
|
||||
test("test onboarding v2 new user third step", async ({ page, users }) => {
|
||||
const user = await users.create({ plan: UserPlan.TRIAL, completedOnboarding: false, name: "new user" });
|
||||
await user.login();
|
||||
await page.goto("/getting-started/setup-availability");
|
||||
|
||||
// Third step
|
||||
|
||||
const nextButtonAvailability = await page.locator("button[data-testid=save-availability]");
|
||||
const isDisabled = await nextButtonAvailability.isDisabled();
|
||||
await expect(isDisabled).toBe(false);
|
||||
|
||||
const skipStepButton = await page.locator("a[data-testid=skip-step]");
|
||||
await skipStepButton.click();
|
||||
await expect(page).toHaveURL(/.*user-profile/);
|
||||
});
|
||||
|
||||
test("test onboarding v2 new user fourth step", async ({ page, users }) => {
|
||||
const user = await users.create({ plan: UserPlan.TRIAL, completedOnboarding: false, name: "new user" });
|
||||
await user.login();
|
||||
await page.goto("/getting-started/user-profile");
|
||||
|
||||
// Fourth step
|
||||
|
||||
const finishButton = await page.locator("button[type=submit]");
|
||||
const bioInput = await page.locator("input[name=bio]");
|
||||
await bioInput.fill("Something about me");
|
||||
const isDisabled = await finishButton.isDisabled();
|
||||
await expect(isDisabled).toBe(false);
|
||||
await finishButton.click();
|
||||
|
||||
await expect(page).toHaveURL(/.*event-types/);
|
||||
|
||||
const userComplete = await user.self();
|
||||
expect(userComplete.bio).toBe("Something about me");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Onboarding", () => {
|
||||
test("update onboarding username via localstorage", async ({ page, users }) => {
|
||||
const [onboardingUser] = users.get();
|
||||
/**
|
||||
* TODO:
|
||||
* We need to come up with a better test since all test are run in an incognito window.
|
||||
* Meaning that all localstorage access is null here.
|
||||
* Let's try saving the desiredUsername in the metadata instead
|
||||
*/
|
||||
test.fixme();
|
||||
await page.addInitScript(() => {
|
||||
// eslint-disable-next-line @calcom/eslint/avoid-web-storage
|
||||
window.localStorage.setItem("username", "alwaysavailable");
|
||||
}, {});
|
||||
// Try to go getting started with a available username
|
||||
await page.goto("/getting-started");
|
||||
// Wait for useEffectUpdate to run
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const updatedUser = await prisma.user.findUnique({
|
||||
where: { id: onboardingUser.id },
|
||||
select: { id: true, username: true },
|
||||
test.describe("Onboarding v2 required field test", () => {
|
||||
test("test onboarding v2 new user first step required fields", async ({ page, users }) => {
|
||||
const user = await users.create({
|
||||
plan: UserPlan.TRIAL,
|
||||
completedOnboarding: false,
|
||||
name: null,
|
||||
username: null,
|
||||
});
|
||||
|
||||
expect(updatedUser?.username).toBe("alwaysavailable");
|
||||
await user.login();
|
||||
await page.goto("/getting-started");
|
||||
|
||||
// First step
|
||||
const nextButtonUserProfile = await page.locator("button[type=submit]");
|
||||
await nextButtonUserProfile.click();
|
||||
|
||||
const requiredName = await page.locator("data-testid=required");
|
||||
await expect(requiredName).toHaveText(/required/i);
|
||||
});
|
||||
|
||||
test("test onboarding v2 new user fourth step required fields", async ({ page, users }) => {
|
||||
const user = await users.create({
|
||||
plan: UserPlan.TRIAL,
|
||||
completedOnboarding: false,
|
||||
});
|
||||
|
||||
await user.login();
|
||||
await page.goto("/getting-started/user-profile");
|
||||
|
||||
// Fourth step
|
||||
await page.waitForSelector("text=Nearly there!");
|
||||
const finishButton = await page.locator("button[type=submit]");
|
||||
await finishButton.click();
|
||||
|
||||
const requiredBio = await page.locator("data-testid=required");
|
||||
await expect(requiredBio).toHaveText(/required/i);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Onboarding redirects", () => {
|
||||
test("redirects to /getting-started after login", async ({ page }) => {
|
||||
await page.goto("/event-types");
|
||||
await page.waitForNavigation();
|
||||
});
|
||||
|
||||
// @TODO: temporary disabled due to flakiness
|
||||
// test("test onboarding v2 new user simulate add calendar redirect", async ({ page, users }) => {
|
||||
// const user = await users.create({
|
||||
// plan: UserPlan.TRIAL,
|
||||
// completedOnboarding: false,
|
||||
// });
|
||||
|
||||
// await user.login();
|
||||
// const url = await page.url();
|
||||
// await page.context().addCookies([
|
||||
// {
|
||||
// name: "return-to",
|
||||
// value: "/getting-started/connected-calendar",
|
||||
// expires: 9999999999,
|
||||
// url,
|
||||
// },
|
||||
// ]);
|
||||
|
||||
// await page.goto("/apps/installed");
|
||||
|
||||
// await expect(page).toHaveURL(/.*connected-calendar/);
|
||||
// });
|
||||
});
|
||||
});
|
||||
|
|
Binary file not shown.
Binary file not shown.
|
@ -220,7 +220,7 @@
|
|||
"check_email_reset_password": "Check your email. We sent you a link to reset your password.",
|
||||
"finish": "Finish",
|
||||
"few_sentences_about_yourself": "A few sentences about yourself. This will appear on your personal url page.",
|
||||
"nearly_there": "Nearly there",
|
||||
"nearly_there": "Nearly there!",
|
||||
"nearly_there_instructions": "Last thing, a brief description about you and a photo really help you get bookings and let people know who they’re booking with.",
|
||||
"set_availability_instructions": "Define ranges of time when you are available on a recurring basis. You can create more of these later and assign them to different calendars.",
|
||||
"set_availability": "Set your availability",
|
||||
|
@ -643,7 +643,7 @@
|
|||
"event_name_tooltip": "The name that will appear in calendars",
|
||||
"meeting_with_user": "Meeting with {ATTENDEE}",
|
||||
"additional_inputs": "Additional Inputs",
|
||||
"additional_input_description":"Require scheduler to input additional inputs prior the booking is confirmed",
|
||||
"additional_input_description": "Require scheduler to input additional inputs prior the booking is confirmed",
|
||||
"label": "Label",
|
||||
"placeholder": "Placeholder",
|
||||
"type": "Type",
|
||||
|
@ -706,6 +706,7 @@
|
|||
"app_store_description": "Connecting people, technology and the workplace.",
|
||||
"settings": "Settings",
|
||||
"event_type_moved_successfully": "Event type has been moved successfully",
|
||||
"next_step_text": "Next Step",
|
||||
"next_step": "Skip step",
|
||||
"prev_step": "Prev step",
|
||||
"install": "Install",
|
||||
|
@ -788,7 +789,7 @@
|
|||
"installed_other": "{{count}} installed",
|
||||
"verify_wallet": "Verify Wallet",
|
||||
"connect_metamask": "Connect Metamask",
|
||||
"create_events_on": "Create events on:",
|
||||
"create_events_on": "Create events on",
|
||||
"missing_license": "Missing License",
|
||||
"signup_requires": "Commercial license required",
|
||||
"signup_requires_description": "Cal.com, Inc. currently does not offer a free open source version of the sign up page. To receive full access to the signup components you need to acquire a commercial license. For personal use we recommend the Prisma Data Platform or any other Postgres interface to create accounts.",
|
||||
|
@ -1046,7 +1047,7 @@
|
|||
"close": "Close",
|
||||
"pro_feature_teams": "This is a Pro feature. Upgrade to Pro to see your team's availability.",
|
||||
"pro_feature_workflows": "This is a Pro feature. Upgrade to Pro to automate your event notifications and reminders with Workflows.",
|
||||
"show_eventtype_on_profile":"Show on Profile",
|
||||
"show_eventtype_on_profile": "Show on Profile",
|
||||
"embed": "Embed",
|
||||
"new_username": "New username",
|
||||
"current_username": "Current username",
|
||||
|
@ -1073,15 +1074,15 @@
|
|||
"missing_connected_calendar": "No default calendar connected",
|
||||
"connect_your_calendar_and_link": "You can connect your calendar from <1>here</1>.",
|
||||
"default_calendar_selected": "Default calendar",
|
||||
"hide_from_profile":"Hide from profile",
|
||||
"event_setup_tab_title":"Event Setup",
|
||||
"event_limit_tab_title":"Limits",
|
||||
"event_limit_tab_description":"How often you can be booked",
|
||||
"event_advanced_tab_description":"Calendar settings & more...",
|
||||
"event_advanced_tab_title":"Advanced",
|
||||
"select_which_cal":"Select which calendar to add bookings to",
|
||||
"custom_event_name":"Custom event name",
|
||||
"custom_event_name_description":"Create customised event names to display on calendar event",
|
||||
"hide_from_profile": "Hide from profile",
|
||||
"event_setup_tab_title": "Event Setup",
|
||||
"event_limit_tab_title": "Limits",
|
||||
"event_limit_tab_description": "How often you can be booked",
|
||||
"event_advanced_tab_description": "Calendar settings & more...",
|
||||
"event_advanced_tab_title": "Advanced",
|
||||
"select_which_cal": "Select which calendar to add bookings to",
|
||||
"custom_event_name": "Custom event name",
|
||||
"custom_event_name_description": "Create customised event names to display on calendar event",
|
||||
"2fa_required": "Two factor authentication required",
|
||||
"incorrect_2fa": "Incorrect two factor authentication code",
|
||||
"which_event_type_apply": "Which event type will this apply to?",
|
||||
|
@ -1090,7 +1091,7 @@
|
|||
"do_this": "Do this",
|
||||
"turn_off": "Turn off",
|
||||
"settings_updated_successfully": "Settings updated successfully",
|
||||
"error_updating_settings":"Error updating settings",
|
||||
"error_updating_settings": "Error updating settings",
|
||||
"personal_cal_url": "My personal Cal URL",
|
||||
"bio_hint": "A few sentences about yourself. this will appear on your personal url page.",
|
||||
"delete_account_modal_title": "Delete Account",
|
||||
|
@ -1105,6 +1106,8 @@
|
|||
"customize_your_brand_colors": "Customize your own brand colour into your booking page.",
|
||||
"pro": "Pro",
|
||||
"removes_cal_branding": "Removes any Cal related brandings, i.e. 'Powered by Cal.'",
|
||||
"profile_picture": "Profile picture",
|
||||
"upload": "Upload",
|
||||
"web3": "Web3",
|
||||
"rainbow_token_gated": "This event type is token gated.",
|
||||
"rainbow_connect_wallet_gate": "Connect your wallet if you own <1>{{name}}</1> (<3>{{symbol}}</3>).",
|
||||
|
@ -1136,6 +1139,14 @@
|
|||
"conferencing_description": "Manage your video conferencing apps for your meetings",
|
||||
"password_description": "Manage settings for your account passwords",
|
||||
"2fa_description": "Manage settings for your account passwords",
|
||||
"we_just_need_basic_info": "We just need some basic info to get your profile setup.",
|
||||
"skip": "Skip",
|
||||
"do_this_later": "Do this later",
|
||||
"set_availability_getting_started_subtitle_1": "Define ranges of time when you are available",
|
||||
"set_availability_getting_started_subtitle_2": "You can customise all of this later in the availability page.",
|
||||
"connect_calendars_from_app_store": "You can add more calendars from the app store",
|
||||
"current_step_of_total": "Step {{currentStep}} of {{maxSteps}}",
|
||||
"copy_all": "Copy All",
|
||||
"add_variable": "Add variable",
|
||||
"custom_phone_number": "Custom phone number",
|
||||
"message_template": "Message template",
|
||||
|
|
|
@ -117,10 +117,10 @@ select:focus {
|
|||
button[role="switch"][data-state="checked"] {
|
||||
@apply bg-gray-900;
|
||||
}
|
||||
|
||||
button[role="switch"][data-state="checked"] span {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
/* TODO: avoid global specific css */
|
||||
/* button[role="switch"][data-state="checked"] span {
|
||||
transform: translateX(16px);
|
||||
} */
|
||||
|
||||
/* DateRangePicker */
|
||||
/*
|
||||
|
|
|
@ -52,7 +52,7 @@
|
|||
"e2e:app-store": "QUICK=true yarn playwright test --project=@calcom/app-store",
|
||||
"test-e2e": "yarn db-seed && yarn build && yarn e2e",
|
||||
"test-e2e:app-store": "yarn db-seed && yarn build && yarn e2e:app-store",
|
||||
"test-playwright": "yarn playwright test --config=tests/config/playwright.config.ts",
|
||||
"test-playwright": "yarn playwright test --config=playwright.config.ts",
|
||||
"test": "jest",
|
||||
"type-check": "turbo run type-check",
|
||||
"web": "yarn workspace @calcom/web"
|
||||
|
|
|
@ -11,17 +11,18 @@ import { UpgradeToProDialog } from "@calcom/ui/UpgradeToProDialog";
|
|||
import { InstallAppButtonMap } from "./apps.browser.generated";
|
||||
import { InstallAppButtonProps } from "./types";
|
||||
|
||||
function InstallAppButtonWithoutPlanCheck(
|
||||
export const InstallAppButtonWithoutPlanCheck = (
|
||||
props: {
|
||||
type: App["type"];
|
||||
} & InstallAppButtonProps
|
||||
) {
|
||||
) => {
|
||||
const key = deriveAppDictKeyFromType(props.type, InstallAppButtonMap);
|
||||
const InstallAppButtonComponent = InstallAppButtonMap[key as keyof typeof InstallAppButtonMap];
|
||||
if (!InstallAppButtonComponent) return <>{props.render({ useDefaultComponent: true })}</>;
|
||||
|
||||
return <InstallAppButtonComponent render={props.render} onChanged={props.onChanged} />;
|
||||
}
|
||||
};
|
||||
|
||||
export const InstallAppButton = (
|
||||
props: {
|
||||
isProOnly?: App["isProOnly"];
|
||||
|
|
|
@ -0,0 +1,405 @@
|
|||
import classNames from "classnames";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Controller,
|
||||
useFieldArray,
|
||||
UseFieldArrayAppend,
|
||||
UseFieldArrayRemove,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
import { GroupBase, Props } from "react-select";
|
||||
|
||||
import dayjs, { ConfigType, Dayjs } from "@calcom/dayjs";
|
||||
import { defaultDayRange as DEFAULT_DAY_RANGE } from "@calcom/lib/availability";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { weekdayNames } from "@calcom/lib/weekday";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import { TimeRange } from "@calcom/types/schedule";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import Dropdown, { DropdownMenuContent, DropdownMenuTrigger } from "@calcom/ui/Dropdown";
|
||||
import { Button, Select, Switch, Tooltip } from "@calcom/ui/v2";
|
||||
|
||||
const Schedule = () => {
|
||||
const { i18n } = useLocale();
|
||||
const form = useFormContext();
|
||||
|
||||
const initialValue = form.watch();
|
||||
|
||||
const copyAllPosition = (initialValue["schedule"] as Array<TimeRange[]>)?.findIndex(
|
||||
(item: TimeRange[]) => item.length > 0
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* First iterate for each day */}
|
||||
{weekdayNames(i18n.language, 0, "long").map((weekday, num) => {
|
||||
const name = `schedule.${num}`;
|
||||
const copyAllShouldRender = copyAllPosition === num;
|
||||
return (
|
||||
<div className="mb-1 flex w-full flex-col py-1 sm:flex-row" key={weekday}>
|
||||
{/* Label & switch container */}
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
<label className="flex flex-row items-center">
|
||||
<Switch
|
||||
defaultChecked={initialValue["schedule"][num].length > 0}
|
||||
checked={!!initialValue["schedule"][num].length}
|
||||
onCheckedChange={(isChecked) => {
|
||||
form.setValue(name, isChecked ? [DEFAULT_DAY_RANGE] : []);
|
||||
}}
|
||||
className="relative mx-2 my-[6px] h-6 w-10 rounded-full bg-gray-200"
|
||||
/>
|
||||
<span className="inline-block min-w-[88px] text-sm capitalize">{weekday}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="inline sm:hidden">
|
||||
<ActionButtons
|
||||
name={name}
|
||||
setValue={form.setValue}
|
||||
watcher={form.watch(name, initialValue[name])}
|
||||
copyAllShouldRender={copyAllShouldRender}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full sm:ml-2">
|
||||
<DayRanges name={name} copyAllShouldRender={copyAllShouldRender} />
|
||||
</div>
|
||||
<div className="my-2 h-[1px] w-full bg-gray-200 sm:hidden" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DayRanges = ({
|
||||
name,
|
||||
copyAllShouldRender,
|
||||
}: {
|
||||
name: string;
|
||||
defaultValue?: TimeRange[];
|
||||
copyAllShouldRender?: boolean;
|
||||
}) => {
|
||||
const form = useFormContext();
|
||||
|
||||
const fields = form.watch(`${name}` as `schedule.0`);
|
||||
|
||||
const { remove } = useFieldArray({
|
||||
name,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{fields.map((field: { id: string }, index: number) => (
|
||||
<div key={field.id} className="mb-2 flex rtl:space-x-reverse">
|
||||
<TimeRangeField name={`${name}.${index}`} />
|
||||
{index === 0 && (
|
||||
<div className="hidden sm:inline">
|
||||
<ActionButtons
|
||||
name={name}
|
||||
setValue={form.setValue}
|
||||
watcher={form.watch(name)}
|
||||
copyAllShouldRender={copyAllShouldRender}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{index !== 0 && <RemoveTimeButton index={index} remove={remove} />}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const RemoveTimeButton = ({
|
||||
index,
|
||||
remove,
|
||||
className,
|
||||
}: {
|
||||
index: number | number[];
|
||||
remove: UseFieldArrayRemove;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
color="minimal"
|
||||
StartIcon={Icon.FiTrash}
|
||||
onClick={() => remove(index)}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface TimeRangeFieldProps {
|
||||
name: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TimeRangeField = ({ name, className }: TimeRangeFieldProps) => {
|
||||
const { watch } = useFormContext();
|
||||
|
||||
const values = watch(name);
|
||||
const minEnd = values["start"];
|
||||
const maxStart = values["end"];
|
||||
|
||||
return (
|
||||
<div className={classNames("mx-1 flex", className)}>
|
||||
<Controller
|
||||
name={`${name}.start`}
|
||||
render={({ field: { onChange } }) => {
|
||||
return (
|
||||
<LazySelect
|
||||
className="h-9 w-[100px]"
|
||||
value={values["start"]}
|
||||
max={maxStart}
|
||||
onChange={(option) => {
|
||||
onChange(new Date(option?.value as number));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="mx-2 w-2 self-center"> - </span>
|
||||
<Controller
|
||||
name={`${name}.end`}
|
||||
render={({ field: { onChange } }) => (
|
||||
<LazySelect
|
||||
className="w-[100px] rounded-md"
|
||||
value={values["end"]}
|
||||
min={minEnd}
|
||||
onChange={(option) => {
|
||||
onChange(new Date(option?.value as number));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const LazySelect = ({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
...props
|
||||
}: Omit<Props<IOption, false, GroupBase<IOption>>, "value"> & {
|
||||
value: ConfigType;
|
||||
min?: ConfigType;
|
||||
max?: ConfigType;
|
||||
}) => {
|
||||
// Lazy-loaded options, otherwise adding a field has a noticeable redraw delay.
|
||||
const { options, filter } = useOptions();
|
||||
|
||||
useEffect(() => {
|
||||
filter({ current: value });
|
||||
}, [filter, value]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
options={options}
|
||||
onMenuOpen={() => {
|
||||
if (min) filter({ offset: min });
|
||||
if (max) filter({ limit: max });
|
||||
}}
|
||||
value={options.find((option) => option.value === dayjs(value).toDate().valueOf())}
|
||||
onMenuClose={() => filter({ current: value })}
|
||||
components={{ DropdownIndicator: () => null, IndicatorSeparator: () => null }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface IOption {
|
||||
readonly label: string;
|
||||
readonly value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an array of times on a 15 minute interval from
|
||||
* 00:00:00 (Start of day) to
|
||||
* 23:45:00 (End of day with enough time for 15 min booking)
|
||||
*/
|
||||
/** Begin Time Increments For Select */
|
||||
const INCREMENT = 15;
|
||||
const useOptions = () => {
|
||||
// Get user so we can determine 12/24 hour format preferences
|
||||
const query = useMeQuery();
|
||||
const { timeFormat } = query.data || { timeFormat: null };
|
||||
|
||||
const [filteredOptions, setFilteredOptions] = useState<IOption[]>([]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
const end = dayjs().utc().endOf("day");
|
||||
let t: Dayjs = dayjs().utc().startOf("day");
|
||||
|
||||
const options: IOption[] = [];
|
||||
while (t.isBefore(end)) {
|
||||
options.push({
|
||||
value: t.toDate().valueOf(),
|
||||
label: dayjs(t)
|
||||
.utc()
|
||||
.format(timeFormat === 12 ? "h:mma" : "HH:mm"),
|
||||
});
|
||||
t = t.add(INCREMENT, "minutes");
|
||||
}
|
||||
return options;
|
||||
}, [timeFormat]);
|
||||
|
||||
const filter = useCallback(
|
||||
({ offset, limit, current }: { offset?: ConfigType; limit?: ConfigType; current?: ConfigType }) => {
|
||||
if (current) {
|
||||
const currentOption = options.find((option) => option.value === dayjs(current).toDate().valueOf());
|
||||
if (currentOption) setFilteredOptions([currentOption]);
|
||||
} else
|
||||
setFilteredOptions(
|
||||
options.filter((option) => {
|
||||
const time = dayjs(option.value);
|
||||
return (!limit || time.isBefore(limit)) && (!offset || time.isAfter(offset));
|
||||
})
|
||||
);
|
||||
},
|
||||
[options]
|
||||
);
|
||||
|
||||
return { options: filteredOptions, filter };
|
||||
};
|
||||
|
||||
const ActionButtons = ({
|
||||
name,
|
||||
watcher,
|
||||
setValue,
|
||||
copyAllShouldRender,
|
||||
}: {
|
||||
name: string;
|
||||
watcher: TimeRange[];
|
||||
setValue: (key: string, value: TimeRange[]) => void;
|
||||
copyAllShouldRender?: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const form = useFormContext();
|
||||
|
||||
const values = form.watch();
|
||||
const { append } = useFieldArray({
|
||||
name,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Tooltip content={t("add_time_availability") as string}>
|
||||
<Button
|
||||
className="text-neutral-400"
|
||||
type="button"
|
||||
color="minimal"
|
||||
size="icon"
|
||||
StartIcon={Icon.FiPlus}
|
||||
onClick={() => {
|
||||
handleAppend({
|
||||
fields: watcher,
|
||||
/* Generics should help with this, but forgive us father as I have sinned */
|
||||
append: append as unknown as UseFieldArrayAppend<TimeRange>,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Dropdown>
|
||||
<Tooltip content={t("duplicate") as string}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" color="minimal" size="icon" StartIcon={Icon.FiCopy} />
|
||||
</DropdownMenuTrigger>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent>
|
||||
<CopyTimes
|
||||
disabled={[parseInt(name.substring(name.lastIndexOf(".") + 1), 10)]}
|
||||
onApply={(selected) =>
|
||||
selected.forEach((day) => {
|
||||
setValue(name.substring(0, name.lastIndexOf(".") + 1) + day, watcher);
|
||||
})
|
||||
}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
{/* This only displays on Desktop */}
|
||||
{copyAllShouldRender && (
|
||||
<Tooltip content={t("add_time_availability") as string}>
|
||||
<Button
|
||||
color="minimal"
|
||||
className="whitespace-nowrap text-sm text-neutral-400"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
values["schedule"].forEach((item: TimeRange[], index: number) => {
|
||||
if (item.length > 0) {
|
||||
setValue(`schedule.${index}`, watcher);
|
||||
}
|
||||
});
|
||||
}}
|
||||
title={`${t("copy_all")}`}>
|
||||
{t("copy_all")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const handleAppend = ({
|
||||
fields = [],
|
||||
append,
|
||||
}: {
|
||||
fields: TimeRange[];
|
||||
append: UseFieldArrayAppend<TimeRange>;
|
||||
}) => {
|
||||
if (fields.length === 0) {
|
||||
return append(DEFAULT_DAY_RANGE);
|
||||
}
|
||||
const nextRangeStart = dayjs((fields[fields.length - 1] as unknown as TimeRange).end);
|
||||
const nextRangeEnd = dayjs(nextRangeStart).add(1, "hour");
|
||||
|
||||
if (nextRangeEnd.isBefore(nextRangeStart.endOf("day"))) {
|
||||
return append({
|
||||
start: nextRangeStart.toDate(),
|
||||
end: nextRangeEnd.toDate(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const CopyTimes = ({ disabled, onApply }: { disabled: number[]; onApply: (selected: number[]) => void }) => {
|
||||
const [selected, setSelected] = useState<number[]>([]);
|
||||
const { i18n, t } = useLocale();
|
||||
|
||||
return (
|
||||
<div className="m-4 space-y-2 py-4">
|
||||
<p className="h6 text-xs font-medium uppercase text-neutral-400">Copy times to</p>
|
||||
<ol className="space-y-2">
|
||||
{weekdayNames(i18n.language).map((weekday, num) => (
|
||||
<li key={weekday}>
|
||||
<label className="flex w-full items-center justify-between">
|
||||
<span className="px-1">{weekday}</span>
|
||||
<input
|
||||
value={num}
|
||||
defaultChecked={disabled.includes(num)}
|
||||
disabled={disabled.includes(num)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked && !selected.includes(num)) {
|
||||
setSelected(selected.concat([num]));
|
||||
} else if (!e.target.checked && selected.includes(num)) {
|
||||
setSelected(selected.slice(selected.indexOf(num), 1));
|
||||
}
|
||||
}}
|
||||
type="checkbox"
|
||||
className="inline-block rounded-[4px] border-gray-300 text-neutral-900 focus:ring-neutral-500 disabled:text-neutral-400"
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<div className="pt-2">
|
||||
<Button className="w-full justify-center" color="primary" onClick={() => onApply(selected)}>
|
||||
{t("apply")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Schedule;
|
|
@ -0,0 +1,3 @@
|
|||
export { NewScheduleButton } from "./NewScheduleButton";
|
||||
export { default as Schedule } from "./Schedule";
|
||||
export { ScheduleListItem } from "./ScheduleListItem";
|
|
@ -0,0 +1 @@
|
|||
export * from "./components";
|
|
@ -20,7 +20,6 @@ function UserV2OptInBanner() {
|
|||
.
|
||||
</>
|
||||
}
|
||||
className="mb-2"
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -36,7 +35,6 @@ function UserV2OptInBanner() {
|
|||
.
|
||||
</>
|
||||
}
|
||||
className="mb-2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -600,8 +600,8 @@ const loggedInViewerRouter = createProtectedRouter()
|
|||
input: z.object({
|
||||
integration: z.string(),
|
||||
externalId: z.string(),
|
||||
eventTypeId: z.number().optional(),
|
||||
bookingId: z.number().optional(),
|
||||
eventTypeId: z.number().nullish(),
|
||||
bookingId: z.number().nullish(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const { user } = ctx;
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { Prisma } from "@prisma/client";
|
||||
import { Availability as AvailabilityModel, Prisma, Schedule as ScheduleModel, User } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getUserAvailability } from "@calcom/core/getUserAvailability";
|
||||
import { getAvailabilityFromSchedule } from "@calcom/lib/availability";
|
||||
import { PrismaClient } from "@calcom/prisma/client";
|
||||
import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
||||
import { Schedule } from "@calcom/types/schedule";
|
||||
|
||||
|
@ -66,34 +67,7 @@ export const availabilityRouter = createProtectedRouter()
|
|||
code: "UNAUTHORIZED",
|
||||
});
|
||||
}
|
||||
const availability = schedule.availability.reduce(
|
||||
(schedule: Schedule, availability) => {
|
||||
availability.days.forEach((day) => {
|
||||
schedule[day].push({
|
||||
start: new Date(
|
||||
Date.UTC(
|
||||
new Date().getUTCFullYear(),
|
||||
new Date().getUTCMonth(),
|
||||
new Date().getUTCDate(),
|
||||
availability.startTime.getUTCHours(),
|
||||
availability.startTime.getUTCMinutes()
|
||||
)
|
||||
),
|
||||
end: new Date(
|
||||
Date.UTC(
|
||||
new Date().getUTCFullYear(),
|
||||
new Date().getUTCMonth(),
|
||||
new Date().getUTCDate(),
|
||||
availability.endTime.getUTCHours(),
|
||||
availability.endTime.getUTCMinutes()
|
||||
)
|
||||
),
|
||||
});
|
||||
});
|
||||
return schedule;
|
||||
},
|
||||
Array.from([...Array(7)]).map(() => [])
|
||||
);
|
||||
const availability = convertScheduleToAvailability(schedule);
|
||||
return {
|
||||
schedule,
|
||||
availability,
|
||||
|
@ -160,6 +134,12 @@ export const availabilityRouter = createProtectedRouter()
|
|||
const schedule = await prisma.schedule.create({
|
||||
data,
|
||||
});
|
||||
const hasDefaultScheduleId = await hasDefaultSchedule(user, prisma);
|
||||
|
||||
if (hasDefaultScheduleId) {
|
||||
await setupDefaultSchedule(user.id, schedule.id, prisma);
|
||||
}
|
||||
|
||||
return { schedule };
|
||||
},
|
||||
})
|
||||
|
@ -219,14 +199,7 @@ export const availabilityRouter = createProtectedRouter()
|
|||
const availability = getAvailabilityFromSchedule(input.schedule);
|
||||
|
||||
if (input.isDefault) {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
defaultScheduleId: input.scheduleId,
|
||||
},
|
||||
});
|
||||
setupDefaultSchedule(user.id, input.scheduleId, prisma);
|
||||
}
|
||||
|
||||
// Not able to update the schedule with userId where clause, so fetch schedule separately and then validate
|
||||
|
@ -277,3 +250,60 @@ export const availabilityRouter = createProtectedRouter()
|
|||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const convertScheduleToAvailability = (
|
||||
schedule: Partial<ScheduleModel> & { availability: AvailabilityModel[] }
|
||||
) => {
|
||||
return schedule.availability.reduce(
|
||||
(schedule: Schedule, availability) => {
|
||||
availability.days.forEach((day) => {
|
||||
schedule[day].push({
|
||||
start: new Date(
|
||||
Date.UTC(
|
||||
new Date().getUTCFullYear(),
|
||||
new Date().getUTCMonth(),
|
||||
new Date().getUTCDate(),
|
||||
availability.startTime.getUTCHours(),
|
||||
availability.startTime.getUTCMinutes()
|
||||
)
|
||||
),
|
||||
end: new Date(
|
||||
Date.UTC(
|
||||
new Date().getUTCFullYear(),
|
||||
new Date().getUTCMonth(),
|
||||
new Date().getUTCDate(),
|
||||
availability.endTime.getUTCHours(),
|
||||
availability.endTime.getUTCMinutes()
|
||||
)
|
||||
),
|
||||
});
|
||||
});
|
||||
return schedule;
|
||||
},
|
||||
Array.from([...Array(7)]).map(() => [])
|
||||
);
|
||||
};
|
||||
|
||||
const setupDefaultSchedule = async (userId: number, scheduleId: number, prisma: PrismaClient) => {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
defaultScheduleId: scheduleId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const isDefaultSchedule = (scheduleId: number, user: Partial<User>) => {
|
||||
return !user.defaultScheduleId || user.defaultScheduleId === scheduleId;
|
||||
};
|
||||
|
||||
const hasDefaultSchedule = async (user: Partial<User>, prisma: PrismaClient) => {
|
||||
const defaultSchedule = await prisma.schedule.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
return !!user.defaultScheduleId || !!defaultSchedule;
|
||||
};
|
||||
|
|
|
@ -10,15 +10,18 @@ import BaseSelect, {
|
|||
import { InputComponent } from "@calcom/ui/v2/core/form/Select";
|
||||
|
||||
function TimezoneSelect({ className, ...props }: SelectProps) {
|
||||
// @TODO: remove borderRadius and haveRoundedClassName logic from theme so we use only new style
|
||||
const haveRoundedClassName = !!(className && className.indexOf("rounded-") > -1);
|
||||
const defaultBorderRadius = 2;
|
||||
|
||||
return (
|
||||
<BaseSelect
|
||||
theme={(theme) => ({
|
||||
...theme,
|
||||
borderRadius: 2,
|
||||
...(haveRoundedClassName ? {} : { borderRadius: defaultBorderRadius }),
|
||||
colors: {
|
||||
...theme.colors,
|
||||
primary: "var(--brand-color)",
|
||||
|
||||
primary50: "rgba(209 , 213, 219, var(--tw-bg-opacity))",
|
||||
primary25: "rgba(244, 245, 246, var(--tw-bg-opacity))",
|
||||
},
|
||||
|
|
|
@ -17,11 +17,11 @@ const Switch = (
|
|||
const id = useId();
|
||||
|
||||
return (
|
||||
<div className="flex h-[20px] items-center">
|
||||
<div className="flex h-auto w-auto flex-row items-center">
|
||||
<PrimitiveSwitch.Root
|
||||
className={classNames(
|
||||
props.checked ? "bg-gray-900" : "bg-gray-200 hover:bg-gray-300",
|
||||
"focus:ring-brand-800 h-[24px] w-[40px] rounded-full p-[3px] shadow-none",
|
||||
"focus:ring-brand-800 h-6 w-10 rounded-full shadow-none",
|
||||
props.className
|
||||
)}
|
||||
{...primitiveProps}>
|
||||
|
@ -30,7 +30,9 @@ const Switch = (
|
|||
// Since we dont support global dark mode - we have to style dark mode components specifically on the instance for now
|
||||
// TODO: Remove once we support global dark mode
|
||||
className={classNames(
|
||||
"block h-[18px] w-[18px] translate-x-0 rounded-full bg-white transition-transform",
|
||||
"block h-[18px] w-[18px] rounded-full bg-white",
|
||||
"translate-x-[4px] transition delay-100 will-change-transform",
|
||||
"[&[data-state='checked']]:translate-x-[18px]",
|
||||
props.checked && "shadow-inner",
|
||||
props.thumbProps?.className
|
||||
)}
|
||||
|
|
|
@ -49,6 +49,14 @@ function Select<
|
|||
},
|
||||
})}
|
||||
styles={{
|
||||
control: (base) => ({
|
||||
...base,
|
||||
// Brute force to remove focus outline of input
|
||||
"& .react-select__input": {
|
||||
borderWidth: 0,
|
||||
boxShadow: "none",
|
||||
},
|
||||
}),
|
||||
option: (provided, state) => ({
|
||||
...provided,
|
||||
backgroundColor: state.isSelected ? "var(--brand-color)" : state.isFocused ? "#F3F4F6" : "",
|
||||
|
@ -63,6 +71,7 @@ function Select<
|
|||
...components,
|
||||
IndicatorSeparator: () => null,
|
||||
Input: InputComponent,
|
||||
...props.components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
@ -1,333 +0,0 @@
|
|||
import classNames from "classnames";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
|
||||
import { GroupBase, Props } from "react-select";
|
||||
|
||||
import dayjs, { Dayjs, ConfigType } from "@calcom/dayjs";
|
||||
import { defaultDayRange } from "@calcom/lib/availability";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { weekdayNames } from "@calcom/lib/weekday";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import { TimeRange } from "@calcom/types/schedule";
|
||||
import Dropdown, { DropdownMenuContent } from "@calcom/ui/Dropdown";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import Button from "@calcom/ui/v2/core/Button";
|
||||
import Switch from "@calcom/ui/v2/core/Switch";
|
||||
import Tooltip from "@calcom/ui/v2/core/Tooltip";
|
||||
import Select from "@calcom/ui/v2/core/form/Select";
|
||||
|
||||
/** Begin Time Increments For Select */
|
||||
const increment = 15;
|
||||
|
||||
type Option = {
|
||||
readonly label: string;
|
||||
readonly value: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates an array of times on a 15 minute interval from
|
||||
* 00:00:00 (Start of day) to
|
||||
* 23:45:00 (End of day with enough time for 15 min booking)
|
||||
*/
|
||||
const useOptions = () => {
|
||||
// Get user so we can determine 12/24 hour format preferences
|
||||
const query = useMeQuery();
|
||||
const { timeFormat } = query.data || { timeFormat: null };
|
||||
|
||||
const [filteredOptions, setFilteredOptions] = useState<Option[]>([]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
const end = dayjs().utc().endOf("day");
|
||||
let t: Dayjs = dayjs().utc().startOf("day");
|
||||
|
||||
const options: Option[] = [];
|
||||
while (t.isBefore(end)) {
|
||||
options.push({
|
||||
value: t.toDate().valueOf(),
|
||||
label: dayjs(t)
|
||||
.utc()
|
||||
.format(timeFormat === 12 ? "h:mma" : "HH:mm"),
|
||||
});
|
||||
t = t.add(increment, "minutes");
|
||||
}
|
||||
return options;
|
||||
}, [timeFormat]);
|
||||
|
||||
const filter = useCallback(
|
||||
({ offset, limit, current }: { offset?: ConfigType; limit?: ConfigType; current?: ConfigType }) => {
|
||||
if (current) {
|
||||
const currentOption = options.find((option) => option.value === dayjs(current).toDate().valueOf());
|
||||
if (currentOption) setFilteredOptions([currentOption]);
|
||||
} else
|
||||
setFilteredOptions(
|
||||
options.filter((option) => {
|
||||
const time = dayjs(option.value);
|
||||
return (!limit || time.isBefore(limit)) && (!offset || time.isAfter(offset));
|
||||
})
|
||||
);
|
||||
},
|
||||
[options]
|
||||
);
|
||||
|
||||
return { options: filteredOptions, filter };
|
||||
};
|
||||
|
||||
type TimeRangeFieldProps = {
|
||||
name: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const LazySelect = ({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
...props
|
||||
}: Omit<Props<Option, false, GroupBase<Option>>, "value"> & {
|
||||
value: ConfigType;
|
||||
min?: ConfigType;
|
||||
max?: ConfigType;
|
||||
}) => {
|
||||
// Lazy-loaded options, otherwise adding a field has a noticable redraw delay.
|
||||
const { options, filter } = useOptions();
|
||||
|
||||
useEffect(() => {
|
||||
filter({ current: value });
|
||||
}, [filter, value]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
options={options}
|
||||
onMenuOpen={() => {
|
||||
if (min) filter({ offset: min });
|
||||
if (max) filter({ limit: max });
|
||||
}}
|
||||
value={options.find((option) => option.value === dayjs(value).toDate().valueOf())}
|
||||
onMenuClose={() => filter({ current: value })}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const TimeRangeField = ({ name, className }: TimeRangeFieldProps) => {
|
||||
const { watch } = useFormContext();
|
||||
const minEnd = watch(`${name}.start`);
|
||||
const maxStart = watch(`${name}.end`);
|
||||
return (
|
||||
<div className={classNames("flex flex-grow items-center space-x-3", className)}>
|
||||
<Controller
|
||||
name={`${name}.start`}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<LazySelect
|
||||
className="w-[120px]"
|
||||
value={value}
|
||||
max={maxStart}
|
||||
onChange={(option) => {
|
||||
onChange(new Date(option?.value as number));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span>-</span>
|
||||
<Controller
|
||||
name={`${name}.end`}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<LazySelect
|
||||
className="flex-grow sm:w-[120px]"
|
||||
value={value}
|
||||
min={minEnd}
|
||||
onChange={(option) => {
|
||||
onChange(new Date(option?.value as number));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ScheduleBlockProps = {
|
||||
day: number;
|
||||
weekday: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const CopyTimes = ({ disabled, onApply }: { disabled: number[]; onApply: (selected: number[]) => void }) => {
|
||||
const [selected, setSelected] = useState<number[]>([]);
|
||||
const { i18n, t } = useLocale();
|
||||
return (
|
||||
<div className="m-4 space-y-2 py-4">
|
||||
<p className="h6 text-xs font-medium uppercase text-neutral-400">Copy times to</p>
|
||||
<ol className="space-y-2">
|
||||
{weekdayNames(i18n.language).map((weekday, num) => (
|
||||
<li key={weekday}>
|
||||
<label className="flex w-full items-center justify-between">
|
||||
<span>{weekday}</span>
|
||||
<input
|
||||
value={num}
|
||||
defaultChecked={disabled.includes(num)}
|
||||
disabled={disabled.includes(num)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked && !selected.includes(num)) {
|
||||
setSelected(selected.concat([num]));
|
||||
} else if (!e.target.checked && selected.includes(num)) {
|
||||
setSelected(selected.slice(selected.indexOf(num), 1));
|
||||
}
|
||||
}}
|
||||
type="checkbox"
|
||||
className="inline-block rounded-sm border-gray-300 text-neutral-900 focus:ring-neutral-500 disabled:text-neutral-400"
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<div className="pt-2">
|
||||
<Button className="w-full justify-center" color="primary" onClick={() => onApply(selected)}>
|
||||
{t("apply")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DayRanges = ({
|
||||
name,
|
||||
defaultValue = [defaultDayRange],
|
||||
}: {
|
||||
name: string;
|
||||
defaultValue?: TimeRange[];
|
||||
}) => {
|
||||
const { setValue, watch } = useFormContext();
|
||||
// XXX: Hack to make copying times work; `fields` is out of date until save.
|
||||
const watcher = watch(name);
|
||||
const { t } = useLocale();
|
||||
const { fields, replace, append, remove } = useFieldArray({
|
||||
name,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultValue.length && !fields.length) {
|
||||
replace(defaultValue);
|
||||
}
|
||||
}, [replace, defaultValue, fields.length]);
|
||||
|
||||
const handleAppend = () => {
|
||||
// FIXME: Fix type-inference, can't get this to work. @see https://github.com/react-hook-form/react-hook-form/issues/4499
|
||||
const nextRangeStart = dayjs((fields[fields.length - 1] as unknown as TimeRange).end);
|
||||
const nextRangeEnd = dayjs(nextRangeStart).add(1, "hour");
|
||||
|
||||
if (nextRangeEnd.isBefore(nextRangeStart.endOf("day"))) {
|
||||
return append({
|
||||
start: nextRangeStart.toDate(),
|
||||
end: nextRangeEnd.toDate(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-center rtl:space-x-reverse">
|
||||
<div className="flex flex-grow space-x-1 sm:flex-grow-0">
|
||||
<TimeRangeField name={`${name}.${index}`} />
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
color="minimal"
|
||||
StartIcon={Icon.FiTrash}
|
||||
onClick={() => remove(index)}
|
||||
/>
|
||||
</div>
|
||||
{index === 0 && (
|
||||
<div className="absolute top-2 right-0 text-right sm:relative sm:top-0 sm:flex-grow">
|
||||
<Tooltip content={t("add_time_availability") as string}>
|
||||
<Button
|
||||
className="text-neutral-400"
|
||||
type="button"
|
||||
color="minimal"
|
||||
size="icon"
|
||||
StartIcon={Icon.FiPlus}
|
||||
onClick={handleAppend}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Dropdown>
|
||||
<Tooltip content={t("duplicate") as string}>
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
size="icon"
|
||||
StartIcon={Icon.FiCopy}
|
||||
onClick={handleAppend}
|
||||
/>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent>
|
||||
<CopyTimes
|
||||
disabled={[parseInt(name.substring(name.lastIndexOf(".") + 1), 10)]}
|
||||
onApply={(selected) =>
|
||||
selected.forEach((day) => {
|
||||
// TODO: Figure out why this is different?
|
||||
// console.log(watcher, fields);
|
||||
setValue(name.substring(0, name.lastIndexOf(".") + 1) + day, watcher);
|
||||
})
|
||||
}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ScheduleBlock = ({ name, day, weekday }: ScheduleBlockProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const form = useFormContext();
|
||||
const watchAvailable = form.watch(`${name}.${day}`, []);
|
||||
|
||||
return (
|
||||
<fieldset className="relative flex flex-col justify-between space-y-2 py-5 sm:flex-row sm:space-y-0">
|
||||
<label
|
||||
className={classNames(
|
||||
"flex space-x-2 rtl:space-x-reverse",
|
||||
!watchAvailable.length ? "w-full" : "w-1/3"
|
||||
)}>
|
||||
<div className={classNames(!watchAvailable.length ? "w-1/3" : "w-full", "flex items-center")}>
|
||||
<Switch
|
||||
checked={watchAvailable.length}
|
||||
onCheckedChange={(value) => {
|
||||
form.setValue(`${name}.${day}`, value ? [defaultDayRange] : []);
|
||||
}}
|
||||
/>
|
||||
<span className="ml-3 text-sm font-medium leading-4 text-gray-900">{weekday}</span>
|
||||
</div>
|
||||
{!watchAvailable.length && (
|
||||
<div className="flex-grow text-right text-sm text-gray-500 sm:flex-shrink">
|
||||
{t("no_availability")}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
{!!watchAvailable.length && (
|
||||
<div className="flex-grow">
|
||||
<DayRanges name={`${name}.${day}`} defaultValue={[]} />
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
};
|
||||
|
||||
const Schedule = ({ name }: { name: string }) => {
|
||||
const { i18n } = useLocale();
|
||||
return (
|
||||
<fieldset className="divide-y divide-gray-200">
|
||||
{weekdayNames(i18n.language).map((weekday, num) => (
|
||||
<ScheduleBlock key={num} name={name} weekday={weekday} day={num} />
|
||||
))}
|
||||
</fieldset>
|
||||
);
|
||||
};
|
||||
|
||||
export default Schedule;
|
|
@ -1,3 +0,0 @@
|
|||
export * from "./NewScheduleButton";
|
||||
export { default as Schedule } from "./Schedule";
|
||||
export * from "./ScheduleListItem";
|
|
@ -1,4 +1,3 @@
|
|||
export * from "./auth";
|
||||
export * from "./availability";
|
||||
export * from "./booker";
|
||||
export * from "./event-types";
|
||||
|
|
Loading…
Reference in New Issue