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>
This commit is contained in:
alannnc 2022-09-06 16:58:16 -06:00 committed by GitHub
parent 7f2db86a83
commit 13c2dc24dc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 1714 additions and 470 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -80,7 +80,7 @@ function Select<
<ReactSelect
theme={(theme) => ({
...theme,
borderRadius: 2,
borderRadius: 6,
colors: {
...theme.colors,
...(hasDarkTheme

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
export { NewScheduleButton } from "./NewScheduleButton";
export { default as Schedule } from "./Schedule";
export { ScheduleListItem } from "./ScheduleListItem";

View File

@ -0,0 +1 @@
export * from "./components";

View File

@ -20,7 +20,6 @@ function UserV2OptInBanner() {
.
</>
}
className="mb-2"
/>
);
@ -36,7 +35,6 @@ function UserV2OptInBanner() {
.
</>
}
className="mb-2"
/>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +0,0 @@
export * from "./NewScheduleButton";
export { default as Schedule } from "./Schedule";
export * from "./ScheduleListItem";

View File

@ -1,4 +1,3 @@
export * from "./auth";
export * from "./availability";
export * from "./booker";
export * from "./event-types";