Use locale to format dates and times (#123)

* Use deployment url in demo description.

Close #131
This commit is contained in:
Luke Vella 2022-04-26 20:10:59 +01:00 committed by GitHub
parent 8263926168
commit 8aec24308e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1240 additions and 864 deletions

223
components/create-poll.tsx Normal file
View 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;

View file

@ -21,6 +21,7 @@ import Trash from "../icons/trash.svg";
import NameInput from "../name-input";
import TruncatedLinkify from "../poll/truncated-linkify";
import UserAvater from "../poll/user-avatar";
import { usePreferences } from "../preferences/use-preferences";
import { useUserName } from "../user-name-context";
export interface DiscussionProps {
@ -37,6 +38,7 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
pollId,
canDelete,
}) => {
const { locale } = usePreferences();
const getCommentsQueryKey = ["poll", pollId, "comments"];
const [userName, setUserName] = useUserName();
const queryClient = useQueryClient();
@ -146,6 +148,9 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
{formatRelative(
new Date(comment.createdAt),
Date.now(),
{
locale,
},
)}
</span>
</div>

View file

@ -1,11 +1,19 @@
import {
flip,
FloatingPortal,
offset,
Placement,
useFloating,
} from "@floating-ui/react-dom-interactions";
import { Menu } from "@headlessui/react";
import { Placement } from "@popperjs/core";
import clsx from "clsx";
import { motion } from "framer-motion";
import * as React from "react";
import ReactDOM from "react-dom";
import { usePopper } from "react-popper";
import { transformOriginByPlacement } from "utils/constants";
import { stopPropagation } from "utils/stop-propagation";
const MotionMenuItems = motion(Menu.Items);
export interface DropdownProps {
trigger?: React.ReactNode;
children?: React.ReactNode;
@ -17,48 +25,50 @@ const Dropdown: React.VoidFunctionComponent<DropdownProps> = ({
children,
className,
trigger,
placement,
placement: preferredPlacement,
}) => {
const [referenceElement, setReferenceElement] =
React.useState<HTMLDivElement | null>(null);
const [popperElement, setPopperElement] =
React.useState<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement,
modifiers: [
{
name: "offset",
options: {
offset: [0, 5],
},
},
],
const { reference, floating, x, y, strategy, placement } = useFloating({
placement: preferredPlacement,
middleware: [offset(5), flip()],
});
const portal = document.getElementById("portal");
const animationOrigin = transformOriginByPlacement[placement];
return (
<Menu>
{({ open }) => (
<>
<Menu.Button
ref={setReferenceElement}
as="div"
className={clsx("inline-block", className)}
ref={reference}
>
{trigger}
</Menu.Button>
{portal &&
ReactDOM.createPortal(
<Menu.Items
as="div"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
className="z-30 divide-gray-100 rounded-md bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
<FloatingPortal>
{open ? (
<MotionMenuItems
transition={{ duration: 0.1 }}
initial={{ opacity: 0, scale: 0.9, y: -10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: -10 }}
className={clsx(
"z-50 divide-gray-100 rounded-md bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none",
animationOrigin,
)}
onMouseDown={stopPropagation}
ref={floating}
style={{
position: strategy,
left: x ?? "",
top: y ?? "",
}}
>
{children}
</Menu.Items>,
portal,
</MotionMenuItems>
) : null}
</FloatingPortal>
</>
)}
</Menu>
);

View file

@ -5,6 +5,8 @@ import isSameDay from "date-fns/isSameDay";
import { usePlausible } from "next-plausible";
import * as React from "react";
import { usePreferences } from "@/components/preferences/use-preferences";
import {
expectTimeOption,
getDateProps,
@ -74,15 +76,18 @@ const MonthCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
);
}, [optionsByDay]);
const { weekStartsOn } = usePreferences();
const datepicker = useHeadlessDatePicker({
selection: datepickerSelection,
onNavigationChange: onNavigate,
weekStartsOn,
date,
});
return (
<div className="overflow-hidden lg:flex">
<div className="shrink-0 border-b p-4 lg:border-r lg:border-b-0">
<div className="border-b p-4 lg:w-[440px] lg:border-r lg:border-b-0">
<div>
<div className="flex w-full flex-col">
<div className="mb-3 flex items-center justify-center space-x-4">
@ -152,7 +157,7 @@ const MonthCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
}
}}
className={clsx(
"relative flex items-center justify-center px-4 py-3 text-sm hover:bg-slate-50 focus:ring-0 focus:ring-offset-0 active:bg-slate-100 lg:w-14",
"relative flex h-12 items-center justify-center text-sm hover:bg-slate-50 focus:ring-0 focus:ring-offset-0 active:bg-slate-100",
{
"bg-slate-50 text-slate-400": day.outOfMonth,
"font-bold text-indigo-500": day.today,
@ -233,7 +238,7 @@ const MonthCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
return (
<div
key={dateString}
className="space-y-3 py-4 xs:flex xs:space-y-0 xs:space-x-4"
className="space-y-3 py-4 sm:flex sm:space-y-0 sm:space-x-4"
>
<div>
<DateCard

View file

@ -1,11 +1,18 @@
import { Combobox } from "@headlessui/react";
import {
flip,
FloatingPortal,
offset,
size,
useFloating,
} from "@floating-ui/react-dom-interactions";
import { Listbox } from "@headlessui/react";
import clsx from "clsx";
import { addMinutes, format, isSameDay, setHours, setMinutes } from "date-fns";
import * as React from "react";
import ReactDOM from "react-dom";
import { usePopper } from "react-popper";
import { stopPropagation } from "utils/stop-propagation";
import { usePreferences } from "@/components/preferences/use-preferences";
import ChevronDown from "../../../icons/chevron-down.svg";
import { styleMenuItem } from "../../../menu-styles";
@ -22,23 +29,24 @@ const TimePicker: React.VoidFunctionComponent<TimePickerProps> = ({
className,
startFrom = setMinutes(setHours(value, 0), 0),
}) => {
const [referenceElement, setReferenceElement] =
React.useState<HTMLDivElement | null>(null);
const [popperElement, setPopperElement] =
React.useState<HTMLUListElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
modifiers: [
{
name: "offset",
options: {
offset: [0, 5],
},
const { locale } = usePreferences();
const { reference, floating, x, y, strategy, refs } = useFloating({
strategy: "fixed",
middleware: [
offset(5),
flip(),
size({
apply: ({ reference }) => {
if (refs.floating.current) {
Object.assign(refs.floating.current.style, {
width: `${reference.width}px`,
});
}
},
}),
],
});
const [query, setQuery] = React.useState("");
const options: React.ReactNode[] = [];
for (let i = 0; i < 96; i++) {
const optionValue = addMinutes(startFrom, i * 15);
@ -47,62 +55,55 @@ const TimePicker: React.VoidFunctionComponent<TimePickerProps> = ({
// because react-big-calendar does not support events that span days
break;
}
if (query && !format(optionValue, "hhmma").includes(query)) {
continue;
}
options.push(
<Combobox.Option
<Listbox.Option
key={i}
className={styleMenuItem}
value={optionValue.toISOString()}
>
{format(optionValue, "p")}
</Combobox.Option>,
{format(optionValue, "p", { locale })}
</Listbox.Option>,
);
}
const portal = document.getElementById("portal");
return (
<Combobox
<Listbox
value={value.toISOString()}
onChange={(newValue) => {
setQuery("");
onChange?.(new Date(newValue));
}}
>
<div ref={setReferenceElement} className={clsx("relative", className)}>
{/* Remove generic params once Combobox.Input can infer the types */}
<Combobox.Input<"input">
className="input w-28 pr-8"
displayValue={() => ""}
onChange={(e) => {
setQuery(e.target.value.toUpperCase().replace(/[\:\s]/g, ""));
}}
/>
<Combobox.Button className="absolute inset-0 flex h-9 cursor-default items-center px-2 text-left">
{(open) => (
<>
<div ref={reference} className={clsx("relative", className)}>
<Listbox.Button className="btn-default text-left">
<span className="grow truncate">
{!query ? format(value, "p") : null}
{format(value, "p", { locale })}
</span>
<span className="pointer-events-none flex">
<span className="pointer-events-none ml-2 flex">
<ChevronDown className="h-5 w-5" />
</span>
</Combobox.Button>
{portal &&
ReactDOM.createPortal(
<Combobox.Options
style={styles.popper}
{...attributes.popper}
ref={setPopperElement}
className="z-50 max-h-72 w-32 overflow-auto rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
</Listbox.Button>
</div>
<FloatingPortal>
{open ? (
<Listbox.Options
style={{
position: strategy,
left: x ?? "",
top: y ?? "",
}}
ref={floating}
className="z-50 max-h-52 overflow-auto rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
onMouseDown={stopPropagation}
>
{options}
</Combobox.Options>,
portal,
</Listbox.Options>
) : null}
</FloatingPortal>
</>
)}
</div>
</Combobox>
</Listbox>
);
};

View file

@ -11,18 +11,12 @@ import React from "react";
import { Calendar, dateFnsLocalizer } from "react-big-calendar";
import { useMount } from "react-use";
import { usePreferences } from "@/components/preferences/use-preferences";
import DateNavigationToolbar from "./date-navigation-toolbar";
import { DateTimeOption, DateTimePickerProps } from "./types";
import { formatDateWithoutTime, formatDateWithoutTz } from "./utils";
const localizer = dateFnsLocalizer({
format,
parse,
startOfWeek: (date: Date | number) => startOfWeek(date, { weekStartsOn: 1 }),
getDay,
locales: {},
});
const WeekCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
title,
options,
@ -39,8 +33,28 @@ const WeekCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
setScrollToTime(addMinutes(date, -60));
});
const { weekStartsOn, timeFormat, locale } = usePreferences();
const localizer = React.useMemo(
() =>
dateFnsLocalizer({
format,
parse,
startOfWeek: (date: Date | number) =>
startOfWeek(date, {
weekStartsOn: weekStartsOn === "monday" ? 1 : 0,
}),
getDay,
locales: {
default: locale,
},
}),
[locale, weekStartsOn],
);
return (
<Calendar
key={timeFormat}
events={options.map((option) => {
if (option.type === "date") {
return { title, start: new Date(option.date) };
@ -52,6 +66,7 @@ const WeekCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
};
}
})}
culture="default"
onNavigate={onNavigate}
date={date}
className="h-[calc(100vh-220px)] max-h-[800px] min-h-[400px] w-full"
@ -103,7 +118,7 @@ const WeekCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
width: `calc(${props.style?.width}%)`,
}}
>
<div>{format(props.event.start, "p")}</div>
<div>{format(props.event.start, "p", { locale })}</div>
<div className="w-full truncate font-bold">
{props.event.title}
</div>

View file

@ -24,6 +24,7 @@ interface HeadlessDatePickerOptions {
date?: Date;
selection?: Date[];
onNavigationChange?: (date: Date) => void;
weekStartsOn?: "monday" | "sunday";
}
const today = new Date();
@ -47,7 +48,9 @@ export const useHeadlessDatePicker = (
const navigationDate = options?.date ?? localNavigationDate;
const firstDayOfMonth = startOfMonth(navigationDate);
const firstDayOfFirstWeek = startOfWeek(firstDayOfMonth, { weekStartsOn: 1 });
const firstDayOfFirstWeek = startOfWeek(firstDayOfMonth, {
weekStartsOn: options?.weekStartsOn === "monday" ? 1 : 0,
});
const currentMonth = getMonth(navigationDate);

View file

@ -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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View file

@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z" />
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>

Before

Width:  |  Height:  |  Size: 362 B

After

Width:  |  Height:  |  Size: 459 B

Before After
Before After

10
components/no-ssr.tsx Normal file
View 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,
});

View file

@ -7,9 +7,9 @@ import * as React from "react";
import { createBreakpoint } from "react-use";
import DotsVertical from "@/components/icons/dots-vertical.svg";
import Github from "@/components/icons/github.svg";
import Logo from "../public/logo.svg";
import Github from "./home/github.svg";
import Footer from "./page-layout/footer";
const Popover = dynamic(() => import("./popover"), { ssr: false });
@ -62,7 +62,7 @@ const Menu: React.VoidFunctionComponent<{ className: string }> = ({
</Link>
<Link href="https://github.com/lukevella/rallly">
<a className="text-gray-400 transition-colors hover:text-indigo-500 hover:no-underline hover:underline-offset-2">
<Github className="w-8" />
<Github className="w-6" />
</a>
</Link>
</nav>

View file

@ -9,6 +9,7 @@ import {
ParsedTimeSlotOption,
} from "utils/date-time-utils";
import { usePreferences } from "./preferences/use-preferences";
import { useRequiredContext } from "./use-required-context";
type VoteType = "yes" | "no";
@ -59,6 +60,7 @@ export const PollContextProvider: React.VoidFunctionComponent<{
});
return res;
}, [participantById, poll.options]);
const { locale } = usePreferences();
const contextValue = React.useMemo<PollContextValue>(() => {
let highScore = 1;
@ -72,6 +74,7 @@ export const PollContextProvider: React.VoidFunctionComponent<{
poll.options,
poll.timeZone,
targetTimeZone,
locale,
);
const getParticipantById = (participantId: string) => {
// TODO (Luke Vella) [2022-04-16]: Build an index instead
@ -106,7 +109,7 @@ export const PollContextProvider: React.VoidFunctionComponent<{
targetTimeZone,
setTargetTimeZone,
};
}, [participantById, participantsByOptionId, poll, targetTimeZone]);
}, [locale, participantById, participantsByOptionId, poll, targetTimeZone]);
return (
<PollContext.Provider value={contextValue}>{children}</PollContext.Provider>
);

232
components/poll.tsx Normal file
View 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;

View file

@ -1 +0,0 @@
export { default } from "./desktop-poll";

View file

@ -1,8 +1,8 @@
import { Placement } from "@popperjs/core";
import { Placement } from "@floating-ui/react-dom-interactions";
import { format } from "date-fns";
import { Trans, useTranslation } from "next-i18next";
import * as React from "react";
import { decodeDateOption, encodeDateOption } from "utils/date-time-utils";
import { encodeDateOption } from "utils/date-time-utils";
import Button from "@/components/button";
import Cog from "@/components/icons/cog.svg";
@ -25,7 +25,7 @@ const ManagePoll: React.VoidFunctionComponent<{
placement?: Placement;
}> = ({ placement }) => {
const { t } = useTranslation("app");
const { poll, targetTimeZone } = usePoll();
const { poll, options } = usePoll();
const modalContext = useModalContext();
@ -181,12 +181,7 @@ const ManagePoll: React.VoidFunctionComponent<{
t("participantCount", {
count: poll.participants.length,
}),
...poll.options.map((option) => {
const decodedOption = decodeDateOption(
option,
poll.timeZone,
targetTimeZone,
);
...options.map((decodedOption) => {
const day = `${decodedOption.dow} ${decodedOption.day} ${decodedOption.month}`;
return decodedOption.type === "date"
? day

View file

@ -7,13 +7,14 @@ import { useMutation } from "react-query";
import Button from "../button";
import { usePoll } from "../poll-context";
import Popover from "../popover";
import { usePreferences } from "../preferences/use-preferences";
export interface PollSubheaderProps {}
const PollSubheader: React.VoidFunctionComponent<PollSubheaderProps> = () => {
const { poll } = usePoll();
const { t } = useTranslation("app");
const { locale } = usePreferences();
const {
mutate: sendVerificationEmail,
isLoading: isSendingVerificationEmail,
@ -29,14 +30,6 @@ const PollSubheader: React.VoidFunctionComponent<PollSubheaderProps> = () => {
t={t}
values={{
name: poll.authorName,
date: Date.parse(poll.createdAt),
formatParams: {
date: {
year: "numeric",
month: "numeric",
day: "numeric",
},
},
}}
components={{
b: <span className="font-medium text-indigo-500" />,
@ -88,7 +81,9 @@ const PollSubheader: React.VoidFunctionComponent<PollSubheaderProps> = () => {
</div>
<span className="hidden md:inline">&nbsp;&bull;&nbsp;</span>
<span className="whitespace-nowrap">
{formatRelative(new Date(poll.createdAt), new Date())}
{formatRelative(new Date(poll.createdAt), new Date(), {
locale,
})}
</span>
</div>
);

View file

@ -1,58 +1,72 @@
import {
flip,
FloatingPortal,
offset,
Placement,
shift,
useFloating,
} from "@floating-ui/react-dom-interactions";
import { Popover as HeadlessPopover } from "@headlessui/react";
import { Placement } from "@popperjs/core";
import clsx from "clsx";
import { motion } from "framer-motion";
import React from "react";
import ReactDOM from "react-dom";
import { usePopper } from "react-popper";
import { transformOriginByPlacement } from "utils/constants";
interface PopoverProps {
trigger: React.ReactNode;
children?: React.ReactNode;
placement?: Placement;
}
const MotionPanel = motion(HeadlessPopover.Panel);
const Popover: React.VoidFunctionComponent<PopoverProps> = ({
children,
trigger,
placement,
placement: preferredPlacement,
}) => {
const [referenceElement, setReferenceElement] =
React.useState<HTMLDivElement | null>(null);
const [popperElement, setPopperElement] =
React.useState<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement,
modifiers: [
{
name: "offset",
options: {
offset: [0, 5],
},
},
],
const { reference, floating, x, y, strategy, placement } = useFloating({
placement: preferredPlacement,
strategy: "fixed",
middleware: [offset(5), flip(), shift({ padding: 10 })],
});
const portal = document.getElementById("portal");
const origin = transformOriginByPlacement[placement];
return (
<HeadlessPopover as={React.Fragment}>
{({ open }) => (
<>
<HeadlessPopover.Button
ref={setReferenceElement}
ref={reference}
as="div"
className={clsx("inline-block")}
>
{trigger}
</HeadlessPopover.Button>
{portal &&
ReactDOM.createPortal(
<HeadlessPopover.Panel
className="max-w-full rounded-lg border bg-white p-4 shadow-md"
style={styles.popper}
{...attributes.popper}
ref={setPopperElement}
<FloatingPortal>
{open ? (
<MotionPanel
transition={{ duration: 0.1 }}
initial={{ opacity: 0, scale: 0.9, y: -10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: -10 }}
className={clsx(
"z-30 max-w-full translate-x-4 rounded-lg border bg-white p-4 shadow-md",
origin,
)}
style={{
position: strategy,
left: x ?? "",
top: y ?? "",
}}
ref={floating}
>
{children}
</HeadlessPopover.Panel>,
portal,
</MotionPanel>
) : null}
</FloatingPortal>
</>
)}
</HeadlessPopover>
);

View 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;

View 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;

View file

@ -0,0 +1,6 @@
import { useRequiredContext } from "../use-required-context";
import { PreferencesContext } from "./preferences-provider";
export const usePreferences = () => {
return useRequiredContext(PreferencesContext);
};

View file

@ -1,47 +1,176 @@
import clsx from "clsx";
import Link from "next/link";
import React from "react";
import Menu from "@/components/icons/menu.svg";
import Logo from "../public/logo.svg";
import Newspaper from "./icons/newspaper.svg";
import Adjustments from "./icons/adjustments.svg";
import Cash from "./icons/cash.svg";
import Github from "./icons/github.svg";
import Pencil from "./icons/pencil.svg";
import Support from "./icons/support.svg";
import Twitter from "./icons/twitter.svg";
import Popover from "./popover";
import Preferences from "./preferences";
const StandardLayout: React.FunctionComponent = ({ children, ...rest }) => {
const HomeLink = () => {
return (
<div className="relative min-h-full bg-gray-50 lg:flex" {...rest}>
<div className="border-b bg-gray-100 px-4 py-2 lg:grow lg:border-b-0 lg:border-r lg:py-6 lg:px-4">
<div className="flex items-center lg:float-right lg:w-40 lg:flex-col lg:items-start">
<div className="grow lg:mb-8 lg:grow-0">
<Link href="/">
<a>
<Logo className="w-24 text-slate-500 transition-colors hover:text-indigo-500 active:text-indigo-600 lg:w-28" />
<Logo className="w-28 text-slate-500 transition-colors hover:text-indigo-500 active:text-indigo-600" />
</a>
</Link>
);
};
const AppMenu: React.VoidFunctionComponent<{ className?: string }> = ({
className,
}) => {
return (
<div className={clsx("space-y-1", className)}>
<Link href="/new">
<a className="flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 pr-4 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300">
<Pencil className="h-5 opacity-75" />
<span className="inline-block">New Poll</span>
</a>
</Link>
<Link href="/support">
<a className="flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 pr-4 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300">
<Support className="h-5 opacity-75" />
<span className="inline-block">Support</span>
</a>
</Link>
</div>
<div className="flex shrink-0 items-center text-sm lg:mb-4 lg:block lg:w-full lg:pb-4 lg:text-base">
<Link passHref={true} href="/new">
<a className="flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 font-medium text-gray-600 transition-colors hover:bg-gray-200 hover:text-gray-600 hover:no-underline active:bg-gray-300 lg:-ml-2">
<Pencil className="h-6 w-6 opacity-75" />
<span className="hidden md:inline-block">New Poll</span>
</a>
</Link>
<a
href="https://blog.rallly.co"
className="flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 font-medium text-gray-600 transition-colors hover:bg-gray-200 hover:text-gray-600 hover:no-underline active:bg-gray-300 lg:-ml-2"
);
};
const StandardLayout: React.VoidFunctionComponent<{
children?: React.ReactNode;
}> = ({ children, ...rest }) => {
return (
<div
className="relative flex min-h-full flex-col bg-gray-50 lg:flex-row"
{...rest}
>
<Newspaper className="h-6 w-6 opacity-75" />
<span className="hidden md:inline-block">Blog</span>
<div className="relative z-10 flex h-12 shrink-0 items-center justify-between border-b px-4 lg:hidden">
<div>
<HomeLink />
</div>
<div className="flex items-center">
<Popover
placement="bottom-end"
trigger={
<button
type="button"
className="flex whitespace-nowrap rounded-md px-2 py-1 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300"
>
<Adjustments className="h-5 opacity-75" />
</button>
}
>
<Preferences />
</Popover>
<Popover
trigger={
<button
type="button"
className="rounded-md px-2 py-1 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300"
>
<Menu className="w-5" />
</button>
}
>
<AppMenu className="-m-2" />
</Popover>
</div>
</div>
<div className="hidden grow px-4 pt-6 pb-5 lg:block">
<div className="sticky top-6 float-right flex w-40 flex-col items-start">
<div className="mb-8 grow-0 px-2">
<HomeLink />
</div>
<div className="mb-4 block w-full shrink-0 grow items-center pb-4 text-base">
<div className="mb-4">
<Link href="/new">
<a className="mb-1 flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 pr-4 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300">
<Pencil className="h-5 opacity-75" />
<span className="inline-block">New Poll</span>
</a>
<Link passHref={true} href="/support">
<a className="flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 font-medium text-gray-600 transition-colors hover:bg-gray-200 hover:text-gray-600 hover:no-underline active:bg-gray-300 lg:-ml-2">
<Support className="h-6 w-6 opacity-75" />
<span className="hidden md:inline-block">Support</span>
</Link>
<Link href="/support">
<a className="mb-1 flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 pr-4 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300">
<Support className="h-5 opacity-75" />
<span className="inline-block">Support</span>
</a>
</Link>
<Popover
placement="right-start"
trigger={
<button className="flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 pr-4 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300">
<Adjustments className="h-5 opacity-75" />
<span className="inline-block">Preferences</span>
</button>
}
>
<Preferences />
</Popover>
</div>
</div>
</div>
</div>
<div className="min-w-0 grow">
<div className="max-w-full md:w-[1024px] lg:min-h-[calc(100vh-64px)]">
{children}
</div>
<div className="flex flex-col items-center space-y-4 px-6 pt-3 pb-6 text-slate-400 lg:h-16 lg:flex-row lg:space-y-0 lg:space-x-6 lg:py-0 lg:px-8 lg:pb-3">
<div>
<Link href="https://rallly.co">
<a className="text-sm text-slate-400 transition-colors hover:text-indigo-500 hover:no-underline">
<Logo className="h-5" />
</a>
</Link>
</div>
<div className="hidden text-slate-300 lg:block">&bull;</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">&bull;</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">&bull;</div>
<Link href="https://www.paypal.com/donate/?hosted_button_id=7QXP2CUBLY88E">
<a className="inline-flex h-8 items-center rounded-full bg-slate-100 pl-2 pr-3 text-sm text-slate-400 transition-colors hover:bg-indigo-500 hover:text-white hover:no-underline focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1 active:bg-indigo-600">
<Cash className="mr-1 inline-block w-5" />
<span>Donate</span>
</a>
</Link>
</div>
</div>
<div className="min-w-0 grow">{children}</div>
</div>
);
};

View file

@ -1,10 +1,18 @@
import { Placement } from "@popperjs/core";
import {
arrow,
flip,
FloatingPortal,
offset,
Placement,
shift,
useFloating,
useHover,
useInteractions,
useRole,
} from "@floating-ui/react-dom-interactions";
import clsx from "clsx";
import { AnimatePresence, motion } from "framer-motion";
import * as React from "react";
import ReactDOM from "react-dom";
import { usePopper } from "react-popper";
import { useClickAway, useDebounce } from "react-use";
import { preventWidows } from "utils/prevent-widows";
export interface TooltipProps {
@ -16,98 +24,73 @@ export interface TooltipProps {
}
const Tooltip: React.VoidFunctionComponent<TooltipProps> = ({
placement = "bottom",
placement: preferredPlacement = "bottom",
className,
children,
disabled,
content,
}) => {
const [referenceElement, setReferenceElement] =
React.useState<HTMLDivElement | null>(null);
const [popperElement, setPopperElement] =
React.useState<HTMLDivElement | null>(null);
const [arrowElement, setArrowElement] =
React.useState<HTMLDivElement | null>(null);
const arrowRef = React.useRef<HTMLDivElement | null>(null);
const [open, setOpen] = React.useState(false);
const { styles, attributes, update } = usePopper(
referenceElement,
popperElement,
{
const {
reference,
floating,
x,
y,
strategy,
context,
middlewareData,
placement,
modifiers: [
{
name: "offset",
options: {
offset: [0, 14],
},
},
{ name: "arrow", options: { element: arrowElement, padding: 5 } },
} = useFloating({
strategy: "fixed",
open,
onOpenChange: setOpen,
placement: preferredPlacement,
middleware: [
offset(10),
flip(),
shift({ padding: 5 }),
arrow({ element: arrowRef }),
],
},
);
const [isVisible, setIsVisible] = React.useState(false);
const [debouncedValue, setDebouncedValue] = React.useState(false);
const [, cancel] = useDebounce(
async () => {
await update?.();
setDebouncedValue(isVisible);
},
300,
[isVisible],
);
const portal = document.getElementById("portal");
const [key, setKey] = React.useState(0);
React.useEffect(() => {
setKey((k) => k + 1);
}, [content]);
const ref = React.useRef<HTMLDivElement | null>(null);
useClickAway(ref, () => {
setIsVisible(false);
});
if (disabled) {
return <>{children}</>;
}
const placementGroup = placement.split("-")[0] as
| "top"
| "right"
| "bottom"
| "left";
const staticSide = {
top: "bottom",
right: "left",
bottom: "top",
left: "right",
}[placementGroup];
const { getReferenceProps, getFloatingProps } = useInteractions([
useHover(context, {
enabled: !disabled,
restMs: 150,
}),
useRole(context, {
role: "tooltip",
}),
]);
return (
<>
<div
key={key}
className={clsx("inline-block", className)}
onMouseEnter={() => {
setIsVisible(true);
}}
onMouseLeave={() => {
setIsVisible(false);
setDebouncedValue(false);
cancel();
}}
ref={(el) => {
setReferenceElement(el);
ref.current = el;
}}
{...getReferenceProps({ ref: reference })}
>
{children}
</div>
{portal
? ReactDOM.createPortal(
<FloatingPortal>
<AnimatePresence>
{debouncedValue ? (
<div
className="pointer-events-none"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
{open ? (
<motion.div
className="rounded-md bg-slate-700 px-3 py-2 text-slate-200 shadow-md"
className="z-30 rounded-md bg-slate-700 px-3 py-2 text-slate-200 shadow-md"
initial="hidden"
transition={{
duration: 0.1,
@ -119,24 +102,32 @@ const Tooltip: React.VoidFunctionComponent<TooltipProps> = ({
},
show: { opacity: 1, translateY: 0 },
}}
animate={debouncedValue ? "show" : "hidden"}
animate={open ? "show" : "hidden"}
{...getFloatingProps({
ref: floating,
style: {
position: strategy,
top: y ?? "",
left: x ?? "",
},
})}
>
<div
ref={setArrowElement}
className="tooltip-arrow h-3 w-3 border-[6px] border-transparent"
style={styles.arrow}
data-popper-arrow
></div>
{typeof content === "string"
? preventWidows(content)
: content}
ref={arrowRef}
className="absolute rotate-45 bg-slate-700"
style={{
width: 8,
height: 8,
left: middlewareData.arrow?.x,
top: middlewareData.arrow?.y,
[staticSide]: -4,
}}
/>
{typeof content === "string" ? preventWidows(content) : content}
</motion.div>
</div>
) : null}
</AnimatePresence>,
portal,
)
: null}
</AnimatePresence>
</FloatingPortal>
</>
);
};

View file

@ -12,9 +12,9 @@
"test": "playwright test"
},
"dependencies": {
"@floating-ui/react-dom-interactions": "^0.3.1",
"@headlessui/react": "^1.5.0",
"@next/bundle-analyzer": "^12.1.0",
"@popperjs/core": "^2.11.4",
"@prisma/client": "^3.12.0",
"@sentry/nextjs": "^6.19.3",
"@svgr/webpack": "^6.2.1",
@ -44,7 +44,6 @@
"react-hot-toast": "^2.2.0",
"react-i18next": "^11.15.4",
"react-linkify": "^1.0.0-alpha",
"react-popper": "^2.2.5",
"react-query": "^3.34.12",
"react-use": "^17.3.2",
"smoothscroll-polyfill": "^0.4.4",

View file

@ -13,6 +13,7 @@ import { MutationCache, QueryClient, QueryClientProvider } from "react-query";
import { useSessionStorage } from "react-use";
import ModalProvider from "@/components/modal/modal-provider";
import PreferencesProvider from "@/components/preferences/preferences-provider";
import { UserNameContext } from "../components/user-name-context";
@ -41,9 +42,13 @@ const MyApp: NextPage<AppProps> = ({ Component, pageProps }) => {
selfHosted={true}
enabled={!!process.env.PLAUSIBLE_DOMAIN}
>
<PreferencesProvider>
<QueryClientProvider client={queryClient}>
<Head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
</Head>
<CrispChat />
<Toaster />
@ -53,6 +58,7 @@ const MyApp: NextPage<AppProps> = ({ Component, pageProps }) => {
</UserNameContext.Provider>
</ModalProvider>
</QueryClientProvider>
</PreferencesProvider>
</PlausibleProvider>
);
};

View file

@ -1,5 +1,6 @@
import { addMinutes } from "date-fns";
import { NextApiRequest, NextApiResponse } from "next";
import absoluteUrl from "utils/absolute-url";
import { nanoid } from "utils/nanoid";
import { prisma } from "../../../db";
@ -64,6 +65,8 @@ export default async function handler(
});
}
const homePageUrl = absoluteUrl(req).origin;
await prisma.poll.create({
data: {
urlId: await nanoid(),
@ -71,8 +74,7 @@ export default async function handler(
title: "Lunch Meeting Demo",
type: "date",
location: "Starbucks, 901 New York Avenue",
description:
"This poll has been automatically generated just for you! Feel free to try out all the different features and when you're ready, you can go to https://rallly.co/new to make a new poll.",
description: `This poll has been automatically generated just for you! Feel free to try out all the different features and when you're ready, you can go to ${homePageUrl}/new to make a new poll.`,
authorName: "Johnny",
verified: true,
demo: true,

View file

@ -1,224 +1,6 @@
import { GetServerSideProps, NextPage } from "next";
import { GetServerSideProps } from "next";
import dynamic from "next/dynamic";
import Head from "next/head";
import { useRouter } from "next/router";
import { useTranslation } from "next-i18next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { usePlausible } from "next-plausible";
import React from "react";
import { useMutation } from "react-query";
import { useSessionStorage } from "react-use";
import { createPoll } from "../api-client/create-poll";
import Button from "../components/button";
import {
NewEventData,
PollDetailsData,
PollDetailsForm,
PollOptionsData,
PollOptionsForm,
UserDetailsData,
UserDetailsForm,
} from "../components/forms";
import StandardLayout from "../components/standard-layout";
import Steps from "../components/steps";
import { useUserName } from "../components/user-name-context";
import { encodeDateOption } from "../utils/date-time-utils";
type StepName = "eventDetails" | "options" | "userDetails";
const steps: StepName[] = ["eventDetails", "options", "userDetails"];
const required = <T extends unknown>(v: T | undefined): T => {
if (!v) {
throw new Error("Required value is missing");
}
return v;
};
const initialNewEventData: NewEventData = { currentStep: 0 };
const sessionStorageKey = "newEventFormData";
const Page: NextPage<{
title?: string;
location?: string;
description?: string;
view?: "week" | "month";
}> = ({ title, location, description, view }) => {
const { t } = useTranslation("app");
const router = useRouter();
const [persistedFormData, setPersistedFormData] =
useSessionStorage<NewEventData>(sessionStorageKey, {
currentStep: 0,
eventDetails: {
title,
location,
description,
},
options: {
view,
},
});
const [formData, setTransientFormData] = React.useState(persistedFormData);
const setFormData = React.useCallback(
(newEventData: NewEventData) => {
setTransientFormData(newEventData);
setPersistedFormData(newEventData);
},
[setPersistedFormData],
);
const currentStepIndex = formData?.currentStep ?? 0;
const currentStepName = steps[currentStepIndex];
const [isRedirecting, setIsRedirecting] = React.useState(false);
const [, setUserName] = useUserName();
const plausible = usePlausible();
const { mutate: createEventMutation, isLoading: isCreatingPoll } =
useMutation(
() => {
const title = required(formData?.eventDetails?.title);
return createPoll({
title: title,
type: "date",
location: formData?.eventDetails?.location,
description: formData?.eventDetails?.description,
user: {
name: required(formData?.userDetails?.name),
email: required(formData?.userDetails?.contact),
},
timeZone: formData?.options?.timeZone,
options: required(formData?.options?.options).map(encodeDateOption),
});
},
{
onSuccess: (poll) => {
setIsRedirecting(true);
setUserName(poll.authorName);
plausible("Created poll", {
props: {
numberOfOptions: formData.options?.options?.length,
optionsView: formData?.options?.view,
},
});
setPersistedFormData(initialNewEventData);
router.replace(`/admin/${poll.urlId}`);
},
},
);
const isBusy = isRedirecting || isCreatingPoll;
const handleSubmit = (
data: PollDetailsData | PollOptionsData | UserDetailsData,
) => {
if (currentStepIndex < steps.length - 1) {
setFormData({
...formData,
currentStep: currentStepIndex + 1,
[currentStepName]: data,
});
} else {
// last step
createEventMutation();
}
};
const handleChange = (
data: Partial<PollDetailsData | PollOptionsData | UserDetailsData>,
) => {
setFormData({
...formData,
currentStep: currentStepIndex,
[currentStepName]: data,
});
};
return (
<StandardLayout>
<Head>
<title>{formData?.eventDetails?.title ?? t("newPoll")}</title>
</Head>
<div className="w-[1024px] max-w-full py-4 px-3 lg:px-6">
<div className="mb-4 flex items-center space-x-4">
<h1 className="m-0">New Poll</h1>
<Steps current={currentStepIndex} total={steps.length} />
</div>
<div className="w-fit max-w-full overflow-hidden rounded-lg border bg-white shadow-sm">
{(() => {
switch (currentStepName) {
case "eventDetails":
return (
<PollDetailsForm
className="max-w-full px-4 pt-4"
name={currentStepName}
defaultValues={formData?.eventDetails}
onSubmit={handleSubmit}
onChange={handleChange}
/>
);
case "options":
return (
<PollOptionsForm
className="grow"
name={currentStepName}
defaultValues={formData?.options}
onSubmit={handleSubmit}
onChange={handleChange}
title={formData.eventDetails?.title}
/>
);
case "userDetails":
return (
<UserDetailsForm
className="grow px-4 pt-4"
name={currentStepName}
defaultValues={formData?.userDetails}
onSubmit={handleSubmit}
onChange={handleChange}
/>
);
}
})()}
<div className="flex w-full justify-end space-x-3 border-t bg-slate-50 px-4 py-3">
{currentStepIndex > 0 ? (
<Button
disabled={isBusy}
onClick={() => {
setFormData({
...persistedFormData,
currentStep: currentStepIndex - 1,
});
}}
>
{t("back")}
</Button>
) : null}
<Button
form={currentStepName}
loading={isBusy}
htmlType="submit"
type="primary"
>
{currentStepIndex < steps.length - 1
? t("next")
: t("createPoll")}
</Button>
</div>
</div>
</div>
</StandardLayout>
);
};
export const getServerSideProps: GetServerSideProps = async ({
locale = "en",
@ -233,4 +15,6 @@ export const getServerSideProps: GetServerSideProps = async ({
};
// We disable SSR because the data on this page relies on sessionStore
export default dynamic(() => Promise.resolve(Page), { ssr: false });
export default dynamic(() => import("@/components/create-poll"), {
ssr: false,
});

View file

@ -1,41 +1,20 @@
import axios from "axios";
import { GetServerSideProps, NextPage } from "next";
import Head from "next/head";
import dynamic from "next/dynamic";
import { useRouter } from "next/router";
import { useTranslation } from "next-i18next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { usePlausible } from "next-plausible";
import React from "react";
import { toast } from "react-hot-toast";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useMount } from "react-use";
import { preventWidows } from "utils/prevent-widows";
import { useQuery } from "react-query";
import Button from "@/components/button";
import ErrorPage from "@/components/error-page";
import FullPageLoader from "@/components/full-page-loader";
import LocationMarker from "@/components/icons/location-marker.svg";
import LockClosed from "@/components/icons/lock-closed.svg";
import Share from "@/components/icons/share.svg";
import ManagePoll from "@/components/poll/manage-poll";
import MobilePoll from "@/components/poll/mobile-poll";
import { useUpdatePollMutation } from "@/components/poll/mutations";
import NotificationsToggle from "@/components/poll/notifications-toggle";
import PollSubheader from "@/components/poll/poll-subheader";
import TruncatedLinkify from "@/components/poll/truncated-linkify";
import { UserAvatarProvider } from "@/components/poll/user-avatar";
import { PollContextProvider, usePoll } from "@/components/poll-context";
import Popover from "@/components/popover";
import Sharing from "@/components/sharing";
import StandardLayout from "@/components/standard-layout";
import { useUserName } from "@/components/user-name-context";
import { GetPollResponse } from "../api-client/get-poll";
import Custom404 from "./404";
const Discussion = React.lazy(() => import("@/components/discussion"));
const Poll = React.lazy(() => import("@/components/poll"));
const PollPage = dynamic(() => import("@/components/poll"), { ssr: false });
const PollPageLoader: NextPage = () => {
const { query } = useRouter();
@ -99,195 +78,7 @@ const PollPageLoader: NextPage = () => {
return !poll ? (
<FullPageLoader>{t("loading")}</FullPageLoader>
) : (
<PollContextProvider value={poll}>
<PollPage />
</PollContextProvider>
);
};
const PollPage: NextPage = () => {
const { poll } = usePoll();
const router = useRouter();
useMount(() => {
const path = poll.role === "admin" ? "admin" : "p";
if (!new RegExp(`^/${path}`).test(router.asPath)) {
router.replace(`/${path}/${poll.urlId}`, undefined, { shallow: true });
}
});
const [, setUserName] = useUserName();
const queryClient = useQueryClient();
const plausible = usePlausible();
const { mutate: updatePollMutation } = useUpdatePollMutation();
const { mutate: verifyEmail } = useMutation(
async (verificationCode: string) => {
await axios.post(`/api/poll/${poll.urlId}/verify`, {
verificationCode,
});
},
{
onSuccess: () => {
toast.success("Your poll has been verified");
router.replace(`/admin/${router.query.urlId}`, undefined, {
shallow: true,
});
queryClient.setQueryData(["getPoll", poll.urlId], {
...poll,
verified: true,
});
plausible("Verified email");
setUserName(poll.authorName);
},
},
);
React.useEffect(() => {
// TODO (Luke Vella) [2022-03-29]: stop looking for "verificationCode". We switched to
// "code" for compatability with v1 and it's generally better since it's more concise
const verificationCode = router.query.verificationCode ?? router.query.code;
if (typeof verificationCode === "string") {
verifyEmail(verificationCode);
}
}, [router, verifyEmail]);
React.useEffect(() => {
if (router.query.unsubscribe) {
updatePollMutation(
{ notifications: false },
{
onSuccess: () => {
toast.success("Notifications have been disabled");
plausible("Unsubscribed from notifications");
},
},
);
router.replace(`/admin/${router.query.urlId}`, undefined, {
shallow: true,
});
}
}, [plausible, router, updatePollMutation]);
const checkIfWideScreen = () => window.innerWidth > 640;
const [isWideScreen, setIsWideScreen] = React.useState(checkIfWideScreen);
React.useEffect(() => {
const listener = () => setIsWideScreen(checkIfWideScreen());
window.addEventListener("resize", listener);
return () => {
window.removeEventListener("resize", listener);
};
}, []);
const PollComponent = isWideScreen ? Poll : MobilePoll;
let highScore = 1; // set to one because we don't want to highlight
poll.options.forEach((option) => {
if (option.votes.length > highScore) {
highScore = option.votes.length;
}
});
const names = React.useMemo(
() => poll.participants.map(({ name }) => name),
[poll.participants],
);
return (
<UserAvatarProvider seed={poll.pollId} names={names}>
<StandardLayout>
<div className="relative max-w-full bg-gray-50 py-4 md:px-4 lg:w-[1024px] lg:px-8">
<Head>
<title>{poll.title}</title>
<meta name="robots" content="noindex,nofollow" />
</Head>
<div
className="max-w-full"
style={{
width: Math.max(600, poll.options.length * 95 + 200 + 160),
}}
>
<div className="mb-6">
<div className="mb-3 items-start px-4 md:flex md:space-x-4">
<div className="mb-3 grow md:mb-0">
<div className="flex flex-col-reverse md:flex-row">
<h1 className="mb-2 grow text-3xl leading-tight">
{preventWidows(poll.title)}
</h1>
{poll.role === "admin" ? (
<div className="mb-4 flex space-x-2 md:mb-2">
<NotificationsToggle />
<ManagePoll
placement={
isWideScreen ? "bottom-end" : "bottom-start"
}
/>
<div>
<Popover
trigger={
<Button type="primary" icon={<Share />}>
Share
</Button>
}
placement={isWideScreen ? "bottom-end" : undefined}
>
<Sharing links={poll.links} />
</Popover>
</div>
</div>
) : null}
</div>
<PollSubheader />
</div>
</div>
{poll.description ? (
<div className="mb-4 whitespace-pre-line bg-white px-4 py-3 text-lg leading-relaxed text-slate-600 shadow-sm md:w-fit md:rounded-xl md:bg-white">
<TruncatedLinkify>
{preventWidows(poll.description)}
</TruncatedLinkify>
</div>
) : null}
{poll.location ? (
<div className="mb-4 flex items-center px-4">
<div>
<LocationMarker
width={20}
className="mr-2 text-slate-400"
/>
</div>
<TruncatedLinkify>{poll.location}</TruncatedLinkify>
</div>
) : null}
</div>
{poll.closed ? (
<div className="mb-4 flex items-center bg-sky-100 py-3 px-4 text-sky-700 shadow-sm md:rounded-lg">
<div className="mr-3 rounded-md">
<LockClosed className="w-5" />
</div>
This poll has been locked (voting is disabled)
</div>
) : null}
<React.Suspense fallback={<div>Loading</div>}>
<div className="mb-4 lg:mb-8">
<PollComponent pollId={poll.urlId} highScore={highScore} />
</div>
<Discussion
pollId={poll.urlId}
canDelete={poll.role === "admin"}
/>
</React.Suspense>
</div>
</div>
</StandardLayout>
</UserAvatarProvider>
<PollPage poll={poll} />
);
};

View file

@ -42,5 +42,12 @@
"participantDescription": "Partial access to vote and comment on this poll.",
"unverifiedMessage": "An email has been sent to <b>{{email}}</b> with a link to verify the email address.",
"notificationsOnDescription": "An email will be sent to <b>{{email}}</b> when there is activity on this poll.",
"deletingOptionsWarning": "You are deleting options that participants have voted for. Their votes will be also be deleted."
"deletingOptionsWarning": "You are deleting options that participants have voted for. Their votes will be also be deleted.",
"timeAndDate": "Time & date",
"weekStartsOn": "Week starts on:",
"timeFormat": "Time format:",
"monday": "Monday",
"sunday": "Sunday",
"12h": "12-hour",
"24h": "24-hour"
}

View file

@ -9,7 +9,7 @@
height: 100%;
}
body {
@apply text-base text-slate-600;
@apply bg-slate-50 text-base text-slate-600;
}
p {
@apply mb-4;
@ -40,7 +40,7 @@
@apply focus:outline-none focus:ring-indigo-600;
}
#portal {
#floating-ui-root {
@apply absolute z-50 w-full;
}
}
@ -87,7 +87,7 @@
@apply pointer-events-none;
}
.btn-primary {
@apply btn border-indigo-600 bg-indigo-500 text-white hover:bg-opacity-90 focus:ring-indigo-500;
@apply btn border-indigo-600 bg-indigo-500 text-white hover:bg-opacity-90 focus:ring-indigo-500 active:bg-indigo-600;
}
a.btn-primary {
@apply text-white;
@ -121,12 +121,9 @@
@apply cursor-not-allowed;
}
[data-popper-placement="bottom"] .tooltip-arrow {
@apply bottom-full border-b-slate-700;
}
[data-popper-placement="top"] .tooltip-arrow {
@apply top-full border-t-slate-700;
.card {
@apply rounded-lg border bg-white p-6
shadow-sm;
}
}
@ -146,9 +143,6 @@
}
@layer utilities {
.contain-paint {
contain: paint;
}
.bg-pattern {
background-color: #f9fafb;
background-image: url("data:image/svg+xml,%3Csvg width='100' height='20' viewBox='0 0 100 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M21.184 20c.357-.13.72-.264 1.088-.402l1.768-.661C33.64 15.347 39.647 14 50 14c10.271 0 15.362 1.222 24.629 4.928.955.383 1.869.74 2.75 1.072h6.225c-2.51-.73-5.139-1.691-8.233-2.928C65.888 13.278 60.562 12 50 12c-10.626 0-16.855 1.397-26.66 5.063l-1.767.662c-2.475.923-4.66 1.674-6.724 2.275h6.335zm0-20C13.258 2.892 8.077 4 0 4V2c5.744 0 9.951-.574 14.85-2h6.334zM77.38 0C85.239 2.966 90.502 4 100 4V2c-6.842 0-11.386-.542-16.396-2h-6.225zM0 14c8.44 0 13.718-1.21 22.272-4.402l1.768-.661C33.64 5.347 39.647 4 50 4c10.271 0 15.362 1.222 24.629 4.928C84.112 12.722 89.438 14 100 14v-2c-10.271 0-15.362-1.222-24.629-4.928C65.888 3.278 60.562 2 50 2 39.374 2 33.145 3.397 23.34 7.063l-1.767.662C13.223 10.84 8.163 12 0 12v2z' fill='%239C92AC' fill-opacity='0.06' fill-rule='evenodd'/%3E%3C/svg%3E");

View file

@ -35,5 +35,7 @@ test("should be able to create a new poll", async ({ page, context }) => {
await page.click('text="Create poll"');
await expect(page.locator('text="Monthly Meetup"')).toBeVisible();
await expect(page.locator("data-testid=poll-title")).toHaveText(
"Monthly Meetup",
);
});

View file

@ -1 +1,18 @@
import { Placement } from "@floating-ui/react-dom-interactions";
export const isInMaintenanceMode = process.env.MAINTENANCE_MODE === "true";
export const transformOriginByPlacement: Record<Placement, string> = {
bottom: "origin-top",
"bottom-end": "origin-top-right",
"bottom-start": "origin-top-left",
left: "origin-right",
"left-start": "origin-top-right",
"left-end": "origin-bottom-right",
right: "origin-left",
"right-start": "origin-top-left",
"right-end": "origin-bottom-left",
top: "origin-bottom",
"top-start": "origin-bottom-left",
"top-end": "origin-bottom-right",
};

View file

@ -5,6 +5,7 @@ import {
format,
formatDuration,
isSameDay,
Locale,
} from "date-fns";
import { formatInTimeZone } from "date-fns-tz";
import spacetime from "spacetime";
@ -58,6 +59,7 @@ export const decodeOptions = (
options: Option[],
timeZone: string | null,
targetTimeZone: string,
locale: Locale,
):
| { pollType: "date"; options: ParsedDateOption[] }
| { pollType: "timeSlot"; options: ParsedTimeSlotOption[] } => {
@ -67,7 +69,7 @@ export const decodeOptions = (
return {
pollType,
options: options.map((option) =>
parseTimeSlotOption(option, timeZone, targetTimeZone),
parseTimeSlotOption(option, timeZone, targetTimeZone, locale),
),
};
} else {
@ -98,7 +100,18 @@ const parseTimeSlotOption = (
option: Option,
timeZone: string | null,
targetTimeZone: string,
locale: Locale,
): ParsedTimeSlotOption => {
const localeFormatInTimezone = (
date: Date,
timezone: string,
formatString: string,
) => {
return formatInTimeZone(date, timezone, formatString, {
locale,
});
};
const [start, end] = option.value.split("/");
if (timeZone && targetTimeZone) {
const startDate = spacetime(start, timeZone).toNativeDate();
@ -106,11 +119,11 @@ const parseTimeSlotOption = (
return {
type: "timeSlot",
optionId: option.id,
startTime: formatInTimeZone(startDate, targetTimeZone, "hh:mm a"),
endTime: formatInTimeZone(endDate, targetTimeZone, "hh:mm a"),
day: formatInTimeZone(startDate, targetTimeZone, "d"),
dow: formatInTimeZone(startDate, targetTimeZone, "E"),
month: formatInTimeZone(startDate, targetTimeZone, "MMM"),
startTime: localeFormatInTimezone(startDate, targetTimeZone, "p"),
endTime: localeFormatInTimezone(endDate, targetTimeZone, "p"),
day: localeFormatInTimezone(startDate, targetTimeZone, "d"),
dow: localeFormatInTimezone(startDate, targetTimeZone, "E"),
month: localeFormatInTimezone(startDate, targetTimeZone, "MMM"),
duration: getDuration(startDate, endDate),
};
} else {
@ -119,8 +132,8 @@ const parseTimeSlotOption = (
return {
type: "timeSlot",
optionId: option.id,
startTime: format(startDate, "hh:mm a"),
endTime: format(endDate, "hh:mm a"),
startTime: format(startDate, "p"),
endTime: format(endDate, "p"),
day: format(startDate, "d"),
dow: format(startDate, "E"),
month: format(startDate, "MMM"),
@ -129,61 +142,6 @@ const parseTimeSlotOption = (
}
};
export const decodeDateOption = (
option: Option,
timeZone: string | null,
targetTimeZone: string,
): ParsedDateTimeOpton => {
const isTimeRange = option.value.indexOf("/") !== -1;
// option can either be an ISO date (ex. 2000-01-01)
// or a time range (ex. 2000-01-01T08:00:00/2000-01-01T09:00:00)
if (isTimeRange) {
const [start, end] = option.value.split("/");
if (timeZone && targetTimeZone) {
const startDate = spacetime(start, timeZone).toNativeDate();
const endDate = spacetime(end, timeZone).toNativeDate();
return {
type: "timeSlot",
optionId: option.id,
startTime: formatInTimeZone(startDate, targetTimeZone, "hh:mm a"),
endTime: formatInTimeZone(endDate, targetTimeZone, "hh:mm a"),
day: formatInTimeZone(startDate, targetTimeZone, "d"),
dow: formatInTimeZone(startDate, targetTimeZone, "E"),
month: formatInTimeZone(startDate, targetTimeZone, "MMM"),
duration: getDuration(startDate, endDate),
};
} else {
const startDate = new Date(start);
const endDate = new Date(end);
return {
type: "timeSlot",
optionId: option.id,
startTime: format(startDate, "hh:mm a"),
endTime: format(endDate, "hh:mm a"),
day: format(startDate, "d"),
dow: format(startDate, "E"),
month: format(startDate, "MMM"),
duration: getDuration(startDate, endDate),
};
}
}
// we add the time because otherwise Date will assume UTC time which might change the day for some time zones
const dateString =
option.value.indexOf("T") === -1
? option.value + "T00:00:00"
: option.value;
const date = new Date(dateString);
return {
type: "date",
optionId: option.id,
day: format(date, "d"),
dow: format(date, "E"),
month: format(date, "MMM"),
};
};
export const removeAllOptionsForDay = (
options: DateTimeOption[],
date: Date,

View file

@ -1138,6 +1138,36 @@
minimatch "^3.0.4"
strip-json-comments "^3.1.1"
"@floating-ui/core@^0.6.2":
version "0.6.2"
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-0.6.2.tgz#f2813f0e5f3d5ed7af5029e1a082203dadf02b7d"
integrity sha512-jktYRmZwmau63adUG3GKOAVCofBXkk55S/zQ94XOorAHhwqFIOFAy1rSp2N0Wp6/tGbe9V3u/ExlGZypyY17rg==
"@floating-ui/dom@^0.4.5":
version "0.4.5"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-0.4.5.tgz#2e88d16646119cc67d44683f75ee99840475bbfa"
integrity sha512-b+prvQgJt8pieaKYMSJBXHxX/DYwdLsAWxKYqnO5dO2V4oo/TYBZJAUQCVNjTWWsrs6o4VDrNcP9+E70HAhJdw==
dependencies:
"@floating-ui/core" "^0.6.2"
"@floating-ui/react-dom-interactions@^0.3.1":
version "0.3.1"
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom-interactions/-/react-dom-interactions-0.3.1.tgz#abc0cb4b18e6f095397e50f9846572eee4e34554"
integrity sha512-tP2KEh7EHJr5hokSBHcPGojb+AorDNUf0NYfZGg/M+FsMvCOOsSEeEF0O1NDfETIzDnpbHnCs0DuvCFhSMSStg==
dependencies:
"@floating-ui/react-dom" "^0.6.3"
aria-hidden "^1.1.3"
point-in-polygon "^1.1.0"
use-isomorphic-layout-effect "^1.1.1"
"@floating-ui/react-dom@^0.6.3":
version "0.6.3"
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-0.6.3.tgz#7b64cfd4fd12e4a0515dbf1b2be16e48c9a06c5a"
integrity sha512-hC+pS5D6AgS2wWjbmSQ6UR6Kpy+drvWGJIri6e1EDGADTPsCaa4KzCgmCczHrQeInx9tqs81EyDmbKJYY2swKg==
dependencies:
"@floating-ui/dom" "^0.4.5"
use-isomorphic-layout-effect "^1.1.1"
"@hapi/hoek@^9.0.0":
version "9.2.1"
resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.2.1.tgz#9551142a1980503752536b5050fd99f4a7f13b17"
@ -1324,11 +1354,6 @@
resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.21.tgz#5de5a2385a35309427f6011992b544514d559aa1"
integrity sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==
"@popperjs/core@^2.11.4":
version "2.11.4"
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.4.tgz#d8c7b8db9226d2d7664553a0741ad7d0397ee503"
integrity sha512-q/ytXxO5NKvyT37pmisQAItCFqA7FD/vNb8dgaJy3/630Fsc+Mz9/9f2SziBoIZ30TJooXyTwZmhi1zjXmObYg==
"@popperjs/core@^2.5.3":
version "2.9.2"
resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.9.2.tgz"
@ -2049,6 +2074,13 @@ argparse@^1.0.7:
dependencies:
sprintf-js "~1.0.2"
aria-hidden@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.1.3.tgz#bb48de18dc84787a3c6eee113709c473c64ec254"
integrity sha512-RhVWFtKH5BiGMycI72q2RAFMLQi8JP9bLuQXgR5a8Znp7P5KOIADSJeyfI8PCVxLEp067B2HbP5JIiI/PXIZeA==
dependencies:
tslib "^1.0.0"
aria-query@^4.2.2:
version "4.2.2"
resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-4.2.2.tgz#0d2ca6c9aceb56b8977e9fed6aed7e15bbd2f83b"
@ -4687,6 +4719,11 @@ pngjs@^4.0.1:
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-4.0.1.tgz#f803869bb2fc1bfe1bf99aa4ec21c108117cfdbe"
integrity sha512-rf5+2/ioHeQxR6IxuYNYGFytUyG3lma/WW1nsmjeHlWwtb2aByla6dkVc8pmJ9nplzkTA0q2xx7mMWrOTqT4Gg==
point-in-polygon@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/point-in-polygon/-/point-in-polygon-1.1.0.tgz#b0af2616c01bdee341cbf2894df643387ca03357"
integrity sha512-3ojrFwjnnw8Q9242TzgXuTD+eKiutbzyslcq1ydfu82Db2y+Ogbmyrkpv0Hgj31qwT3lbS9+QAAO/pIQM35XRw==
popmotion@11.0.3:
version "11.0.3"
resolved "https://registry.yarnpkg.com/popmotion/-/popmotion-11.0.3.tgz#565c5f6590bbcddab7a33a074bb2ba97e24b0cc9"
@ -4881,11 +4918,6 @@ react-dom@17.0.2:
object-assign "^4.1.1"
scheduler "^0.20.2"
react-fast-compare@^3.0.1:
version "3.2.0"
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb"
integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==
react-github-btn@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/react-github-btn/-/react-github-btn-1.2.2.tgz#9aab2498ff311b9f9c448a2d2b902d0277037d5c"
@ -4960,14 +4992,6 @@ react-overlays@^4.1.1:
uncontrollable "^7.0.0"
warning "^4.0.3"
react-popper@^2.2.5:
version "2.2.5"
resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-2.2.5.tgz#1214ef3cec86330a171671a4fbcbeeb65ee58e96"
integrity sha512-kxGkS80eQGtLl18+uig1UIf9MKixFSyPxglsgLBxlYnyDf65BiY9B3nZSc6C9XUNDgStROB0fMQlTEz1KxGddw==
dependencies:
react-fast-compare "^3.0.1"
warning "^4.0.2"
react-query@^3.34.12:
version "3.34.12"
resolved "https://registry.npmjs.org/react-query/-/react-query-3.34.12.tgz"
@ -5734,7 +5758,7 @@ tsconfig-paths@^3.9.0:
minimist "^1.2.0"
strip-bom "^3.0.0"
tslib@^1.8.1, tslib@^1.9.3:
tslib@^1.0.0, tslib@^1.8.1, tslib@^1.9.3:
version "1.14.1"
resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
@ -5836,6 +5860,11 @@ uri-js@^4.2.2:
dependencies:
punycode "^2.1.0"
use-isomorphic-layout-effect@^1.1.1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb"
integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==
util-deprecate@^1.0.2, util-deprecate@~1.0.1:
version "1.0.2"
resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
@ -5870,7 +5899,7 @@ wait-on@^6.0.1:
minimist "^1.2.5"
rxjs "^7.5.4"
warning@^4.0.2, warning@^4.0.3:
warning@^4.0.3:
version "4.0.3"
resolved "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz"
integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==