rallly/components/forms/poll-options-form/month-calendar/month-calendar.tsx
2022-04-16 10:26:53 +01:00

428 lines
17 KiB
TypeScript

import clsx from "clsx";
import differenceInMinutes from "date-fns/differenceInMinutes";
import { addMinutes, setHours } from "date-fns/esm";
import isSameDay from "date-fns/isSameDay";
import { usePlausible } from "next-plausible";
import * as React from "react";
import {
expectTimeOption,
getDateProps,
removeAllOptionsForDay,
} from "../../../../utils/date-time-utils";
import Button from "../../../button";
import CompactButton from "../../../compact-button";
import DateCard from "../../../date-card";
import Dropdown, { DropdownItem } from "../../../dropdown";
import { useHeadlessDatePicker } from "../../../headless-date-picker";
import Calendar from "../../../icons/calendar.svg";
import ChevronLeft from "../../../icons/chevron-left.svg";
import ChevronRight from "../../../icons/chevron-right.svg";
import DotsHorizontal from "../../../icons/dots-horizontal.svg";
import Magic from "../../../icons/magic.svg";
import PlusSm from "../../../icons/plus-sm.svg";
import Trash from "../../../icons/trash.svg";
import X from "../../../icons/x.svg";
import Switch from "../../../switch";
import { DateTimeOption } from "..";
import { DateTimePickerProps } from "../types";
import { formatDateWithoutTime, formatDateWithoutTz } from "../utils";
import TimePicker from "./time-picker";
const MonthCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
options,
onNavigate,
date,
onChange,
duration,
onChangeDuration,
}) => {
const isTimedEvent = options.some((option) => option.type === "timeSlot");
const plausible = usePlausible();
const optionsByDay = React.useMemo(() => {
const res: Record<
string,
[
{
option: DateTimeOption;
index: number;
},
]
> = {};
options.forEach((option, index) => {
const dateString =
option.type === "date"
? option.date
: option.start.substring(0, option.start.indexOf("T"));
if (res[dateString]) {
res[dateString].push({ option, index });
} else {
res[dateString] = [{ option, index }];
}
});
return res;
}, [options]);
const datepickerSelection = React.useMemo(() => {
return Object.keys(optionsByDay).map(
(dateString) => new Date(dateString + "T12:00:00"),
);
}, [optionsByDay]);
const datepicker = useHeadlessDatePicker({
selection: datepickerSelection,
onNavigationChange: onNavigate,
date,
});
return (
<div className="overflow-hidden lg:flex">
<div className="shrink-0 border-b p-4 lg:border-r lg:border-b-0">
<div>
<div className="flex w-full flex-col">
<div className="mb-3 flex items-center justify-center space-x-4">
<Button
icon={<ChevronLeft />}
title="Previous month"
onClick={datepicker.prev}
/>
<div className="grow text-center text-lg font-medium">
{datepicker.label}
</div>
<Button
title="Next month"
icon={<ChevronRight />}
onClick={datepicker.next}
/>
</div>
<div className="grid grid-cols-7">
{datepicker.daysOfWeek.map((dayOfWeek) => {
return (
<div
key={dayOfWeek}
className="flex items-center justify-center pb-2 text-sm font-medium text-slate-400"
>
{dayOfWeek.substring(0, 2)}
</div>
);
})}
</div>
<div className="grid grow grid-cols-7 overflow-hidden rounded-lg border bg-white shadow-sm">
{datepicker.days.map((day, i) => {
return (
<button
type="button"
key={i}
onClick={() => {
if (
datepicker.selection.some((selectedDate) =>
isSameDay(selectedDate, day.date),
)
) {
onChange(removeAllOptionsForDay(options, day.date));
} else {
const selectedDate = setHours(day.date, 12);
const newOption: DateTimeOption = !isTimedEvent
? {
type: "date",
date: formatDateWithoutTime(selectedDate),
}
: {
type: "timeSlot",
start: formatDateWithoutTz(selectedDate),
end: formatDateWithoutTz(
addMinutes(selectedDate, duration),
),
};
onChange([...options, newOption]);
onNavigate(selectedDate);
}
if (day.outOfMonth) {
if (i < 6) {
datepicker.prev();
} else {
datepicker.next();
}
}
}}
className={clsx(
"relative flex items-center justify-center px-4 py-3 text-sm hover:bg-slate-50 focus:ring-0 focus:ring-offset-0 active:bg-slate-100 lg:w-14",
{
"bg-slate-50 text-slate-400": day.outOfMonth,
"font-bold text-indigo-500": day.today,
"border-r": (i + 1) % 7 !== 0,
"border-b": i < datepicker.days.length - 7,
"font-normal text-white after:absolute after:-z-0 after:h-8 after:w-8 after:animate-popIn after:rounded-full after:bg-green-500 after:content-['']":
day.selected,
},
)}
>
<span className="z-10">{day.day}</span>
</button>
);
})}
</div>
<Button className="mt-3" onClick={datepicker.today}>
Today
</Button>
</div>
</div>
</div>
<div className="flex grow flex-col">
<div
className={clsx("border-b", {
hidden: datepicker.selection.length === 0,
})}
>
<div className="flex items-center space-x-3 p-4">
<div className="grow">
<div className="font-medium">Specify times</div>
<div className="text-sm text-slate-400">
Include start and end times for each option
</div>
</div>
<div>
<Switch
data-testid="specify-times-switch"
checked={isTimedEvent}
onChange={(checked) => {
if (checked) {
// convert dates to time slots
onChange(
options.map((option) => {
if (option.type === "timeSlot") {
throw new Error(
"Expected option to be a date but received timeSlot",
);
}
const startDate = new Date(`${option.date}T12:00:00`);
const endDate = addMinutes(startDate, duration);
return {
type: "timeSlot",
start: formatDateWithoutTz(startDate),
end: formatDateWithoutTz(endDate),
};
}),
);
} else {
onChange(
datepicker.selection.map((date) => ({
type: "date",
date: formatDateWithoutTime(date),
})),
);
}
}}
/>
</div>
</div>
</div>
<div className="grow px-4">
{isTimedEvent ? (
<div className="divide-y">
{Object.keys(optionsByDay)
.sort((a, b) => (a > b ? 1 : -1))
.map((dateString) => {
const optionsForDay = optionsByDay[dateString];
return (
<div
key={dateString}
className="space-y-3 py-4 xs:flex xs:space-y-0 xs:space-x-4"
>
<div>
<DateCard
{...getDateProps(new Date(dateString + "T12:00:00"))}
/>
</div>
<div className="grow space-y-3">
{optionsForDay.map(({ option, index }) => {
if (option.type === "date") {
throw new Error("Expected timeSlot but got date");
}
const startDate = new Date(option.start);
return (
<div
key={index}
className="flex items-center space-x-3"
>
<TimePicker
value={startDate}
onChange={(newStart) => {
const newEnd = addMinutes(newStart, duration);
// replace enter with updated start time
onChange([
...options.slice(0, index),
{
...option,
start: formatDateWithoutTz(newStart),
end: formatDateWithoutTz(newEnd),
},
...options.slice(index + 1),
]);
onNavigate(newStart);
onChangeDuration(
differenceInMinutes(newEnd, newStart),
);
}}
/>
<TimePicker
value={new Date(option.end)}
startFrom={addMinutes(startDate, 15)}
onChange={(newEnd) => {
onChange([
...options.slice(0, index),
{
...option,
end: formatDateWithoutTz(newEnd),
},
...options.slice(index + 1),
]);
onNavigate(newEnd);
onChangeDuration(
differenceInMinutes(newEnd, startDate),
);
}}
/>
<CompactButton
icon={X}
onClick={() => {
onChange([
...options.slice(0, index),
...options.slice(index + 1),
]);
}}
/>
</div>
);
})}
<div className="flex items-center space-x-3">
<Button
icon={<PlusSm />}
onClick={() => {
const lastOption = expectTimeOption(
optionsForDay[optionsForDay.length - 1].option,
);
const startTime = lastOption.start;
onChange([
...options,
{
type: "timeSlot",
start: startTime,
end: formatDateWithoutTz(
addMinutes(new Date(startTime), duration),
),
},
]);
}}
>
Add time option
</Button>
<Dropdown
trigger={<CompactButton icon={DotsHorizontal} />}
placement="bottom-start"
>
<DropdownItem
icon={Magic}
disabled={datepicker.selection.length < 2}
label="Apply to all dates"
onClick={() => {
plausible("Applied options to all dates");
const times = optionsForDay.map(
({ option }) => {
if (option.type === "date") {
throw new Error(
"Expected timeSlot but got date",
);
}
return {
startTime: option.start.substring(
option.start.indexOf("T"),
),
endTime: option.end.substring(
option.end.indexOf("T"),
),
};
},
);
const newOptions: DateTimeOption[] = [];
Object.keys(optionsByDay).forEach(
(dateString) => {
times.forEach((time) => {
newOptions.push({
type: "timeSlot",
start: dateString + time.startTime,
end: dateString + time.endTime,
});
});
},
);
onChange(newOptions);
}}
/>
<DropdownItem
label="Delete date"
icon={Trash}
onClick={() => {
onChange(
removeAllOptionsForDay(
options,
new Date(dateString),
),
);
}}
/>
</Dropdown>
</div>
</div>
</div>
);
})}
</div>
) : datepicker.selection.length ? (
<div className="grid grid-cols-[repeat(auto-fill,60px)] gap-5 py-4">
{datepicker.selection
.sort((a, b) => a.getTime() - b.getTime())
.map((selectedDate, i) => {
return (
<DateCard
key={i}
{...getDateProps(selectedDate)}
annotation={
<CompactButton
icon={X}
onClick={() => {
// TODO (Luke Vella) [2022-03-19]: Find cleaner way to manage this state
// Quite tedious right now to remove a single element
onChange(
removeAllOptionsForDay(options, selectedDate),
);
}}
/>
}
/>
);
})}
</div>
) : (
<div className="flex h-full items-center justify-center py-12">
<div className="text-center font-medium text-gray-400">
<Calendar className="mb-2 inline-block h-12 w-12" />
<div>No dates selected</div>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default MonthCalendar;