Feat/3334 show times in timezones for bookingpage (#4971)
Co-authored-by: alannnc <alannnc@gmail.com> Co-authored-by: Alan <alannnc@gmail.com> Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>pull/5091/head
parent
f4fb4ddad1
commit
373c255733
|
@ -1,10 +1,11 @@
|
|||
import { BookingStatus } from "@prisma/client";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useState, useRef } from "react";
|
||||
|
||||
import { EventLocationType, getEventLocationType } from "@calcom/app-store/locations";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { formatTime } from "@calcom/lib/date-fns";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
|
@ -15,10 +16,10 @@ import { Tooltip } from "@calcom/ui/Tooltip";
|
|||
import { TextArea } from "@calcom/ui/form/fields";
|
||||
import Badge from "@calcom/ui/v2/core/Badge";
|
||||
import Button from "@calcom/ui/v2/core/Button";
|
||||
import MeetingTimeInTimezones from "@calcom/ui/v2/core/MeetingTimeInTimezones";
|
||||
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
|
||||
// import { extractRecurringDates } from "@lib/parseDate";
|
||||
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
||||
import TableActions, { ActionType } from "@components/ui/TableActions";
|
||||
|
@ -185,7 +186,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||
|
||||
const location = booking.location || "";
|
||||
|
||||
const onClick = () => {
|
||||
const onClickTableData = () => {
|
||||
router.push({
|
||||
pathname: "/success",
|
||||
query: {
|
||||
|
@ -207,8 +208,6 @@ function BookingListItem(booking: BookingItemProps) {
|
|||
});
|
||||
};
|
||||
|
||||
const utcOffset = dayjs().tz(user?.timeZone).utcOffset();
|
||||
|
||||
return (
|
||||
<>
|
||||
<RescheduleDialog
|
||||
|
@ -257,19 +256,21 @@ function BookingListItem(booking: BookingItemProps) {
|
|||
</Dialog>
|
||||
|
||||
<tr className="flex flex-col hover:bg-neutral-50 sm:flex-row">
|
||||
<td className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:min-w-[10rem]" onClick={onClick}>
|
||||
<td
|
||||
className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:min-w-[12rem]"
|
||||
onClick={onClickTableData}>
|
||||
<div className="cursor-pointer py-4">
|
||||
<div className="text-sm leading-6 text-gray-900">{startTime}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{dayjs
|
||||
.utc(booking.startTime)
|
||||
.utcOffset(utcOffset)
|
||||
.format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}{" "}
|
||||
-{" "}
|
||||
{dayjs
|
||||
.utc(booking.endTime)
|
||||
.utcOffset(utcOffset)
|
||||
.format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}
|
||||
{formatTime(booking.startTime, user?.timeFormat, user?.timeZone)} -{" "}
|
||||
{formatTime(booking.endTime, user?.timeFormat, user?.timeZone)}
|
||||
<MeetingTimeInTimezones
|
||||
timeFormat={user?.timeFormat}
|
||||
userTimezone={user?.timeZone}
|
||||
startTime={booking.startTime}
|
||||
endTime={booking.endTime}
|
||||
attendees={booking.attendees}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isPending && (
|
||||
|
@ -298,21 +299,21 @@ function BookingListItem(booking: BookingItemProps) {
|
|||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className={"w-full px-4" + (isRejected ? " line-through" : "")} onClick={onClick}>
|
||||
<td className={"w-full px-4" + (isRejected ? " line-through" : "")} onClick={onClickTableData}>
|
||||
{/* Time and Badges for mobile */}
|
||||
<div className="w-full pt-4 pb-2 sm:hidden">
|
||||
<div className="flex w-full items-center justify-between sm:hidden">
|
||||
<div className="text-sm leading-6 text-gray-900">{startTime}</div>
|
||||
<div className="pr-2 text-sm text-gray-500">
|
||||
{dayjs
|
||||
.utc(booking.startTime)
|
||||
.utcOffset(utcOffset)
|
||||
.format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}{" "}
|
||||
-{" "}
|
||||
{dayjs
|
||||
.utc(booking.endTime)
|
||||
.utcOffset(utcOffset)
|
||||
.format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}
|
||||
{formatTime(booking.startTime, user?.timeFormat, user?.timeZone)} -{" "}
|
||||
{formatTime(booking.endTime, user?.timeFormat, user?.timeZone)}
|
||||
<MeetingTimeInTimezones
|
||||
timeFormat={user?.timeFormat}
|
||||
userTimezone={user?.timeZone}
|
||||
startTime={booking.startTime}
|
||||
endTime={booking.endTime}
|
||||
attendees={booking.attendees}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -6,3 +6,60 @@ export const yyyymmdd = (date: Date | Dayjs) =>
|
|||
|
||||
export const daysInMonth = (date: Date | Dayjs) =>
|
||||
date instanceof Date ? dayjs(date).daysInMonth() : date.daysInMonth();
|
||||
|
||||
/**
|
||||
* Expects timeFormat to be either 12 or 24, if null or undefined
|
||||
* is passed in, we always default back to 24 hour notation.
|
||||
*/
|
||||
export const formatTime = (
|
||||
date: string | Date | Dayjs,
|
||||
timeFormat?: number | null,
|
||||
timeZone?: string | null
|
||||
) =>
|
||||
timeZone
|
||||
? dayjs(date)
|
||||
.tz(timeZone)
|
||||
.format(timeFormat === 12 ? "h:mma" : "HH:mm")
|
||||
: dayjs(date).format(timeFormat === 12 ? "h:mma" : "HH:mm");
|
||||
|
||||
/**
|
||||
* Sorts two timezones by their offset from GMT.
|
||||
*/
|
||||
export const sortByTimezone = (timezoneA: string, timezoneB: string) => {
|
||||
const timezoneAGmtOffset = dayjs.utc().tz(timezoneA).utcOffset();
|
||||
const timezoneBGmtOffset = dayjs.utc().tz(timezoneB).utcOffset();
|
||||
|
||||
if (timezoneAGmtOffset === timezoneBGmtOffset) return 0;
|
||||
|
||||
return timezoneAGmtOffset < timezoneBGmtOffset ? -1 : 1;
|
||||
};
|
||||
|
||||
/**
|
||||
* Verifies given time is a day before in timezoneB.
|
||||
*/
|
||||
export const isPreviousDayInTimezone = (time: string, timezoneA: string, timezoneB: string) => {
|
||||
const timeInTimezoneA = formatTime(time, 24, timezoneA);
|
||||
const timeInTimezoneB = formatTime(time, 24, timezoneB);
|
||||
if (time === timeInTimezoneB) return false;
|
||||
|
||||
// Eg timeInTimezoneA = 12:00 and timeInTimezoneB = 23:00
|
||||
const hoursTimezoneBIsLater = timeInTimezoneB.localeCompare(timeInTimezoneA) === 1;
|
||||
// If it is 23:00, does timezoneA come before or after timezoneB in GMT?
|
||||
const timezoneBIsEarlierTimezone = sortByTimezone(timezoneA, timezoneB) === 1;
|
||||
return hoursTimezoneBIsLater && timezoneBIsEarlierTimezone;
|
||||
};
|
||||
|
||||
/**
|
||||
* Verifies given time is a day after in timezoneB.
|
||||
*/
|
||||
export const isNextDayInTimezone = (time: string, timezoneA: string, timezoneB: string) => {
|
||||
const timeInTimezoneA = formatTime(time, 24, timezoneA);
|
||||
const timeInTimezoneB = formatTime(time, 24, timezoneB);
|
||||
if (time === timeInTimezoneB) return false;
|
||||
|
||||
// Eg timeInTimezoneA = 12:00 and timeInTimezoneB = 09:00
|
||||
const hoursTimezoneBIsEarlier = timeInTimezoneB.localeCompare(timeInTimezoneA) === -1;
|
||||
// If it is 09:00, does timezoneA come before or after timezoneB in GMT?
|
||||
const timezoneBIsLaterTimezone = sortByTimezone(timezoneA, timezoneB) === -1;
|
||||
return hoursTimezoneBIsEarlier && timezoneBIsLaterTimezone;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,94 @@
|
|||
import * as Popover from "@radix-ui/react-popover";
|
||||
|
||||
import {
|
||||
formatTime,
|
||||
isNextDayInTimezone,
|
||||
isPreviousDayInTimezone,
|
||||
sortByTimezone,
|
||||
} from "@calcom/lib/date-fns";
|
||||
|
||||
import { Icon } from "../../Icon";
|
||||
import { Attendee } from ".prisma/client";
|
||||
|
||||
interface MeetingTimeInTimezonesProps {
|
||||
attendees: Attendee[];
|
||||
userTimezone?: string;
|
||||
timeFormat?: number | null;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}
|
||||
|
||||
const MeetingTimeInTimezones = ({
|
||||
attendees,
|
||||
userTimezone,
|
||||
timeFormat,
|
||||
startTime,
|
||||
endTime,
|
||||
}: MeetingTimeInTimezonesProps) => {
|
||||
if (!userTimezone || !attendees.length) return null;
|
||||
|
||||
const attendeeTimezones = attendees.map((attendee) => attendee.timeZone);
|
||||
const uniqueTimezones = [userTimezone, ...attendeeTimezones].filter(
|
||||
(value, index, self) => self.indexOf(value) === index
|
||||
);
|
||||
|
||||
// Convert times to time in timezone, and then sort from earliest to latest time in timezone.
|
||||
const times = uniqueTimezones
|
||||
.map((timezone) => {
|
||||
const isPreviousDay = isPreviousDayInTimezone(startTime, userTimezone, timezone);
|
||||
const isNextDay = isNextDayInTimezone(startTime, userTimezone, timezone);
|
||||
return {
|
||||
startTime: formatTime(startTime, timeFormat, timezone),
|
||||
endTime: formatTime(endTime, timeFormat, timezone),
|
||||
timezone,
|
||||
isPreviousDay,
|
||||
isNextDay,
|
||||
};
|
||||
})
|
||||
.sort((timeA, timeB) => sortByTimezone(timeA.timezone, timeB.timezone));
|
||||
|
||||
// We don't show the popover if there's only one timezone.
|
||||
if (times.length === 1) return null;
|
||||
|
||||
return (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger
|
||||
onClick={preventBubbling}
|
||||
className="popover-button ml-2 inline-flex h-5 w-5 items-center justify-center rounded-sm text-gray-900 transition-colors hover:bg-gray-200 focus:bg-gray-200">
|
||||
<Icon.FiGlobe />
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
onClick={preventBubbling}
|
||||
side="top"
|
||||
className="popover-content slideInBottom shadow-dropdown border-5 bg-brand-500 rounded-md border-gray-200 p-3 text-sm text-white shadow-sm">
|
||||
{times.map((time) => (
|
||||
<span className="mt-2 block first:mt-0" key={time.timezone}>
|
||||
<span className="inline-flex align-baseline">
|
||||
{time.startTime} - {time.endTime}
|
||||
{(time.isNextDay || time.isPreviousDay) && (
|
||||
<span className="text-medium ml-2 inline-flex h-5 w-5 items-center justify-center rounded-full bg-gray-700 text-[10px]">
|
||||
{time.isNextDay ? "+1" : "-1"}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<br />
|
||||
<span className="text-gray-400">{time.timezone}</span>
|
||||
</span>
|
||||
))}
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
};
|
||||
|
||||
MeetingTimeInTimezones.displayName = "MeetingTimeInTimezones";
|
||||
|
||||
// Prevents propagation so the click on eg booking overview won't
|
||||
// bubble to the row of the table, causing a navigation to the
|
||||
// detaill page.
|
||||
const preventBubbling = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
export default MeetingTimeInTimezones;
|
|
@ -11,6 +11,7 @@ export { default as Dropdown } from "./Dropdown";
|
|||
export * from "./Dropdown";
|
||||
export { default as EmptyScreen } from "./EmptyScreen";
|
||||
export { default as Loader } from "./Loader";
|
||||
export { default as MeetingTimeInTimezones } from "./MeetingTimeInTimezones";
|
||||
export { default as PageHeader } from "./PageHeader";
|
||||
export { default as Shell } from "./Shell";
|
||||
export { default as Stepper } from "./Stepper";
|
||||
|
|
Loading…
Reference in New Issue