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:
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" },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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,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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user