mirror of
https://github.com/lukevella/rallly.git
synced 2025-05-24 06:16:25 +02:00
428 lines
17 KiB
TypeScript
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;
|