Use locale to format dates and times (#123)
* Use deployment url in demo description. Close #131
223
components/create-poll.tsx
Normal file
|
@ -0,0 +1,223 @@
|
|||
import { NextPage } from "next";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { usePlausible } from "next-plausible";
|
||||
import React from "react";
|
||||
import { useMutation } from "react-query";
|
||||
import { useSessionStorage } from "react-use";
|
||||
|
||||
import { createPoll } from "../api-client/create-poll";
|
||||
import Button from "../components/button";
|
||||
import {
|
||||
NewEventData,
|
||||
PollDetailsData,
|
||||
PollDetailsForm,
|
||||
PollOptionsData,
|
||||
PollOptionsForm,
|
||||
UserDetailsData,
|
||||
UserDetailsForm,
|
||||
} from "../components/forms";
|
||||
import StandardLayout from "../components/standard-layout";
|
||||
import Steps from "../components/steps";
|
||||
import { useUserName } from "../components/user-name-context";
|
||||
import { encodeDateOption } from "../utils/date-time-utils";
|
||||
|
||||
type StepName = "eventDetails" | "options" | "userDetails";
|
||||
|
||||
const steps: StepName[] = ["eventDetails", "options", "userDetails"];
|
||||
|
||||
const required = <T extends unknown>(v: T | undefined): T => {
|
||||
if (!v) {
|
||||
throw new Error("Required value is missing");
|
||||
}
|
||||
|
||||
return v;
|
||||
};
|
||||
|
||||
const initialNewEventData: NewEventData = { currentStep: 0 };
|
||||
const sessionStorageKey = "newEventFormData";
|
||||
|
||||
const Page: NextPage<{
|
||||
title?: string;
|
||||
location?: string;
|
||||
description?: string;
|
||||
view?: "week" | "month";
|
||||
}> = ({ title, location, description, view }) => {
|
||||
const { t } = useTranslation("app");
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [persistedFormData, setPersistedFormData] =
|
||||
useSessionStorage<NewEventData>(sessionStorageKey, {
|
||||
currentStep: 0,
|
||||
eventDetails: {
|
||||
title,
|
||||
location,
|
||||
description,
|
||||
},
|
||||
options: {
|
||||
view,
|
||||
},
|
||||
});
|
||||
|
||||
const [formData, setTransientFormData] = React.useState(persistedFormData);
|
||||
|
||||
const setFormData = React.useCallback(
|
||||
(newEventData: NewEventData) => {
|
||||
setTransientFormData(newEventData);
|
||||
setPersistedFormData(newEventData);
|
||||
},
|
||||
[setPersistedFormData],
|
||||
);
|
||||
|
||||
const currentStepIndex = formData?.currentStep ?? 0;
|
||||
|
||||
const currentStepName = steps[currentStepIndex];
|
||||
|
||||
const [isRedirecting, setIsRedirecting] = React.useState(false);
|
||||
|
||||
const [, setUserName] = useUserName();
|
||||
|
||||
const plausible = usePlausible();
|
||||
|
||||
const { mutate: createEventMutation, isLoading: isCreatingPoll } =
|
||||
useMutation(
|
||||
() => {
|
||||
const title = required(formData?.eventDetails?.title);
|
||||
return createPoll({
|
||||
title: title,
|
||||
type: "date",
|
||||
location: formData?.eventDetails?.location,
|
||||
description: formData?.eventDetails?.description,
|
||||
user: {
|
||||
name: required(formData?.userDetails?.name),
|
||||
email: required(formData?.userDetails?.contact),
|
||||
},
|
||||
timeZone: formData?.options?.timeZone,
|
||||
options: required(formData?.options?.options).map(encodeDateOption),
|
||||
});
|
||||
},
|
||||
{
|
||||
onSuccess: (poll) => {
|
||||
setIsRedirecting(true);
|
||||
setUserName(poll.authorName);
|
||||
plausible("Created poll", {
|
||||
props: {
|
||||
numberOfOptions: formData.options?.options?.length,
|
||||
optionsView: formData?.options?.view,
|
||||
},
|
||||
});
|
||||
setPersistedFormData(initialNewEventData);
|
||||
router.replace(`/admin/${poll.urlId}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const isBusy = isRedirecting || isCreatingPoll;
|
||||
|
||||
const handleSubmit = (
|
||||
data: PollDetailsData | PollOptionsData | UserDetailsData,
|
||||
) => {
|
||||
if (currentStepIndex < steps.length - 1) {
|
||||
setFormData({
|
||||
...formData,
|
||||
currentStep: currentStepIndex + 1,
|
||||
[currentStepName]: data,
|
||||
});
|
||||
} else {
|
||||
// last step
|
||||
createEventMutation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
data: Partial<PollDetailsData | PollOptionsData | UserDetailsData>,
|
||||
) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
currentStep: currentStepIndex,
|
||||
[currentStepName]: data,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<StandardLayout>
|
||||
<Head>
|
||||
<title>{formData?.eventDetails?.title ?? t("newPoll")}</title>
|
||||
</Head>
|
||||
<div className="max-w-full py-4 md:px-3 lg:px-6">
|
||||
<div className="mx-auto w-fit max-w-full lg:mx-0">
|
||||
<div className="mb-4 flex items-center justify-center space-x-4 px-4 lg:justify-start">
|
||||
<h1 className="m-0">New Poll</h1>
|
||||
<Steps current={currentStepIndex} total={steps.length} />
|
||||
</div>
|
||||
<div className="overflow-hidden border-t border-b bg-white shadow-sm md:rounded-lg md:border">
|
||||
{(() => {
|
||||
switch (currentStepName) {
|
||||
case "eventDetails":
|
||||
return (
|
||||
<PollDetailsForm
|
||||
className="max-w-full px-4 pt-4"
|
||||
name={currentStepName}
|
||||
defaultValues={formData?.eventDetails}
|
||||
onSubmit={handleSubmit}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
case "options":
|
||||
return (
|
||||
<PollOptionsForm
|
||||
className="grow"
|
||||
name={currentStepName}
|
||||
defaultValues={formData?.options}
|
||||
onSubmit={handleSubmit}
|
||||
onChange={handleChange}
|
||||
title={formData.eventDetails?.title}
|
||||
/>
|
||||
);
|
||||
case "userDetails":
|
||||
return (
|
||||
<UserDetailsForm
|
||||
className="grow px-4 pt-4"
|
||||
name={currentStepName}
|
||||
defaultValues={formData?.userDetails}
|
||||
onSubmit={handleSubmit}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
<div className="flex w-full justify-end space-x-3 border-t bg-slate-50 px-4 py-3">
|
||||
{currentStepIndex > 0 ? (
|
||||
<Button
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
setFormData({
|
||||
...persistedFormData,
|
||||
currentStep: currentStepIndex - 1,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("back")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
form={currentStepName}
|
||||
loading={isBusy}
|
||||
htmlType="submit"
|
||||
type="primary"
|
||||
>
|
||||
{currentStepIndex < steps.length - 1
|
||||
? t("next")
|
||||
: t("createPoll")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StandardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
|
@ -21,6 +21,7 @@ import Trash from "../icons/trash.svg";
|
|||
import NameInput from "../name-input";
|
||||
import TruncatedLinkify from "../poll/truncated-linkify";
|
||||
import UserAvater from "../poll/user-avatar";
|
||||
import { usePreferences } from "../preferences/use-preferences";
|
||||
import { useUserName } from "../user-name-context";
|
||||
|
||||
export interface DiscussionProps {
|
||||
|
@ -37,6 +38,7 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
|
|||
pollId,
|
||||
canDelete,
|
||||
}) => {
|
||||
const { locale } = usePreferences();
|
||||
const getCommentsQueryKey = ["poll", pollId, "comments"];
|
||||
const [userName, setUserName] = useUserName();
|
||||
const queryClient = useQueryClient();
|
||||
|
@ -146,6 +148,9 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
|
|||
{formatRelative(
|
||||
new Date(comment.createdAt),
|
||||
Date.now(),
|
||||
{
|
||||
locale,
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
@ -1,11 +1,19 @@
|
|||
import {
|
||||
flip,
|
||||
FloatingPortal,
|
||||
offset,
|
||||
Placement,
|
||||
useFloating,
|
||||
} from "@floating-ui/react-dom-interactions";
|
||||
import { Menu } from "@headlessui/react";
|
||||
import { Placement } from "@popperjs/core";
|
||||
import clsx from "clsx";
|
||||
import { motion } from "framer-motion";
|
||||
import * as React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { usePopper } from "react-popper";
|
||||
import { transformOriginByPlacement } from "utils/constants";
|
||||
import { stopPropagation } from "utils/stop-propagation";
|
||||
|
||||
const MotionMenuItems = motion(Menu.Items);
|
||||
|
||||
export interface DropdownProps {
|
||||
trigger?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
|
@ -17,48 +25,50 @@ const Dropdown: React.VoidFunctionComponent<DropdownProps> = ({
|
|||
children,
|
||||
className,
|
||||
trigger,
|
||||
placement,
|
||||
placement: preferredPlacement,
|
||||
}) => {
|
||||
const [referenceElement, setReferenceElement] =
|
||||
React.useState<HTMLDivElement | null>(null);
|
||||
const [popperElement, setPopperElement] =
|
||||
React.useState<HTMLDivElement | null>(null);
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement,
|
||||
modifiers: [
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: [0, 5],
|
||||
},
|
||||
},
|
||||
],
|
||||
const { reference, floating, x, y, strategy, placement } = useFloating({
|
||||
placement: preferredPlacement,
|
||||
middleware: [offset(5), flip()],
|
||||
});
|
||||
|
||||
const portal = document.getElementById("portal");
|
||||
const animationOrigin = transformOriginByPlacement[placement];
|
||||
|
||||
return (
|
||||
<Menu>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Menu.Button
|
||||
ref={setReferenceElement}
|
||||
as="div"
|
||||
className={clsx("inline-block", className)}
|
||||
ref={reference}
|
||||
>
|
||||
{trigger}
|
||||
</Menu.Button>
|
||||
{portal &&
|
||||
ReactDOM.createPortal(
|
||||
<Menu.Items
|
||||
as="div"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
className="z-30 divide-gray-100 rounded-md bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
<FloatingPortal>
|
||||
{open ? (
|
||||
<MotionMenuItems
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, scale: 0.9, y: -10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: -10 }}
|
||||
className={clsx(
|
||||
"z-50 divide-gray-100 rounded-md bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none",
|
||||
animationOrigin,
|
||||
)}
|
||||
onMouseDown={stopPropagation}
|
||||
ref={floating}
|
||||
style={{
|
||||
position: strategy,
|
||||
left: x ?? "",
|
||||
top: y ?? "",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Menu.Items>,
|
||||
portal,
|
||||
</MotionMenuItems>
|
||||
) : null}
|
||||
</FloatingPortal>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
|
|
|
@ -5,6 +5,8 @@ import isSameDay from "date-fns/isSameDay";
|
|||
import { usePlausible } from "next-plausible";
|
||||
import * as React from "react";
|
||||
|
||||
import { usePreferences } from "@/components/preferences/use-preferences";
|
||||
|
||||
import {
|
||||
expectTimeOption,
|
||||
getDateProps,
|
||||
|
@ -74,15 +76,18 @@ const MonthCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
|
|||
);
|
||||
}, [optionsByDay]);
|
||||
|
||||
const { weekStartsOn } = usePreferences();
|
||||
|
||||
const datepicker = useHeadlessDatePicker({
|
||||
selection: datepickerSelection,
|
||||
onNavigationChange: onNavigate,
|
||||
weekStartsOn,
|
||||
date,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden lg:flex">
|
||||
<div className="shrink-0 border-b p-4 lg:border-r lg:border-b-0">
|
||||
<div className="border-b p-4 lg:w-[440px] 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">
|
||||
|
@ -152,7 +157,7 @@ const MonthCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
|
|||
}
|
||||
}}
|
||||
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",
|
||||
"relative flex h-12 items-center justify-center text-sm hover:bg-slate-50 focus:ring-0 focus:ring-offset-0 active:bg-slate-100",
|
||||
{
|
||||
"bg-slate-50 text-slate-400": day.outOfMonth,
|
||||
"font-bold text-indigo-500": day.today,
|
||||
|
@ -233,7 +238,7 @@ const MonthCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
|
|||
return (
|
||||
<div
|
||||
key={dateString}
|
||||
className="space-y-3 py-4 xs:flex xs:space-y-0 xs:space-x-4"
|
||||
className="space-y-3 py-4 sm:flex sm:space-y-0 sm:space-x-4"
|
||||
>
|
||||
<div>
|
||||
<DateCard
|
||||
|
|
|
@ -1,11 +1,18 @@
|
|||
import { Combobox } from "@headlessui/react";
|
||||
import {
|
||||
flip,
|
||||
FloatingPortal,
|
||||
offset,
|
||||
size,
|
||||
useFloating,
|
||||
} from "@floating-ui/react-dom-interactions";
|
||||
import { Listbox } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import { addMinutes, format, isSameDay, setHours, setMinutes } from "date-fns";
|
||||
import * as React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { usePopper } from "react-popper";
|
||||
import { stopPropagation } from "utils/stop-propagation";
|
||||
|
||||
import { usePreferences } from "@/components/preferences/use-preferences";
|
||||
|
||||
import ChevronDown from "../../../icons/chevron-down.svg";
|
||||
import { styleMenuItem } from "../../../menu-styles";
|
||||
|
||||
|
@ -22,23 +29,24 @@ const TimePicker: React.VoidFunctionComponent<TimePickerProps> = ({
|
|||
className,
|
||||
startFrom = setMinutes(setHours(value, 0), 0),
|
||||
}) => {
|
||||
const [referenceElement, setReferenceElement] =
|
||||
React.useState<HTMLDivElement | null>(null);
|
||||
const [popperElement, setPopperElement] =
|
||||
React.useState<HTMLUListElement | null>(null);
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
modifiers: [
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: [0, 5],
|
||||
},
|
||||
const { locale } = usePreferences();
|
||||
const { reference, floating, x, y, strategy, refs } = useFloating({
|
||||
strategy: "fixed",
|
||||
middleware: [
|
||||
offset(5),
|
||||
flip(),
|
||||
size({
|
||||
apply: ({ reference }) => {
|
||||
if (refs.floating.current) {
|
||||
Object.assign(refs.floating.current.style, {
|
||||
width: `${reference.width}px`,
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const [query, setQuery] = React.useState("");
|
||||
const options: React.ReactNode[] = [];
|
||||
for (let i = 0; i < 96; i++) {
|
||||
const optionValue = addMinutes(startFrom, i * 15);
|
||||
|
@ -47,62 +55,55 @@ const TimePicker: React.VoidFunctionComponent<TimePickerProps> = ({
|
|||
// because react-big-calendar does not support events that span days
|
||||
break;
|
||||
}
|
||||
if (query && !format(optionValue, "hhmma").includes(query)) {
|
||||
continue;
|
||||
}
|
||||
options.push(
|
||||
<Combobox.Option
|
||||
<Listbox.Option
|
||||
key={i}
|
||||
className={styleMenuItem}
|
||||
value={optionValue.toISOString()}
|
||||
>
|
||||
{format(optionValue, "p")}
|
||||
</Combobox.Option>,
|
||||
{format(optionValue, "p", { locale })}
|
||||
</Listbox.Option>,
|
||||
);
|
||||
}
|
||||
|
||||
const portal = document.getElementById("portal");
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
<Listbox
|
||||
value={value.toISOString()}
|
||||
onChange={(newValue) => {
|
||||
setQuery("");
|
||||
onChange?.(new Date(newValue));
|
||||
}}
|
||||
>
|
||||
<div ref={setReferenceElement} className={clsx("relative", className)}>
|
||||
{/* Remove generic params once Combobox.Input can infer the types */}
|
||||
<Combobox.Input<"input">
|
||||
className="input w-28 pr-8"
|
||||
displayValue={() => ""}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value.toUpperCase().replace(/[\:\s]/g, ""));
|
||||
}}
|
||||
/>
|
||||
<Combobox.Button className="absolute inset-0 flex h-9 cursor-default items-center px-2 text-left">
|
||||
{(open) => (
|
||||
<>
|
||||
<div ref={reference} className={clsx("relative", className)}>
|
||||
<Listbox.Button className="btn-default text-left">
|
||||
<span className="grow truncate">
|
||||
{!query ? format(value, "p") : null}
|
||||
{format(value, "p", { locale })}
|
||||
</span>
|
||||
<span className="pointer-events-none flex">
|
||||
<span className="pointer-events-none ml-2 flex">
|
||||
<ChevronDown className="h-5 w-5" />
|
||||
</span>
|
||||
</Combobox.Button>
|
||||
{portal &&
|
||||
ReactDOM.createPortal(
|
||||
<Combobox.Options
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
ref={setPopperElement}
|
||||
className="z-50 max-h-72 w-32 overflow-auto rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
</Listbox.Button>
|
||||
</div>
|
||||
<FloatingPortal>
|
||||
{open ? (
|
||||
<Listbox.Options
|
||||
style={{
|
||||
position: strategy,
|
||||
left: x ?? "",
|
||||
top: y ?? "",
|
||||
}}
|
||||
ref={floating}
|
||||
className="z-50 max-h-52 overflow-auto rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
onMouseDown={stopPropagation}
|
||||
>
|
||||
{options}
|
||||
</Combobox.Options>,
|
||||
portal,
|
||||
</Listbox.Options>
|
||||
) : null}
|
||||
</FloatingPortal>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Combobox>
|
||||
</Listbox>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -11,18 +11,12 @@ import React from "react";
|
|||
import { Calendar, dateFnsLocalizer } from "react-big-calendar";
|
||||
import { useMount } from "react-use";
|
||||
|
||||
import { usePreferences } from "@/components/preferences/use-preferences";
|
||||
|
||||
import DateNavigationToolbar from "./date-navigation-toolbar";
|
||||
import { DateTimeOption, DateTimePickerProps } from "./types";
|
||||
import { formatDateWithoutTime, formatDateWithoutTz } from "./utils";
|
||||
|
||||
const localizer = dateFnsLocalizer({
|
||||
format,
|
||||
parse,
|
||||
startOfWeek: (date: Date | number) => startOfWeek(date, { weekStartsOn: 1 }),
|
||||
getDay,
|
||||
locales: {},
|
||||
});
|
||||
|
||||
const WeekCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
|
||||
title,
|
||||
options,
|
||||
|
@ -39,8 +33,28 @@ const WeekCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
|
|||
setScrollToTime(addMinutes(date, -60));
|
||||
});
|
||||
|
||||
const { weekStartsOn, timeFormat, locale } = usePreferences();
|
||||
|
||||
const localizer = React.useMemo(
|
||||
() =>
|
||||
dateFnsLocalizer({
|
||||
format,
|
||||
parse,
|
||||
startOfWeek: (date: Date | number) =>
|
||||
startOfWeek(date, {
|
||||
weekStartsOn: weekStartsOn === "monday" ? 1 : 0,
|
||||
}),
|
||||
getDay,
|
||||
locales: {
|
||||
default: locale,
|
||||
},
|
||||
}),
|
||||
[locale, weekStartsOn],
|
||||
);
|
||||
|
||||
return (
|
||||
<Calendar
|
||||
key={timeFormat}
|
||||
events={options.map((option) => {
|
||||
if (option.type === "date") {
|
||||
return { title, start: new Date(option.date) };
|
||||
|
@ -52,6 +66,7 @@ const WeekCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
|
|||
};
|
||||
}
|
||||
})}
|
||||
culture="default"
|
||||
onNavigate={onNavigate}
|
||||
date={date}
|
||||
className="h-[calc(100vh-220px)] max-h-[800px] min-h-[400px] w-full"
|
||||
|
@ -103,7 +118,7 @@ const WeekCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
|
|||
width: `calc(${props.style?.width}%)`,
|
||||
}}
|
||||
>
|
||||
<div>{format(props.event.start, "p")}</div>
|
||||
<div>{format(props.event.start, "p", { locale })}</div>
|
||||
<div className="w-full truncate font-bold">
|
||||
{props.event.title}
|
||||
</div>
|
||||
|
|
|
@ -24,6 +24,7 @@ interface HeadlessDatePickerOptions {
|
|||
date?: Date;
|
||||
selection?: Date[];
|
||||
onNavigationChange?: (date: Date) => void;
|
||||
weekStartsOn?: "monday" | "sunday";
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
|
@ -47,7 +48,9 @@ export const useHeadlessDatePicker = (
|
|||
const navigationDate = options?.date ?? localNavigationDate;
|
||||
|
||||
const firstDayOfMonth = startOfMonth(navigationDate);
|
||||
const firstDayOfFirstWeek = startOfWeek(firstDayOfMonth, { weekStartsOn: 1 });
|
||||
const firstDayOfFirstWeek = startOfWeek(firstDayOfMonth, {
|
||||
weekStartsOn: options?.weekStartsOn === "monday" ? 1 : 0,
|
||||
});
|
||||
|
||||
const currentMonth = getMonth(navigationDate);
|
||||
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
<svg width="24" height="24" fill="currentColor" class="text-purple-600 mr-3 text-opacity-50 transform">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.477 2 2 6.463 2 11.97c0 4.404 2.865 8.14 6.839 9.458.5.092.682-.216.682-.48 0-.236-.008-.864-.013-1.695-2.782.602-3.369-1.337-3.369-1.337-.454-1.151-1.11-1.458-1.11-1.458-.908-.618.069-.606.069-.606 1.003.07 1.531 1.027 1.531 1.027.892 1.524 2.341 1.084 2.91.828.092-.643.35-1.083.636-1.332-2.22-.251-4.555-1.107-4.555-4.927 0-1.088.39-1.979 1.029-2.675-.103-.252-.446-1.266.098-2.638 0 0 .84-.268 2.75 1.022A9.606 9.606 0 0112 6.82c.85.004 1.705.114 2.504.336 1.909-1.29 2.747-1.022 2.747-1.022.546 1.372.202 2.386.1 2.638.64.696 1.028 1.587 1.028 2.675 0 3.83-2.339 4.673-4.566 4.92.359.307.678.915.678 1.846 0 1.332-.012 2.407-.012 2.734 0 .267.18.577.688.48C19.137 20.107 22 16.373 22 11.969 22 6.463 17.522 2 12 2z"></path>
|
||||
</svg>
|
Before Width: | Height: | Size: 898 B |
3
components/icons/adjustments.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
|
||||
</svg>
|
After Width: | Height: | Size: 332 B |
3
components/icons/cash.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
After Width: | Height: | Size: 343 B |
3
components/icons/currency-dollar.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
After Width: | Height: | Size: 368 B |
3
components/icons/document.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
After Width: | Height: | Size: 303 B |
3
components/icons/github.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
After Width: | Height: | Size: 816 B |
3
components/icons/menu.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16m-7 6h7" />
|
||||
</svg>
|
After Width: | Height: | Size: 219 B |
3
components/icons/twitter.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z" />
|
||||
</svg>
|
After Width: | Height: | Size: 609 B |
|
@ -1,3 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z" />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
Before Width: | Height: | Size: 362 B After Width: | Height: | Size: 459 B |
10
components/no-ssr.tsx
Normal file
|
@ -0,0 +1,10 @@
|
|||
import dynamic from "next/dynamic";
|
||||
import React from "react";
|
||||
|
||||
const NoSsr = (props: { children?: React.ReactNode }) => (
|
||||
<React.Fragment>{props.children}</React.Fragment>
|
||||
);
|
||||
|
||||
export default dynamic(() => Promise.resolve(NoSsr), {
|
||||
ssr: false,
|
||||
});
|
|
@ -7,9 +7,9 @@ import * as React from "react";
|
|||
import { createBreakpoint } from "react-use";
|
||||
|
||||
import DotsVertical from "@/components/icons/dots-vertical.svg";
|
||||
import Github from "@/components/icons/github.svg";
|
||||
|
||||
import Logo from "../public/logo.svg";
|
||||
import Github from "./home/github.svg";
|
||||
import Footer from "./page-layout/footer";
|
||||
|
||||
const Popover = dynamic(() => import("./popover"), { ssr: false });
|
||||
|
@ -62,7 +62,7 @@ const Menu: React.VoidFunctionComponent<{ className: string }> = ({
|
|||
</Link>
|
||||
<Link href="https://github.com/lukevella/rallly">
|
||||
<a className="text-gray-400 transition-colors hover:text-indigo-500 hover:no-underline hover:underline-offset-2">
|
||||
<Github className="w-8" />
|
||||
<Github className="w-6" />
|
||||
</a>
|
||||
</Link>
|
||||
</nav>
|
||||
|
|
|
@ -9,6 +9,7 @@ import {
|
|||
ParsedTimeSlotOption,
|
||||
} from "utils/date-time-utils";
|
||||
|
||||
import { usePreferences } from "./preferences/use-preferences";
|
||||
import { useRequiredContext } from "./use-required-context";
|
||||
|
||||
type VoteType = "yes" | "no";
|
||||
|
@ -59,6 +60,7 @@ export const PollContextProvider: React.VoidFunctionComponent<{
|
|||
});
|
||||
return res;
|
||||
}, [participantById, poll.options]);
|
||||
const { locale } = usePreferences();
|
||||
|
||||
const contextValue = React.useMemo<PollContextValue>(() => {
|
||||
let highScore = 1;
|
||||
|
@ -72,6 +74,7 @@ export const PollContextProvider: React.VoidFunctionComponent<{
|
|||
poll.options,
|
||||
poll.timeZone,
|
||||
targetTimeZone,
|
||||
locale,
|
||||
);
|
||||
const getParticipantById = (participantId: string) => {
|
||||
// TODO (Luke Vella) [2022-04-16]: Build an index instead
|
||||
|
@ -106,7 +109,7 @@ export const PollContextProvider: React.VoidFunctionComponent<{
|
|||
targetTimeZone,
|
||||
setTargetTimeZone,
|
||||
};
|
||||
}, [participantById, participantsByOptionId, poll, targetTimeZone]);
|
||||
}, [locale, participantById, participantsByOptionId, poll, targetTimeZone]);
|
||||
return (
|
||||
<PollContext.Provider value={contextValue}>{children}</PollContext.Provider>
|
||||
);
|
||||
|
|
232
components/poll.tsx
Normal file
|
@ -0,0 +1,232 @@
|
|||
import { GetPollResponse } from "api-client/get-poll";
|
||||
import axios from "axios";
|
||||
import { NextPage } from "next";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { usePlausible } from "next-plausible";
|
||||
import React from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useMutation, useQueryClient } from "react-query";
|
||||
import { useMount } from "react-use";
|
||||
import { preventWidows } from "utils/prevent-widows";
|
||||
|
||||
import Button from "@/components/button";
|
||||
import LocationMarker from "@/components/icons/location-marker.svg";
|
||||
import LockClosed from "@/components/icons/lock-closed.svg";
|
||||
import Share from "@/components/icons/share.svg";
|
||||
|
||||
import ManagePoll from "./poll/manage-poll";
|
||||
import { useUpdatePollMutation } from "./poll/mutations";
|
||||
import NotificationsToggle from "./poll/notifications-toggle";
|
||||
import PollSubheader from "./poll/poll-subheader";
|
||||
import TruncatedLinkify from "./poll/truncated-linkify";
|
||||
import { UserAvatarProvider } from "./poll/user-avatar";
|
||||
import { PollContextProvider, usePoll } from "./poll-context";
|
||||
import Popover from "./popover";
|
||||
import Sharing from "./sharing";
|
||||
import StandardLayout from "./standard-layout";
|
||||
import { useUserName } from "./user-name-context";
|
||||
|
||||
const Discussion = React.lazy(() => import("@/components/discussion"));
|
||||
|
||||
const DesktopPoll = React.lazy(() => import("@/components/poll/desktop-poll"));
|
||||
const MobilePoll = React.lazy(() => import("@/components/poll/mobile-poll"));
|
||||
|
||||
const PollInner: NextPage = () => {
|
||||
const { poll } = usePoll();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
useMount(() => {
|
||||
const path = poll.role === "admin" ? "admin" : "p";
|
||||
|
||||
if (!new RegExp(`^/${path}`).test(router.asPath)) {
|
||||
router.replace(`/${path}/${poll.urlId}`, undefined, { shallow: true });
|
||||
}
|
||||
});
|
||||
|
||||
const [, setUserName] = useUserName();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const plausible = usePlausible();
|
||||
|
||||
const { mutate: updatePollMutation } = useUpdatePollMutation();
|
||||
|
||||
const { mutate: verifyEmail } = useMutation(
|
||||
async (verificationCode: string) => {
|
||||
await axios.post(`/api/poll/${poll.urlId}/verify`, {
|
||||
verificationCode,
|
||||
});
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Your poll has been verified");
|
||||
router.replace(`/admin/${router.query.urlId}`, undefined, {
|
||||
shallow: true,
|
||||
});
|
||||
queryClient.setQueryData(["getPoll", poll.urlId], {
|
||||
...poll,
|
||||
verified: true,
|
||||
});
|
||||
plausible("Verified email");
|
||||
setUserName(poll.authorName);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
// TODO (Luke Vella) [2022-03-29]: stop looking for "verificationCode". We switched to
|
||||
// "code" for compatability with v1 and it's generally better since it's more concise
|
||||
const verificationCode = router.query.verificationCode ?? router.query.code;
|
||||
if (typeof verificationCode === "string") {
|
||||
verifyEmail(verificationCode);
|
||||
}
|
||||
}, [router, verifyEmail]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (router.query.unsubscribe) {
|
||||
updatePollMutation(
|
||||
{ notifications: false },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Notifications have been disabled");
|
||||
plausible("Unsubscribed from notifications");
|
||||
},
|
||||
},
|
||||
);
|
||||
router.replace(`/admin/${router.query.urlId}`, undefined, {
|
||||
shallow: true,
|
||||
});
|
||||
}
|
||||
}, [plausible, router, updatePollMutation]);
|
||||
|
||||
const checkIfWideScreen = () => window.innerWidth > 640;
|
||||
|
||||
const [isWideScreen, setIsWideScreen] = React.useState(checkIfWideScreen);
|
||||
|
||||
React.useEffect(() => {
|
||||
const listener = () => setIsWideScreen(checkIfWideScreen());
|
||||
|
||||
window.addEventListener("resize", listener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", listener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const PollComponent = isWideScreen ? DesktopPoll : MobilePoll;
|
||||
|
||||
let highScore = 1; // set to one because we don't want to highlight
|
||||
poll.options.forEach((option) => {
|
||||
if (option.votes.length > highScore) {
|
||||
highScore = option.votes.length;
|
||||
}
|
||||
});
|
||||
|
||||
const names = React.useMemo(
|
||||
() => poll.participants.map(({ name }) => name),
|
||||
[poll.participants],
|
||||
);
|
||||
|
||||
return (
|
||||
<UserAvatarProvider seed={poll.pollId} names={names}>
|
||||
<StandardLayout>
|
||||
<div className="relative max-w-full bg-gray-50 py-4 md:px-4 lg:px-4">
|
||||
<Head>
|
||||
<title>{poll.title}</title>
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
</Head>
|
||||
<div
|
||||
className="mx-auto max-w-full lg:mx-0"
|
||||
style={{
|
||||
width: Math.max(768, poll.options.length * 95 + 200 + 160),
|
||||
}}
|
||||
>
|
||||
<div className="mb-6">
|
||||
<div className="mb-3 items-start px-4 md:flex md:space-x-4">
|
||||
<div className="mb-3 grow md:mb-0">
|
||||
<div className="flex flex-col-reverse md:flex-row">
|
||||
<h1
|
||||
data-testid="poll-title"
|
||||
className="mb-2 grow text-3xl leading-tight"
|
||||
>
|
||||
{preventWidows(poll.title)}
|
||||
</h1>
|
||||
{poll.role === "admin" ? (
|
||||
<div className="mb-4 flex space-x-2 md:mb-2">
|
||||
<NotificationsToggle />
|
||||
<ManagePoll
|
||||
placement={
|
||||
isWideScreen ? "bottom-end" : "bottom-start"
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<Popover
|
||||
trigger={
|
||||
<Button type="primary" icon={<Share />}>
|
||||
Share
|
||||
</Button>
|
||||
}
|
||||
placement={isWideScreen ? "bottom-end" : undefined}
|
||||
>
|
||||
<Sharing links={poll.links} />
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<PollSubheader />
|
||||
</div>
|
||||
</div>
|
||||
{poll.description ? (
|
||||
<div className="mb-4 whitespace-pre-line bg-white px-4 py-3 text-lg leading-relaxed text-slate-600 shadow-sm md:w-fit md:rounded-xl md:bg-white">
|
||||
<TruncatedLinkify>
|
||||
{preventWidows(poll.description)}
|
||||
</TruncatedLinkify>
|
||||
</div>
|
||||
) : null}
|
||||
{poll.location ? (
|
||||
<div className="mb-4 flex items-center px-4">
|
||||
<div>
|
||||
<LocationMarker
|
||||
width={20}
|
||||
className="mr-2 text-slate-400"
|
||||
/>
|
||||
</div>
|
||||
<TruncatedLinkify>{poll.location}</TruncatedLinkify>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{poll.closed ? (
|
||||
<div className="mb-4 flex items-center bg-sky-100 py-3 px-4 text-sky-700 shadow-sm md:rounded-lg">
|
||||
<div className="mr-3 rounded-md">
|
||||
<LockClosed className="w-5" />
|
||||
</div>
|
||||
This poll has been locked (voting is disabled)
|
||||
</div>
|
||||
) : null}
|
||||
<React.Suspense fallback={<div>Loading…</div>}>
|
||||
<div className="mb-4 lg:mb-8">
|
||||
<PollComponent pollId={poll.urlId} highScore={highScore} />
|
||||
</div>
|
||||
<Discussion
|
||||
pollId={poll.urlId}
|
||||
canDelete={poll.role === "admin"}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</StandardLayout>
|
||||
</UserAvatarProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const PollPage = ({ poll }: { poll: GetPollResponse }) => {
|
||||
return (
|
||||
<PollContextProvider value={poll}>
|
||||
<PollInner />
|
||||
</PollContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default PollPage;
|
|
@ -1 +0,0 @@
|
|||
export { default } from "./desktop-poll";
|
|
@ -1,8 +1,8 @@
|
|||
import { Placement } from "@popperjs/core";
|
||||
import { Placement } from "@floating-ui/react-dom-interactions";
|
||||
import { format } from "date-fns";
|
||||
import { Trans, useTranslation } from "next-i18next";
|
||||
import * as React from "react";
|
||||
import { decodeDateOption, encodeDateOption } from "utils/date-time-utils";
|
||||
import { encodeDateOption } from "utils/date-time-utils";
|
||||
|
||||
import Button from "@/components/button";
|
||||
import Cog from "@/components/icons/cog.svg";
|
||||
|
@ -25,7 +25,7 @@ const ManagePoll: React.VoidFunctionComponent<{
|
|||
placement?: Placement;
|
||||
}> = ({ placement }) => {
|
||||
const { t } = useTranslation("app");
|
||||
const { poll, targetTimeZone } = usePoll();
|
||||
const { poll, options } = usePoll();
|
||||
|
||||
const modalContext = useModalContext();
|
||||
|
||||
|
@ -181,12 +181,7 @@ const ManagePoll: React.VoidFunctionComponent<{
|
|||
t("participantCount", {
|
||||
count: poll.participants.length,
|
||||
}),
|
||||
...poll.options.map((option) => {
|
||||
const decodedOption = decodeDateOption(
|
||||
option,
|
||||
poll.timeZone,
|
||||
targetTimeZone,
|
||||
);
|
||||
...options.map((decodedOption) => {
|
||||
const day = `${decodedOption.dow} ${decodedOption.day} ${decodedOption.month}`;
|
||||
return decodedOption.type === "date"
|
||||
? day
|
||||
|
|
|
@ -7,13 +7,14 @@ import { useMutation } from "react-query";
|
|||
import Button from "../button";
|
||||
import { usePoll } from "../poll-context";
|
||||
import Popover from "../popover";
|
||||
import { usePreferences } from "../preferences/use-preferences";
|
||||
|
||||
export interface PollSubheaderProps {}
|
||||
|
||||
const PollSubheader: React.VoidFunctionComponent<PollSubheaderProps> = () => {
|
||||
const { poll } = usePoll();
|
||||
const { t } = useTranslation("app");
|
||||
|
||||
const { locale } = usePreferences();
|
||||
const {
|
||||
mutate: sendVerificationEmail,
|
||||
isLoading: isSendingVerificationEmail,
|
||||
|
@ -29,14 +30,6 @@ const PollSubheader: React.VoidFunctionComponent<PollSubheaderProps> = () => {
|
|||
t={t}
|
||||
values={{
|
||||
name: poll.authorName,
|
||||
date: Date.parse(poll.createdAt),
|
||||
formatParams: {
|
||||
date: {
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
},
|
||||
},
|
||||
}}
|
||||
components={{
|
||||
b: <span className="font-medium text-indigo-500" />,
|
||||
|
@ -88,7 +81,9 @@ const PollSubheader: React.VoidFunctionComponent<PollSubheaderProps> = () => {
|
|||
</div>
|
||||
<span className="hidden md:inline"> • </span>
|
||||
<span className="whitespace-nowrap">
|
||||
{formatRelative(new Date(poll.createdAt), new Date())}
|
||||
{formatRelative(new Date(poll.createdAt), new Date(), {
|
||||
locale,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,58 +1,72 @@
|
|||
import {
|
||||
flip,
|
||||
FloatingPortal,
|
||||
offset,
|
||||
Placement,
|
||||
shift,
|
||||
useFloating,
|
||||
} from "@floating-ui/react-dom-interactions";
|
||||
import { Popover as HeadlessPopover } from "@headlessui/react";
|
||||
import { Placement } from "@popperjs/core";
|
||||
import clsx from "clsx";
|
||||
import { motion } from "framer-motion";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { usePopper } from "react-popper";
|
||||
import { transformOriginByPlacement } from "utils/constants";
|
||||
|
||||
interface PopoverProps {
|
||||
trigger: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
placement?: Placement;
|
||||
}
|
||||
|
||||
const MotionPanel = motion(HeadlessPopover.Panel);
|
||||
|
||||
const Popover: React.VoidFunctionComponent<PopoverProps> = ({
|
||||
children,
|
||||
trigger,
|
||||
placement,
|
||||
placement: preferredPlacement,
|
||||
}) => {
|
||||
const [referenceElement, setReferenceElement] =
|
||||
React.useState<HTMLDivElement | null>(null);
|
||||
const [popperElement, setPopperElement] =
|
||||
React.useState<HTMLDivElement | null>(null);
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement,
|
||||
modifiers: [
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: [0, 5],
|
||||
},
|
||||
},
|
||||
],
|
||||
const { reference, floating, x, y, strategy, placement } = useFloating({
|
||||
placement: preferredPlacement,
|
||||
strategy: "fixed",
|
||||
middleware: [offset(5), flip(), shift({ padding: 10 })],
|
||||
});
|
||||
|
||||
const portal = document.getElementById("portal");
|
||||
const origin = transformOriginByPlacement[placement];
|
||||
|
||||
return (
|
||||
<HeadlessPopover as={React.Fragment}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<HeadlessPopover.Button
|
||||
ref={setReferenceElement}
|
||||
ref={reference}
|
||||
as="div"
|
||||
className={clsx("inline-block")}
|
||||
>
|
||||
{trigger}
|
||||
</HeadlessPopover.Button>
|
||||
{portal &&
|
||||
ReactDOM.createPortal(
|
||||
<HeadlessPopover.Panel
|
||||
className="max-w-full rounded-lg border bg-white p-4 shadow-md"
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
ref={setPopperElement}
|
||||
<FloatingPortal>
|
||||
{open ? (
|
||||
<MotionPanel
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, scale: 0.9, y: -10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: -10 }}
|
||||
className={clsx(
|
||||
"z-30 max-w-full translate-x-4 rounded-lg border bg-white p-4 shadow-md",
|
||||
origin,
|
||||
)}
|
||||
style={{
|
||||
position: strategy,
|
||||
left: x ?? "",
|
||||
top: y ?? "",
|
||||
}}
|
||||
ref={floating}
|
||||
>
|
||||
{children}
|
||||
</HeadlessPopover.Panel>,
|
||||
portal,
|
||||
</MotionPanel>
|
||||
) : null}
|
||||
</FloatingPortal>
|
||||
</>
|
||||
)}
|
||||
</HeadlessPopover>
|
||||
);
|
||||
|
|
83
components/preferences.tsx
Normal file
|
@ -0,0 +1,83 @@
|
|||
import clsx from "clsx";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import React from "react";
|
||||
|
||||
import Calendar from "@/components/icons/calendar.svg";
|
||||
|
||||
import { usePreferences } from "./preferences/use-preferences";
|
||||
|
||||
const Preferences: React.VoidFunctionComponent = () => {
|
||||
const { t } = useTranslation("app");
|
||||
|
||||
const { weekStartsOn, setWeekStartsOn, timeFormat, setTimeFormat } =
|
||||
usePreferences();
|
||||
|
||||
return (
|
||||
<div className="-mb-2">
|
||||
<div className="mb-4 flex items-center space-x-2 text-base font-semibold">
|
||||
<Calendar className="inline-block w-5" />
|
||||
<span>{t("timeAndDate")}</span>
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="mb-2">
|
||||
<div className="mb-2 grow text-sm text-slate-500">Week starts on</div>
|
||||
<div>
|
||||
<div className="segment-button inline-flex">
|
||||
<button
|
||||
className={clsx({
|
||||
"segment-button-active": weekStartsOn === "monday",
|
||||
})}
|
||||
onClick={() => {
|
||||
setWeekStartsOn("monday");
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{t("monday")}
|
||||
</button>
|
||||
<button
|
||||
className={clsx({
|
||||
"segment-button-active": weekStartsOn === "sunday",
|
||||
})}
|
||||
onClick={() => {
|
||||
setWeekStartsOn("sunday");
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{t("sunday")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<div className="mb-2 grow text-sm text-slate-500">Time format</div>
|
||||
<div className="segment-button inline-flex">
|
||||
<button
|
||||
className={clsx({
|
||||
"segment-button-active": timeFormat === "12h",
|
||||
})}
|
||||
onClick={() => {
|
||||
setTimeFormat("12h");
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{t("12h")}
|
||||
</button>
|
||||
<button
|
||||
className={clsx({
|
||||
"segment-button-active": timeFormat === "24h",
|
||||
})}
|
||||
onClick={() => {
|
||||
setTimeFormat("24h");
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{t("24h")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Preferences;
|
50
components/preferences/preferences-provider.tsx
Normal file
|
@ -0,0 +1,50 @@
|
|||
import { Locale } from "date-fns";
|
||||
import enGB from "date-fns/locale/en-GB";
|
||||
import enUS from "date-fns/locale/en-US";
|
||||
import * as React from "react";
|
||||
import { useLocalStorage } from "react-use";
|
||||
|
||||
type TimeFormat = "12h" | "24h";
|
||||
type StartOfWeek = "monday" | "sunday";
|
||||
|
||||
export const PreferencesContext =
|
||||
React.createContext<{
|
||||
locale: Locale;
|
||||
weekStartsOn: StartOfWeek;
|
||||
timeFormat: TimeFormat;
|
||||
setWeekStartsOn: React.Dispatch<
|
||||
React.SetStateAction<StartOfWeek | undefined>
|
||||
>;
|
||||
setTimeFormat: React.Dispatch<React.SetStateAction<TimeFormat | undefined>>;
|
||||
} | null>(null);
|
||||
|
||||
PreferencesContext.displayName = "PreferencesContext";
|
||||
|
||||
const PreferencesProvider: React.VoidFunctionComponent<{
|
||||
children?: React.ReactNode;
|
||||
}> = ({ children }) => {
|
||||
const [weekStartsOn = "monday", setWeekStartsOn] =
|
||||
useLocalStorage<StartOfWeek>("rallly-week-starts-on");
|
||||
|
||||
const [timeFormat = "12h", setTimeFormat] =
|
||||
useLocalStorage<TimeFormat>("rallly-time-format");
|
||||
|
||||
const contextValue = React.useMemo(
|
||||
() => ({
|
||||
weekStartsOn,
|
||||
timeFormat,
|
||||
setWeekStartsOn,
|
||||
setTimeFormat,
|
||||
locale: timeFormat === "12h" ? enUS : enGB,
|
||||
}),
|
||||
[setTimeFormat, setWeekStartsOn, timeFormat, weekStartsOn],
|
||||
);
|
||||
|
||||
return (
|
||||
<PreferencesContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</PreferencesContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreferencesProvider;
|
6
components/preferences/use-preferences.tsx
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { useRequiredContext } from "../use-required-context";
|
||||
import { PreferencesContext } from "./preferences-provider";
|
||||
|
||||
export const usePreferences = () => {
|
||||
return useRequiredContext(PreferencesContext);
|
||||
};
|
|
@ -1,47 +1,176 @@
|
|||
import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
import Menu from "@/components/icons/menu.svg";
|
||||
|
||||
import Logo from "../public/logo.svg";
|
||||
import Newspaper from "./icons/newspaper.svg";
|
||||
import Adjustments from "./icons/adjustments.svg";
|
||||
import Cash from "./icons/cash.svg";
|
||||
import Github from "./icons/github.svg";
|
||||
import Pencil from "./icons/pencil.svg";
|
||||
import Support from "./icons/support.svg";
|
||||
import Twitter from "./icons/twitter.svg";
|
||||
import Popover from "./popover";
|
||||
import Preferences from "./preferences";
|
||||
|
||||
const StandardLayout: React.FunctionComponent = ({ children, ...rest }) => {
|
||||
const HomeLink = () => {
|
||||
return (
|
||||
<div className="relative min-h-full bg-gray-50 lg:flex" {...rest}>
|
||||
<div className="border-b bg-gray-100 px-4 py-2 lg:grow lg:border-b-0 lg:border-r lg:py-6 lg:px-4">
|
||||
<div className="flex items-center lg:float-right lg:w-40 lg:flex-col lg:items-start">
|
||||
<div className="grow lg:mb-8 lg:grow-0">
|
||||
<Link href="/">
|
||||
<a>
|
||||
<Logo className="w-24 text-slate-500 transition-colors hover:text-indigo-500 active:text-indigo-600 lg:w-28" />
|
||||
<Logo className="w-28 text-slate-500 transition-colors hover:text-indigo-500 active:text-indigo-600" />
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
const AppMenu: React.VoidFunctionComponent<{ className?: string }> = ({
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div className={clsx("space-y-1", className)}>
|
||||
<Link href="/new">
|
||||
<a className="flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 pr-4 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300">
|
||||
<Pencil className="h-5 opacity-75" />
|
||||
<span className="inline-block">New Poll</span>
|
||||
</a>
|
||||
</Link>
|
||||
<Link href="/support">
|
||||
<a className="flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 pr-4 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300">
|
||||
<Support className="h-5 opacity-75" />
|
||||
<span className="inline-block">Support</span>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center text-sm lg:mb-4 lg:block lg:w-full lg:pb-4 lg:text-base">
|
||||
<Link passHref={true} href="/new">
|
||||
<a className="flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 font-medium text-gray-600 transition-colors hover:bg-gray-200 hover:text-gray-600 hover:no-underline active:bg-gray-300 lg:-ml-2">
|
||||
<Pencil className="h-6 w-6 opacity-75" />
|
||||
<span className="hidden md:inline-block">New Poll</span>
|
||||
</a>
|
||||
</Link>
|
||||
<a
|
||||
href="https://blog.rallly.co"
|
||||
className="flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 font-medium text-gray-600 transition-colors hover:bg-gray-200 hover:text-gray-600 hover:no-underline active:bg-gray-300 lg:-ml-2"
|
||||
);
|
||||
};
|
||||
|
||||
const StandardLayout: React.VoidFunctionComponent<{
|
||||
children?: React.ReactNode;
|
||||
}> = ({ children, ...rest }) => {
|
||||
return (
|
||||
<div
|
||||
className="relative flex min-h-full flex-col bg-gray-50 lg:flex-row"
|
||||
{...rest}
|
||||
>
|
||||
<Newspaper className="h-6 w-6 opacity-75" />
|
||||
<span className="hidden md:inline-block">Blog</span>
|
||||
<div className="relative z-10 flex h-12 shrink-0 items-center justify-between border-b px-4 lg:hidden">
|
||||
<div>
|
||||
<HomeLink />
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Popover
|
||||
placement="bottom-end"
|
||||
trigger={
|
||||
<button
|
||||
type="button"
|
||||
className="flex whitespace-nowrap rounded-md px-2 py-1 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300"
|
||||
>
|
||||
<Adjustments className="h-5 opacity-75" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<Preferences />
|
||||
</Popover>
|
||||
<Popover
|
||||
trigger={
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md px-2 py-1 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300"
|
||||
>
|
||||
<Menu className="w-5" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<AppMenu className="-m-2" />
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden grow px-4 pt-6 pb-5 lg:block">
|
||||
<div className="sticky top-6 float-right flex w-40 flex-col items-start">
|
||||
<div className="mb-8 grow-0 px-2">
|
||||
<HomeLink />
|
||||
</div>
|
||||
<div className="mb-4 block w-full shrink-0 grow items-center pb-4 text-base">
|
||||
<div className="mb-4">
|
||||
<Link href="/new">
|
||||
<a className="mb-1 flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 pr-4 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300">
|
||||
<Pencil className="h-5 opacity-75" />
|
||||
<span className="inline-block">New Poll</span>
|
||||
</a>
|
||||
<Link passHref={true} href="/support">
|
||||
<a className="flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 font-medium text-gray-600 transition-colors hover:bg-gray-200 hover:text-gray-600 hover:no-underline active:bg-gray-300 lg:-ml-2">
|
||||
<Support className="h-6 w-6 opacity-75" />
|
||||
<span className="hidden md:inline-block">Support</span>
|
||||
</Link>
|
||||
<Link href="/support">
|
||||
<a className="mb-1 flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 pr-4 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300">
|
||||
<Support className="h-5 opacity-75" />
|
||||
<span className="inline-block">Support</span>
|
||||
</a>
|
||||
</Link>
|
||||
<Popover
|
||||
placement="right-start"
|
||||
trigger={
|
||||
<button className="flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 pr-4 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300">
|
||||
<Adjustments className="h-5 opacity-75" />
|
||||
<span className="inline-block">Preferences</span>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<Preferences />
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 grow">
|
||||
<div className="max-w-full md:w-[1024px] lg:min-h-[calc(100vh-64px)]">
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex flex-col items-center space-y-4 px-6 pt-3 pb-6 text-slate-400 lg:h-16 lg:flex-row lg:space-y-0 lg:space-x-6 lg:py-0 lg:px-8 lg:pb-3">
|
||||
<div>
|
||||
<Link href="https://rallly.co">
|
||||
<a className="text-sm text-slate-400 transition-colors hover:text-indigo-500 hover:no-underline">
|
||||
<Logo className="h-5" />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="hidden text-slate-300 lg:block">•</div>
|
||||
<div className="flex items-center justify-center space-x-6 md:justify-start">
|
||||
<Link href="/support">
|
||||
<a className="text-sm text-slate-400 transition-colors hover:text-indigo-500 hover:no-underline">
|
||||
Support
|
||||
</a>
|
||||
</Link>
|
||||
<Link href="https://github.com/lukevella/rallly/discussions">
|
||||
<a className="text-sm text-slate-400 transition-colors hover:text-indigo-500 hover:no-underline">
|
||||
Discussions
|
||||
</a>
|
||||
</Link>
|
||||
<Link href="https://blog.rallly.co">
|
||||
<a className="text-sm text-slate-400 transition-colors hover:text-indigo-500 hover:no-underline">
|
||||
Blog
|
||||
</a>
|
||||
</Link>
|
||||
<div className="hidden text-slate-300 lg:block">•</div>
|
||||
<div className="flex items-center space-x-6">
|
||||
<Link href="https://twitter.com/ralllyco">
|
||||
<a className="text-sm text-slate-400 transition-colors hover:text-indigo-500 hover:no-underline">
|
||||
<Twitter className="h-5 w-5" />
|
||||
</a>
|
||||
</Link>
|
||||
<Link href="https://github.com/lukevella/rallly">
|
||||
<a className="text-sm text-slate-400 transition-colors hover:text-indigo-500 hover:no-underline">
|
||||
<Github className="h-5 w-5" />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden text-slate-300 lg:block">•</div>
|
||||
<Link href="https://www.paypal.com/donate/?hosted_button_id=7QXP2CUBLY88E">
|
||||
<a className="inline-flex h-8 items-center rounded-full bg-slate-100 pl-2 pr-3 text-sm text-slate-400 transition-colors hover:bg-indigo-500 hover:text-white hover:no-underline focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1 active:bg-indigo-600">
|
||||
<Cash className="mr-1 inline-block w-5" />
|
||||
<span>Donate</span>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 grow">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,10 +1,18 @@
|
|||
import { Placement } from "@popperjs/core";
|
||||
import {
|
||||
arrow,
|
||||
flip,
|
||||
FloatingPortal,
|
||||
offset,
|
||||
Placement,
|
||||
shift,
|
||||
useFloating,
|
||||
useHover,
|
||||
useInteractions,
|
||||
useRole,
|
||||
} from "@floating-ui/react-dom-interactions";
|
||||
import clsx from "clsx";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import * as React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { usePopper } from "react-popper";
|
||||
import { useClickAway, useDebounce } from "react-use";
|
||||
import { preventWidows } from "utils/prevent-widows";
|
||||
|
||||
export interface TooltipProps {
|
||||
|
@ -16,98 +24,73 @@ export interface TooltipProps {
|
|||
}
|
||||
|
||||
const Tooltip: React.VoidFunctionComponent<TooltipProps> = ({
|
||||
placement = "bottom",
|
||||
placement: preferredPlacement = "bottom",
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
content,
|
||||
}) => {
|
||||
const [referenceElement, setReferenceElement] =
|
||||
React.useState<HTMLDivElement | null>(null);
|
||||
const [popperElement, setPopperElement] =
|
||||
React.useState<HTMLDivElement | null>(null);
|
||||
const [arrowElement, setArrowElement] =
|
||||
React.useState<HTMLDivElement | null>(null);
|
||||
const arrowRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const { styles, attributes, update } = usePopper(
|
||||
referenceElement,
|
||||
popperElement,
|
||||
{
|
||||
const {
|
||||
reference,
|
||||
floating,
|
||||
x,
|
||||
y,
|
||||
strategy,
|
||||
context,
|
||||
middlewareData,
|
||||
placement,
|
||||
modifiers: [
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: [0, 14],
|
||||
},
|
||||
},
|
||||
{ name: "arrow", options: { element: arrowElement, padding: 5 } },
|
||||
} = useFloating({
|
||||
strategy: "fixed",
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
placement: preferredPlacement,
|
||||
middleware: [
|
||||
offset(10),
|
||||
flip(),
|
||||
shift({ padding: 5 }),
|
||||
arrow({ element: arrowRef }),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const [isVisible, setIsVisible] = React.useState(false);
|
||||
|
||||
const [debouncedValue, setDebouncedValue] = React.useState(false);
|
||||
|
||||
const [, cancel] = useDebounce(
|
||||
async () => {
|
||||
await update?.();
|
||||
setDebouncedValue(isVisible);
|
||||
},
|
||||
300,
|
||||
[isVisible],
|
||||
);
|
||||
|
||||
const portal = document.getElementById("portal");
|
||||
|
||||
const [key, setKey] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
setKey((k) => k + 1);
|
||||
}, [content]);
|
||||
|
||||
const ref = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useClickAway(ref, () => {
|
||||
setIsVisible(false);
|
||||
});
|
||||
|
||||
if (disabled) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
const placementGroup = placement.split("-")[0] as
|
||||
| "top"
|
||||
| "right"
|
||||
| "bottom"
|
||||
| "left";
|
||||
|
||||
const staticSide = {
|
||||
top: "bottom",
|
||||
right: "left",
|
||||
bottom: "top",
|
||||
left: "right",
|
||||
}[placementGroup];
|
||||
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([
|
||||
useHover(context, {
|
||||
enabled: !disabled,
|
||||
restMs: 150,
|
||||
}),
|
||||
useRole(context, {
|
||||
role: "tooltip",
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
key={key}
|
||||
className={clsx("inline-block", className)}
|
||||
onMouseEnter={() => {
|
||||
setIsVisible(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setIsVisible(false);
|
||||
setDebouncedValue(false);
|
||||
cancel();
|
||||
}}
|
||||
ref={(el) => {
|
||||
setReferenceElement(el);
|
||||
ref.current = el;
|
||||
}}
|
||||
{...getReferenceProps({ ref: reference })}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{portal
|
||||
? ReactDOM.createPortal(
|
||||
<FloatingPortal>
|
||||
<AnimatePresence>
|
||||
{debouncedValue ? (
|
||||
<div
|
||||
className="pointer-events-none"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
{open ? (
|
||||
<motion.div
|
||||
className="rounded-md bg-slate-700 px-3 py-2 text-slate-200 shadow-md"
|
||||
className="z-30 rounded-md bg-slate-700 px-3 py-2 text-slate-200 shadow-md"
|
||||
initial="hidden"
|
||||
transition={{
|
||||
duration: 0.1,
|
||||
|
@ -119,24 +102,32 @@ const Tooltip: React.VoidFunctionComponent<TooltipProps> = ({
|
|||
},
|
||||
show: { opacity: 1, translateY: 0 },
|
||||
}}
|
||||
animate={debouncedValue ? "show" : "hidden"}
|
||||
animate={open ? "show" : "hidden"}
|
||||
{...getFloatingProps({
|
||||
ref: floating,
|
||||
style: {
|
||||
position: strategy,
|
||||
top: y ?? "",
|
||||
left: x ?? "",
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
ref={setArrowElement}
|
||||
className="tooltip-arrow h-3 w-3 border-[6px] border-transparent"
|
||||
style={styles.arrow}
|
||||
data-popper-arrow
|
||||
></div>
|
||||
{typeof content === "string"
|
||||
? preventWidows(content)
|
||||
: content}
|
||||
ref={arrowRef}
|
||||
className="absolute rotate-45 bg-slate-700"
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
left: middlewareData.arrow?.x,
|
||||
top: middlewareData.arrow?.y,
|
||||
[staticSide]: -4,
|
||||
}}
|
||||
/>
|
||||
{typeof content === "string" ? preventWidows(content) : content}
|
||||
</motion.div>
|
||||
</div>
|
||||
) : null}
|
||||
</AnimatePresence>,
|
||||
portal,
|
||||
)
|
||||
: null}
|
||||
</AnimatePresence>
|
||||
</FloatingPortal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -12,9 +12,9 @@
|
|||
"test": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom-interactions": "^0.3.1",
|
||||
"@headlessui/react": "^1.5.0",
|
||||
"@next/bundle-analyzer": "^12.1.0",
|
||||
"@popperjs/core": "^2.11.4",
|
||||
"@prisma/client": "^3.12.0",
|
||||
"@sentry/nextjs": "^6.19.3",
|
||||
"@svgr/webpack": "^6.2.1",
|
||||
|
@ -44,7 +44,6 @@
|
|||
"react-hot-toast": "^2.2.0",
|
||||
"react-i18next": "^11.15.4",
|
||||
"react-linkify": "^1.0.0-alpha",
|
||||
"react-popper": "^2.2.5",
|
||||
"react-query": "^3.34.12",
|
||||
"react-use": "^17.3.2",
|
||||
"smoothscroll-polyfill": "^0.4.4",
|
||||
|
|
|
@ -13,6 +13,7 @@ import { MutationCache, QueryClient, QueryClientProvider } from "react-query";
|
|||
import { useSessionStorage } from "react-use";
|
||||
|
||||
import ModalProvider from "@/components/modal/modal-provider";
|
||||
import PreferencesProvider from "@/components/preferences/preferences-provider";
|
||||
|
||||
import { UserNameContext } from "../components/user-name-context";
|
||||
|
||||
|
@ -41,9 +42,13 @@ const MyApp: NextPage<AppProps> = ({ Component, pageProps }) => {
|
|||
selfHosted={true}
|
||||
enabled={!!process.env.PLAUSIBLE_DOMAIN}
|
||||
>
|
||||
<PreferencesProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1"
|
||||
/>
|
||||
</Head>
|
||||
<CrispChat />
|
||||
<Toaster />
|
||||
|
@ -53,6 +58,7 @@ const MyApp: NextPage<AppProps> = ({ Component, pageProps }) => {
|
|||
</UserNameContext.Provider>
|
||||
</ModalProvider>
|
||||
</QueryClientProvider>
|
||||
</PreferencesProvider>
|
||||
</PlausibleProvider>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { addMinutes } from "date-fns";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import absoluteUrl from "utils/absolute-url";
|
||||
import { nanoid } from "utils/nanoid";
|
||||
|
||||
import { prisma } from "../../../db";
|
||||
|
@ -64,6 +65,8 @@ export default async function handler(
|
|||
});
|
||||
}
|
||||
|
||||
const homePageUrl = absoluteUrl(req).origin;
|
||||
|
||||
await prisma.poll.create({
|
||||
data: {
|
||||
urlId: await nanoid(),
|
||||
|
@ -71,8 +74,7 @@ export default async function handler(
|
|||
title: "Lunch Meeting Demo",
|
||||
type: "date",
|
||||
location: "Starbucks, 901 New York Avenue",
|
||||
description:
|
||||
"This poll has been automatically generated just for you! Feel free to try out all the different features and when you're ready, you can go to https://rallly.co/new to make a new poll.",
|
||||
description: `This poll has been automatically generated just for you! Feel free to try out all the different features and when you're ready, you can go to ${homePageUrl}/new to make a new poll.`,
|
||||
authorName: "Johnny",
|
||||
verified: true,
|
||||
demo: true,
|
||||
|
|
224
pages/new.tsx
|
@ -1,224 +1,6 @@
|
|||
import { GetServerSideProps, NextPage } from "next";
|
||||
import { GetServerSideProps } from "next";
|
||||
import dynamic from "next/dynamic";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||
import { usePlausible } from "next-plausible";
|
||||
import React from "react";
|
||||
import { useMutation } from "react-query";
|
||||
import { useSessionStorage } from "react-use";
|
||||
|
||||
import { createPoll } from "../api-client/create-poll";
|
||||
import Button from "../components/button";
|
||||
import {
|
||||
NewEventData,
|
||||
PollDetailsData,
|
||||
PollDetailsForm,
|
||||
PollOptionsData,
|
||||
PollOptionsForm,
|
||||
UserDetailsData,
|
||||
UserDetailsForm,
|
||||
} from "../components/forms";
|
||||
import StandardLayout from "../components/standard-layout";
|
||||
import Steps from "../components/steps";
|
||||
import { useUserName } from "../components/user-name-context";
|
||||
import { encodeDateOption } from "../utils/date-time-utils";
|
||||
|
||||
type StepName = "eventDetails" | "options" | "userDetails";
|
||||
|
||||
const steps: StepName[] = ["eventDetails", "options", "userDetails"];
|
||||
|
||||
const required = <T extends unknown>(v: T | undefined): T => {
|
||||
if (!v) {
|
||||
throw new Error("Required value is missing");
|
||||
}
|
||||
|
||||
return v;
|
||||
};
|
||||
|
||||
const initialNewEventData: NewEventData = { currentStep: 0 };
|
||||
const sessionStorageKey = "newEventFormData";
|
||||
|
||||
const Page: NextPage<{
|
||||
title?: string;
|
||||
location?: string;
|
||||
description?: string;
|
||||
view?: "week" | "month";
|
||||
}> = ({ title, location, description, view }) => {
|
||||
const { t } = useTranslation("app");
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [persistedFormData, setPersistedFormData] =
|
||||
useSessionStorage<NewEventData>(sessionStorageKey, {
|
||||
currentStep: 0,
|
||||
eventDetails: {
|
||||
title,
|
||||
location,
|
||||
description,
|
||||
},
|
||||
options: {
|
||||
view,
|
||||
},
|
||||
});
|
||||
|
||||
const [formData, setTransientFormData] = React.useState(persistedFormData);
|
||||
|
||||
const setFormData = React.useCallback(
|
||||
(newEventData: NewEventData) => {
|
||||
setTransientFormData(newEventData);
|
||||
setPersistedFormData(newEventData);
|
||||
},
|
||||
[setPersistedFormData],
|
||||
);
|
||||
|
||||
const currentStepIndex = formData?.currentStep ?? 0;
|
||||
|
||||
const currentStepName = steps[currentStepIndex];
|
||||
|
||||
const [isRedirecting, setIsRedirecting] = React.useState(false);
|
||||
|
||||
const [, setUserName] = useUserName();
|
||||
|
||||
const plausible = usePlausible();
|
||||
|
||||
const { mutate: createEventMutation, isLoading: isCreatingPoll } =
|
||||
useMutation(
|
||||
() => {
|
||||
const title = required(formData?.eventDetails?.title);
|
||||
return createPoll({
|
||||
title: title,
|
||||
type: "date",
|
||||
location: formData?.eventDetails?.location,
|
||||
description: formData?.eventDetails?.description,
|
||||
user: {
|
||||
name: required(formData?.userDetails?.name),
|
||||
email: required(formData?.userDetails?.contact),
|
||||
},
|
||||
timeZone: formData?.options?.timeZone,
|
||||
options: required(formData?.options?.options).map(encodeDateOption),
|
||||
});
|
||||
},
|
||||
{
|
||||
onSuccess: (poll) => {
|
||||
setIsRedirecting(true);
|
||||
setUserName(poll.authorName);
|
||||
plausible("Created poll", {
|
||||
props: {
|
||||
numberOfOptions: formData.options?.options?.length,
|
||||
optionsView: formData?.options?.view,
|
||||
},
|
||||
});
|
||||
setPersistedFormData(initialNewEventData);
|
||||
router.replace(`/admin/${poll.urlId}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const isBusy = isRedirecting || isCreatingPoll;
|
||||
|
||||
const handleSubmit = (
|
||||
data: PollDetailsData | PollOptionsData | UserDetailsData,
|
||||
) => {
|
||||
if (currentStepIndex < steps.length - 1) {
|
||||
setFormData({
|
||||
...formData,
|
||||
currentStep: currentStepIndex + 1,
|
||||
[currentStepName]: data,
|
||||
});
|
||||
} else {
|
||||
// last step
|
||||
createEventMutation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
data: Partial<PollDetailsData | PollOptionsData | UserDetailsData>,
|
||||
) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
currentStep: currentStepIndex,
|
||||
[currentStepName]: data,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<StandardLayout>
|
||||
<Head>
|
||||
<title>{formData?.eventDetails?.title ?? t("newPoll")}</title>
|
||||
</Head>
|
||||
<div className="w-[1024px] max-w-full py-4 px-3 lg:px-6">
|
||||
<div className="mb-4 flex items-center space-x-4">
|
||||
<h1 className="m-0">New Poll</h1>
|
||||
<Steps current={currentStepIndex} total={steps.length} />
|
||||
</div>
|
||||
<div className="w-fit max-w-full overflow-hidden rounded-lg border bg-white shadow-sm">
|
||||
{(() => {
|
||||
switch (currentStepName) {
|
||||
case "eventDetails":
|
||||
return (
|
||||
<PollDetailsForm
|
||||
className="max-w-full px-4 pt-4"
|
||||
name={currentStepName}
|
||||
defaultValues={formData?.eventDetails}
|
||||
onSubmit={handleSubmit}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
case "options":
|
||||
return (
|
||||
<PollOptionsForm
|
||||
className="grow"
|
||||
name={currentStepName}
|
||||
defaultValues={formData?.options}
|
||||
onSubmit={handleSubmit}
|
||||
onChange={handleChange}
|
||||
title={formData.eventDetails?.title}
|
||||
/>
|
||||
);
|
||||
case "userDetails":
|
||||
return (
|
||||
<UserDetailsForm
|
||||
className="grow px-4 pt-4"
|
||||
name={currentStepName}
|
||||
defaultValues={formData?.userDetails}
|
||||
onSubmit={handleSubmit}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
<div className="flex w-full justify-end space-x-3 border-t bg-slate-50 px-4 py-3">
|
||||
{currentStepIndex > 0 ? (
|
||||
<Button
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
setFormData({
|
||||
...persistedFormData,
|
||||
currentStep: currentStepIndex - 1,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("back")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
form={currentStepName}
|
||||
loading={isBusy}
|
||||
htmlType="submit"
|
||||
type="primary"
|
||||
>
|
||||
{currentStepIndex < steps.length - 1
|
||||
? t("next")
|
||||
: t("createPoll")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StandardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async ({
|
||||
locale = "en",
|
||||
|
@ -233,4 +15,6 @@ export const getServerSideProps: GetServerSideProps = async ({
|
|||
};
|
||||
|
||||
// We disable SSR because the data on this page relies on sessionStore
|
||||
export default dynamic(() => Promise.resolve(Page), { ssr: false });
|
||||
export default dynamic(() => import("@/components/create-poll"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
|
217
pages/poll.tsx
|
@ -1,41 +1,20 @@
|
|||
import axios from "axios";
|
||||
import { GetServerSideProps, NextPage } from "next";
|
||||
import Head from "next/head";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/router";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||
import { usePlausible } from "next-plausible";
|
||||
import React from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { useMount } from "react-use";
|
||||
import { preventWidows } from "utils/prevent-widows";
|
||||
import { useQuery } from "react-query";
|
||||
|
||||
import Button from "@/components/button";
|
||||
import ErrorPage from "@/components/error-page";
|
||||
import FullPageLoader from "@/components/full-page-loader";
|
||||
import LocationMarker from "@/components/icons/location-marker.svg";
|
||||
import LockClosed from "@/components/icons/lock-closed.svg";
|
||||
import Share from "@/components/icons/share.svg";
|
||||
import ManagePoll from "@/components/poll/manage-poll";
|
||||
import MobilePoll from "@/components/poll/mobile-poll";
|
||||
import { useUpdatePollMutation } from "@/components/poll/mutations";
|
||||
import NotificationsToggle from "@/components/poll/notifications-toggle";
|
||||
import PollSubheader from "@/components/poll/poll-subheader";
|
||||
import TruncatedLinkify from "@/components/poll/truncated-linkify";
|
||||
import { UserAvatarProvider } from "@/components/poll/user-avatar";
|
||||
import { PollContextProvider, usePoll } from "@/components/poll-context";
|
||||
import Popover from "@/components/popover";
|
||||
import Sharing from "@/components/sharing";
|
||||
import StandardLayout from "@/components/standard-layout";
|
||||
import { useUserName } from "@/components/user-name-context";
|
||||
|
||||
import { GetPollResponse } from "../api-client/get-poll";
|
||||
import Custom404 from "./404";
|
||||
|
||||
const Discussion = React.lazy(() => import("@/components/discussion"));
|
||||
|
||||
const Poll = React.lazy(() => import("@/components/poll"));
|
||||
const PollPage = dynamic(() => import("@/components/poll"), { ssr: false });
|
||||
|
||||
const PollPageLoader: NextPage = () => {
|
||||
const { query } = useRouter();
|
||||
|
@ -99,195 +78,7 @@ const PollPageLoader: NextPage = () => {
|
|||
return !poll ? (
|
||||
<FullPageLoader>{t("loading")}</FullPageLoader>
|
||||
) : (
|
||||
<PollContextProvider value={poll}>
|
||||
<PollPage />
|
||||
</PollContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const PollPage: NextPage = () => {
|
||||
const { poll } = usePoll();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
useMount(() => {
|
||||
const path = poll.role === "admin" ? "admin" : "p";
|
||||
|
||||
if (!new RegExp(`^/${path}`).test(router.asPath)) {
|
||||
router.replace(`/${path}/${poll.urlId}`, undefined, { shallow: true });
|
||||
}
|
||||
});
|
||||
|
||||
const [, setUserName] = useUserName();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const plausible = usePlausible();
|
||||
|
||||
const { mutate: updatePollMutation } = useUpdatePollMutation();
|
||||
|
||||
const { mutate: verifyEmail } = useMutation(
|
||||
async (verificationCode: string) => {
|
||||
await axios.post(`/api/poll/${poll.urlId}/verify`, {
|
||||
verificationCode,
|
||||
});
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Your poll has been verified");
|
||||
router.replace(`/admin/${router.query.urlId}`, undefined, {
|
||||
shallow: true,
|
||||
});
|
||||
queryClient.setQueryData(["getPoll", poll.urlId], {
|
||||
...poll,
|
||||
verified: true,
|
||||
});
|
||||
plausible("Verified email");
|
||||
setUserName(poll.authorName);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
// TODO (Luke Vella) [2022-03-29]: stop looking for "verificationCode". We switched to
|
||||
// "code" for compatability with v1 and it's generally better since it's more concise
|
||||
const verificationCode = router.query.verificationCode ?? router.query.code;
|
||||
if (typeof verificationCode === "string") {
|
||||
verifyEmail(verificationCode);
|
||||
}
|
||||
}, [router, verifyEmail]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (router.query.unsubscribe) {
|
||||
updatePollMutation(
|
||||
{ notifications: false },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Notifications have been disabled");
|
||||
plausible("Unsubscribed from notifications");
|
||||
},
|
||||
},
|
||||
);
|
||||
router.replace(`/admin/${router.query.urlId}`, undefined, {
|
||||
shallow: true,
|
||||
});
|
||||
}
|
||||
}, [plausible, router, updatePollMutation]);
|
||||
|
||||
const checkIfWideScreen = () => window.innerWidth > 640;
|
||||
|
||||
const [isWideScreen, setIsWideScreen] = React.useState(checkIfWideScreen);
|
||||
|
||||
React.useEffect(() => {
|
||||
const listener = () => setIsWideScreen(checkIfWideScreen());
|
||||
|
||||
window.addEventListener("resize", listener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", listener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const PollComponent = isWideScreen ? Poll : MobilePoll;
|
||||
|
||||
let highScore = 1; // set to one because we don't want to highlight
|
||||
poll.options.forEach((option) => {
|
||||
if (option.votes.length > highScore) {
|
||||
highScore = option.votes.length;
|
||||
}
|
||||
});
|
||||
|
||||
const names = React.useMemo(
|
||||
() => poll.participants.map(({ name }) => name),
|
||||
[poll.participants],
|
||||
);
|
||||
|
||||
return (
|
||||
<UserAvatarProvider seed={poll.pollId} names={names}>
|
||||
<StandardLayout>
|
||||
<div className="relative max-w-full bg-gray-50 py-4 md:px-4 lg:w-[1024px] lg:px-8">
|
||||
<Head>
|
||||
<title>{poll.title}</title>
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
</Head>
|
||||
<div
|
||||
className="max-w-full"
|
||||
style={{
|
||||
width: Math.max(600, poll.options.length * 95 + 200 + 160),
|
||||
}}
|
||||
>
|
||||
<div className="mb-6">
|
||||
<div className="mb-3 items-start px-4 md:flex md:space-x-4">
|
||||
<div className="mb-3 grow md:mb-0">
|
||||
<div className="flex flex-col-reverse md:flex-row">
|
||||
<h1 className="mb-2 grow text-3xl leading-tight">
|
||||
{preventWidows(poll.title)}
|
||||
</h1>
|
||||
{poll.role === "admin" ? (
|
||||
<div className="mb-4 flex space-x-2 md:mb-2">
|
||||
<NotificationsToggle />
|
||||
<ManagePoll
|
||||
placement={
|
||||
isWideScreen ? "bottom-end" : "bottom-start"
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<Popover
|
||||
trigger={
|
||||
<Button type="primary" icon={<Share />}>
|
||||
Share
|
||||
</Button>
|
||||
}
|
||||
placement={isWideScreen ? "bottom-end" : undefined}
|
||||
>
|
||||
<Sharing links={poll.links} />
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<PollSubheader />
|
||||
</div>
|
||||
</div>
|
||||
{poll.description ? (
|
||||
<div className="mb-4 whitespace-pre-line bg-white px-4 py-3 text-lg leading-relaxed text-slate-600 shadow-sm md:w-fit md:rounded-xl md:bg-white">
|
||||
<TruncatedLinkify>
|
||||
{preventWidows(poll.description)}
|
||||
</TruncatedLinkify>
|
||||
</div>
|
||||
) : null}
|
||||
{poll.location ? (
|
||||
<div className="mb-4 flex items-center px-4">
|
||||
<div>
|
||||
<LocationMarker
|
||||
width={20}
|
||||
className="mr-2 text-slate-400"
|
||||
/>
|
||||
</div>
|
||||
<TruncatedLinkify>{poll.location}</TruncatedLinkify>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{poll.closed ? (
|
||||
<div className="mb-4 flex items-center bg-sky-100 py-3 px-4 text-sky-700 shadow-sm md:rounded-lg">
|
||||
<div className="mr-3 rounded-md">
|
||||
<LockClosed className="w-5" />
|
||||
</div>
|
||||
This poll has been locked (voting is disabled)
|
||||
</div>
|
||||
) : null}
|
||||
<React.Suspense fallback={<div>Loading…</div>}>
|
||||
<div className="mb-4 lg:mb-8">
|
||||
<PollComponent pollId={poll.urlId} highScore={highScore} />
|
||||
</div>
|
||||
<Discussion
|
||||
pollId={poll.urlId}
|
||||
canDelete={poll.role === "admin"}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</StandardLayout>
|
||||
</UserAvatarProvider>
|
||||
<PollPage poll={poll} />
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -42,5 +42,12 @@
|
|||
"participantDescription": "Partial access to vote and comment on this poll.",
|
||||
"unverifiedMessage": "An email has been sent to <b>{{email}}</b> with a link to verify the email address.",
|
||||
"notificationsOnDescription": "An email will be sent to <b>{{email}}</b> when there is activity on this poll.",
|
||||
"deletingOptionsWarning": "You are deleting options that participants have voted for. Their votes will be also be deleted."
|
||||
"deletingOptionsWarning": "You are deleting options that participants have voted for. Their votes will be also be deleted.",
|
||||
"timeAndDate": "Time & date",
|
||||
"weekStartsOn": "Week starts on:",
|
||||
"timeFormat": "Time format:",
|
||||
"monday": "Monday",
|
||||
"sunday": "Sunday",
|
||||
"12h": "12-hour",
|
||||
"24h": "24-hour"
|
||||
}
|
||||
|
|
18
style.css
|
@ -9,7 +9,7 @@
|
|||
height: 100%;
|
||||
}
|
||||
body {
|
||||
@apply text-base text-slate-600;
|
||||
@apply bg-slate-50 text-base text-slate-600;
|
||||
}
|
||||
p {
|
||||
@apply mb-4;
|
||||
|
@ -40,7 +40,7 @@
|
|||
@apply focus:outline-none focus:ring-indigo-600;
|
||||
}
|
||||
|
||||
#portal {
|
||||
#floating-ui-root {
|
||||
@apply absolute z-50 w-full;
|
||||
}
|
||||
}
|
||||
|
@ -87,7 +87,7 @@
|
|||
@apply pointer-events-none;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply btn border-indigo-600 bg-indigo-500 text-white hover:bg-opacity-90 focus:ring-indigo-500;
|
||||
@apply btn border-indigo-600 bg-indigo-500 text-white hover:bg-opacity-90 focus:ring-indigo-500 active:bg-indigo-600;
|
||||
}
|
||||
a.btn-primary {
|
||||
@apply text-white;
|
||||
|
@ -121,12 +121,9 @@
|
|||
@apply cursor-not-allowed;
|
||||
}
|
||||
|
||||
[data-popper-placement="bottom"] .tooltip-arrow {
|
||||
@apply bottom-full border-b-slate-700;
|
||||
}
|
||||
|
||||
[data-popper-placement="top"] .tooltip-arrow {
|
||||
@apply top-full border-t-slate-700;
|
||||
.card {
|
||||
@apply rounded-lg border bg-white p-6
|
||||
shadow-sm;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -146,9 +143,6 @@
|
|||
}
|
||||
|
||||
@layer utilities {
|
||||
.contain-paint {
|
||||
contain: paint;
|
||||
}
|
||||
.bg-pattern {
|
||||
background-color: #f9fafb;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='100' height='20' viewBox='0 0 100 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M21.184 20c.357-.13.72-.264 1.088-.402l1.768-.661C33.64 15.347 39.647 14 50 14c10.271 0 15.362 1.222 24.629 4.928.955.383 1.869.74 2.75 1.072h6.225c-2.51-.73-5.139-1.691-8.233-2.928C65.888 13.278 60.562 12 50 12c-10.626 0-16.855 1.397-26.66 5.063l-1.767.662c-2.475.923-4.66 1.674-6.724 2.275h6.335zm0-20C13.258 2.892 8.077 4 0 4V2c5.744 0 9.951-.574 14.85-2h6.334zM77.38 0C85.239 2.966 90.502 4 100 4V2c-6.842 0-11.386-.542-16.396-2h-6.225zM0 14c8.44 0 13.718-1.21 22.272-4.402l1.768-.661C33.64 5.347 39.647 4 50 4c10.271 0 15.362 1.222 24.629 4.928C84.112 12.722 89.438 14 100 14v-2c-10.271 0-15.362-1.222-24.629-4.928C65.888 3.278 60.562 2 50 2 39.374 2 33.145 3.397 23.34 7.063l-1.767.662C13.223 10.84 8.163 12 0 12v2z' fill='%239C92AC' fill-opacity='0.06' fill-rule='evenodd'/%3E%3C/svg%3E");
|
||||
|
|
|
@ -35,5 +35,7 @@ test("should be able to create a new poll", async ({ page, context }) => {
|
|||
|
||||
await page.click('text="Create poll"');
|
||||
|
||||
await expect(page.locator('text="Monthly Meetup"')).toBeVisible();
|
||||
await expect(page.locator("data-testid=poll-title")).toHaveText(
|
||||
"Monthly Meetup",
|
||||
);
|
||||
});
|
||||
|
|
|
@ -1 +1,18 @@
|
|||
import { Placement } from "@floating-ui/react-dom-interactions";
|
||||
|
||||
export const isInMaintenanceMode = process.env.MAINTENANCE_MODE === "true";
|
||||
|
||||
export const transformOriginByPlacement: Record<Placement, string> = {
|
||||
bottom: "origin-top",
|
||||
"bottom-end": "origin-top-right",
|
||||
"bottom-start": "origin-top-left",
|
||||
left: "origin-right",
|
||||
"left-start": "origin-top-right",
|
||||
"left-end": "origin-bottom-right",
|
||||
right: "origin-left",
|
||||
"right-start": "origin-top-left",
|
||||
"right-end": "origin-bottom-left",
|
||||
top: "origin-bottom",
|
||||
"top-start": "origin-bottom-left",
|
||||
"top-end": "origin-bottom-right",
|
||||
};
|
||||
|
|
|
@ -5,6 +5,7 @@ import {
|
|||
format,
|
||||
formatDuration,
|
||||
isSameDay,
|
||||
Locale,
|
||||
} from "date-fns";
|
||||
import { formatInTimeZone } from "date-fns-tz";
|
||||
import spacetime from "spacetime";
|
||||
|
@ -58,6 +59,7 @@ export const decodeOptions = (
|
|||
options: Option[],
|
||||
timeZone: string | null,
|
||||
targetTimeZone: string,
|
||||
locale: Locale,
|
||||
):
|
||||
| { pollType: "date"; options: ParsedDateOption[] }
|
||||
| { pollType: "timeSlot"; options: ParsedTimeSlotOption[] } => {
|
||||
|
@ -67,7 +69,7 @@ export const decodeOptions = (
|
|||
return {
|
||||
pollType,
|
||||
options: options.map((option) =>
|
||||
parseTimeSlotOption(option, timeZone, targetTimeZone),
|
||||
parseTimeSlotOption(option, timeZone, targetTimeZone, locale),
|
||||
),
|
||||
};
|
||||
} else {
|
||||
|
@ -98,7 +100,18 @@ const parseTimeSlotOption = (
|
|||
option: Option,
|
||||
timeZone: string | null,
|
||||
targetTimeZone: string,
|
||||
locale: Locale,
|
||||
): ParsedTimeSlotOption => {
|
||||
const localeFormatInTimezone = (
|
||||
date: Date,
|
||||
timezone: string,
|
||||
formatString: string,
|
||||
) => {
|
||||
return formatInTimeZone(date, timezone, formatString, {
|
||||
locale,
|
||||
});
|
||||
};
|
||||
|
||||
const [start, end] = option.value.split("/");
|
||||
if (timeZone && targetTimeZone) {
|
||||
const startDate = spacetime(start, timeZone).toNativeDate();
|
||||
|
@ -106,11 +119,11 @@ const parseTimeSlotOption = (
|
|||
return {
|
||||
type: "timeSlot",
|
||||
optionId: option.id,
|
||||
startTime: formatInTimeZone(startDate, targetTimeZone, "hh:mm a"),
|
||||
endTime: formatInTimeZone(endDate, targetTimeZone, "hh:mm a"),
|
||||
day: formatInTimeZone(startDate, targetTimeZone, "d"),
|
||||
dow: formatInTimeZone(startDate, targetTimeZone, "E"),
|
||||
month: formatInTimeZone(startDate, targetTimeZone, "MMM"),
|
||||
startTime: localeFormatInTimezone(startDate, targetTimeZone, "p"),
|
||||
endTime: localeFormatInTimezone(endDate, targetTimeZone, "p"),
|
||||
day: localeFormatInTimezone(startDate, targetTimeZone, "d"),
|
||||
dow: localeFormatInTimezone(startDate, targetTimeZone, "E"),
|
||||
month: localeFormatInTimezone(startDate, targetTimeZone, "MMM"),
|
||||
duration: getDuration(startDate, endDate),
|
||||
};
|
||||
} else {
|
||||
|
@ -119,8 +132,8 @@ const parseTimeSlotOption = (
|
|||
return {
|
||||
type: "timeSlot",
|
||||
optionId: option.id,
|
||||
startTime: format(startDate, "hh:mm a"),
|
||||
endTime: format(endDate, "hh:mm a"),
|
||||
startTime: format(startDate, "p"),
|
||||
endTime: format(endDate, "p"),
|
||||
day: format(startDate, "d"),
|
||||
dow: format(startDate, "E"),
|
||||
month: format(startDate, "MMM"),
|
||||
|
@ -129,61 +142,6 @@ const parseTimeSlotOption = (
|
|||
}
|
||||
};
|
||||
|
||||
export const decodeDateOption = (
|
||||
option: Option,
|
||||
timeZone: string | null,
|
||||
targetTimeZone: string,
|
||||
): ParsedDateTimeOpton => {
|
||||
const isTimeRange = option.value.indexOf("/") !== -1;
|
||||
// option can either be an ISO date (ex. 2000-01-01)
|
||||
// or a time range (ex. 2000-01-01T08:00:00/2000-01-01T09:00:00)
|
||||
if (isTimeRange) {
|
||||
const [start, end] = option.value.split("/");
|
||||
|
||||
if (timeZone && targetTimeZone) {
|
||||
const startDate = spacetime(start, timeZone).toNativeDate();
|
||||
const endDate = spacetime(end, timeZone).toNativeDate();
|
||||
return {
|
||||
type: "timeSlot",
|
||||
optionId: option.id,
|
||||
startTime: formatInTimeZone(startDate, targetTimeZone, "hh:mm a"),
|
||||
endTime: formatInTimeZone(endDate, targetTimeZone, "hh:mm a"),
|
||||
day: formatInTimeZone(startDate, targetTimeZone, "d"),
|
||||
dow: formatInTimeZone(startDate, targetTimeZone, "E"),
|
||||
month: formatInTimeZone(startDate, targetTimeZone, "MMM"),
|
||||
duration: getDuration(startDate, endDate),
|
||||
};
|
||||
} else {
|
||||
const startDate = new Date(start);
|
||||
const endDate = new Date(end);
|
||||
return {
|
||||
type: "timeSlot",
|
||||
optionId: option.id,
|
||||
startTime: format(startDate, "hh:mm a"),
|
||||
endTime: format(endDate, "hh:mm a"),
|
||||
day: format(startDate, "d"),
|
||||
dow: format(startDate, "E"),
|
||||
month: format(startDate, "MMM"),
|
||||
duration: getDuration(startDate, endDate),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// we add the time because otherwise Date will assume UTC time which might change the day for some time zones
|
||||
const dateString =
|
||||
option.value.indexOf("T") === -1
|
||||
? option.value + "T00:00:00"
|
||||
: option.value;
|
||||
const date = new Date(dateString);
|
||||
return {
|
||||
type: "date",
|
||||
optionId: option.id,
|
||||
day: format(date, "d"),
|
||||
dow: format(date, "E"),
|
||||
month: format(date, "MMM"),
|
||||
};
|
||||
};
|
||||
|
||||
export const removeAllOptionsForDay = (
|
||||
options: DateTimeOption[],
|
||||
date: Date,
|
||||
|
|
69
yarn.lock
|
@ -1138,6 +1138,36 @@
|
|||
minimatch "^3.0.4"
|
||||
strip-json-comments "^3.1.1"
|
||||
|
||||
"@floating-ui/core@^0.6.2":
|
||||
version "0.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-0.6.2.tgz#f2813f0e5f3d5ed7af5029e1a082203dadf02b7d"
|
||||
integrity sha512-jktYRmZwmau63adUG3GKOAVCofBXkk55S/zQ94XOorAHhwqFIOFAy1rSp2N0Wp6/tGbe9V3u/ExlGZypyY17rg==
|
||||
|
||||
"@floating-ui/dom@^0.4.5":
|
||||
version "0.4.5"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-0.4.5.tgz#2e88d16646119cc67d44683f75ee99840475bbfa"
|
||||
integrity sha512-b+prvQgJt8pieaKYMSJBXHxX/DYwdLsAWxKYqnO5dO2V4oo/TYBZJAUQCVNjTWWsrs6o4VDrNcP9+E70HAhJdw==
|
||||
dependencies:
|
||||
"@floating-ui/core" "^0.6.2"
|
||||
|
||||
"@floating-ui/react-dom-interactions@^0.3.1":
|
||||
version "0.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom-interactions/-/react-dom-interactions-0.3.1.tgz#abc0cb4b18e6f095397e50f9846572eee4e34554"
|
||||
integrity sha512-tP2KEh7EHJr5hokSBHcPGojb+AorDNUf0NYfZGg/M+FsMvCOOsSEeEF0O1NDfETIzDnpbHnCs0DuvCFhSMSStg==
|
||||
dependencies:
|
||||
"@floating-ui/react-dom" "^0.6.3"
|
||||
aria-hidden "^1.1.3"
|
||||
point-in-polygon "^1.1.0"
|
||||
use-isomorphic-layout-effect "^1.1.1"
|
||||
|
||||
"@floating-ui/react-dom@^0.6.3":
|
||||
version "0.6.3"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-0.6.3.tgz#7b64cfd4fd12e4a0515dbf1b2be16e48c9a06c5a"
|
||||
integrity sha512-hC+pS5D6AgS2wWjbmSQ6UR6Kpy+drvWGJIri6e1EDGADTPsCaa4KzCgmCczHrQeInx9tqs81EyDmbKJYY2swKg==
|
||||
dependencies:
|
||||
"@floating-ui/dom" "^0.4.5"
|
||||
use-isomorphic-layout-effect "^1.1.1"
|
||||
|
||||
"@hapi/hoek@^9.0.0":
|
||||
version "9.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.2.1.tgz#9551142a1980503752536b5050fd99f4a7f13b17"
|
||||
|
@ -1324,11 +1354,6 @@
|
|||
resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.21.tgz#5de5a2385a35309427f6011992b544514d559aa1"
|
||||
integrity sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==
|
||||
|
||||
"@popperjs/core@^2.11.4":
|
||||
version "2.11.4"
|
||||
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.4.tgz#d8c7b8db9226d2d7664553a0741ad7d0397ee503"
|
||||
integrity sha512-q/ytXxO5NKvyT37pmisQAItCFqA7FD/vNb8dgaJy3/630Fsc+Mz9/9f2SziBoIZ30TJooXyTwZmhi1zjXmObYg==
|
||||
|
||||
"@popperjs/core@^2.5.3":
|
||||
version "2.9.2"
|
||||
resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.9.2.tgz"
|
||||
|
@ -2049,6 +2074,13 @@ argparse@^1.0.7:
|
|||
dependencies:
|
||||
sprintf-js "~1.0.2"
|
||||
|
||||
aria-hidden@^1.1.3:
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.1.3.tgz#bb48de18dc84787a3c6eee113709c473c64ec254"
|
||||
integrity sha512-RhVWFtKH5BiGMycI72q2RAFMLQi8JP9bLuQXgR5a8Znp7P5KOIADSJeyfI8PCVxLEp067B2HbP5JIiI/PXIZeA==
|
||||
dependencies:
|
||||
tslib "^1.0.0"
|
||||
|
||||
aria-query@^4.2.2:
|
||||
version "4.2.2"
|
||||
resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-4.2.2.tgz#0d2ca6c9aceb56b8977e9fed6aed7e15bbd2f83b"
|
||||
|
@ -4687,6 +4719,11 @@ pngjs@^4.0.1:
|
|||
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-4.0.1.tgz#f803869bb2fc1bfe1bf99aa4ec21c108117cfdbe"
|
||||
integrity sha512-rf5+2/ioHeQxR6IxuYNYGFytUyG3lma/WW1nsmjeHlWwtb2aByla6dkVc8pmJ9nplzkTA0q2xx7mMWrOTqT4Gg==
|
||||
|
||||
point-in-polygon@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/point-in-polygon/-/point-in-polygon-1.1.0.tgz#b0af2616c01bdee341cbf2894df643387ca03357"
|
||||
integrity sha512-3ojrFwjnnw8Q9242TzgXuTD+eKiutbzyslcq1ydfu82Db2y+Ogbmyrkpv0Hgj31qwT3lbS9+QAAO/pIQM35XRw==
|
||||
|
||||
popmotion@11.0.3:
|
||||
version "11.0.3"
|
||||
resolved "https://registry.yarnpkg.com/popmotion/-/popmotion-11.0.3.tgz#565c5f6590bbcddab7a33a074bb2ba97e24b0cc9"
|
||||
|
@ -4881,11 +4918,6 @@ react-dom@17.0.2:
|
|||
object-assign "^4.1.1"
|
||||
scheduler "^0.20.2"
|
||||
|
||||
react-fast-compare@^3.0.1:
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb"
|
||||
integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==
|
||||
|
||||
react-github-btn@^1.2.2:
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/react-github-btn/-/react-github-btn-1.2.2.tgz#9aab2498ff311b9f9c448a2d2b902d0277037d5c"
|
||||
|
@ -4960,14 +4992,6 @@ react-overlays@^4.1.1:
|
|||
uncontrollable "^7.0.0"
|
||||
warning "^4.0.3"
|
||||
|
||||
react-popper@^2.2.5:
|
||||
version "2.2.5"
|
||||
resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-2.2.5.tgz#1214ef3cec86330a171671a4fbcbeeb65ee58e96"
|
||||
integrity sha512-kxGkS80eQGtLl18+uig1UIf9MKixFSyPxglsgLBxlYnyDf65BiY9B3nZSc6C9XUNDgStROB0fMQlTEz1KxGddw==
|
||||
dependencies:
|
||||
react-fast-compare "^3.0.1"
|
||||
warning "^4.0.2"
|
||||
|
||||
react-query@^3.34.12:
|
||||
version "3.34.12"
|
||||
resolved "https://registry.npmjs.org/react-query/-/react-query-3.34.12.tgz"
|
||||
|
@ -5734,7 +5758,7 @@ tsconfig-paths@^3.9.0:
|
|||
minimist "^1.2.0"
|
||||
strip-bom "^3.0.0"
|
||||
|
||||
tslib@^1.8.1, tslib@^1.9.3:
|
||||
tslib@^1.0.0, tslib@^1.8.1, tslib@^1.9.3:
|
||||
version "1.14.1"
|
||||
resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz"
|
||||
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
|
||||
|
@ -5836,6 +5860,11 @@ uri-js@^4.2.2:
|
|||
dependencies:
|
||||
punycode "^2.1.0"
|
||||
|
||||
use-isomorphic-layout-effect@^1.1.1:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb"
|
||||
integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==
|
||||
|
||||
util-deprecate@^1.0.2, util-deprecate@~1.0.1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
|
||||
|
@ -5870,7 +5899,7 @@ wait-on@^6.0.1:
|
|||
minimist "^1.2.5"
|
||||
rxjs "^7.5.4"
|
||||
|
||||
warning@^4.0.2, warning@^4.0.3:
|
||||
warning@^4.0.3:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz"
|
||||
integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==
|
||||
|
|