Feature/improved timeformat selection booking page (#5175)

* Aded new togglegroup component.

* Make 12/24h time format toggle functional and store value in localstorage.

* Responsive tweaks

* Added translations

* Added 12/24h timeformat hint on profile page.

* Update apps/web/public/static/locales/en/common.json

Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
Jeroen ReumkensandOmar López authored and GitHub committed 2022-10-24 18:14:06 -06:00
1 parent b18eabc6e8
commit a0e93e1eeb
8 files changed
+130 -86

No files matched your search

@@ -0,0 +1,20 @@
import { ComponentMeta } from "@storybook/react";
import { ToggleGroup } from "@calcom/ui/v2/core/form/ToggleGroup";
export default {
title: "Toggle Group",
component: ToggleGroup,
} as ComponentMeta<typeof ToggleGroup>;
export const Default = () => {
return (
<ToggleGroup
defaultValue="12"
options={[
{ value: "12", label: "12h" },
{ value: "24", label: "24h" },
]}
/>
);
};
+24 -9
View File
@@ -4,15 +4,18 @@ import { FC, useEffect, useState } from "react";
import dayjs, { Dayjs } from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { TimeFormat } from "@calcom/lib/timeFormat";
import { nameOfDay } from "@calcom/lib/weekday";
import type { Slot } from "@calcom/trpc/server/routers/viewer/slots";
import { SkeletonContainer, SkeletonText } from "@calcom/ui";
import { ToggleGroup } from "@calcom/ui/v2/core/form/ToggleGroup";
import classNames from "@lib/classNames";
import { timeZone } from "@lib/clock";
type AvailableTimesProps = {
timeFormat: string;
timeFormat: TimeFormat;
onTimeFormatChange: (is24Hour: boolean) => void;
eventTypeId: number;
recurringCount: number | undefined;
eventTypeSlug: string;
@@ -31,6 +34,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
eventTypeSlug,
recurringCount,
timeFormat,
onTimeFormatChange,
seatsPerTimeSlot,
ethSignature,
}) => {
@@ -48,14 +52,25 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
return (
<div className="dark:bg-darkgray-100 mt-8 flex h-full w-full flex-col px-4 text-center sm:mt-0 sm:p-5 md:-mb-5 md:min-w-[200px] lg:min-w-[300px]">
<div className="mb-4 text-left text-base">
<span className="text-bookingdarker dark:text-darkgray-800 mb-8 w-1/2 break-words font-semibold text-gray-900">
{nameOfDay(i18n.language, Number(date.format("d")))}
</span>
<span className="text-bookinglight font-medium">
{date.format(", D ")}
{date.toDate().toLocaleString(i18n.language, { month: "long" })}
</span>
<div className="mb-6 flex items-center text-left text-base">
<div className="mr-4">
<span className="text-bookingdarker dark:text-darkgray-800 font-semibold text-gray-900">
{nameOfDay(i18n.language, Number(date.format("d")), "short")}
</span>
<span className="text-bookinglight font-medium">
, {date.toDate().toLocaleString(i18n.language, { month: "long", day: "numeric" })}
</span>
</div>
<div className="ml-auto">
<ToggleGroup
onValueChange={(timeFormat) => onTimeFormatChange(timeFormat === "24")}
defaultValue={timeFormat === TimeFormat.TWELVE_HOUR ? "12" : "24"}
options={[
{ value: "12", label: t("12_hour_short") },
{ value: "24", label: t("24_hour_short") },
]}
/>
</div>
</div>
<div className="-mb-5 grid flex-grow grid-cols-1 gap-x-2 overflow-y-auto sm:block md:h-[364px]">
{slots.length > 0 &&
+2 -26
View File
@@ -1,25 +1,19 @@
import { FC, useEffect, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Switch } from "@calcom/ui/v2";
import TimezoneSelect, { ITimezoneOption } from "@calcom/ui/v2/core/TimezoneSelect";
import { is24h, timeZone } from "../../lib/clock";
import { timeZone } from "../../lib/clock";
type Props = {
onSelectTimeZone: (selectedTimeZone: string) => void;
onToggle24hClock: (is24hClock: boolean) => void;
timeFormat: string;
hideTimeFormatToggle?: boolean;
};
const TimeOptions: FC<Props> = ({ onToggle24hClock, onSelectTimeZone, timeFormat, hideTimeFormatToggle }) => {
const TimeOptions: FC<Props> = ({ onSelectTimeZone }) => {
const [selectedTimeZone, setSelectedTimeZone] = useState("");
const [is24hClock, setIs24hClock] = useState(timeFormat === "HH:mm" && true);
const { t } = useLocale();
useEffect(() => {
setIs24hClock(is24h());
setSelectedTimeZone(timeZone());
}, []);
@@ -29,28 +23,10 @@ const TimeOptions: FC<Props> = ({ onToggle24hClock, onSelectTimeZone, timeFormat
}
}, [selectedTimeZone, onSelectTimeZone]);
const handle24hClockToggle = (is24hClock: boolean) => {
setIs24hClock(is24hClock);
onToggle24hClock(is24h(is24hClock));
};
return selectedTimeZone !== "" ? (
<div className="dark:border-darkgray-300 dark:bg-darkgray-200 rounded-sm border border-gray-200 bg-white px-4 pt-4 pb-3 shadow-sm">
<div className="mb-4 flex">
<div className="text-sm font-medium text-gray-600 dark:text-white">{t("time_options")}</div>
{!hideTimeFormatToggle && (
<div className="ml-auto flex items-center">
<label className="ltl:mr-3 mr-2 align-text-top text-sm font-medium text-neutral-700 ltr:ml-3 rtl:mr-3 dark:text-white">
{t("am_pm")}
</label>
<Switch
name="24hClock"
label={t("24_h")}
defaultChecked={is24hClock}
onCheckedChange={handle24hClockToggle}
/>
</div>
)}
</div>
<TimezoneSelect
id="timeZone"
@@ -26,7 +26,7 @@ import useTheme from "@calcom/lib/hooks/useTheme";
import notEmpty from "@calcom/lib/notEmpty";
import { getRecurringFreq } from "@calcom/lib/recurringStrings";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { detectBrowserTimeFormat, getIs24hClockFromLocalStorage } from "@calcom/lib/timeFormat";
import { detectBrowserTimeFormat, setIs24hClockInLocalStorage, TimeFormat } from "@calcom/lib/timeFormat";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui/Icon";
import DatePicker from "@calcom/ui/v2/modules/booker/DatePicker";
@@ -48,7 +48,6 @@ import type { DynamicAvailabilityPageProps } from "../../../pages/d/[link]/[slug
import type { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]";
// Get router variables
const GoBackToPreviousPage = ({ t }: { t: TFunction }) => {
const router = useRouter();
const path = router.asPath.split("/");
@@ -109,6 +108,7 @@ const useSlots = ({
const SlotPicker = ({
eventType,
timeFormat,
onTimeFormatChange,
timeZone,
recurringEventCount,
users,
@@ -117,7 +117,8 @@ const SlotPicker = ({
ethSignature,
}: {
eventType: Pick<EventType, "id" | "schedulingType" | "slug">;
timeFormat: string;
timeFormat: TimeFormat;
onTimeFormatChange: (is24Hour: boolean) => void;
timeZone?: string;
seatsPerTimeSlot?: number;
recurringEventCount?: number;
@@ -201,6 +202,7 @@ const SlotPicker = ({
slots={selectedDate && slots[selectedDate.format("YYYY-MM-DD")]}
date={selectedDate}
timeFormat={timeFormat}
onTimeFormatChange={onTimeFormatChange}
eventTypeId={eventType.id}
eventTypeSlug={eventType.slug}
seatsPerTimeSlot={seatsPerTimeSlot}
@@ -213,35 +215,20 @@ const SlotPicker = ({
};
function TimezoneDropdown({
onChangeTimeFormat,
onChangeTimeZone,
timeZone,
timeFormat,
hideTimeFormatToggle,
}: {
onChangeTimeFormat: (newTimeFormat: string) => void;
onChangeTimeZone: (newTimeZone: string) => void;
timeZone?: string;
timeFormat: string;
hideTimeFormatToggle?: boolean;
}) {
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
useEffect(() => {
handleToggle24hClock(!!getIs24hClockFromLocalStorage());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSelectTimeZone = (newTimeZone: string) => {
onChangeTimeZone(newTimeZone);
localStorageTimeZone(newTimeZone);
setIsTimeOptionsOpen(false);
};
const handleToggle24hClock = (is24hClock: boolean) => {
onChangeTimeFormat(is24hClock ? "HH:mm" : "h:mma");
};
return (
<Popover.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
<Popover.Trigger className="min-w-32 dark:text-darkgray-600 radix-state-open:bg-gray-200 dark:radix-state-open:bg-darkgray-200 group relative mb-2 -ml-2 inline-block rounded-md px-2 py-2 text-left text-gray-600">
@@ -260,12 +247,7 @@ function TimezoneDropdown({
hideWhenDetached
align="start"
className="animate-fade-in-up absolute left-0 top-2 w-80 max-w-[calc(100vw_-_1.5rem)]">
<TimeOptions
onSelectTimeZone={handleSelectTimeZone}
onToggle24hClock={handleToggle24hClock}
timeFormat={timeFormat}
hideTimeFormatToggle={hideTimeFormatToggle}
/>
<TimeOptions onSelectTimeZone={handleSelectTimeZone} />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
@@ -297,14 +279,7 @@ const useRouterQuery = <T extends string>(name: T) => {
export type Props = AvailabilityTeamPageProps | AvailabilityPageProps | DynamicAvailabilityPageProps;
const timeFormatTotimeFormatString = (timeFormat?: number | null) => {
if (!timeFormat) return null;
return timeFormat === 24 ? "HH:mm" : "h:mma";
};
const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
const { data: user } = trpc.useQuery(["viewer.me"]);
const timeFormatFromProfile = timeFormatTotimeFormatString(user?.timeFormat);
const router = useRouter();
const isEmbed = useIsEmbed(restProps.isEmbed);
const query = dateQuerySchema.parse(router.query);
@@ -317,7 +292,12 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
const isBackgroundTransparent = useIsBackgroundTransparent();
const [timeZone, setTimeZone] = useState<string>();
const [timeFormat, setTimeFormat] = useState<string>("HH:mm");
const [timeFormat, setTimeFormat] = useState<TimeFormat>(detectBrowserTimeFormat);
const onTimeFormatChange = (is24Hours: boolean) => {
setTimeFormat(is24Hours ? TimeFormat.TWENTY_FOUR_HOUR : TimeFormat.TWELVE_HOUR);
setIs24hClockInLocalStorage(is24Hours);
};
const [gateState, gateDispatcher] = useReducer(
(state: GateState, newState: Partial<GateState>) => ({
@@ -329,8 +309,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
useEffect(() => {
setTimeZone(localStorageTimeZone() || dayjs.tz.guess());
setTimeFormat(timeFormatFromProfile || detectBrowserTimeFormat);
}, [timeFormatFromProfile]);
}, []);
// TODO: Improve this;
useExposePlanGlobally(eventType.users.length === 1 ? eventType.users[0].plan : "PRO");
@@ -352,18 +331,8 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
const userList = eventType.users ? eventType.users.map((user) => user.username).filter(notEmpty) : [];
const timezoneDropdown = useMemo(
() => (
<TimezoneDropdown
timeFormat={timeFormat}
onChangeTimeFormat={setTimeFormat}
timeZone={timeZone}
onChangeTimeZone={setTimeZone}
// Currently we don't allow the user to change the timeformat when they're logged in,
// the only way to change it is if they go to their profile.
hideTimeFormatToggle={!!timeFormatFromProfile}
/>
),
[timeZone, timeFormat, timeFormatFromProfile]
() => <TimezoneDropdown timeZone={timeZone} onChangeTimeZone={setTimeZone} />,
[timeZone]
);
const stripeAppData = getStripeAppData(eventType);
const rainbowAppData = getEventTypeAppData(eventType, "rainbow") || {};
@@ -421,7 +390,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
<div
className={classNames(
"sm:dark:border-darkgray-200 flex flex-col border-gray-200 p-5 sm:border-r",
"min-w-full md:w-[280px] md:min-w-[280px]",
"min-w-full md:w-[230px] md:min-w-[230px]",
recurringEventCount && "xl:w-[380px] xl:min-w-[380px]"
)}>
<BookingDescription profile={profile} eventType={eventType} rescheduleUid={rescheduleUid}>
@@ -501,6 +470,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
}
eventType={eventType}
timeFormat={timeFormat}
onTimeFormatChange={onTimeFormatChange}
timeZone={timeZone}
users={userList}
seatsPerTimeSlot={eventType.seatsPerTimeSlot || undefined}
@@ -3,7 +3,6 @@ import { useMemo } from "react";
import { useForm, Controller } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { setIs24hClockInLocalStorage } from "@calcom/lib/timeFormat";
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/v2/core/Button";
import Meta from "@calcom/ui/v2/core/Meta";
@@ -116,8 +115,6 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
<Form
form={formMethods}
handleSubmit={(values) => {
setIs24hClockInLocalStorage(values.timeFormat.value === 24);
mutation.mutate({
...values,
locale: values.locale.value,
@@ -178,6 +175,9 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
</>
)}
/>
<div className="text-gray mt-2 flex items-center text-sm text-gray-700">
{t("timeformat_profile_hint")}
</div>
<Controller
name="weekStart"
control={formMethods.control}
@@ -830,6 +830,8 @@
"time_format": "Time format",
"12_hour": "12 hour",
"24_hour": "24 hour",
"12_hour_short": "12h",
"24_hour_short": "24h",
"redirect_success_booking": "Redirect on booking ",
"you_are_being_redirected": "You are being redirected to {{ url }} in $t(second, {\"count\": {{seconds}} }).",
"external_redirect_url": "https://example.com/redirect-to-my-success-page",
@@ -1110,6 +1112,7 @@
"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.",
"timeformat_profile_hint": "This is an internal setting and will not affect how times are displayed on public booking pages for you or anyone booking you.",
"delete_account_modal_title": "Delete Account",
"confirm_delete_account_modal": "Are you sure you want to delete your Cal.com account?",
"delete_my_account": "Delete my account",
+8 -1
View File
@@ -8,6 +8,11 @@ import { localStorage } from "@calcom/lib/webstorage";
const is24hLocalstorageKey = "timeOption.is24hClock";
export enum TimeFormat {
TWELVE_HOUR = "h:mma",
TWENTY_FOUR_HOUR = "HH:mm",
}
export const setIs24hClockInLocalStorage = (is24h: boolean) =>
localStorage.setItem(is24hLocalstorageKey, is24h.toString());
@@ -41,4 +46,6 @@ export const isBrowserLocale24h = () => {
}
};
export const detectBrowserTimeFormat = isBrowserLocale24h() ? "HH:mm" : "h:mma";
export const detectBrowserTimeFormat = isBrowserLocale24h()
? TimeFormat.TWENTY_FOUR_HOUR
: TimeFormat.TWELVE_HOUR;
+53
View File
@@ -0,0 +1,53 @@
import * as RadixToggleGroup from "@radix-ui/react-toggle-group";
import { useEffect, useState } from "react";
import { classNames } from "@calcom/lib";
export const ToggleGroupItem = () => <div>hi</div>;
interface ToggleGroupProps extends Omit<RadixToggleGroup.ToggleGroupSingleProps, "type"> {
options: { value: string; label: string }[];
}
export const ToggleGroup = ({ options, onValueChange, ...props }: ToggleGroupProps) => {
const [value, setValue] = useState<string | undefined>(props.defaultValue);
const [activeToggleElement, setActiveToggleElement] = useState<null | HTMLButtonElement>(null);
useEffect(() => {
if (value && onValueChange) onValueChange(value);
}, [value, onValueChange]);
return (
<>
<RadixToggleGroup.Root
type="single"
{...props}
onValueChange={setValue}
className={classNames(
"dark:border-darkgray-200 relative inline-flex rounded-md border border-gray-200 p-1",
props.className
)}>
{/* Active toggle. It's a separate element so we can animate it nicely. */}
<span
aria-hidden
className="dark:bg-darkgray-200 absolute top-[4px] bottom-[4px] left-0 z-[0] rounded-[4px] bg-gray-200 transition-all"
style={{ left: activeToggleElement?.offsetLeft, width: activeToggleElement?.offsetWidth }}
/>
{options.map((option) => (
<RadixToggleGroup.Item
key={option.value}
value={option.value}
className="relative rounded-[4px] px-3 py-1 text-sm dark:text-neutral-200 [&[aria-checked='false']]:hover:font-medium"
ref={(node) => {
if (node && value === option.value) {
setActiveToggleElement(node);
}
return node;
}}>
{option.label}
</RadixToggleGroup.Item>
))}
</RadixToggleGroup.Root>
</>
);
};