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 NameInput from "../name-input";
|
||||||
import TruncatedLinkify from "../poll/truncated-linkify";
|
import TruncatedLinkify from "../poll/truncated-linkify";
|
||||||
import UserAvater from "../poll/user-avatar";
|
import UserAvater from "../poll/user-avatar";
|
||||||
|
import { usePreferences } from "../preferences/use-preferences";
|
||||||
import { useUserName } from "../user-name-context";
|
import { useUserName } from "../user-name-context";
|
||||||
|
|
||||||
export interface DiscussionProps {
|
export interface DiscussionProps {
|
||||||
|
@ -37,6 +38,7 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
|
||||||
pollId,
|
pollId,
|
||||||
canDelete,
|
canDelete,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { locale } = usePreferences();
|
||||||
const getCommentsQueryKey = ["poll", pollId, "comments"];
|
const getCommentsQueryKey = ["poll", pollId, "comments"];
|
||||||
const [userName, setUserName] = useUserName();
|
const [userName, setUserName] = useUserName();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
@ -146,6 +148,9 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
|
||||||
{formatRelative(
|
{formatRelative(
|
||||||
new Date(comment.createdAt),
|
new Date(comment.createdAt),
|
||||||
Date.now(),
|
Date.now(),
|
||||||
|
{
|
||||||
|
locale,
|
||||||
|
},
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,11 +1,19 @@
|
||||||
|
import {
|
||||||
|
flip,
|
||||||
|
FloatingPortal,
|
||||||
|
offset,
|
||||||
|
Placement,
|
||||||
|
useFloating,
|
||||||
|
} from "@floating-ui/react-dom-interactions";
|
||||||
import { Menu } from "@headlessui/react";
|
import { Menu } from "@headlessui/react";
|
||||||
import { Placement } from "@popperjs/core";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import ReactDOM from "react-dom";
|
import { transformOriginByPlacement } from "utils/constants";
|
||||||
import { usePopper } from "react-popper";
|
|
||||||
import { stopPropagation } from "utils/stop-propagation";
|
import { stopPropagation } from "utils/stop-propagation";
|
||||||
|
|
||||||
|
const MotionMenuItems = motion(Menu.Items);
|
||||||
|
|
||||||
export interface DropdownProps {
|
export interface DropdownProps {
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
@ -17,49 +25,51 @@ const Dropdown: React.VoidFunctionComponent<DropdownProps> = ({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
trigger,
|
trigger,
|
||||||
placement,
|
placement: preferredPlacement,
|
||||||
}) => {
|
}) => {
|
||||||
const [referenceElement, setReferenceElement] =
|
const { reference, floating, x, y, strategy, placement } = useFloating({
|
||||||
React.useState<HTMLDivElement | null>(null);
|
placement: preferredPlacement,
|
||||||
const [popperElement, setPopperElement] =
|
middleware: [offset(5), flip()],
|
||||||
React.useState<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
|
||||||
placement,
|
|
||||||
modifiers: [
|
|
||||||
{
|
|
||||||
name: "offset",
|
|
||||||
options: {
|
|
||||||
offset: [0, 5],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const portal = document.getElementById("portal");
|
const animationOrigin = transformOriginByPlacement[placement];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu>
|
<Menu>
|
||||||
<Menu.Button
|
{({ open }) => (
|
||||||
ref={setReferenceElement}
|
<>
|
||||||
as="div"
|
<Menu.Button
|
||||||
className={clsx("inline-block", className)}
|
|
||||||
>
|
|
||||||
{trigger}
|
|
||||||
</Menu.Button>
|
|
||||||
{portal &&
|
|
||||||
ReactDOM.createPortal(
|
|
||||||
<Menu.Items
|
|
||||||
as="div"
|
as="div"
|
||||||
ref={setPopperElement}
|
className={clsx("inline-block", className)}
|
||||||
style={styles.popper}
|
ref={reference}
|
||||||
{...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"
|
|
||||||
onMouseDown={stopPropagation}
|
|
||||||
>
|
>
|
||||||
{children}
|
{trigger}
|
||||||
</Menu.Items>,
|
</Menu.Button>
|
||||||
portal,
|
<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}
|
||||||
|
</MotionMenuItems>
|
||||||
|
) : null}
|
||||||
|
</FloatingPortal>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -5,6 +5,8 @@ import isSameDay from "date-fns/isSameDay";
|
||||||
import { usePlausible } from "next-plausible";
|
import { usePlausible } from "next-plausible";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { usePreferences } from "@/components/preferences/use-preferences";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
expectTimeOption,
|
expectTimeOption,
|
||||||
getDateProps,
|
getDateProps,
|
||||||
|
@ -74,15 +76,18 @@ const MonthCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
|
||||||
);
|
);
|
||||||
}, [optionsByDay]);
|
}, [optionsByDay]);
|
||||||
|
|
||||||
|
const { weekStartsOn } = usePreferences();
|
||||||
|
|
||||||
const datepicker = useHeadlessDatePicker({
|
const datepicker = useHeadlessDatePicker({
|
||||||
selection: datepickerSelection,
|
selection: datepickerSelection,
|
||||||
onNavigationChange: onNavigate,
|
onNavigationChange: onNavigate,
|
||||||
|
weekStartsOn,
|
||||||
date,
|
date,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden lg:flex">
|
<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>
|
||||||
<div className="flex w-full flex-col">
|
<div className="flex w-full flex-col">
|
||||||
<div className="mb-3 flex items-center justify-center space-x-4">
|
<div className="mb-3 flex items-center justify-center space-x-4">
|
||||||
|
@ -152,7 +157,7 @@ const MonthCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={clsx(
|
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,
|
"bg-slate-50 text-slate-400": day.outOfMonth,
|
||||||
"font-bold text-indigo-500": day.today,
|
"font-bold text-indigo-500": day.today,
|
||||||
|
@ -233,7 +238,7 @@ const MonthCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={dateString}
|
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>
|
<div>
|
||||||
<DateCard
|
<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 clsx from "clsx";
|
||||||
import { addMinutes, format, isSameDay, setHours, setMinutes } from "date-fns";
|
import { addMinutes, format, isSameDay, setHours, setMinutes } from "date-fns";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import ReactDOM from "react-dom";
|
|
||||||
import { usePopper } from "react-popper";
|
|
||||||
import { stopPropagation } from "utils/stop-propagation";
|
import { stopPropagation } from "utils/stop-propagation";
|
||||||
|
|
||||||
|
import { usePreferences } from "@/components/preferences/use-preferences";
|
||||||
|
|
||||||
import ChevronDown from "../../../icons/chevron-down.svg";
|
import ChevronDown from "../../../icons/chevron-down.svg";
|
||||||
import { styleMenuItem } from "../../../menu-styles";
|
import { styleMenuItem } from "../../../menu-styles";
|
||||||
|
|
||||||
|
@ -22,23 +29,24 @@ const TimePicker: React.VoidFunctionComponent<TimePickerProps> = ({
|
||||||
className,
|
className,
|
||||||
startFrom = setMinutes(setHours(value, 0), 0),
|
startFrom = setMinutes(setHours(value, 0), 0),
|
||||||
}) => {
|
}) => {
|
||||||
const [referenceElement, setReferenceElement] =
|
const { locale } = usePreferences();
|
||||||
React.useState<HTMLDivElement | null>(null);
|
const { reference, floating, x, y, strategy, refs } = useFloating({
|
||||||
const [popperElement, setPopperElement] =
|
strategy: "fixed",
|
||||||
React.useState<HTMLUListElement | null>(null);
|
middleware: [
|
||||||
|
offset(5),
|
||||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
flip(),
|
||||||
modifiers: [
|
size({
|
||||||
{
|
apply: ({ reference }) => {
|
||||||
name: "offset",
|
if (refs.floating.current) {
|
||||||
options: {
|
Object.assign(refs.floating.current.style, {
|
||||||
offset: [0, 5],
|
width: `${reference.width}px`,
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const [query, setQuery] = React.useState("");
|
|
||||||
const options: React.ReactNode[] = [];
|
const options: React.ReactNode[] = [];
|
||||||
for (let i = 0; i < 96; i++) {
|
for (let i = 0; i < 96; i++) {
|
||||||
const optionValue = addMinutes(startFrom, i * 15);
|
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
|
// because react-big-calendar does not support events that span days
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (query && !format(optionValue, "hhmma").includes(query)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
options.push(
|
options.push(
|
||||||
<Combobox.Option
|
<Listbox.Option
|
||||||
key={i}
|
key={i}
|
||||||
className={styleMenuItem}
|
className={styleMenuItem}
|
||||||
value={optionValue.toISOString()}
|
value={optionValue.toISOString()}
|
||||||
>
|
>
|
||||||
{format(optionValue, "p")}
|
{format(optionValue, "p", { locale })}
|
||||||
</Combobox.Option>,
|
</Listbox.Option>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const portal = document.getElementById("portal");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox
|
<Listbox
|
||||||
value={value.toISOString()}
|
value={value.toISOString()}
|
||||||
onChange={(newValue) => {
|
onChange={(newValue) => {
|
||||||
setQuery("");
|
|
||||||
onChange?.(new Date(newValue));
|
onChange?.(new Date(newValue));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div ref={setReferenceElement} className={clsx("relative", className)}>
|
{(open) => (
|
||||||
{/* Remove generic params once Combobox.Input can infer the types */}
|
<>
|
||||||
<Combobox.Input<"input">
|
<div ref={reference} className={clsx("relative", className)}>
|
||||||
className="input w-28 pr-8"
|
<Listbox.Button className="btn-default text-left">
|
||||||
displayValue={() => ""}
|
<span className="grow truncate">
|
||||||
onChange={(e) => {
|
{format(value, "p", { locale })}
|
||||||
setQuery(e.target.value.toUpperCase().replace(/[\:\s]/g, ""));
|
</span>
|
||||||
}}
|
<span className="pointer-events-none ml-2 flex">
|
||||||
/>
|
<ChevronDown className="h-5 w-5" />
|
||||||
<Combobox.Button className="absolute inset-0 flex h-9 cursor-default items-center px-2 text-left">
|
</span>
|
||||||
<span className="grow truncate">
|
</Listbox.Button>
|
||||||
{!query ? format(value, "p") : null}
|
</div>
|
||||||
</span>
|
<FloatingPortal>
|
||||||
<span className="pointer-events-none flex">
|
{open ? (
|
||||||
<ChevronDown className="h-5 w-5" />
|
<Listbox.Options
|
||||||
</span>
|
style={{
|
||||||
</Combobox.Button>
|
position: strategy,
|
||||||
{portal &&
|
left: x ?? "",
|
||||||
ReactDOM.createPortal(
|
top: y ?? "",
|
||||||
<Combobox.Options
|
}}
|
||||||
style={styles.popper}
|
ref={floating}
|
||||||
{...attributes.popper}
|
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"
|
||||||
ref={setPopperElement}
|
onMouseDown={stopPropagation}
|
||||||
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"
|
>
|
||||||
onMouseDown={stopPropagation}
|
{options}
|
||||||
>
|
</Listbox.Options>
|
||||||
{options}
|
) : null}
|
||||||
</Combobox.Options>,
|
</FloatingPortal>
|
||||||
portal,
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Listbox>
|
||||||
</Combobox>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -11,18 +11,12 @@ import React from "react";
|
||||||
import { Calendar, dateFnsLocalizer } from "react-big-calendar";
|
import { Calendar, dateFnsLocalizer } from "react-big-calendar";
|
||||||
import { useMount } from "react-use";
|
import { useMount } from "react-use";
|
||||||
|
|
||||||
|
import { usePreferences } from "@/components/preferences/use-preferences";
|
||||||
|
|
||||||
import DateNavigationToolbar from "./date-navigation-toolbar";
|
import DateNavigationToolbar from "./date-navigation-toolbar";
|
||||||
import { DateTimeOption, DateTimePickerProps } from "./types";
|
import { DateTimeOption, DateTimePickerProps } from "./types";
|
||||||
import { formatDateWithoutTime, formatDateWithoutTz } from "./utils";
|
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> = ({
|
const WeekCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
|
||||||
title,
|
title,
|
||||||
options,
|
options,
|
||||||
|
@ -39,8 +33,28 @@ const WeekCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
|
||||||
setScrollToTime(addMinutes(date, -60));
|
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 (
|
return (
|
||||||
<Calendar
|
<Calendar
|
||||||
|
key={timeFormat}
|
||||||
events={options.map((option) => {
|
events={options.map((option) => {
|
||||||
if (option.type === "date") {
|
if (option.type === "date") {
|
||||||
return { title, start: new Date(option.date) };
|
return { title, start: new Date(option.date) };
|
||||||
|
@ -52,6 +66,7 @@ const WeekCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
|
culture="default"
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
date={date}
|
date={date}
|
||||||
className="h-[calc(100vh-220px)] max-h-[800px] min-h-[400px] w-full"
|
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}%)`,
|
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">
|
<div className="w-full truncate font-bold">
|
||||||
{props.event.title}
|
{props.event.title}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -24,6 +24,7 @@ interface HeadlessDatePickerOptions {
|
||||||
date?: Date;
|
date?: Date;
|
||||||
selection?: Date[];
|
selection?: Date[];
|
||||||
onNavigationChange?: (date: Date) => void;
|
onNavigationChange?: (date: Date) => void;
|
||||||
|
weekStartsOn?: "monday" | "sunday";
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
|
@ -47,7 +48,9 @@ export const useHeadlessDatePicker = (
|
||||||
const navigationDate = options?.date ?? localNavigationDate;
|
const navigationDate = options?.date ?? localNavigationDate;
|
||||||
|
|
||||||
const firstDayOfMonth = startOfMonth(navigationDate);
|
const firstDayOfMonth = startOfMonth(navigationDate);
|
||||||
const firstDayOfFirstWeek = startOfWeek(firstDayOfMonth, { weekStartsOn: 1 });
|
const firstDayOfFirstWeek = startOfWeek(firstDayOfMonth, {
|
||||||
|
weekStartsOn: options?.weekStartsOn === "monday" ? 1 : 0,
|
||||||
|
});
|
||||||
|
|
||||||
const currentMonth = getMonth(navigationDate);
|
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">
|
<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 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" />
|
<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>
|
</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 { createBreakpoint } from "react-use";
|
||||||
|
|
||||||
import DotsVertical from "@/components/icons/dots-vertical.svg";
|
import DotsVertical from "@/components/icons/dots-vertical.svg";
|
||||||
|
import Github from "@/components/icons/github.svg";
|
||||||
|
|
||||||
import Logo from "../public/logo.svg";
|
import Logo from "../public/logo.svg";
|
||||||
import Github from "./home/github.svg";
|
|
||||||
import Footer from "./page-layout/footer";
|
import Footer from "./page-layout/footer";
|
||||||
|
|
||||||
const Popover = dynamic(() => import("./popover"), { ssr: false });
|
const Popover = dynamic(() => import("./popover"), { ssr: false });
|
||||||
|
@ -62,7 +62,7 @@ const Menu: React.VoidFunctionComponent<{ className: string }> = ({
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="https://github.com/lukevella/rallly">
|
<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">
|
<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>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
@ -9,6 +9,7 @@ import {
|
||||||
ParsedTimeSlotOption,
|
ParsedTimeSlotOption,
|
||||||
} from "utils/date-time-utils";
|
} from "utils/date-time-utils";
|
||||||
|
|
||||||
|
import { usePreferences } from "./preferences/use-preferences";
|
||||||
import { useRequiredContext } from "./use-required-context";
|
import { useRequiredContext } from "./use-required-context";
|
||||||
|
|
||||||
type VoteType = "yes" | "no";
|
type VoteType = "yes" | "no";
|
||||||
|
@ -59,6 +60,7 @@ export const PollContextProvider: React.VoidFunctionComponent<{
|
||||||
});
|
});
|
||||||
return res;
|
return res;
|
||||||
}, [participantById, poll.options]);
|
}, [participantById, poll.options]);
|
||||||
|
const { locale } = usePreferences();
|
||||||
|
|
||||||
const contextValue = React.useMemo<PollContextValue>(() => {
|
const contextValue = React.useMemo<PollContextValue>(() => {
|
||||||
let highScore = 1;
|
let highScore = 1;
|
||||||
|
@ -72,6 +74,7 @@ export const PollContextProvider: React.VoidFunctionComponent<{
|
||||||
poll.options,
|
poll.options,
|
||||||
poll.timeZone,
|
poll.timeZone,
|
||||||
targetTimeZone,
|
targetTimeZone,
|
||||||
|
locale,
|
||||||
);
|
);
|
||||||
const getParticipantById = (participantId: string) => {
|
const getParticipantById = (participantId: string) => {
|
||||||
// TODO (Luke Vella) [2022-04-16]: Build an index instead
|
// TODO (Luke Vella) [2022-04-16]: Build an index instead
|
||||||
|
@ -106,7 +109,7 @@ export const PollContextProvider: React.VoidFunctionComponent<{
|
||||||
targetTimeZone,
|
targetTimeZone,
|
||||||
setTargetTimeZone,
|
setTargetTimeZone,
|
||||||
};
|
};
|
||||||
}, [participantById, participantsByOptionId, poll, targetTimeZone]);
|
}, [locale, participantById, participantsByOptionId, poll, targetTimeZone]);
|
||||||
return (
|
return (
|
||||||
<PollContext.Provider value={contextValue}>{children}</PollContext.Provider>
|
<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 { format } from "date-fns";
|
||||||
import { Trans, useTranslation } from "next-i18next";
|
import { Trans, useTranslation } from "next-i18next";
|
||||||
import * as React from "react";
|
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 Button from "@/components/button";
|
||||||
import Cog from "@/components/icons/cog.svg";
|
import Cog from "@/components/icons/cog.svg";
|
||||||
|
@ -25,7 +25,7 @@ const ManagePoll: React.VoidFunctionComponent<{
|
||||||
placement?: Placement;
|
placement?: Placement;
|
||||||
}> = ({ placement }) => {
|
}> = ({ placement }) => {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
const { poll, targetTimeZone } = usePoll();
|
const { poll, options } = usePoll();
|
||||||
|
|
||||||
const modalContext = useModalContext();
|
const modalContext = useModalContext();
|
||||||
|
|
||||||
|
@ -181,12 +181,7 @@ const ManagePoll: React.VoidFunctionComponent<{
|
||||||
t("participantCount", {
|
t("participantCount", {
|
||||||
count: poll.participants.length,
|
count: poll.participants.length,
|
||||||
}),
|
}),
|
||||||
...poll.options.map((option) => {
|
...options.map((decodedOption) => {
|
||||||
const decodedOption = decodeDateOption(
|
|
||||||
option,
|
|
||||||
poll.timeZone,
|
|
||||||
targetTimeZone,
|
|
||||||
);
|
|
||||||
const day = `${decodedOption.dow} ${decodedOption.day} ${decodedOption.month}`;
|
const day = `${decodedOption.dow} ${decodedOption.day} ${decodedOption.month}`;
|
||||||
return decodedOption.type === "date"
|
return decodedOption.type === "date"
|
||||||
? day
|
? day
|
||||||
|
|
|
@ -7,13 +7,14 @@ import { useMutation } from "react-query";
|
||||||
import Button from "../button";
|
import Button from "../button";
|
||||||
import { usePoll } from "../poll-context";
|
import { usePoll } from "../poll-context";
|
||||||
import Popover from "../popover";
|
import Popover from "../popover";
|
||||||
|
import { usePreferences } from "../preferences/use-preferences";
|
||||||
|
|
||||||
export interface PollSubheaderProps {}
|
export interface PollSubheaderProps {}
|
||||||
|
|
||||||
const PollSubheader: React.VoidFunctionComponent<PollSubheaderProps> = () => {
|
const PollSubheader: React.VoidFunctionComponent<PollSubheaderProps> = () => {
|
||||||
const { poll } = usePoll();
|
const { poll } = usePoll();
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
|
const { locale } = usePreferences();
|
||||||
const {
|
const {
|
||||||
mutate: sendVerificationEmail,
|
mutate: sendVerificationEmail,
|
||||||
isLoading: isSendingVerificationEmail,
|
isLoading: isSendingVerificationEmail,
|
||||||
|
@ -29,14 +30,6 @@ const PollSubheader: React.VoidFunctionComponent<PollSubheaderProps> = () => {
|
||||||
t={t}
|
t={t}
|
||||||
values={{
|
values={{
|
||||||
name: poll.authorName,
|
name: poll.authorName,
|
||||||
date: Date.parse(poll.createdAt),
|
|
||||||
formatParams: {
|
|
||||||
date: {
|
|
||||||
year: "numeric",
|
|
||||||
month: "numeric",
|
|
||||||
day: "numeric",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
components={{
|
components={{
|
||||||
b: <span className="font-medium text-indigo-500" />,
|
b: <span className="font-medium text-indigo-500" />,
|
||||||
|
@ -88,7 +81,9 @@ const PollSubheader: React.VoidFunctionComponent<PollSubheaderProps> = () => {
|
||||||
</div>
|
</div>
|
||||||
<span className="hidden md:inline"> • </span>
|
<span className="hidden md:inline"> • </span>
|
||||||
<span className="whitespace-nowrap">
|
<span className="whitespace-nowrap">
|
||||||
{formatRelative(new Date(poll.createdAt), new Date())}
|
{formatRelative(new Date(poll.createdAt), new Date(), {
|
||||||
|
locale,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,59 +1,73 @@
|
||||||
|
import {
|
||||||
|
flip,
|
||||||
|
FloatingPortal,
|
||||||
|
offset,
|
||||||
|
Placement,
|
||||||
|
shift,
|
||||||
|
useFloating,
|
||||||
|
} from "@floating-ui/react-dom-interactions";
|
||||||
import { Popover as HeadlessPopover } from "@headlessui/react";
|
import { Popover as HeadlessPopover } from "@headlessui/react";
|
||||||
import { Placement } from "@popperjs/core";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom";
|
import { transformOriginByPlacement } from "utils/constants";
|
||||||
import { usePopper } from "react-popper";
|
|
||||||
|
|
||||||
interface PopoverProps {
|
interface PopoverProps {
|
||||||
trigger: React.ReactNode;
|
trigger: React.ReactNode;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
placement?: Placement;
|
placement?: Placement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MotionPanel = motion(HeadlessPopover.Panel);
|
||||||
|
|
||||||
const Popover: React.VoidFunctionComponent<PopoverProps> = ({
|
const Popover: React.VoidFunctionComponent<PopoverProps> = ({
|
||||||
children,
|
children,
|
||||||
trigger,
|
trigger,
|
||||||
placement,
|
placement: preferredPlacement,
|
||||||
}) => {
|
}) => {
|
||||||
const [referenceElement, setReferenceElement] =
|
const { reference, floating, x, y, strategy, placement } = useFloating({
|
||||||
React.useState<HTMLDivElement | null>(null);
|
placement: preferredPlacement,
|
||||||
const [popperElement, setPopperElement] =
|
strategy: "fixed",
|
||||||
React.useState<HTMLDivElement | null>(null);
|
middleware: [offset(5), flip(), shift({ padding: 10 })],
|
||||||
|
|
||||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
|
||||||
placement,
|
|
||||||
modifiers: [
|
|
||||||
{
|
|
||||||
name: "offset",
|
|
||||||
options: {
|
|
||||||
offset: [0, 5],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const portal = document.getElementById("portal");
|
const origin = transformOriginByPlacement[placement];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HeadlessPopover as={React.Fragment}>
|
<HeadlessPopover as={React.Fragment}>
|
||||||
<HeadlessPopover.Button
|
{({ open }) => (
|
||||||
ref={setReferenceElement}
|
<>
|
||||||
as="div"
|
<HeadlessPopover.Button
|
||||||
className={clsx("inline-block")}
|
ref={reference}
|
||||||
>
|
as="div"
|
||||||
{trigger}
|
className={clsx("inline-block")}
|
||||||
</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}
|
|
||||||
>
|
>
|
||||||
{children}
|
{trigger}
|
||||||
</HeadlessPopover.Panel>,
|
</HeadlessPopover.Button>
|
||||||
portal,
|
<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}
|
||||||
|
</MotionPanel>
|
||||||
|
) : null}
|
||||||
|
</FloatingPortal>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</HeadlessPopover>
|
</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 Link from "next/link";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
|
import Menu from "@/components/icons/menu.svg";
|
||||||
|
|
||||||
import Logo from "../public/logo.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 Pencil from "./icons/pencil.svg";
|
||||||
import Support from "./icons/support.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 (
|
return (
|
||||||
<div className="relative min-h-full bg-gray-50 lg:flex" {...rest}>
|
<Link href="/">
|
||||||
<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">
|
<a>
|
||||||
<div className="flex items-center lg:float-right lg:w-40 lg:flex-col lg:items-start">
|
<Logo className="w-28 text-slate-500 transition-colors hover:text-indigo-500 active:text-indigo-600" />
|
||||||
<div className="grow lg:mb-8 lg:grow-0">
|
</a>
|
||||||
<Link href="/">
|
</Link>
|
||||||
<a>
|
);
|
||||||
<Logo className="w-24 text-slate-500 transition-colors hover:text-indigo-500 active:text-indigo-600 lg:w-28" />
|
};
|
||||||
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
<div className="flex shrink-0 items-center text-sm lg:mb-4 lg:block lg:w-full lg:pb-4 lg:text-base">
|
<div className="mb-4 block w-full shrink-0 grow items-center pb-4 text-base">
|
||||||
<Link passHref={true} href="/new">
|
<div className="mb-4">
|
||||||
<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">
|
<Link href="/new">
|
||||||
<Pencil className="h-6 w-6 opacity-75" />
|
<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">
|
||||||
<span className="hidden md:inline-block">New Poll</span>
|
<Pencil className="h-5 opacity-75" />
|
||||||
</a>
|
<span className="inline-block">New Poll</span>
|
||||||
</Link>
|
</a>
|
||||||
<a
|
</Link>
|
||||||
href="https://blog.rallly.co"
|
<Link href="/support">
|
||||||
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"
|
<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" />
|
||||||
<Newspaper className="h-6 w-6 opacity-75" />
|
<span className="inline-block">Support</span>
|
||||||
<span className="hidden md:inline-block">Blog</span>
|
</a>
|
||||||
</a>
|
</Link>
|
||||||
<Link passHref={true} href="/support">
|
<Popover
|
||||||
<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">
|
placement="right-start"
|
||||||
<Support className="h-6 w-6 opacity-75" />
|
trigger={
|
||||||
<span className="hidden md:inline-block">Support</span>
|
<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">
|
||||||
</a>
|
<Adjustments className="h-5 opacity-75" />
|
||||||
</Link>
|
<span className="inline-block">Preferences</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Preferences />
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 grow">{children}</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>
|
</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 clsx from "clsx";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import * as React from "react";
|
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";
|
import { preventWidows } from "utils/prevent-widows";
|
||||||
|
|
||||||
export interface TooltipProps {
|
export interface TooltipProps {
|
||||||
|
@ -16,127 +24,110 @@ export interface TooltipProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const Tooltip: React.VoidFunctionComponent<TooltipProps> = ({
|
const Tooltip: React.VoidFunctionComponent<TooltipProps> = ({
|
||||||
placement = "bottom",
|
placement: preferredPlacement = "bottom",
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
disabled,
|
disabled,
|
||||||
content,
|
content,
|
||||||
}) => {
|
}) => {
|
||||||
const [referenceElement, setReferenceElement] =
|
const arrowRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
React.useState<HTMLDivElement | null>(null);
|
const [open, setOpen] = React.useState(false);
|
||||||
const [popperElement, setPopperElement] =
|
|
||||||
React.useState<HTMLDivElement | null>(null);
|
|
||||||
const [arrowElement, setArrowElement] =
|
|
||||||
React.useState<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const { styles, attributes, update } = usePopper(
|
const {
|
||||||
referenceElement,
|
reference,
|
||||||
popperElement,
|
floating,
|
||||||
{
|
x,
|
||||||
placement,
|
y,
|
||||||
modifiers: [
|
strategy,
|
||||||
{
|
context,
|
||||||
name: "offset",
|
middlewareData,
|
||||||
options: {
|
placement,
|
||||||
offset: [0, 14],
|
} = useFloating({
|
||||||
},
|
strategy: "fixed",
|
||||||
},
|
open,
|
||||||
{ name: "arrow", options: { element: arrowElement, padding: 5 } },
|
onOpenChange: setOpen,
|
||||||
],
|
placement: preferredPlacement,
|
||||||
},
|
middleware: [
|
||||||
);
|
offset(10),
|
||||||
|
flip(),
|
||||||
const [isVisible, setIsVisible] = React.useState(false);
|
shift({ padding: 5 }),
|
||||||
|
arrow({ element: arrowRef }),
|
||||||
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) {
|
const placementGroup = placement.split("-")[0] as
|
||||||
return <>{children}</>;
|
| "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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
key={key}
|
|
||||||
className={clsx("inline-block", className)}
|
className={clsx("inline-block", className)}
|
||||||
onMouseEnter={() => {
|
{...getReferenceProps({ ref: reference })}
|
||||||
setIsVisible(true);
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
setIsVisible(false);
|
|
||||||
setDebouncedValue(false);
|
|
||||||
cancel();
|
|
||||||
}}
|
|
||||||
ref={(el) => {
|
|
||||||
setReferenceElement(el);
|
|
||||||
ref.current = el;
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
{portal
|
<FloatingPortal>
|
||||||
? ReactDOM.createPortal(
|
<AnimatePresence>
|
||||||
<AnimatePresence>
|
{open ? (
|
||||||
{debouncedValue ? (
|
<motion.div
|
||||||
<div
|
className="z-30 rounded-md bg-slate-700 px-3 py-2 text-slate-200 shadow-md"
|
||||||
className="pointer-events-none"
|
initial="hidden"
|
||||||
ref={setPopperElement}
|
transition={{
|
||||||
style={styles.popper}
|
duration: 0.1,
|
||||||
{...attributes.popper}
|
}}
|
||||||
>
|
variants={{
|
||||||
<motion.div
|
hidden: {
|
||||||
className="rounded-md bg-slate-700 px-3 py-2 text-slate-200 shadow-md"
|
opacity: 0,
|
||||||
initial="hidden"
|
translateY: placement === "bottom" ? -4 : 4,
|
||||||
transition={{
|
},
|
||||||
duration: 0.1,
|
show: { opacity: 1, translateY: 0 },
|
||||||
}}
|
}}
|
||||||
variants={{
|
animate={open ? "show" : "hidden"}
|
||||||
hidden: {
|
{...getFloatingProps({
|
||||||
opacity: 0,
|
ref: floating,
|
||||||
translateY: placement === "bottom" ? -4 : 4,
|
style: {
|
||||||
},
|
position: strategy,
|
||||||
show: { opacity: 1, translateY: 0 },
|
top: y ?? "",
|
||||||
}}
|
left: x ?? "",
|
||||||
animate={debouncedValue ? "show" : "hidden"}
|
},
|
||||||
>
|
})}
|
||||||
<div
|
>
|
||||||
ref={setArrowElement}
|
<div
|
||||||
className="tooltip-arrow h-3 w-3 border-[6px] border-transparent"
|
ref={arrowRef}
|
||||||
style={styles.arrow}
|
className="absolute rotate-45 bg-slate-700"
|
||||||
data-popper-arrow
|
style={{
|
||||||
></div>
|
width: 8,
|
||||||
{typeof content === "string"
|
height: 8,
|
||||||
? preventWidows(content)
|
left: middlewareData.arrow?.x,
|
||||||
: content}
|
top: middlewareData.arrow?.y,
|
||||||
</motion.div>
|
[staticSide]: -4,
|
||||||
</div>
|
}}
|
||||||
) : null}
|
/>
|
||||||
</AnimatePresence>,
|
{typeof content === "string" ? preventWidows(content) : content}
|
||||||
portal,
|
</motion.div>
|
||||||
)
|
) : null}
|
||||||
: null}
|
</AnimatePresence>
|
||||||
|
</FloatingPortal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -12,9 +12,9 @@
|
||||||
"test": "playwright test"
|
"test": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@floating-ui/react-dom-interactions": "^0.3.1",
|
||||||
"@headlessui/react": "^1.5.0",
|
"@headlessui/react": "^1.5.0",
|
||||||
"@next/bundle-analyzer": "^12.1.0",
|
"@next/bundle-analyzer": "^12.1.0",
|
||||||
"@popperjs/core": "^2.11.4",
|
|
||||||
"@prisma/client": "^3.12.0",
|
"@prisma/client": "^3.12.0",
|
||||||
"@sentry/nextjs": "^6.19.3",
|
"@sentry/nextjs": "^6.19.3",
|
||||||
"@svgr/webpack": "^6.2.1",
|
"@svgr/webpack": "^6.2.1",
|
||||||
|
@ -44,7 +44,6 @@
|
||||||
"react-hot-toast": "^2.2.0",
|
"react-hot-toast": "^2.2.0",
|
||||||
"react-i18next": "^11.15.4",
|
"react-i18next": "^11.15.4",
|
||||||
"react-linkify": "^1.0.0-alpha",
|
"react-linkify": "^1.0.0-alpha",
|
||||||
"react-popper": "^2.2.5",
|
|
||||||
"react-query": "^3.34.12",
|
"react-query": "^3.34.12",
|
||||||
"react-use": "^17.3.2",
|
"react-use": "^17.3.2",
|
||||||
"smoothscroll-polyfill": "^0.4.4",
|
"smoothscroll-polyfill": "^0.4.4",
|
||||||
|
|
|
@ -13,6 +13,7 @@ import { MutationCache, QueryClient, QueryClientProvider } from "react-query";
|
||||||
import { useSessionStorage } from "react-use";
|
import { useSessionStorage } from "react-use";
|
||||||
|
|
||||||
import ModalProvider from "@/components/modal/modal-provider";
|
import ModalProvider from "@/components/modal/modal-provider";
|
||||||
|
import PreferencesProvider from "@/components/preferences/preferences-provider";
|
||||||
|
|
||||||
import { UserNameContext } from "../components/user-name-context";
|
import { UserNameContext } from "../components/user-name-context";
|
||||||
|
|
||||||
|
@ -41,18 +42,23 @@ const MyApp: NextPage<AppProps> = ({ Component, pageProps }) => {
|
||||||
selfHosted={true}
|
selfHosted={true}
|
||||||
enabled={!!process.env.PLAUSIBLE_DOMAIN}
|
enabled={!!process.env.PLAUSIBLE_DOMAIN}
|
||||||
>
|
>
|
||||||
<QueryClientProvider client={queryClient}>
|
<PreferencesProvider>
|
||||||
<Head>
|
<QueryClientProvider client={queryClient}>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<Head>
|
||||||
</Head>
|
<meta
|
||||||
<CrispChat />
|
name="viewport"
|
||||||
<Toaster />
|
content="width=device-width, initial-scale=1"
|
||||||
<ModalProvider>
|
/>
|
||||||
<UserNameContext.Provider value={sessionUserName}>
|
</Head>
|
||||||
<Component {...pageProps} />
|
<CrispChat />
|
||||||
</UserNameContext.Provider>
|
<Toaster />
|
||||||
</ModalProvider>
|
<ModalProvider>
|
||||||
</QueryClientProvider>
|
<UserNameContext.Provider value={sessionUserName}>
|
||||||
|
<Component {...pageProps} />
|
||||||
|
</UserNameContext.Provider>
|
||||||
|
</ModalProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</PreferencesProvider>
|
||||||
</PlausibleProvider>
|
</PlausibleProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { addMinutes } from "date-fns";
|
import { addMinutes } from "date-fns";
|
||||||
import { NextApiRequest, NextApiResponse } from "next";
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import absoluteUrl from "utils/absolute-url";
|
||||||
import { nanoid } from "utils/nanoid";
|
import { nanoid } from "utils/nanoid";
|
||||||
|
|
||||||
import { prisma } from "../../../db";
|
import { prisma } from "../../../db";
|
||||||
|
@ -64,6 +65,8 @@ export default async function handler(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const homePageUrl = absoluteUrl(req).origin;
|
||||||
|
|
||||||
await prisma.poll.create({
|
await prisma.poll.create({
|
||||||
data: {
|
data: {
|
||||||
urlId: await nanoid(),
|
urlId: await nanoid(),
|
||||||
|
@ -71,8 +74,7 @@ export default async function handler(
|
||||||
title: "Lunch Meeting Demo",
|
title: "Lunch Meeting Demo",
|
||||||
type: "date",
|
type: "date",
|
||||||
location: "Starbucks, 901 New York Avenue",
|
location: "Starbucks, 901 New York Avenue",
|
||||||
description:
|
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.`,
|
||||||
"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.",
|
|
||||||
authorName: "Johnny",
|
authorName: "Johnny",
|
||||||
verified: true,
|
verified: true,
|
||||||
demo: 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 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 { 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 ({
|
export const getServerSideProps: GetServerSideProps = async ({
|
||||||
locale = "en",
|
locale = "en",
|
||||||
|
@ -233,4 +15,6 @@ export const getServerSideProps: GetServerSideProps = async ({
|
||||||
};
|
};
|
||||||
|
|
||||||
// We disable SSR because the data on this page relies on sessionStore
|
// 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 axios from "axios";
|
||||||
import { GetServerSideProps, NextPage } from "next";
|
import { GetServerSideProps, NextPage } from "next";
|
||||||
import Head from "next/head";
|
import dynamic from "next/dynamic";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||||
import { usePlausible } from "next-plausible";
|
import { usePlausible } from "next-plausible";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { useQuery } from "react-query";
|
||||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
|
||||||
import { useMount } from "react-use";
|
|
||||||
import { preventWidows } from "utils/prevent-widows";
|
|
||||||
|
|
||||||
import Button from "@/components/button";
|
|
||||||
import ErrorPage from "@/components/error-page";
|
import ErrorPage from "@/components/error-page";
|
||||||
import FullPageLoader from "@/components/full-page-loader";
|
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 { GetPollResponse } from "../api-client/get-poll";
|
||||||
import Custom404 from "./404";
|
import Custom404 from "./404";
|
||||||
|
|
||||||
const Discussion = React.lazy(() => import("@/components/discussion"));
|
const PollPage = dynamic(() => import("@/components/poll"), { ssr: false });
|
||||||
|
|
||||||
const Poll = React.lazy(() => import("@/components/poll"));
|
|
||||||
|
|
||||||
const PollPageLoader: NextPage = () => {
|
const PollPageLoader: NextPage = () => {
|
||||||
const { query } = useRouter();
|
const { query } = useRouter();
|
||||||
|
@ -99,195 +78,7 @@ const PollPageLoader: NextPage = () => {
|
||||||
return !poll ? (
|
return !poll ? (
|
||||||
<FullPageLoader>{t("loading")}</FullPageLoader>
|
<FullPageLoader>{t("loading")}</FullPageLoader>
|
||||||
) : (
|
) : (
|
||||||
<PollContextProvider value={poll}>
|
<PollPage poll={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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -42,5 +42,12 @@
|
||||||
"participantDescription": "Partial access to vote and comment on this poll.",
|
"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.",
|
"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.",
|
"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%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply text-base text-slate-600;
|
@apply bg-slate-50 text-base text-slate-600;
|
||||||
}
|
}
|
||||||
p {
|
p {
|
||||||
@apply mb-4;
|
@apply mb-4;
|
||||||
|
@ -40,7 +40,7 @@
|
||||||
@apply focus:outline-none focus:ring-indigo-600;
|
@apply focus:outline-none focus:ring-indigo-600;
|
||||||
}
|
}
|
||||||
|
|
||||||
#portal {
|
#floating-ui-root {
|
||||||
@apply absolute z-50 w-full;
|
@apply absolute z-50 w-full;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -87,7 +87,7 @@
|
||||||
@apply pointer-events-none;
|
@apply pointer-events-none;
|
||||||
}
|
}
|
||||||
.btn-primary {
|
.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 {
|
a.btn-primary {
|
||||||
@apply text-white;
|
@apply text-white;
|
||||||
|
@ -121,12 +121,9 @@
|
||||||
@apply cursor-not-allowed;
|
@apply cursor-not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-popper-placement="bottom"] .tooltip-arrow {
|
.card {
|
||||||
@apply bottom-full border-b-slate-700;
|
@apply rounded-lg border bg-white p-6
|
||||||
}
|
shadow-sm;
|
||||||
|
|
||||||
[data-popper-placement="top"] .tooltip-arrow {
|
|
||||||
@apply top-full border-t-slate-700;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -146,9 +143,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.contain-paint {
|
|
||||||
contain: paint;
|
|
||||||
}
|
|
||||||
.bg-pattern {
|
.bg-pattern {
|
||||||
background-color: #f9fafb;
|
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");
|
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 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 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,
|
format,
|
||||||
formatDuration,
|
formatDuration,
|
||||||
isSameDay,
|
isSameDay,
|
||||||
|
Locale,
|
||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { formatInTimeZone } from "date-fns-tz";
|
import { formatInTimeZone } from "date-fns-tz";
|
||||||
import spacetime from "spacetime";
|
import spacetime from "spacetime";
|
||||||
|
@ -58,6 +59,7 @@ export const decodeOptions = (
|
||||||
options: Option[],
|
options: Option[],
|
||||||
timeZone: string | null,
|
timeZone: string | null,
|
||||||
targetTimeZone: string,
|
targetTimeZone: string,
|
||||||
|
locale: Locale,
|
||||||
):
|
):
|
||||||
| { pollType: "date"; options: ParsedDateOption[] }
|
| { pollType: "date"; options: ParsedDateOption[] }
|
||||||
| { pollType: "timeSlot"; options: ParsedTimeSlotOption[] } => {
|
| { pollType: "timeSlot"; options: ParsedTimeSlotOption[] } => {
|
||||||
|
@ -67,7 +69,7 @@ export const decodeOptions = (
|
||||||
return {
|
return {
|
||||||
pollType,
|
pollType,
|
||||||
options: options.map((option) =>
|
options: options.map((option) =>
|
||||||
parseTimeSlotOption(option, timeZone, targetTimeZone),
|
parseTimeSlotOption(option, timeZone, targetTimeZone, locale),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
|
@ -98,7 +100,18 @@ const parseTimeSlotOption = (
|
||||||
option: Option,
|
option: Option,
|
||||||
timeZone: string | null,
|
timeZone: string | null,
|
||||||
targetTimeZone: string,
|
targetTimeZone: string,
|
||||||
|
locale: Locale,
|
||||||
): ParsedTimeSlotOption => {
|
): ParsedTimeSlotOption => {
|
||||||
|
const localeFormatInTimezone = (
|
||||||
|
date: Date,
|
||||||
|
timezone: string,
|
||||||
|
formatString: string,
|
||||||
|
) => {
|
||||||
|
return formatInTimeZone(date, timezone, formatString, {
|
||||||
|
locale,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const [start, end] = option.value.split("/");
|
const [start, end] = option.value.split("/");
|
||||||
if (timeZone && targetTimeZone) {
|
if (timeZone && targetTimeZone) {
|
||||||
const startDate = spacetime(start, timeZone).toNativeDate();
|
const startDate = spacetime(start, timeZone).toNativeDate();
|
||||||
|
@ -106,11 +119,11 @@ const parseTimeSlotOption = (
|
||||||
return {
|
return {
|
||||||
type: "timeSlot",
|
type: "timeSlot",
|
||||||
optionId: option.id,
|
optionId: option.id,
|
||||||
startTime: formatInTimeZone(startDate, targetTimeZone, "hh:mm a"),
|
startTime: localeFormatInTimezone(startDate, targetTimeZone, "p"),
|
||||||
endTime: formatInTimeZone(endDate, targetTimeZone, "hh:mm a"),
|
endTime: localeFormatInTimezone(endDate, targetTimeZone, "p"),
|
||||||
day: formatInTimeZone(startDate, targetTimeZone, "d"),
|
day: localeFormatInTimezone(startDate, targetTimeZone, "d"),
|
||||||
dow: formatInTimeZone(startDate, targetTimeZone, "E"),
|
dow: localeFormatInTimezone(startDate, targetTimeZone, "E"),
|
||||||
month: formatInTimeZone(startDate, targetTimeZone, "MMM"),
|
month: localeFormatInTimezone(startDate, targetTimeZone, "MMM"),
|
||||||
duration: getDuration(startDate, endDate),
|
duration: getDuration(startDate, endDate),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
|
@ -119,8 +132,8 @@ const parseTimeSlotOption = (
|
||||||
return {
|
return {
|
||||||
type: "timeSlot",
|
type: "timeSlot",
|
||||||
optionId: option.id,
|
optionId: option.id,
|
||||||
startTime: format(startDate, "hh:mm a"),
|
startTime: format(startDate, "p"),
|
||||||
endTime: format(endDate, "hh:mm a"),
|
endTime: format(endDate, "p"),
|
||||||
day: format(startDate, "d"),
|
day: format(startDate, "d"),
|
||||||
dow: format(startDate, "E"),
|
dow: format(startDate, "E"),
|
||||||
month: format(startDate, "MMM"),
|
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 = (
|
export const removeAllOptionsForDay = (
|
||||||
options: DateTimeOption[],
|
options: DateTimeOption[],
|
||||||
date: Date,
|
date: Date,
|
||||||
|
|
69
yarn.lock
|
@ -1138,6 +1138,36 @@
|
||||||
minimatch "^3.0.4"
|
minimatch "^3.0.4"
|
||||||
strip-json-comments "^3.1.1"
|
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":
|
"@hapi/hoek@^9.0.0":
|
||||||
version "9.2.1"
|
version "9.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.2.1.tgz#9551142a1980503752536b5050fd99f4a7f13b17"
|
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"
|
resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.21.tgz#5de5a2385a35309427f6011992b544514d559aa1"
|
||||||
integrity sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==
|
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":
|
"@popperjs/core@^2.5.3":
|
||||||
version "2.9.2"
|
version "2.9.2"
|
||||||
resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.9.2.tgz"
|
resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.9.2.tgz"
|
||||||
|
@ -2049,6 +2074,13 @@ argparse@^1.0.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
sprintf-js "~1.0.2"
|
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:
|
aria-query@^4.2.2:
|
||||||
version "4.2.2"
|
version "4.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-4.2.2.tgz#0d2ca6c9aceb56b8977e9fed6aed7e15bbd2f83b"
|
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"
|
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-4.0.1.tgz#f803869bb2fc1bfe1bf99aa4ec21c108117cfdbe"
|
||||||
integrity sha512-rf5+2/ioHeQxR6IxuYNYGFytUyG3lma/WW1nsmjeHlWwtb2aByla6dkVc8pmJ9nplzkTA0q2xx7mMWrOTqT4Gg==
|
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:
|
popmotion@11.0.3:
|
||||||
version "11.0.3"
|
version "11.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/popmotion/-/popmotion-11.0.3.tgz#565c5f6590bbcddab7a33a074bb2ba97e24b0cc9"
|
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"
|
object-assign "^4.1.1"
|
||||||
scheduler "^0.20.2"
|
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:
|
react-github-btn@^1.2.2:
|
||||||
version "1.2.2"
|
version "1.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/react-github-btn/-/react-github-btn-1.2.2.tgz#9aab2498ff311b9f9c448a2d2b902d0277037d5c"
|
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"
|
uncontrollable "^7.0.0"
|
||||||
warning "^4.0.3"
|
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:
|
react-query@^3.34.12:
|
||||||
version "3.34.12"
|
version "3.34.12"
|
||||||
resolved "https://registry.npmjs.org/react-query/-/react-query-3.34.12.tgz"
|
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"
|
minimist "^1.2.0"
|
||||||
strip-bom "^3.0.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"
|
version "1.14.1"
|
||||||
resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz"
|
resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz"
|
||||||
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
|
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
|
||||||
|
@ -5836,6 +5860,11 @@ uri-js@^4.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
punycode "^2.1.0"
|
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:
|
util-deprecate@^1.0.2, util-deprecate@~1.0.1:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
|
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"
|
minimist "^1.2.5"
|
||||||
rxjs "^7.5.4"
|
rxjs "^7.5.4"
|
||||||
|
|
||||||
warning@^4.0.2, warning@^4.0.3:
|
warning@^4.0.3:
|
||||||
version "4.0.3"
|
version "4.0.3"
|
||||||
resolved "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz"
|
resolved "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz"
|
||||||
integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==
|
integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==
|
||||||
|
|