Allow users to customize poll behaviour (#785)

This commit is contained in:
Luke Vella 2023-07-25 17:24:45 +01:00 committed by GitHub
parent 14cfa2bd50
commit b1e3f63a2e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 1361 additions and 1042 deletions

View file

@ -23,5 +23,7 @@
"howToUpgrade": "How do I upgrade to a paid plan?", "howToUpgrade": "How do I upgrade to a paid plan?",
"howToUpgradeAnswer": "To upgrade, you can go to your <a>billing settings</a> and click on <b>Upgrade</b>.", "howToUpgradeAnswer": "To upgrade, you can go to your <a>billing settings</a> and click on <b>Upgrade</b>.",
"cancelSubscription": "How do I cancel my subscription?", "cancelSubscription": "How do I cancel my subscription?",
"cancelSubscriptionAnswer": "You can cancel your subscription at any time by going to your <a>billing settings</a>. Once you cancel your subscription, you will still have access to your paid plan until the end of your billing period. After that, you will be downgraded to a free plan." "cancelSubscriptionAnswer": "You can cancel your subscription at any time by going to your <a>billing settings</a>. Once you cancel your subscription, you will still have access to your paid plan until the end of your billing period. After that, you will be downgraded to a free plan.",
"earlyAccess": "Early bird discount",
"customPollSettings": "Customizable poll settings"
} }

View file

@ -1,4 +1,5 @@
import { CheckIcon, InfoIcon } from "@rallly/icons"; import { InfoIcon } from "@rallly/icons";
import { Badge } from "@rallly/ui/badge";
import { import {
BillingPlan, BillingPlan,
BillingPlanFooter, BillingPlanFooter,
@ -23,15 +24,6 @@ import { linkToApp } from "@/lib/linkToApp";
import { NextPageWithLayout } from "@/types"; import { NextPageWithLayout } from "@/types";
import { getStaticTranslations } from "@/utils/page-translations"; import { getStaticTranslations } from "@/utils/page-translations";
const Perk = ({ children }: React.PropsWithChildren) => {
return (
<li className="flex">
<CheckIcon className="mr-2 inline h-4 w-4 translate-y-0.5 -translate-x-0.5 text-green-600" />
<span>{children}</span>
</li>
);
};
const monthlyPriceUsd = 5; const monthlyPriceUsd = 5;
const annualPriceUsd = 30; const annualPriceUsd = 30;
@ -102,9 +94,17 @@ const Page: NextPageWithLayout = () => {
</BillingPlan> </BillingPlan>
<BillingPlan variant="primary"> <BillingPlan variant="primary">
<BillingPlanHeader> <BillingPlanHeader>
<BillingPlanTitle className="text-primary m-0"> <div className="flex justify-between">
<Trans i18nKey="pricing:planPro" defaults="Pro" /> <BillingPlanTitle className="text-primary m-0">
</BillingPlanTitle> <Trans i18nKey="pricing:planPro" defaults="Pro" />
</BillingPlanTitle>
<Badge variant="secondary">
<Trans
i18nKey="pricing:earlyAccess"
defaults="Early bird discount"
/>
</Badge>
</div>
{annualBilling ? ( {annualBilling ? (
<> <>
<BillingPlanPrice <BillingPlanPrice
@ -144,12 +144,18 @@ const Page: NextPageWithLayout = () => {
defaults="Unlimited participants" defaults="Unlimited participants"
/> />
</BillingPlanPerk> </BillingPlanPerk>
<Perk> <BillingPlanPerk>
<Trans
i18nKey="pricing:customPollSettings"
defaults="Customizable poll settings"
/>
</BillingPlanPerk>
<BillingPlanPerk>
<Trans <Trans
i18nKey="pricing:finalizePolls" i18nKey="pricing:finalizePolls"
defaults="Finalize polls" defaults="Finalize polls"
/> />
</Perk> </BillingPlanPerk>
<BillingPlanPerk> <BillingPlanPerk>
<Trans <Trans
i18nKey="pricing:extendedPollLife" i18nKey="pricing:extendedPollLife"

View file

@ -54,8 +54,9 @@
"php-serialize": "^4.1.1", "php-serialize": "^4.1.1",
"postcss": "^8.4.21", "postcss": "^8.4.21",
"posthog-js": "^1.57.2", "posthog-js": "^1.57.2",
"react-big-calendar": "^1.5.0", "react-big-calendar": "^1.8.1",
"react-hook-form": "^7.42.1", "react-hook-form": "^7.42.1",
"react-hook-form-persist": "^3.0.0",
"react-hot-toast": "^2.4.0", "react-hot-toast": "^2.4.0",
"react-i18next": "^12.1.4", "react-i18next": "^12.1.4",
"react-linkify": "^1.0.0-alpha", "react-linkify": "^1.0.0-alpha",
@ -70,7 +71,6 @@
"zod": "^3.20.2" "zod": "^3.20.2"
}, },
"devDependencies": { "devDependencies": {
"cross-env": "^7.0.3",
"@playwright/test": "^1.35.1", "@playwright/test": "^1.35.1",
"@rallly/tsconfig": "*", "@rallly/tsconfig": "*",
"@types/accept-language-parser": "^1.5.3", "@types/accept-language-parser": "^1.5.3",
@ -84,6 +84,7 @@
"@typescript-eslint/eslint-plugin": "^5.21.0", "@typescript-eslint/eslint-plugin": "^5.21.0",
"@typescript-eslint/parser": "^5.50.0", "@typescript-eslint/parser": "^5.50.0",
"cheerio": "^1.0.0-rc.12", "cheerio": "^1.0.0-rc.12",
"cross-env": "^7.0.3",
"eslint": "^7.26.0", "eslint": "^7.26.0",
"eslint-config-next": "^13.0.1", "eslint-config-next": "^13.0.1",
"eslint-config-turbo": "^0.0.9", "eslint-config-turbo": "^0.0.9",

View file

@ -5,15 +5,12 @@
"alreadyRegistered": "Already registered? <a>Login →</a>", "alreadyRegistered": "Already registered? <a>Login →</a>",
"applyToAllDates": "Apply to all dates", "applyToAllDates": "Apply to all dates",
"areYouSure": "Are you sure?", "areYouSure": "Are you sure?",
"calendarHelp": "You can't create a poll without any options. Add at least one option to continue.",
"calendarHelpTitle": "Forget something?",
"cancel": "Cancel", "cancel": "Cancel",
"changeName": "Change name", "changeName": "Change name",
"settings": "Settings", "settings": "Settings",
"changeNameDescription": "Enter a new name for this participant.", "changeNameDescription": "Enter a new name for this participant.",
"changeNameInfo": "This will not affect any votes you have already made.", "changeNameInfo": "This will not affect any votes you have already made.",
"close": "Close", "close": "Close",
"commentPlaceholder": "Leave a comment on this poll (visible to everyone)",
"comments": "Comments", "comments": "Comments",
"continue": "Continue", "continue": "Continue",
"copied": "Copied", "copied": "Copied",
@ -69,7 +66,6 @@
"noDatesSelected": "No dates selected", "noDatesSelected": "No dates selected",
"notificationsDisabled": "Notifications have been disabled for <b>{title}</b>", "notificationsDisabled": "Notifications have been disabled for <b>{title}</b>",
"noVotes": "No one has voted for this option", "noVotes": "No one has voted for this option",
"ok": "Ok",
"optional": "optional", "optional": "optional",
"preferences": "Preferences", "preferences": "Preferences",
"previousMonth": "Previous month", "previousMonth": "Previous month",
@ -125,7 +121,6 @@
"languageDescription": "Change your preferred language", "languageDescription": "Change your preferred language",
"dateAndTime": "Date & Time", "dateAndTime": "Date & Time",
"profileDescription": "Change your profile settings", "profileDescription": "Change your profile settings",
"back": "Back",
"dates": "Dates", "dates": "Dates",
"menu": "Menu", "menu": "Menu",
"useLocaleDefaults": "Use locale defaults", "useLocaleDefaults": "Use locale defaults",
@ -157,7 +152,6 @@
"differentOwnerDescription": "This poll was created by a different user. Would you like to transfer ownership to the current user?", "differentOwnerDescription": "This poll was created by a different user. Would you like to transfer ownership to the current user?",
"yesTransfer": "Yes, transfer to current user", "yesTransfer": "Yes, transfer to current user",
"noTransfer": "No, take me home", "noTransfer": "No, take me home",
"createPollDescription": "Create an event and invite participants to vote on the best time to meet.",
"share": "Share", "share": "Share",
"timeShownIn": "Times shown in {timeZone}", "timeShownIn": "Times shown in {timeZone}",
"timeShownInLocalTime": "Times shown in local time", "timeShownInLocalTime": "Times shown in local time",
@ -203,5 +197,21 @@
"plan_prioritySupport": "Priority support", "plan_prioritySupport": "Priority support",
"becomeATranslator": "Help translate", "becomeATranslator": "Help translate",
"noPolls": "No polls", "noPolls": "No polls",
"noPollsDescription": "Get started by creating a new poll." "noPollsDescription": "Get started by creating a new poll.",
"event": "Event",
"describeYourEvent": "Describe what your event is about",
"calendar": "Calendar",
"selectPotentialDates": "Select potential dates or times for your event",
"optionalLabel": "(Optional)",
"calendarHelp": "You can't create a poll without any options. Add at least one option to continue.",
"hideParticipants": "Hide participant list",
"hideParticipantsDescription": "Keep participant details hidden from other participants",
"editSettings": "Edit settings",
"pollSettingsDescription": "Customize the behaviour of your poll",
"commentPlaceholder": "Leave a comment on this poll (visible to everyone)",
"hideScores": "Hide scores",
"hideScoresDescription": "Only show scores until after a participant has voted",
"disableComments": "Disable comments",
"disableCommentsDescription": "Remove the option to leave a comment on the poll",
"planCustomizablePollSettings": "Customizable poll settings"
} }

View file

@ -1,4 +1,3 @@
import { CheckIcon } from "@rallly/icons";
import { import {
BillingPlan, BillingPlan,
BillingPlanFooter, BillingPlanFooter,
@ -126,15 +125,6 @@ export const BillingPlans = () => {
); );
}; };
const Perk = ({ children }: React.PropsWithChildren) => {
return (
<li className="flex">
<CheckIcon className="mr-2 inline h-4 w-4 translate-y-0.5 -translate-x-0.5 text-green-600" />
<span>{children}</span>
</li>
);
};
export const ProPlan = ({ export const ProPlan = ({
annual, annual,
children, children,
@ -178,9 +168,15 @@ export const ProPlan = ({
defaults="Unlimited participants" defaults="Unlimited participants"
/> />
</BillingPlanPerk> </BillingPlanPerk>
<Perk> <BillingPlanPerk>
<Trans i18nKey="plan_finalizePolls" defaults="Finalize polls" /> <Trans i18nKey="plan_finalizePolls" defaults="Finalize polls" />
</Perk> </BillingPlanPerk>
<BillingPlanPerk>
<Trans
i18nKey="planCustomizablePollSettings"
defaults="Customizable poll settings"
/>
</BillingPlanPerk>
<BillingPlanPerk> <BillingPlanPerk>
<Trans <Trans
i18nKey="plan_extendedPollLife" i18nKey="plan_extendedPollLife"

View file

@ -1,34 +1,23 @@
import { trpc } from "@rallly/backend"; import { trpc } from "@rallly/backend";
import { ArrowLeftIcon } from "@rallly/icons";
import { Button } from "@rallly/ui/button"; import { Button } from "@rallly/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardFooter,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@rallly/ui/card"; } from "@rallly/ui/card";
import { Form } from "@rallly/ui/form";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useTranslation } from "next-i18next";
import React from "react"; import React from "react";
import { useForm } from "react-hook-form";
import useFormPersist from "react-hook-form-persist";
import { PollSettingsForm } from "@/components/forms/poll-settings";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { usePostHog } from "@/utils/posthog"; import { usePostHog } from "@/utils/posthog";
import { import { NewEventData, PollDetailsForm, PollOptionsForm } from "./forms";
NewEventData,
PollDetailsData,
PollDetailsForm,
PollOptionsData,
PollOptionsForm,
UserDetailsData,
UserDetailsForm,
} from "./forms";
import Steps from "./steps";
import { useUser } from "./user-provider";
type StepName = "eventDetails" | "options" | "userDetails";
const required = <T,>(v: T | undefined): T => { const required = <T,>(v: T | undefined): T => {
if (!v) { if (!v) {
@ -46,192 +35,96 @@ export interface CreatePollPageProps {
} }
export const CreatePoll: React.FunctionComponent = () => { export const CreatePoll: React.FunctionComponent = () => {
const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const session = useUser(); const form = useForm<NewEventData>({
defaultValues: {
const steps: StepName[] = React.useMemo( view: "month",
() => options: [],
session.user.isGuest hideScores: false,
? ["eventDetails", "options", "userDetails"] hideParticipants: false,
: ["eventDetails", "options"], disableComments: false,
[session.user.isGuest], duration: 60,
);
const [formData, setFormData] = React.useState<NewEventData>({
currentStep: 0,
});
React.useEffect(() => {
const newStep = Math.min(steps.length - 1, formData.currentStep);
if (newStep !== formData.currentStep) {
setFormData((prevData) => ({
...prevData,
currentStep: newStep,
}));
}
}, [formData.currentStep, steps.length]);
const currentStepIndex = formData?.currentStep ?? 0;
const currentStepName = steps[currentStepIndex];
const [isRedirecting, setIsRedirecting] = React.useState(false);
const posthog = usePostHog();
const queryClient = trpc.useContext();
const createPoll = trpc.polls.create.useMutation({
onSuccess: (res) => {
setIsRedirecting(true);
posthog?.capture("created poll", {
pollId: res.id,
numberOfOptions: formData.options?.options?.length,
optionsView: formData?.options?.view,
});
queryClient.polls.list.invalidate();
router.replace(`/poll/${res.id}`);
}, },
}); });
const isBusy = isRedirecting || createPoll.isLoading; useFormPersist("new-poll", {
watch: form.watch,
setValue: form.setValue,
});
const handleSubmit = async ( const posthog = usePostHog();
data: PollDetailsData | PollOptionsData | UserDetailsData, const queryClient = trpc.useContext();
) => { const createPoll = trpc.polls.create.useMutation();
if (currentStepIndex < steps.length - 1) {
setFormData({
...formData,
currentStep: currentStepIndex + 1,
[currentStepName]: data,
});
} else {
// last step
const title = required(formData?.eventDetails?.title);
await createPoll.mutateAsync({
title: title,
location: formData?.eventDetails?.location,
description: formData?.eventDetails?.description,
user: session.user.isGuest
? {
name: required(formData?.userDetails?.name),
email: required(formData?.userDetails?.contact),
}
: undefined,
timeZone: formData?.options?.timeZone,
options: required(formData?.options?.options).map((option) => ({
startDate: option.type === "date" ? option.date : option.start,
endDate: option.type === "timeSlot" ? option.end : undefined,
})),
});
}
};
const handleChange = (
data: Partial<PollDetailsData | PollOptionsData | UserDetailsData>,
) => {
setFormData({
...formData,
currentStep: currentStepIndex,
[currentStepName]: data,
});
};
return ( return (
<div> <Form {...form}>
<div className="sm:p-8"> <form
<div className="my-4 flex justify-center"> className="pb-16"
<Steps current={currentStepIndex} total={steps.length} /> onSubmit={form.handleSubmit(async (formData) => {
const title = required(formData?.title);
await createPoll.mutateAsync(
{
title: title,
location: formData?.location,
description: formData?.description,
timeZone: formData?.timeZone,
hideParticipants: formData?.hideParticipants,
disableComments: formData?.disableComments,
hideScores: formData?.hideScores,
options: required(formData?.options).map((option) => ({
startDate: option.type === "date" ? option.date : option.start,
endDate: option.type === "timeSlot" ? option.end : undefined,
})),
},
{
onSuccess: (res) => {
posthog?.capture("created poll", {
pollId: res.id,
numberOfOptions: formData.options?.length,
optionsView: formData?.view,
});
queryClient.polls.list.invalidate();
router.replace(`/poll/${res.id}`);
},
},
);
})}
>
<div className="mx-auto max-w-4xl space-y-4 p-2 sm:p-8">
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey="event" defaults="Event" />
</CardTitle>
<CardDescription>
<Trans
i18nKey="describeYourEvent"
defaults="Describe what your event is about"
/>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<PollDetailsForm />
</CardContent>
</Card>
<PollOptionsForm />
<PollSettingsForm />
<hr />
<Button
loading={form.formState.isSubmitting}
size="lg"
type="submit"
className="w-full"
variant="primary"
>
<Trans i18nKey="createPoll" />
</Button>
</div> </div>
<Card className="mx-auto max-w-4xl rounded-none border-x-0 sm:rounded-md sm:border-x"> </form>
<CardHeader> </Form>
<CardTitle>
<Trans i18nKey="newPoll" />
</CardTitle>
<CardDescription>
<Trans
i18nKey="createPollDescription"
defaults="Create an event and invite participants to vote on the best time to meet."
/>
</CardDescription>
</CardHeader>
<CardContent>
{(() => {
switch (currentStepName) {
case "eventDetails":
return (
<PollDetailsForm
name={currentStepName}
defaultValues={formData?.eventDetails}
onSubmit={handleSubmit}
onChange={handleChange}
/>
);
case "options":
return (
<PollOptionsForm
name={currentStepName}
defaultValues={formData?.options}
onSubmit={handleSubmit}
onChange={handleChange}
title={formData.eventDetails?.title}
/>
);
case "userDetails":
return (
<UserDetailsForm
name={currentStepName}
defaultValues={formData?.userDetails}
onSubmit={handleSubmit}
onChange={handleChange}
/>
);
}
})()}
</CardContent>
<CardFooter className="justify-between">
<div>
{currentStepIndex > 0 ? (
<Button
icon={ArrowLeftIcon}
disabled={isBusy}
onClick={() => {
if (currentStepIndex > 0) {
setFormData({
...formData,
currentStep: currentStepIndex - 1,
});
}
}}
>
<Trans i18nKey="back" />
</Button>
) : null}
</div>
{currentStepIndex < steps.length - 1 ? (
<Button
variant="primary"
form={currentStepName}
loading={isBusy}
type="submit"
>
{t("continue")}
</Button>
) : (
<Button
form={currentStepName}
variant="primary"
loading={isBusy}
type="submit"
>
{t("createPoll")}
</Button>
)}
</CardFooter>
</Card>
</div>
</div>
); );
}; };

View file

@ -196,7 +196,10 @@ const Discussion: React.FunctionComponent = () => {
className="border-input text-muted-foreground flex w-full rounded border bg-transparent px-3 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1" className="border-input text-muted-foreground flex w-full rounded border bg-transparent px-3 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1"
onClick={() => setIsWriting(true)} onClick={() => setIsWriting(true)}
> >
<Trans i18nKey="commentPlaceholder" /> <Trans
i18nKey="commentPlaceholder"
defaults="Leave a comment on this poll (visible to everyone)"
/>
</button> </button>
)} )}
</div> </div>

View file

@ -9,6 +9,7 @@ import { useParticipants } from "@/components/participants-provider";
import { PollStatusBadge } from "@/components/poll-status"; import { PollStatusBadge } from "@/components/poll-status";
import { TextSummary } from "@/components/text-summary"; import { TextSummary } from "@/components/text-summary";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { IfParticipantsVisible } from "@/components/visibility";
import { usePoll } from "@/contexts/poll"; import { usePoll } from "@/contexts/poll";
import { generateGradient } from "@/utils/color-hash"; import { generateGradient } from "@/utils/color-hash";
import { useDayjs } from "@/utils/dayjs"; import { useDayjs } from "@/utils/dayjs";
@ -88,15 +89,17 @@ export const EventCard = () => {
{!poll.event ? ( {!poll.event ? (
<PollSubheader /> <PollSubheader />
) : ( ) : (
<div className="mt-4"> <div className="mt-4 space-y-2">
<div className="text-muted-foreground mb-2 text-sm"> <div className="text-muted-foreground text-sm">
<Trans <Trans
i18nKey="attendeeCount" i18nKey="attendeeCount"
defaults="{count, plural, one {# attendee} other {# attendees}}" defaults="{count, plural, one {# attendee} other {# attendees}}"
values={{ count: attendees.length }} values={{ count: attendees.length }}
/> />
</div> </div>
<ParticipantAvatarBar participants={attendees} max={10} /> <IfParticipantsVisible>
<ParticipantAvatarBar participants={attendees} max={10} />
</IfParticipantsVisible>
</div> </div>
)} )}
</div> </div>

View file

@ -3,5 +3,3 @@ export { PollDetailsForm } from "./poll-details-form";
export type { PollOptionsData } from "./poll-options-form/poll-options-form"; export type { PollOptionsData } from "./poll-options-form/poll-options-form";
export { default as PollOptionsForm } from "./poll-options-form/poll-options-form"; export { default as PollOptionsForm } from "./poll-options-form/poll-options-form";
export * from "./types"; export * from "./types";
export type { UserDetailsData } from "./user-details-form";
export { UserDetailsForm } from "./user-details-form";

View file

@ -1,13 +1,14 @@
import { Form, FormItem, FormLabel } from "@rallly/ui/form"; import { FormField, FormItem, FormLabel, FormMessage } from "@rallly/ui/form";
import { Input } from "@rallly/ui/input"; import { Input } from "@rallly/ui/input";
import { Textarea } from "@rallly/ui/textarea"; import { Textarea } from "@rallly/ui/textarea";
import clsx from "clsx"; import clsx from "clsx";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import * as React from "react"; import { useFormContext } from "react-hook-form";
import { useForm } from "react-hook-form";
import { requiredString } from "../../utils/form-validation"; import { Trans } from "@/components/trans";
import { PollFormProps } from "./types"; import { useFormValidation } from "@/utils/form-validation";
import { NewEventData } from "./types";
export interface PollDetailsData { export interface PollDetailsData {
title: string; title: string;
@ -15,77 +16,71 @@ export interface PollDetailsData {
description: string; description: string;
} }
export const PollDetailsForm: React.FunctionComponent< export const PollDetailsForm = () => {
PollFormProps<PollDetailsData>
> = ({ name, defaultValues, onSubmit, onChange, className }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const form = useForm<PollDetailsData>({ defaultValues }); const form = useFormContext<NewEventData>();
const { requiredString } = useFormValidation();
const { const {
handleSubmit,
register, register,
watch,
formState: { errors }, formState: { errors },
} = form; } = form;
React.useEffect(() => {
if (onChange) {
const subscription = watch(onChange);
return () => {
subscription.unsubscribe();
};
}
}, [onChange, watch]);
return ( return (
<Form {...form}> <div className="grid gap-4 py-1">
<form <FormField
id={name} control={form.control}
className={clsx("space-y-6", className)} name="title"
onSubmit={handleSubmit(onSubmit)} rules={{
> validate: requiredString(t("title")),
{/* <div className="mb-8"> }}
<h2 className=""> render={({ field }) => (
<Trans i18nKey="eventDetails" defaults="Event Details" /> <FormItem>
</h2> <FormLabel htmlFor="title">{t("title")}</FormLabel>
<p className="leading-6 text-gray-500"> <Input
<Trans {...field}
i18nKey="eventDetailsDescription" type="text"
defaults="What are you organzing?" id="title"
className={clsx("w-full", {
"input-error": errors.title,
})}
placeholder={t("titlePlaceholder")}
/> />
</p> <FormMessage />
</div> */} </FormItem>
<FormItem> )}
<FormLabel htmlFor="title">{t("title")}</FormLabel> />
<Input
type="text" <FormItem>
id="title" <div>
className={clsx("w-full", { <FormLabel className="inline-block">{t("location")}</FormLabel>
"input-error": errors.title, <span className="text-muted-foreground ml-1 text-sm">
})} <Trans i18nKey="optionalLabel" defaults="(Optional)" />
placeholder={t("titlePlaceholder")} </span>
{...register("title", { validate: requiredString })} </div>
/> <Input
</FormItem> type="text"
<FormItem> id="location"
<FormLabel>{t("location")}</FormLabel> placeholder={t("locationPlaceholder")}
<Input {...register("location")}
type="text" />
id="location" </FormItem>
placeholder={t("locationPlaceholder")} <FormItem>
{...register("location")} <div>
/> <FormLabel className="inline-block" htmlFor="description">
</FormItem> {t("description")}
<FormItem> </FormLabel>
<FormLabel htmlFor="description">{t("description")}</FormLabel> <span className="text-muted-foreground ml-1 text-sm">
<Textarea <Trans i18nKey="optionalLabel" defaults="(Optional)" />
id="description" </span>
placeholder={t("descriptionPlaceholder")} </div>
rows={5} <Textarea
{...register("description")} id="description"
/> placeholder={t("descriptionPlaceholder")}
</FormItem> rows={5}
</form> {...register("description")}
</Form> />
</FormItem>
</div>
); );
}; };

View file

@ -1,4 +1,5 @@
import { ChevronLeftIcon, ChevronRightIcon } from "@rallly/icons"; import { ChevronLeftIcon, ChevronRightIcon } from "@rallly/icons";
import { Button } from "@rallly/ui/button";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import * as React from "react"; import * as React from "react";
@ -15,22 +16,20 @@ const DateNavigationToolbar: React.FunctionComponent<
> = ({ year, label, onPrevious, onToday, onNext }) => { > = ({ year, label, onPrevious, onToday, onNext }) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="flex h-14 w-full shrink-0 items-center border-b px-4"> <div className="flex h-14 w-full shrink-0 items-center px-4">
<div className="grow"> <div className="grow font-semibold tracking-tight">
<span className="mr-2 text-sm font-bold text-gray-400">{year}</span> <span className="mr-2 text-sm font-normal text-gray-500">{year}</span>
<span className="text-lg font-bold text-gray-700">{label}</span> <span className="font-semibold">{label}</span>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="segment-button"> <div className="flex items-center gap-x-2">
<button type="button" onClick={onPrevious}> <Button type="button" onClick={onPrevious}>
<ChevronLeftIcon className="h-5" /> <ChevronLeftIcon className="h-4 w-4" />
</button> </Button>
<button type="button" onClick={onToday}> <Button onClick={onToday}>{t("today")}</Button>
{t("today")} <Button onClick={onNext}>
</button> <ChevronRightIcon className="h-4 w-4" />
<button type="button" onClick={onNext}> </Button>
<ChevronRightIcon className="h-5" />
</button>
</div> </div>
</div> </div>
</div> </div>

View file

@ -7,6 +7,7 @@ import {
SparklesIcon, SparklesIcon,
XIcon, XIcon,
} from "@rallly/icons"; } from "@rallly/icons";
import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button"; import { Button } from "@rallly/ui/button";
import { import {
DropdownMenu, DropdownMenu,
@ -16,6 +17,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@rallly/ui/dropdown-menu"; } from "@rallly/ui/dropdown-menu";
import { Switch } from "@rallly/ui/switch";
import clsx from "clsx"; import clsx from "clsx";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
@ -31,7 +33,6 @@ import {
import CompactButton from "../../../compact-button"; import CompactButton from "../../../compact-button";
import DateCard from "../../../date-card"; import DateCard from "../../../date-card";
import { useHeadlessDatePicker } from "../../../headless-date-picker"; import { useHeadlessDatePicker } from "../../../headless-date-picker";
import Switch from "../../../switch";
import { DateTimeOption } from ".."; import { DateTimeOption } from "..";
import { DateTimePickerProps } from "../types"; import { DateTimePickerProps } from "../types";
import { formatDateWithoutTime, formatDateWithoutTz } from "../utils"; import { formatDateWithoutTime, formatDateWithoutTz } from "../utils";
@ -88,8 +89,8 @@ const MonthCalendar: React.FunctionComponent<DateTimePickerProps> = ({
}); });
return ( return (
<div className="overflow-hidden lg:flex"> <div className="overflow-hidden md:flex">
<div className="shrink-0 border-b p-3 sm:p-4 lg:w-[440px] lg:border-b-0 lg:border-r"> <div className="border-b p-3 sm:p-4 md:w-[400px] md:border-b-0 md:border-r">
<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">
@ -98,7 +99,7 @@ const MonthCalendar: React.FunctionComponent<DateTimePickerProps> = ({
title={t("previousMonth")} title={t("previousMonth")}
onClick={datepicker.prev} onClick={datepicker.prev}
/> />
<div className="grow text-center text-lg font-medium"> <div className="grow text-center font-semibold tracking-tight">
{datepicker.label} {datepicker.label}
</div> </div>
<Button <Button
@ -119,12 +120,12 @@ const MonthCalendar: React.FunctionComponent<DateTimePickerProps> = ({
); );
})} })}
</div> </div>
<div className="grid grow grid-cols-7 rounded-lg border bg-white shadow-sm"> <div className="grid grow grid-cols-7 rounded-md border bg-white shadow-sm">
{datepicker.days.map((day, i) => { {datepicker.days.map((day, i) => {
return ( return (
<div <div
key={i} key={i}
className={clsx("h-12", { className={clsx("h-11", {
"border-r": (i + 1) % 7 !== 0, "border-r": (i + 1) % 7 !== 0,
"border-b": i < datepicker.days.length - 7, "border-b": i < datepicker.days.length - 7,
})} })}
@ -170,16 +171,25 @@ const MonthCalendar: React.FunctionComponent<DateTimePickerProps> = ({
} }
}} }}
className={clsx( className={clsx(
"relative flex h-full w-full items-center justify-center text-sm hover:bg-gray-50 focus:z-10 focus:rounded active:bg-gray-100", "group relative flex h-full w-full items-start justify-end rounded-none px-2.5 py-1.5 text-sm font-medium tracking-tight focus:z-10 focus:rounded",
{ {
"bg-gray-50 text-gray-500": day.outOfMonth, "bg-gray-100 text-gray-400": day.isPast,
"font-bold": day.today, "text-rose-600": day.today && !day.selected,
"text-primary-600": day.today && !day.selected, "bg-gray-50 text-gray-500":
"font-normal text-white after:absolute after:-z-0 after:h-8 after:w-8 after:rounded-full after:bg-green-600 after:content-['']": day.outOfMonth && !day.isPast,
day.selected, "text-primary-600": day.selected,
}, },
)} )}
> >
<span
aria-hidden
className={cn(
"absolute inset-1 -z-0 rounded-md border",
day.selected
? "border-primary-300 group-hover:border-primary-400 border-dashed shadow-sm"
: "border-dashed border-transparent group-hover:border-gray-400 group-active:bg-gray-200",
)}
></span>
<span className="z-10">{day.day}</span> <span className="z-10">{day.day}</span>
</button> </button>
</div> </div>
@ -209,11 +219,11 @@ const MonthCalendar: React.FunctionComponent<DateTimePickerProps> = ({
<Switch <Switch
data-testid="specify-times-switch" data-testid="specify-times-switch"
checked={isTimedEvent} checked={isTimedEvent}
onChange={(checked) => { onCheckedChange={(checked) => {
if (checked) { if (checked) {
// convert dates to time slots // convert dates to time slots
onChange( onChange(
options.map((option) => { options.map<DateTimeOption>((option) => {
if (option.type === "timeSlot") { if (option.type === "timeSlot") {
throw new Error( throw new Error(
"Expected option to be a date but received timeSlot", "Expected option to be a date but received timeSlot",

View file

@ -1,14 +1,16 @@
import { CalendarIcon, TableIcon } from "@rallly/icons"; import { CalendarIcon, TableIcon } from "@rallly/icons";
import { Form, FormItem } from "@rallly/ui/form"; import { Card, CardDescription, CardHeader, CardTitle } from "@rallly/ui/card";
import clsx from "clsx"; import { FormField, FormMessage } from "@rallly/ui/form";
import { useTranslation } from "next-i18next"; import { Tabs, TabsList, TabsTrigger } from "@rallly/ui/tabs";
import { Trans, useTranslation } from "next-i18next";
import * as React from "react"; import * as React from "react";
import { Controller, useForm } from "react-hook-form"; import { useFormContext } from "react-hook-form";
import TimeZonePicker from "@/components/time-zone-picker";
import { getBrowserTimeZone } from "../../../utils/date-time-utils"; import { getBrowserTimeZone } from "../../../utils/date-time-utils";
import { useModal } from "../../modal"; import { useModal } from "../../modal";
import TimeZonePicker from "../../time-zone-picker"; import { NewEventData } from "../types";
import { PollFormProps } from "../types";
import MonthCalendar from "./month-calendar"; import MonthCalendar from "./month-calendar";
import { DateTimeOption } from "./types"; import { DateTimeOption } from "./types";
import WeekCalendar from "./week-calendar"; import WeekCalendar from "./week-calendar";
@ -21,32 +23,11 @@ export type PollOptionsData = {
options: DateTimeOption[]; options: DateTimeOption[];
}; };
const PollOptionsForm: React.FunctionComponent< const PollOptionsForm = ({ children }: React.PropsWithChildren) => {
PollFormProps<PollOptionsData> & { title?: string }
> = ({ name, defaultValues, onSubmit, onChange, title, className }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const form = useForm<PollOptionsData>({ const form = useFormContext<NewEventData>();
defaultValues: {
options: [],
duration: 30,
timeZone: "",
navigationDate: new Date().toISOString(),
...defaultValues,
},
resolver: (values) => {
return {
values,
errors:
values.options.length === 0
? {
options: true,
}
: {},
};
},
});
const { control, handleSubmit, watch, setValue, formState } = form; const { watch, setValue, formState } = form;
const views = React.useMemo(() => { const views = React.useMemo(() => {
const res = [ const res = [
@ -71,7 +52,8 @@ const PollOptionsForm: React.FunctionComponent<
[views, watchView], [views, watchView],
); );
const watchOptions = watch("options"); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const watchOptions = watch("options", [])!;
const watchDuration = watch("duration"); const watchDuration = watch("duration");
const watchTimeZone = watch("timeZone"); const watchTimeZone = watch("timeZone");
@ -100,19 +82,6 @@ const PollOptionsForm: React.FunctionComponent<
}, },
}); });
React.useEffect(() => {
if (onChange) {
const subscription = watch(({ options = [], ...rest }) => {
// Watch returns a deep partial here which is not really accurate and messes up
// the types a bit. Repackaging it to keep the types sane.
onChange({ options: options as DateTimeOption[], ...rest });
});
return () => {
subscription.unsubscribe();
};
}
}, [watch, onChange]);
React.useEffect(() => { React.useEffect(() => {
if (watchOptions.length > 1) { if (watchOptions.length > 1) {
const optionType = watchOptions[0].type; const optionType = watchOptions[0].type;
@ -124,115 +93,117 @@ const PollOptionsForm: React.FunctionComponent<
}, [watchOptions, openDateOrTimeRangeModal]); }, [watchOptions, openDateOrTimeRangeModal]);
const watchNavigationDate = watch("navigationDate"); const watchNavigationDate = watch("navigationDate");
const navigationDate = new Date(watchNavigationDate); const navigationDate = new Date(watchNavigationDate ?? Date.now());
const [calendarHelpModal, openHelpModal] = useModal({
overlayClosable: true,
title: t("calendarHelpTitle"),
description: t("calendarHelp"),
okText: t("ok"),
});
return ( return (
<Form {...form}> <Card>
<form <CardHeader>
id={name} <div className="flex flex-col justify-between gap-4 sm:flex-row">
className={clsx("w-full", className)} <div>
onSubmit={handleSubmit(onSubmit, openHelpModal)} <CardTitle>
> <Trans i18nKey="calendar">Calendar</Trans>
{calendarHelpModal} </CardTitle>
{dateOrTimeRangeModal} <CardDescription>
{/* <div className="mb-8"> <Trans i18nKey="selectPotentialDates">
<h2 className=""> Select potential dates for your event
<Trans i18nKey="dates" defaults="Dates" /> </Trans>
</h2> </CardDescription>
<p className="leading-6 text-gray-500">
<Trans
i18nKey="datesDescription"
defaults="Select a few dates for your participants to choose from"
/>
</p>
</div> */}
<FormItem>
<div className="mb-3 flex flex-col gap-x-4 gap-y-3 sm:flex-row">
<div className="grow">
<Controller
control={control}
name="timeZone"
render={({ field }) => (
<TimeZonePicker
value={field.value}
onBlur={field.onBlur}
onChange={(timeZone) => {
setValue("timeZone", timeZone, { shouldTouch: true });
}}
disabled={datesOnly}
/>
)}
/>
</div>
<div className="flex space-x-3">
<div className="segment-button w-full sm:w-auto">
<button
className={clsx({
"segment-button-active": selectedView.value === "month",
})}
onClick={() => {
setValue("view", "month");
}}
type="button"
>
<CalendarIcon className="mr-2 h-4 w-4" /> {t("monthView")}
</button>
<button
className={clsx({
"segment-button-active": selectedView.value === "week",
})}
type="button"
onClick={() => {
setValue("view", "week");
}}
>
<TableIcon className="mr-2 h-4 w-4" /> {t("weekView")}
</button>
</div>
</div>
</div> </div>
<div className="rounded-md border"> <div>
<selectedView.Component <FormField
title={title} control={form.control}
options={watchOptions} name="view"
date={navigationDate} render={({ field }) => (
onNavigate={(date) => { <Tabs value={field.value} onValueChange={field.onChange}>
setValue("navigationDate", date.toISOString()); <TabsList className="w-full">
}} <TabsTrigger className="grow" value="month">
onChange={(options) => { <CalendarIcon className="mr-2 h-4 w-4" />
setValue("options", options); <Trans i18nKey="monthView" />
if ( </TabsTrigger>
options.length === 0 || <TabsTrigger className="grow" value="week">
options.every((option) => option.type === "date") <TableIcon className="mr-2 h-4 w-4" />
) { <Trans i18nKey="weekView" />
// unset the timeZone if we only have date option </TabsTrigger>
setValue("timeZone", ""); </TabsList>
} </Tabs>
if ( )}
options.length > 0 &&
!formState.touchedFields.timeZone &&
options.every((option) => option.type === "timeSlot")
) {
// set timeZone if we are adding time ranges and we haven't touched the timeZone field
setValue("timeZone", getBrowserTimeZone());
}
}}
duration={watchDuration}
onChangeDuration={(duration) => {
setValue("duration", duration);
}}
/> />
</div> </div>
</FormItem> </div>
</form> </CardHeader>
</Form> {dateOrTimeRangeModal}
<div>
<FormField
control={form.control}
name="options"
rules={{
validate: (options) => {
return options.length > 0
? true
: t("calendarHelp", {
defaultValue:
"You can't create a poll without any options. Add at least one option to continue.",
});
},
}}
render={({ field }) => (
<div>
<selectedView.Component
options={field.value}
date={navigationDate}
onNavigate={(date) => {
setValue("navigationDate", date.toISOString());
}}
onChange={(options) => {
field.onChange(options);
if (
length === 0 ||
options.every((option) => option.type === "date")
) {
// unset the timeZone if we only have date option
setValue("timeZone", "");
}
if (
options.length > 0 &&
!formState.touchedFields.timeZone &&
options.every((option) => option.type === "timeSlot")
) {
// set timeZone if we are adding time ranges and we haven't touched the timeZone field
setValue("timeZone", getBrowserTimeZone());
}
}}
duration={watchDuration}
onChangeDuration={(duration) => {
setValue("duration", duration);
}}
/>
{formState.errors.options ? (
<div className="border-t bg-red-50 p-3 text-center">
<FormMessage />
</div>
) : null}
</div>
)}
/>
</div>
<div className="grow border-t bg-gray-50 px-5 py-3">
<FormField
control={form.control}
name="timeZone"
render={({ field }) => (
<TimeZonePicker
value={field.value}
onBlur={field.onBlur}
onChange={(timeZone) => {
setValue("timeZone", timeZone, { shouldTouch: true });
}}
disabled={datesOnly}
/>
)}
/>
</div>
{children}
</Card>
); );
}; };

View file

@ -15,7 +15,7 @@ export type DateTimeOption = DateOption | TimeOption;
export interface DateTimePickerProps { export interface DateTimePickerProps {
title?: string; title?: string;
options: DateTimeOption[]; options: DateTimeOption[];
date: Date; date?: Date;
onNavigate: (date: Date) => void; onNavigate: (date: Date) => void;
onChange: (options: DateTimeOption[]) => void; onChange: (options: DateTimeOption[]) => void;
duration: number; duration: number;

View file

@ -1,190 +1,183 @@
import clsx from "clsx"; import "react-big-calendar/lib/css/react-big-calendar.css";
import { XIcon } from "@rallly/icons";
import dayjs from "dayjs"; import dayjs from "dayjs";
import React from "react"; import React from "react";
import { Calendar } from "react-big-calendar"; import { Calendar } from "react-big-calendar";
import { useMount } from "react-use"; import { createBreakpoint } from "react-use";
import { getDuration } from "../../../utils/date-time-utils"; import { getDuration } from "../../../utils/date-time-utils";
import DateNavigationToolbar from "./date-navigation-toolbar"; import DateNavigationToolbar from "./date-navigation-toolbar";
import dayjsLocalizer from "./dayjs-localizer"; import dayjsLocalizer from "./dayjs-localizer";
import { DateTimeOption, DateTimePickerProps } from "./types"; import { DateTimeOption, DateTimePickerProps } from "./types";
import { formatDateWithoutTime, formatDateWithoutTz } from "./utils"; import { formatDateWithoutTz } from "./utils";
const localizer = dayjsLocalizer(dayjs); const localizer = dayjsLocalizer(dayjs);
const useDevice = createBreakpoint({ desktop: 720, mobile: 360 });
const WeekCalendar: React.FunctionComponent<DateTimePickerProps> = ({ const WeekCalendar: React.FunctionComponent<DateTimePickerProps> = ({
title,
options, options,
onNavigate, onNavigate,
date, date,
onChange, onChange,
duration, duration = 60,
onChangeDuration, onChangeDuration,
}) => { }) => {
const [scrollToTime, setScrollToTime] = React.useState<Date>(); const scrollToTime =
options.length > 0
? options[0].type === "timeSlot"
? new Date(options[0].start)
: undefined
: undefined;
useMount(() => { const defaultView = useDevice() === "mobile" ? "day" : "week";
// Bit of a hack to force rbc to scroll to the right time when we close/open a modal
setScrollToTime(dayjs(date).add(-60, "minutes").toDate());
});
return ( return (
<Calendar <div className="relative flex h-[600px]">
events={options.map((option) => { <Calendar
if (option.type === "date") { className="absolute inset-0"
return { title, start: new Date(option.date) }; events={options.map((option) => {
} else { if (option.type === "date") {
return { return { start: new Date(option.date) };
title, } else {
start: new Date(option.start), return {
end: new Date(option.end), start: new Date(option.start),
}; end: new Date(option.end),
} };
})} }
culture="default" })}
onNavigate={onNavigate} culture="default"
date={date} onNavigate={onNavigate}
className="h-[calc(100vh-220px)] max-h-[800px] min-h-[400px] w-full" date={date}
defaultView="week" defaultView={defaultView}
views={["week"]} views={["week", "day"]}
selectable={true} selectable={true}
localizer={localizer} localizer={localizer}
onSelectEvent={(event) => { onSelectEvent={(event) => {
onChange( onChange(
options.filter( options.filter(
(option) => (option) =>
!( !(
option.type === "timeSlot" && option.type === "timeSlot" &&
option.start === formatDateWithoutTz(event.start) && option.start === formatDateWithoutTz(event.start) &&
event.end && event.end &&
option.end === formatDateWithoutTz(event.end) option.end === formatDateWithoutTz(event.end)
), ),
), ),
);
}}
components={{
toolbar: function Toolbar(props) {
return (
<DateNavigationToolbar
year={props.date.getFullYear()}
label={props.label}
onPrevious={() => {
props.onNavigate("PREV");
}}
onToday={() => {
props.onNavigate("TODAY");
}}
onNext={() => {
props.onNavigate("NEXT");
}}
/>
); );
}, }}
eventWrapper: function EventWraper(props) { components={{
const start = dayjs(props.event.start); toolbar: function Toolbar(props) {
const end = dayjs(props.event.end);
return (
<div
// onClick prop doesn't work properly. Seems like some other element is cancelling the event before it reaches this element
onMouseUp={props.onClick}
className="absolute ml-1 max-h-full overflow-hidden rounded-md bg-green-100 p-1 text-xs text-green-500 transition-colors"
style={{
top: `calc(${props.style?.top}% + 4px)`,
height: `calc(${props.style?.height}% - 8px)`,
left: `${props.style?.xOffset}%`,
width: `calc(${props.style?.width}%)`,
}}
>
<div>{start.format("LT")}</div>
<div className="font-semibold">{getDuration(start, end)}</div>
</div>
);
},
week: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
header: function Header({ date }: any) {
const dateString = formatDateWithoutTime(date);
const selectedOption = options.find((option) => {
return option.type === "date" && option.date === dateString;
});
return ( return (
<span <DateNavigationToolbar
onClick={() => { year={props.date.getFullYear()}
if (!selectedOption) { label={props.label}
onChange([ onPrevious={() => {
...options, props.onNavigate("PREV");
{
type: "date",
date: formatDateWithoutTime(date),
},
]);
} else {
onChange(
options.filter((option) => option !== selectedOption),
);
}
}} }}
className={clsx( onToday={() => {
"inline-flex w-full items-center justify-center rounded-md py-2 text-sm hover:bg-gray-50 hover:text-gray-700", props.onNavigate("TODAY");
{ }}
"bg-green-50 text-green-600 hover:bg-green-50 hover:bg-opacity-75 hover:text-green-600": onNext={() => {
!!selectedOption, props.onNavigate("NEXT");
}, }}
)} />
>
<span className="mr-1 font-normal opacity-50">
{dayjs(date).format("ddd")}
</span>
<span className="font-medium">{dayjs(date).format("DD")}</span>
</span>
); );
}, },
}, eventWrapper: function EventWraper(props) {
timeSlotWrapper: function TimeSlotWrapper({ const start = dayjs(props.event.start);
children, const end = dayjs(props.event.end);
}: { return (
children?: React.ReactNode; <div
}) { // onClick prop doesn't work properly. Seems like some other element is cancelling the event before it reaches this element
return <div className="h-8 text-xs text-gray-500">{children}</div>; onMouseUp={props.onClick}
}, className="text-primary-500 border-primary-300 hover:border-primary-400 hover:text-primary-600 group absolute ml-1 flex max-h-full flex-col justify-between overflow-hidden rounded-lg border border-dashed bg-white/50 p-1 text-xs shadow-sm hover:cursor-pointer"
}} style={{
step={15} top: `calc(${props.style?.top}% + 4px)`,
onSelectSlot={({ start, end, action }) => { height: `calc(${props.style?.height}% - 8px)`,
// on select slot left: `${props.style?.xOffset}%`,
const startDate = new Date(start); width: `calc(${props.style?.width}%)`,
const endDate = new Date(end); }}
>
<div className="absolute top-1.5 right-1.5 flex justify-end opacity-0 group-hover:opacity-100">
<XIcon className="h-3 w-3" />
</div>
<div>
<div className="font-semibold">{start.format("LT")}</div>
<div className="opacity-50">{getDuration(start, end)}</div>
</div>
<div>
<div className="opacity-50">{end.format("LT")}</div>
</div>
</div>
);
},
week: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
header: function Header({ date }: any) {
return (
<span className="w-full rounded-md text-center text-sm tracking-tight">
<span className="mr-1.5 font-normal opacity-50">
{dayjs(date).format("ddd")}
</span>
<span className="font-medium">
{dayjs(date).format("DD")}
</span>
</span>
);
},
},
timeSlotWrapper: function TimeSlotWrapper({
children,
}: {
children?: React.ReactNode;
}) {
return (
<div className="h-6 text-xs leading-none text-gray-500">
{children}
</div>
);
},
}}
step={15}
onSelectSlot={({ start, end, action }) => {
// on select slot
const startDate = new Date(start);
const endDate = new Date(end);
const newEvent: DateTimeOption = { const newEvent: DateTimeOption = {
type: "timeSlot", type: "timeSlot",
start: formatDateWithoutTz(startDate), start: formatDateWithoutTz(startDate),
duration: dayjs(endDate).diff(endDate, "minutes"), duration: dayjs(endDate).diff(endDate, "minutes"),
end: formatDateWithoutTz(endDate), end: formatDateWithoutTz(endDate),
}; };
if (action === "select") { if (action === "select") {
const diff = dayjs(endDate).diff(startDate, "minutes"); const diff = dayjs(endDate).diff(startDate, "minutes");
if (diff < 60 * 24) { if (diff < 60 * 24) {
onChangeDuration(diff); onChangeDuration(diff);
}
} else {
newEvent.end = formatDateWithoutTz(
dayjs(startDate).add(duration, "minutes").toDate(),
);
} }
} else {
newEvent.end = formatDateWithoutTz( const alreadyExists = options.some(
dayjs(startDate).add(duration, "minutes").toDate(), (option) =>
option.type === "timeSlot" &&
option.start === newEvent.start &&
option.end === newEvent.end,
); );
}
const alreadyExists = options.some( if (!alreadyExists) {
(option) => onChange([...options, newEvent]);
option.type === "timeSlot" && }
option.start === newEvent.start && }}
option.end === newEvent.end, scrollToTime={scrollToTime}
); />
</div>
if (!alreadyExists) {
onChange([...options, newEvent]);
}
}}
scrollToTime={scrollToTime}
/>
); );
}; };

View file

@ -0,0 +1,176 @@
import { EyeIcon, MessageCircleIcon, VoteIcon } from "@rallly/icons";
import { cn } from "@rallly/ui";
import { Badge } from "@rallly/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@rallly/ui/card";
import { FormField, FormItem } from "@rallly/ui/form";
import { Label } from "@rallly/ui/label";
import { Switch } from "@rallly/ui/switch";
import Link from "next/link";
import React from "react";
import { useFormContext } from "react-hook-form";
import { Trans } from "react-i18next";
import { usePlan } from "@/contexts/plan";
export type PollSettingsFormData = {
hideParticipants: boolean;
hideScores: boolean;
disableComments: boolean;
};
const SettingContent = ({ children }: React.PropsWithChildren) => {
return <div className="grid grow gap-1.5 pt-0.5">{children}</div>;
};
const SettingDescription = ({ children }: React.PropsWithChildren) => {
return <p className="text-muted-foreground text-sm">{children}</p>;
};
const SettingTitle = Label;
const Setting = ({ children }: React.PropsWithChildren) => {
return (
<FormItem className="rounded-lg border p-4">
<div className="flex items-start justify-between gap-x-4">{children}</div>
</FormItem>
);
};
export const PollSettingsForm = ({ children }: React.PropsWithChildren) => {
const form = useFormContext<PollSettingsFormData>();
const plan = usePlan();
const disabled = plan === "free";
return (
<Card>
<CardHeader>
<div className="flex justify-between gap-x-4">
<div>
<div className="flex items-center gap-x-2">
<CardTitle>
<Trans i18nKey="settings" />
</CardTitle>
<Badge>
<Trans i18nKey="planPro" />
</Badge>
</div>
<CardDescription>
<Trans
i18nKey="pollSettingsDescription"
defaults="Customize the behaviour of your poll"
/>
</CardDescription>
</div>
{disabled ? (
<div>
<Link className="text-link text-sm" href="/settings/billing">
<Trans i18nKey="planUpgrade" />
</Link>
</div>
) : null}
</div>
</CardHeader>
<CardContent>
<div
className={cn(
"grid gap-2.5",
disabled ? "pointer-events-none opacity-50" : "",
)}
>
<FormField
control={form.control}
name="hideParticipants"
render={({ field }) => (
<Setting>
<EyeIcon className="h-6 w-6" />
<SettingContent>
<SettingTitle>
<Trans i18nKey="hideParticipants">
Hide participant list
</Trans>
</SettingTitle>
<SettingDescription>
<Trans
i18nKey="hideParticipantsDescription"
defaults="Keep participant details private"
/>
</SettingDescription>
</SettingContent>
<Switch
disabled={disabled}
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
}}
/>
</Setting>
)}
/>
<FormField
control={form.control}
name="hideScores"
render={({ field }) => (
<Setting>
<VoteIcon className="h-6 w-6" />
<SettingContent>
<SettingTitle>
<Trans i18nKey="hideScores">Hide scores</Trans>
</SettingTitle>
<SettingDescription>
<Trans
i18nKey="hideScoresDescription"
defaults="Reduce bias by hiding the current vote counts from participants"
/>
</SettingDescription>
</SettingContent>
<Switch
disabled={disabled}
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
}}
/>
</Setting>
)}
/>
<FormField
control={form.control}
name="disableComments"
render={({ field }) => (
<Setting>
<MessageCircleIcon className="h-6 w-6" />
<SettingContent>
<SettingTitle>
<Trans i18nKey="disableComments">Disable comments</Trans>
</SettingTitle>
<SettingDescription>
<Trans
i18nKey="disableCommentsDescription"
defaults="Remove the option to leave a comment on the poll"
/>
</SettingDescription>
</SettingContent>
<Switch
disabled={disabled}
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
}}
/>
</Setting>
)}
/>
</div>
</CardContent>
{children}
</Card>
);
};

View file

@ -1,16 +1,15 @@
import { PollSettingsFormData } from "@/components/forms/poll-settings";
import { PollDetailsData } from "./poll-details-form"; import { PollDetailsData } from "./poll-details-form";
import { PollOptionsData } from "./poll-options-form/poll-options-form"; import { PollOptionsData } from "./poll-options-form/poll-options-form";
import { UserDetailsData } from "./user-details-form";
export interface NewEventData { export type NewEventData = PollDetailsData &
currentStep: number; PollOptionsData &
eventDetails?: Partial<PollDetailsData>; PollSettingsFormData;
options?: Partial<PollOptionsData>;
userDetails?: Partial<UserDetailsData>;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface PollFormProps<T extends Record<string, any>> { export interface PollFormProps<T extends Record<string, any>> {
onSubmit: (data: T) => void; onSubmit?: (data: T) => void;
onChange?: (data: Partial<T>) => void; onChange?: (data: Partial<T>) => void;
defaultValues?: Partial<T>; defaultValues?: Partial<T>;
name?: string; name?: string;

View file

@ -1,73 +0,0 @@
import { Form, FormItem, FormLabel } from "@rallly/ui/form";
import { Input } from "@rallly/ui/input";
import clsx from "clsx";
import { useTranslation } from "next-i18next";
import * as React from "react";
import { useForm } from "react-hook-form";
import { requiredString, validEmail } from "../../utils/form-validation";
import { PollFormProps } from "./types";
export interface UserDetailsData {
name: string;
contact: string;
}
export const UserDetailsForm: React.FunctionComponent<
PollFormProps<UserDetailsData>
> = ({ name, defaultValues, onSubmit, onChange, className }) => {
const { t } = useTranslation();
const form = useForm<UserDetailsData>({ defaultValues });
const {
handleSubmit,
register,
watch,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = form;
const isWorking = isSubmitting || isSubmitSuccessful;
React.useEffect(() => {
if (onChange) {
const subscription = watch(onChange);
return () => {
subscription.unsubscribe();
};
}
}, [watch, onChange]);
return (
<Form {...form}>
<form id={name} className={className} onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-6">
<FormItem>
<FormLabel htmlFor="name">{t("name")}</FormLabel>
<Input
type="text"
id="name"
className={clsx("input w-full", {
"input-error": errors.name,
})}
disabled={isWorking}
placeholder={t("namePlaceholder")}
{...register("name", { validate: requiredString })}
/>
</FormItem>
<FormItem>
<FormLabel htmlFor="contact">{t("email")}</FormLabel>
<Input
id="contact"
className={clsx("input w-full", {
"input-error": errors.contact,
})}
disabled={isWorking}
placeholder={t("emailPlaceholder")}
{...register("contact", {
validate: validEmail,
})}
/>
</FormItem>
</div>
</form>
</Form>
);
};

View file

@ -7,6 +7,7 @@ interface DayProps {
weekend: boolean; weekend: boolean;
outOfMonth: boolean; outOfMonth: boolean;
today: boolean; today: boolean;
isPast: boolean;
selected: boolean; selected: boolean;
} }
@ -61,6 +62,7 @@ export const useHeadlessDatePicker = (
outOfMonth: d.month() !== currentMonth, outOfMonth: d.month() !== currentMonth,
today: d.isSame(today, "day"), today: d.isSame(today, "day"),
selected: selection.some((selectedDate) => d.isSame(selectedDate, "day")), selected: selection.some((selectedDate) => d.isSame(selectedDate, "day")),
isPast: d.isBefore(today, "day"),
}); });
i++; i++;
reachedEnd = reachedEnd =

View file

@ -7,7 +7,7 @@ export const styleMenuItem = ({
active: boolean; active: boolean;
selected: boolean; selected: boolean;
}) => }) =>
clsx("menu-item", { clsx("menu-item text-sm", {
"font-medium": selected, "font-medium": selected,
"bg-blue-50": active, "bg-blue-50": active,
}); });

View file

@ -2,6 +2,9 @@ import { trpc } from "@rallly/backend";
import { Participant, Vote, VoteType } from "@rallly/database"; import { Participant, Vote, VoteType } from "@rallly/database";
import * as React from "react"; import * as React from "react";
import { useVisibility } from "@/components/visibility";
import { usePermissions } from "@/contexts/permissions";
import { useRequiredContext } from "./use-required-context"; import { useRequiredContext } from "./use-required-context";
const ParticipantsContext = React.createContext<{ const ParticipantsContext = React.createContext<{
@ -40,9 +43,6 @@ export const ParticipantsProvider: React.FunctionComponent<{
}); });
}); });
}; };
// TODO (Luke Vella) [2022-05-18]: Add mutations here
if (!participants) { if (!participants) {
return null; return null;
} }
@ -53,3 +53,20 @@ export const ParticipantsProvider: React.FunctionComponent<{
</ParticipantsContext.Provider> </ParticipantsContext.Provider>
); );
}; };
export const useVisibleParticipants = () => {
const { canSeeOtherParticipants } = useVisibility();
const { canEditParticipant } = usePermissions();
const { participants } = useParticipants();
const filteredParticipants = React.useMemo(() => {
if (!canSeeOtherParticipants) {
return participants.filter((participant) =>
canEditParticipant(participant.id),
);
}
return participants;
}, [canEditParticipant, canSeeOtherParticipants, participants]);
return filteredParticipants;
};

View file

@ -36,10 +36,14 @@ export const Poll = () => {
<Card fullWidthOnMobile={false}> <Card fullWidthOnMobile={false}>
<PollComponent /> <PollComponent />
</Card> </Card>
<hr className="my-4" /> {poll.disableComments ? null : (
<Card fullWidthOnMobile={false}> <>
<Discussion /> <hr className="my-4" />
</Card> <Card fullWidthOnMobile={false}>
<Discussion />
</Card>
</>
)}
</div> </div>
); );
}; };

View file

@ -15,7 +15,10 @@ import { useRole } from "@/contexts/role";
import { TimePreferences } from "@/contexts/time-preferences"; import { TimePreferences } from "@/contexts/time-preferences";
import { useNewParticipantModal } from "../new-participant-modal"; import { useNewParticipantModal } from "../new-participant-modal";
import { useParticipants } from "../participants-provider"; import {
useParticipants,
useVisibleParticipants,
} from "../participants-provider";
import { usePoll } from "../poll-context"; import { usePoll } from "../poll-context";
import ParticipantRow from "./desktop-poll/participant-row"; import ParticipantRow from "./desktop-poll/participant-row";
import ParticipantRowForm from "./desktop-poll/participant-row-form"; import ParticipantRowForm from "./desktop-poll/participant-row-form";
@ -101,6 +104,8 @@ const Poll: React.FunctionComponent = () => {
const updateParticipant = useUpdateParticipantMutation(); const updateParticipant = useUpdateParticipantMutation();
const showNewParticipantModal = useNewParticipantModal(); const showNewParticipantModal = useNewParticipantModal();
const visibleParticipants = useVisibleParticipants();
return ( return (
<PollContext.Provider <PollContext.Provider
value={{ value={{
@ -218,9 +223,9 @@ const Poll: React.FunctionComponent = () => {
}} }}
/> />
) : null} ) : null}
{participants.length > 0 ? ( {visibleParticipants.length > 0 ? (
<div className="py-2"> <div className="py-2">
{participants.map((participant, i) => { {visibleParticipants.map((participant, i) => {
return ( return (
<ParticipantRow <ParticipantRow
key={i} key={i}

View file

@ -48,9 +48,7 @@ const PollHeader: React.FunctionComponent = () => {
{option.type === "timeSlot" ? ( {option.type === "timeSlot" ? (
<TimeRange start={option.startTime} end={option.endTime} /> <TimeRange start={option.startTime} end={option.endTime} />
) : null} ) : null}
<div className="flex justify-center"> <ConnectedScoreSummary optionId={option.optionId} />
<ConnectedScoreSummary optionId={option.optionId} />
</div>
</div> </div>
); );
})} })}

View file

@ -2,6 +2,7 @@ import {
ChevronDownIcon, ChevronDownIcon,
DownloadIcon, DownloadIcon,
PencilIcon, PencilIcon,
Settings2Icon,
SettingsIcon, SettingsIcon,
TableIcon, TableIcon,
TrashIcon, TrashIcon,
@ -57,6 +58,13 @@ const ManagePoll: React.FunctionComponent<{
</DropdownMenuItemIconLabel> </DropdownMenuItemIconLabel>
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/poll/${poll.id}/edit-settings`}>
<DropdownMenuItemIconLabel icon={Settings2Icon}>
<Trans i18nKey="editSettings" defaults="Edit settings" />
</DropdownMenuItemIconLabel>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={exportToCsv}> <DropdownMenuItem onClick={exportToCsv}>
<DropdownMenuItemIconLabel icon={DownloadIcon}> <DropdownMenuItemIconLabel icon={DownloadIcon}>

View file

@ -15,7 +15,10 @@ import { TimePreferences } from "@/contexts/time-preferences";
import { styleMenuItem } from "../menu-styles"; import { styleMenuItem } from "../menu-styles";
import { useNewParticipantModal } from "../new-participant-modal"; import { useNewParticipantModal } from "../new-participant-modal";
import { useParticipants } from "../participants-provider"; import {
useParticipants,
useVisibleParticipants,
} from "../participants-provider";
import { useUser } from "../user-provider"; import { useUser } from "../user-provider";
import GroupedOptions from "./mobile-poll/grouped-options"; import GroupedOptions from "./mobile-poll/grouped-options";
import { normalizeVotes, useUpdateParticipantMutation } from "./mutations"; import { normalizeVotes, useUpdateParticipantMutation } from "./mutations";
@ -59,6 +62,7 @@ const MobilePoll: React.FunctionComponent = () => {
} }
}); });
const visibleParticipants = useVisibleParticipants();
const selectedParticipant = selectedParticipantId const selectedParticipant = selectedParticipantId
? getParticipantById(selectedParticipantId) ? getParticipantById(selectedParticipantId)
: undefined; : undefined;
@ -149,7 +153,7 @@ const MobilePoll: React.FunctionComponent = () => {
<Listbox.Option value={undefined} className={styleMenuItem}> <Listbox.Option value={undefined} className={styleMenuItem}>
{t("participantCount", { count: participants.length })} {t("participantCount", { count: participants.length })}
</Listbox.Option> </Listbox.Option>
{participants.map((participant) => ( {visibleParticipants.map((participant) => (
<Listbox.Option <Listbox.Option
key={participant.id} key={participant.id}
value={participant.id} value={participant.id}

View file

@ -5,6 +5,8 @@ import { AnimatePresence, m } from "framer-motion";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import * as React from "react"; import * as React from "react";
import { IfParticipantsVisible } from "@/components/visibility";
import { useParticipants } from "../../participants-provider"; import { useParticipants } from "../../participants-provider";
import { ConnectedScoreSummary } from "../score-summary"; import { ConnectedScoreSummary } from "../score-summary";
import UserAvatar from "../user-avatar"; import UserAvatar from "../user-avatar";
@ -212,17 +214,19 @@ const PollOption: React.FunctionComponent<PollOptionProps> = ({
setExpanded((value) => !value); setExpanded((value) => !value);
}} }}
> >
{participants.length > 0 ? ( <IfParticipantsVisible>
<SummarizedParticipantList participants={participants} /> {participants.length > 0 ? (
) : null} <SummarizedParticipantList participants={participants} />
<ChevronDownIcon ) : null}
className={clsx( <ChevronDownIcon
"h-5 shrink-0 text-gray-500 transition-transform", className={clsx(
{ "h-5 shrink-0 text-gray-500 transition-transform",
"-rotate-180": expanded, {
}, "-rotate-180": expanded,
)} },
/> )}
/>
</IfParticipantsVisible>
</m.button> </m.button>
)} )}
</AnimatePresence> </AnimatePresence>

View file

@ -5,6 +5,7 @@ import * as React from "react";
import { usePrevious } from "react-use"; import { usePrevious } from "react-use";
import { usePoll } from "@/components/poll-context"; import { usePoll } from "@/components/poll-context";
import { IfScoresVisible } from "@/components/visibility";
export interface PopularityScoreProps { export interface PopularityScoreProps {
yesScore: number; yesScore: number;
@ -20,12 +21,14 @@ export const ConnectedScoreSummary: React.FunctionComponent<{
const { yes, ifNeedBe } = getScore(optionId); const { yes, ifNeedBe } = getScore(optionId);
const score = yes + ifNeedBe; const score = yes + ifNeedBe;
return ( return (
<ScoreSummary <IfScoresVisible>
yesScore={yes} <ScoreSummary
ifNeedBeScore={ifNeedBe} yesScore={yes}
highScore={highScore} ifNeedBeScore={ifNeedBe}
highlight={score === highScore && score > 1} highScore={highScore}
/> highlight={score === highScore && score > 1}
/>
</IfScoresVisible>
); );
}; };

View file

@ -109,7 +109,7 @@ export const YouAvatar = () => {
const you = t("you"); const you = t("you");
return ( return (
<span className="inline-flex items-center gap-x-2.5"> <span className="inline-flex items-center gap-x-2.5">
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold uppercase outline-dashed outline-2 outline-gray-200"> <span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-gray-200 text-xs font-semibold uppercase">
{you[0]} {you[0]}
</span> </span>
{t("you")} {t("you")}

View file

@ -202,7 +202,7 @@ const TimeZonePicker: React.FunctionComponent<{
}} }}
onBlur={onBlur} onBlur={onBlur}
/> />
<Combobox.Button className="absolute inset-0 flex h-9 w-full cursor-default items-center px-2 text-left"> <Combobox.Button className="absolute inset-0 flex h-9 w-full cursor-default items-center px-2 text-left text-sm">
<span className="grow truncate"> <span className="grow truncate">
{!query ? selectedTimeZone.label : null} {!query ? selectedTimeZone.label : null}
</span> </span>

View file

@ -0,0 +1,63 @@
/**
* Manage what the user can and cannot see on the page
*/
import React from "react";
import { useParticipants } from "@/components/participants-provider";
import { usePermissions } from "@/contexts/permissions";
import { usePoll } from "@/contexts/poll";
export const IfParticipantsVisible = (props: React.PropsWithChildren) => {
const context = React.useContext(VisibilityContext);
if (context.canSeeOtherParticipants) {
return <>{props.children}</>;
}
return null;
};
export const IfScoresVisible = (props: React.PropsWithChildren) => {
const context = React.useContext(VisibilityContext);
if (context.canSeeScores) {
return <>{props.children}</>;
}
return null;
};
export const useVisibility = () => {
return React.useContext(VisibilityContext);
};
const VisibilityContext = React.createContext<{
canSeeOtherParticipants: boolean;
canSeeScores: boolean;
}>({
canSeeScores: true,
canSeeOtherParticipants: true,
});
export const VisibilityProvider = ({ children }: React.PropsWithChildren) => {
const poll = usePoll();
const { participants } = useParticipants();
const { canEditParticipant } = usePermissions();
const userAlreadyVoted = participants.some((participant) => {
return canEditParticipant(participant.id);
});
const canSeeScores = poll.hideScores ? userAlreadyVoted : true;
const canSeeOtherParticipants = poll.hideParticipants ? false : canSeeScores;
return (
<VisibilityContext.Provider
value={{
canSeeOtherParticipants,
canSeeScores,
}}
>
{children}
</VisibilityContext.Provider>
);
};

View file

@ -25,7 +25,6 @@ export const usePermissions = () => {
if (isClosed) { if (isClosed) {
return false; return false;
} }
if (role === "admin" && user.id === poll.userId) { if (role === "admin" && user.id === poll.userId) {
return true; return true;
} }
@ -37,7 +36,7 @@ export const usePermissions = () => {
if ( if (
participant && participant &&
(participant.userId === user.id || (participant.userId === user.id ||
participant.userId === context.userId) (context.userId && participant.userId === context.userId))
) { ) {
return true; return true;
} }

View file

@ -37,6 +37,9 @@ export const TimePreferences = () => {
}; };
}); });
}, },
onSuccess: () => {
queryClient.userPreferences.get.invalidate();
},
}); });
if (data === undefined) { if (data === undefined) {

View file

@ -10,6 +10,7 @@ import { Poll } from "@/components/poll";
import { LegacyPollContextProvider } from "@/components/poll/poll-context-provider"; import { LegacyPollContextProvider } from "@/components/poll/poll-context-provider";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider"; import { useUser } from "@/components/user-provider";
import { VisibilityProvider } from "@/components/visibility";
import { PermissionsContext } from "@/contexts/permissions"; import { PermissionsContext } from "@/contexts/permissions";
import { usePoll } from "@/contexts/poll"; import { usePoll } from "@/contexts/poll";
import { getStaticTranslations } from "@/utils/with-page-translations"; import { getStaticTranslations } from "@/utils/with-page-translations";
@ -82,52 +83,54 @@ const Page = () => {
return ( return (
<Prefetch> <Prefetch>
<LegacyPollContextProvider> <LegacyPollContextProvider>
<div className=""> <VisibilityProvider>
<svg <div className="">
className="absolute inset-x-0 top-0 -z-10 hidden h-[64rem] w-full stroke-gray-300/75 [mask-image:radial-gradient(800px_800px_at_center,white,transparent)] sm:block" <svg
aria-hidden="true" className="absolute inset-x-0 top-0 -z-10 hidden h-[64rem] w-full stroke-gray-300/75 [mask-image:radial-gradient(800px_800px_at_center,white,transparent)] sm:block"
> aria-hidden="true"
<defs> >
<pattern <defs>
id="1f932ae7-37de-4c0a-a8b0-a6e3b4d44b84" <pattern
width={240} id="1f932ae7-37de-4c0a-a8b0-a6e3b4d44b84"
height={240} width={240}
x="50%" height={240}
y={-1} x="50%"
patternUnits="userSpaceOnUse" y={-1}
> patternUnits="userSpaceOnUse"
<path d="M.5 240V.5H240" fill="none" /> >
</pattern> <path d="M.5 240V.5H240" fill="none" />
</defs> </pattern>
<rect </defs>
width="100%" <rect
height="100%" width="100%"
strokeWidth={0} height="100%"
fill="url(#1f932ae7-37de-4c0a-a8b0-a6e3b4d44b84)" strokeWidth={0}
/> fill="url(#1f932ae7-37de-4c0a-a8b0-a6e3b4d44b84)"
</svg> />
<div className="mx-auto max-w-4xl space-y-4 p-3 sm:py-8"> </svg>
<GoToApp /> <div className="mx-auto max-w-4xl space-y-4 p-3 sm:py-8">
<Poll /> <GoToApp />
<div className="mt-4 space-y-4 text-center text-gray-500"> <Poll />
<div className="py-8"> <div className="mt-4 space-y-4 text-center text-gray-500">
<Trans <div className="py-8">
defaults="Powered by <a>{name}</a>" <Trans
i18nKey="poweredByRallly" defaults="Powered by <a>{name}</a>"
values={{ name: "rallly.co" }} i18nKey="poweredByRallly"
components={{ values={{ name: "rallly.co" }}
a: ( components={{
<Link a: (
className="hover:text-primary-600 rounded-none border-b border-b-gray-500 font-semibold" <Link
href="https://rallly.co" className="hover:text-primary-600 rounded-none border-b border-b-gray-500 font-semibold"
/> href="https://rallly.co"
), />
}} ),
/> }}
/>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </VisibilityProvider>
</LegacyPollContextProvider> </LegacyPollContextProvider>
</Prefetch> </Prefetch>
); );

View file

@ -7,9 +7,15 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@rallly/ui/card"; } from "@rallly/ui/card";
import { Form } from "@rallly/ui/form";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useForm } from "react-hook-form";
import { PollDetailsForm } from "@/components/forms/poll-details-form"; import {
PollDetailsData,
PollDetailsForm,
} from "@/components/forms/poll-details-form";
import { getPollLayout } from "@/components/layouts/poll-layout"; import { getPollLayout } from "@/components/layouts/poll-layout";
import { useUpdatePollMutation } from "@/components/poll/mutations"; import { useUpdatePollMutation } from "@/components/poll/mutations";
import { usePoll } from "@/components/poll-context"; import { usePoll } from "@/components/poll-context";
@ -23,53 +29,61 @@ const Page: NextPageWithLayout = () => {
const { mutate: updatePollMutation, isLoading: isUpdating } = const { mutate: updatePollMutation, isLoading: isUpdating } =
useUpdatePollMutation(); useUpdatePollMutation();
const router = useRouter(); const router = useRouter();
const pollLink = `/poll/${poll.id}`;
const redirectBackToPoll = () => { const redirectBackToPoll = () => {
router.replace(`/poll/${poll.id}`); router.push(pollLink);
}; };
const form = useForm<PollDetailsData>({
defaultValues: {
title: poll.title,
location: poll.location ?? "",
description: poll.description ?? "",
},
});
return ( return (
<Card className="mx-auto max-w-4xl"> <Form {...form}>
<CardHeader> <form
<CardTitle> className="mx-auto max-w-3xl"
<Trans i18nKey="editDetails" defaults="Edit details" /> onSubmit={form.handleSubmit((data) => {
</CardTitle> //submit
<CardDescription> updatePollMutation(
<Trans { urlId, ...data },
i18nKey="editDetailsDescription" { onSuccess: redirectBackToPoll },
defaults="Change the details of your event." );
/> })}
</CardDescription> >
</CardHeader> <Card>
<CardContent> <CardHeader>
<PollDetailsForm <CardTitle>
name="updateDetails" <Trans i18nKey="editDetails" defaults="Edit details" />
defaultValues={{ </CardTitle>
title: poll.title, <CardDescription>
location: poll.location ?? "", <Trans
description: poll.description ?? "", i18nKey="editDetailsDescription"
}} defaults="Change the details of your event."
onSubmit={(data) => { />
//submit </CardDescription>
updatePollMutation( </CardHeader>
{ urlId, ...data }, <CardContent>
{ onSuccess: redirectBackToPoll }, <PollDetailsForm />
); </CardContent>
}} <CardFooter className="justify-between">
/> <Button asChild>
</CardContent> <Link href={pollLink}>
<CardFooter className="justify-between"> <Trans i18nKey="cancel" />
<Button onClick={redirectBackToPoll}> </Link>
<Trans i18nKey="cancel" /> </Button>
</Button> <Button type="submit" loading={isUpdating} variant="primary">
<Button <Trans i18nKey="save" />
type="submit" </Button>
loading={isUpdating} </CardFooter>
form="updateDetails" </Card>
variant="primary" </form>
> </Form>
<Trans i18nKey="save" />
</Button>
</CardFooter>
</Card>
); );
}; };

View file

@ -1,16 +1,19 @@
import { Button } from "@rallly/ui/button"; import { Button } from "@rallly/ui/button";
import { import {
Card, Card,
CardContent,
CardDescription, CardDescription,
CardFooter, CardFooter,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@rallly/ui/card"; } from "@rallly/ui/card";
import { Form } from "@rallly/ui/form";
import dayjs from "dayjs"; import dayjs from "dayjs";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useForm } from "react-hook-form";
import { PollOptionsData } from "@/components/forms";
import PollOptionsForm from "@/components/forms/poll-options-form"; import PollOptionsForm from "@/components/forms/poll-options-form";
import { getPollLayout } from "@/components/layouts/poll-layout"; import { getPollLayout } from "@/components/layouts/poll-layout";
import { useModalContext } from "@/components/modal/modal-provider"; import { useModalContext } from "@/components/modal/modal-provider";
@ -37,116 +40,116 @@ const Page: NextPageWithLayout = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const modalContext = useModalContext(); const modalContext = useModalContext();
const router = useRouter(); const router = useRouter();
const pollLink = `/poll/${poll.id}`;
const redirectBackToPoll = () => { const redirectBackToPoll = () => {
router.replace(`/poll/${poll.id}`); router.push(pollLink);
}; };
return ( const form = useForm<PollOptionsData>({
<Card className="mx-auto max-w-4xl"> defaultValues: {
<CardHeader> navigationDate: dayjs(poll.options[0].start).utc().format("YYYY-MM-DD"),
<CardTitle> view: "month",
<Trans i18nKey="editOptions" /> options: poll.options.map((option) => {
</CardTitle> const start = dayjs(option.start).utc();
<CardDescription> return option.duration > 0
<Trans ? {
i18nKey="editOptionsDescription" type: "timeSlot",
defaults="Change the options available in your poll." start: start.format("YYYY-MM-DDTHH:mm:ss"),
/> duration: option.duration,
</CardDescription> end: start
</CardHeader> .add(option.duration, "minute")
<CardContent> .format("YYYY-MM-DDTHH:mm:ss"),
<PollOptionsForm
name="pollOptions"
title={poll.title}
defaultValues={{
navigationDate: dayjs(poll.options[0].start)
.utc()
.format("YYYY-MM-DD"),
options: poll.options.map((option) => {
const start = dayjs(option.start).utc();
return option.duration > 0
? {
type: "timeSlot",
start: start.format("YYYY-MM-DDTHH:mm:ss"),
duration: option.duration,
end: start
.add(option.duration, "minute")
.format("YYYY-MM-DDTHH:mm:ss"),
}
: {
type: "date",
date: start.format("YYYY-MM-DD"),
};
}),
timeZone: poll.timeZone ?? "",
}}
onSubmit={(data) => {
const encodedOptions = data.options.map(encodeDateOption);
const optionsToDelete = poll.options.filter((option) => {
return !encodedOptions.includes(convertOptionToString(option));
});
const optionsToAdd = encodedOptions.filter(
(encodedOption) =>
!poll.options.find(
(o) => convertOptionToString(o) === encodedOption,
),
);
const onOk = () => {
updatePollMutation(
{
urlId: poll.adminUrlId,
timeZone: data.timeZone,
optionsToDelete: optionsToDelete.map(({ id }) => id),
optionsToAdd,
},
{
onSuccess: redirectBackToPoll,
},
);
};
const optionsToDeleteThatHaveVotes = optionsToDelete.filter(
(option) =>
getParticipantsWhoVotedForOption(option.id).length > 0,
);
if (optionsToDeleteThatHaveVotes.length > 0) {
modalContext.render({
title: t("areYouSure"),
description: (
<Trans
i18nKey="deletingOptionsWarning"
components={{ b: <strong /> }}
/>
),
onOk,
okButtonProps: {
type: "danger",
},
okText: t("delete"),
cancelText: t("cancel"),
});
} else {
onOk();
} }
}} : {
/> type: "date",
</CardContent> date: start.format("YYYY-MM-DD"),
<CardFooter className="justify-between"> };
<Button onClick={redirectBackToPoll}> }),
<Trans i18nKey="cancel" /> timeZone: poll.timeZone ?? "",
</Button> duration: poll.options[0].duration || 60,
<Button },
type="submit" });
loading={isUpdating} return (
form="pollOptions" <Form {...form}>
variant="primary" <form
> onSubmit={form.handleSubmit((data) => {
<Trans i18nKey="save" /> const encodedOptions = data.options.map(encodeDateOption);
</Button> const optionsToDelete = poll.options.filter((option) => {
</CardFooter> return !encodedOptions.includes(convertOptionToString(option));
</Card> });
const optionsToAdd = encodedOptions.filter(
(encodedOption) =>
!poll.options.find(
(o) => convertOptionToString(o) === encodedOption,
),
);
const onOk = () => {
updatePollMutation(
{
urlId: poll.adminUrlId,
timeZone: data.timeZone,
optionsToDelete: optionsToDelete.map(({ id }) => id),
optionsToAdd,
},
{
onSuccess: redirectBackToPoll,
},
);
};
const optionsToDeleteThatHaveVotes = optionsToDelete.filter(
(option) => getParticipantsWhoVotedForOption(option.id).length > 0,
);
if (optionsToDeleteThatHaveVotes.length > 0) {
modalContext.render({
title: t("areYouSure"),
description: (
<Trans
i18nKey="deletingOptionsWarning"
components={{ b: <strong /> }}
/>
),
onOk,
okButtonProps: {
type: "danger",
},
okText: t("delete"),
cancelText: t("cancel"),
});
} else {
onOk();
}
})}
>
<Card className="mx-auto max-w-4xl">
<CardHeader>
<CardTitle>
<Trans i18nKey="editOptions" />
</CardTitle>
<CardDescription>
<Trans
i18nKey="editOptionsDescription"
defaults="Change the options available in your poll."
/>
</CardDescription>
</CardHeader>
<PollOptionsForm />
<CardFooter className="justify-between">
<Button asChild>
<Link href={pollLink}>
<Trans i18nKey="cancel" />
</Link>
</Button>
<Button type="submit" loading={isUpdating} variant="primary">
<Trans i18nKey="save" />
</Button>
</CardFooter>
</Card>
</form>
</Form>
); );
}; };

View file

@ -0,0 +1,82 @@
import { Button } from "@rallly/ui/button";
import { CardFooter } from "@rallly/ui/card";
import { Form } from "@rallly/ui/form";
import Link from "next/link";
import { useRouter } from "next/router";
import { useForm } from "react-hook-form";
import {
PollSettingsForm,
PollSettingsFormData,
} from "@/components/forms/poll-settings";
import { getPollLayout } from "@/components/layouts/poll-layout";
import { useUpdatePollMutation } from "@/components/poll/mutations";
import { Trans } from "@/components/trans";
import { usePoll } from "@/contexts/poll";
import { NextPageWithLayout } from "@/types";
import { getStaticTranslations } from "@/utils/with-page-translations";
const Page: NextPageWithLayout = () => {
const poll = usePoll();
const router = useRouter();
const pollLink = `/poll/${poll.id}`;
const redirectBackToPoll = () => {
router.push(pollLink);
};
const update = useUpdatePollMutation();
const form = useForm<PollSettingsFormData>({
defaultValues: {
hideParticipants: poll.hideParticipants,
hideScores: poll.hideScores,
disableComments: poll.disableComments,
},
});
return (
<Form {...form}>
<form
className="mx-auto max-w-3xl"
onSubmit={form.handleSubmit(async (data) => {
//submit
await update.mutateAsync(
{ urlId: poll.adminUrlId, ...data },
{
onSuccess: redirectBackToPoll,
},
);
})}
>
<PollSettingsForm>
<CardFooter className="justify-between">
<Button asChild>
<Link href={pollLink}>
<Trans i18nKey="cancel" />
</Link>
</Button>
<Button type="submit" variant="primary">
<Trans i18nKey="save" />
</Button>
</CardFooter>
</PollSettingsForm>
</form>
</Form>
);
};
Page.getLayout = getPollLayout;
export const getStaticPaths = async () => {
return {
paths: [],
fallback: "blocking",
};
};
export const getStaticProps = getStaticTranslations;
export default Page;

View file

@ -39,7 +39,7 @@
input, input,
select, select,
textarea { textarea {
@apply rounded outline-none focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-gray-300; @apply rounded outline-none focus-visible:ring-2 focus-visible:ring-gray-300;
} }
#floating-ui-root { #floating-ui-root {
@ -88,7 +88,7 @@
} }
.btn.btn-disabled { .btn.btn-disabled {
text-shadow: none; text-shadow: none;
@apply pointer-events-none border-gray-200 bg-gray-500/5 text-gray-400 shadow-none; @apply pointer-events-none border-gray-200 bg-gray-50 text-gray-400 shadow-none;
} }
.btn-primary { .btn-primary {
text-shadow: rgb(0 0 0 / 20%) 0px 1px 1px; text-shadow: rgb(0 0 0 / 20%) 0px 1px 1px;
@ -163,7 +163,7 @@
} }
.rbc-today { .rbc-today {
@apply bg-blue-50 bg-opacity-50; @apply bg-gray-50 bg-opacity-50;
} }
.rbc-day-slot .rbc-time-slot { .rbc-day-slot .rbc-time-slot {
@ -176,7 +176,7 @@
.rbc-time-header.rbc-overflowing, .rbc-time-header.rbc-overflowing,
.rbc-time-header-content, .rbc-time-header-content,
.rbc-header { .rbc-header {
@apply border-gray-200; @apply border-gray-100;
} }
.rbc-time-content { .rbc-time-content {
@ -187,10 +187,6 @@
@apply hidden; @apply hidden;
} }
.rbc-label.rbc-time-header-gutter {
@apply border-b;
}
.rbc-current-time-indicator { .rbc-current-time-indicator {
@apply bg-rose-400; @apply bg-rose-400;
} }
@ -198,22 +194,35 @@
.rbc-header + .rbc-header { .rbc-header + .rbc-header {
@apply border-l-0; @apply border-l-0;
} }
.rbc-time-slot {
@apply pl-2 pt-1;
}
.rbc-header a { .rbc-timeslot-group {
@apply block h-full w-full p-1 no-underline hover:text-gray-800; @apply border-gray-100;
}
.rbc-day-slot .rbc-time-slot {
@apply border-dashed border-gray-50;
} }
.rbc-day-slot .rbc-events-container { .rbc-day-slot .rbc-events-container {
@apply mr-2; @apply mr-2;
} }
.rbc-slot-selection { .rbc-slot-selection {
@apply rounded-sm bg-green-50 text-green-500; @apply bg-gray-100/50 leading-tight text-gray-600;
} }
.rbc-header.rbc-today { .rbc-header.rbc-today {
@apply bg-white text-rose-600; @apply bg-white text-rose-600;
} }
.rbc-button-link { .rbc-button-link {
@apply m-1 w-full; @apply pointer-events-none m-1 w-full;
}
.rbc-time-content > * + * > * {
@apply border-gray-100;
}
.rbc-time-header-gutter {
@apply border-b border-gray-100;
} }

View file

@ -180,7 +180,7 @@ export const DayjsProvider: React.FunctionComponent<{
const localeConfig = dayjsLocales[router.locale ?? "en"]; const localeConfig = dayjsLocales[router.locale ?? "en"];
const { data } = trpc.userPreferences.get.useQuery(); const { data } = trpc.userPreferences.get.useQuery();
useAsync(async () => { const state = useAsync(async () => {
const locale = await localeConfig.import(); const locale = await localeConfig.import();
const localeTimeFormat = localeConfig.timeFormat; const localeTimeFormat = localeConfig.timeFormat;
const timeFormat = data?.timeFormat ?? localeConfig.timeFormat; const timeFormat = data?.timeFormat ?? localeConfig.timeFormat;
@ -205,6 +205,11 @@ export const DayjsProvider: React.FunctionComponent<{
const preferredTimeZone = data?.timeZone ?? locale.timeZone; const preferredTimeZone = data?.timeZone ?? locale.timeZone;
if (state.loading) {
// wait for locale to load before rendering
return null;
}
return ( return (
<DayjsContext.Provider <DayjsContext.Provider
value={{ value={{

View file

@ -16,7 +16,7 @@ export const useFormValidation = () => {
return { return {
requiredString: (name?: string) => (value: string) => { requiredString: (name?: string) => (value: string) => {
if (!value.trim()) { if (!value || !value.trim()) {
return t("requiredString", { name }); return t("requiredString", { name });
} }
}, },

View file

@ -23,11 +23,11 @@ test.describe.serial(() => {
await expect(page.getByTestId("poll-title")).toHaveText("Monthly Meetup"); await expect(page.getByTestId("poll-title")).toHaveText("Monthly Meetup");
const { email } = await mailServer.captureOne("john.doe@example.com", { // const { email } = await mailServer.captureOne("john.doe@example.com", {
wait: 5000, // wait: 5000,
}); // });
expect(email.headers.subject).toBe("Let's find a date for Monthly Meetup"); // expect(email.headers.subject).toBe("Let's find a date for Monthly Meetup");
}); });
// delete the poll we just created // delete the poll we just created
@ -41,6 +41,6 @@ test.describe.serial(() => {
deletePollDialog.getByRole("button", { name: "delete" }).click(); deletePollDialog.getByRole("button", { name: "delete" }).click();
await page.waitForURL("/polls"); await expect(page).toHaveURL("/polls");
}); });
}); });

View file

@ -4,12 +4,12 @@ test("should show correct language if supported", async ({ browser }) => {
const context = await browser.newContext({ locale: "de" }); const context = await browser.newContext({ locale: "de" });
const page = await context.newPage(); const page = await context.newPage();
await page.goto("/new"); await page.goto("/new");
await expect(page.locator("text=Neue Umfrage")).toBeVisible(); await expect(page.locator("text=Titel")).toBeVisible();
}); });
test("should default to english", async ({ browser }) => { test("should default to english", async ({ browser }) => {
const context = await browser.newContext({ locale: "mt" }); const context = await browser.newContext({ locale: "mt" });
const page = await context.newPage(); const page = await context.newPage();
await page.goto("/new"); await page.goto("/new");
await expect(page.locator("text=New Poll")).toBeVisible(); await expect(page.locator("text=Title")).toBeVisible();
}); });

View file

@ -25,8 +25,6 @@ export class NewPollPage {
await page.keyboard.type("This is a test description"); await page.keyboard.type("This is a test description");
await page.click('text="Continue"');
await page.click('[title="Next month"]'); await page.click('[title="Next month"]');
// Select a few days // Select a few days
@ -35,14 +33,6 @@ export class NewPollPage {
await page.click("text=/^10$/"); await page.click("text=/^10$/");
await page.click("text=/^15$/"); await page.click("text=/^15$/");
await page.click('text="Continue"');
await page.type('[placeholder="Jessie Smith"]', "John");
await page.type(
'[placeholder="jessie.smith@example.com"]',
"john.doe@example.com",
);
await page.click('text="Create poll"'); await page.click('text="Create poll"');
return new PollPage(page); return new PollPage(page);

View file

@ -38,6 +38,19 @@ const getPollIdFromAdminUrlId = async (urlId: string) => {
return res.id; return res.id;
}; };
const getPro = async (userId: string) => {
return Boolean(
await prisma.userPaymentData.findFirst({
where: {
userId,
endDate: {
gt: new Date(),
},
},
}),
);
};
export const polls = router({ export const polls = router({
demo, demo,
participants, participants,
@ -51,12 +64,9 @@ export const polls = router({
timeZone: z.string().optional(), timeZone: z.string().optional(),
location: z.string().optional(), location: z.string().optional(),
description: z.string().optional(), description: z.string().optional(),
user: z hideParticipants: z.boolean().optional(),
.object({ hideScores: z.boolean().optional(),
name: z.string(), disableComments: z.boolean().optional(),
email: z.string(),
})
.optional(),
options: z options: z
.object({ .object({
startDate: z.string(), startDate: z.string(),
@ -70,27 +80,8 @@ export const polls = router({
const adminToken = nanoid(); const adminToken = nanoid();
const participantUrlId = nanoid(); const participantUrlId = nanoid();
const pollId = nanoid(); const pollId = nanoid();
let email: string;
let name: string;
if (input.user && ctx.user.isGuest) {
email = input.user.email;
name = input.user.name;
} else {
const user = await prisma.user.findUnique({
select: { email: true, name: true },
where: { id: ctx.user.id },
});
if (!user) { const isPro = await getPro(ctx.user.id);
throw new TRPCError({
code: "BAD_REQUEST",
message: "User not found",
});
}
email = user.email;
name = user.name;
}
const poll = await prisma.poll.create({ const poll = await prisma.poll.create({
select: { select: {
@ -133,6 +124,13 @@ export const polls = router({
})), })),
}, },
}, },
...(isPro
? {
hideParticipants: input.hideParticipants,
disableComments: input.disableComments,
hideScores: input.hideScores,
}
: undefined),
}, },
}); });
@ -142,17 +140,24 @@ export const polls = router({
const participantLink = shortUrl(`/invite/${pollId}`); const participantLink = shortUrl(`/invite/${pollId}`);
if (email && name) { if (ctx.user.isGuest === false) {
await sendEmail("NewPollEmail", { const user = await prisma.user.findUnique({
to: email, select: { email: true, name: true },
subject: `Let's find a date for ${poll.title}`, where: { id: ctx.user.id },
props: {
title: poll.title,
name,
adminLink: pollLink,
participantLink,
},
}); });
if (user) {
await sendEmail("NewPollEmail", {
to: user.email,
subject: `Let's find a date for ${poll.title}`,
props: {
title: poll.title,
name: user.name,
adminLink: pollLink,
participantLink,
},
});
}
} }
return { id: poll.id }; return { id: poll.id };
@ -168,11 +173,16 @@ export const polls = router({
optionsToDelete: z.string().array().optional(), optionsToDelete: z.string().array().optional(),
optionsToAdd: z.string().array().optional(), optionsToAdd: z.string().array().optional(),
closed: z.boolean().optional(), closed: z.boolean().optional(),
hideParticipants: z.boolean().optional(),
disableComments: z.boolean().optional(),
hideScores: z.boolean().optional(),
}), }),
) )
.mutation(async ({ input }) => { .mutation(async ({ ctx, input }) => {
const pollId = await getPollIdFromAdminUrlId(input.urlId); const pollId = await getPollIdFromAdminUrlId(input.urlId);
const isPro = await getPro(ctx.user.id);
if (input.optionsToDelete && input.optionsToDelete.length > 0) { if (input.optionsToDelete && input.optionsToDelete.length > 0) {
await prisma.option.deleteMany({ await prisma.option.deleteMany({
where: { where: {
@ -215,6 +225,13 @@ export const polls = router({
description: input.description, description: input.description,
timeZone: input.timeZone, timeZone: input.timeZone,
closed: input.closed, closed: input.closed,
...(isPro
? {
hideScores: input.hideScores,
hideParticipants: input.hideParticipants,
disableComments: input.disableComments,
}
: undefined),
}, },
}); });
}), }),
@ -378,6 +395,9 @@ export const polls = router({
participantUrlId: true, participantUrlId: true,
closed: true, closed: true,
legacy: true, legacy: true,
hideParticipants: true,
disableComments: true,
hideScores: true,
demo: true, demo: true,
options: { options: {
orderBy: { orderBy: {

View file

@ -12,11 +12,12 @@ export const comments = router({
.input( .input(
z.object({ z.object({
pollId: z.string(), pollId: z.string(),
hideParticipants: z.boolean().optional(),
}), }),
) )
.query(async ({ input: { pollId } }) => { .query(async ({ input: { pollId, hideParticipants }, ctx }) => {
return await prisma.comment.findMany({ return await prisma.comment.findMany({
where: { pollId }, where: { pollId, userId: hideParticipants ? ctx.user.id : undefined },
orderBy: [ orderBy: [
{ {
createdAt: "asc", createdAt: "asc",

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "polls" ADD COLUMN "hide_participants" BOOLEAN NOT NULL DEFAULT false;

View file

@ -0,0 +1,6 @@
-- CreateEnum
CREATE TYPE "participant_visibility" AS ENUM ('full', 'scoresOnly', 'limited');
-- AlterTable
ALTER TABLE "polls" ADD COLUMN "disable_comments" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "hide_scores" BOOLEAN NOT NULL DEFAULT false;

View file

@ -62,6 +62,14 @@ model UserPreferences {
@@map("user_preferences") @@map("user_preferences")
} }
enum ParticipantVisibility {
full
scoresOnly
limited
@@map("participant_visibility")
}
model Poll { model Poll {
id String @id @unique @map("id") id String @id @unique @map("id")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
@ -88,6 +96,9 @@ model Poll {
adminUrlId String @unique @map("admin_url_id") adminUrlId String @unique @map("admin_url_id")
eventId String? @map("event_id") eventId String? @map("event_id")
event Event? event Event?
hideParticipants Boolean @default(false) @map("hide_participants")
hideScores Boolean @default(false) @map("hide_scores")
disableComments Boolean @default(false) @map("disable_comments")
@@index([userId], type: Hash) @@index([userId], type: Hash)
@@map("polls") @@map("polls")

View file

@ -45,13 +45,13 @@ module.exports = {
background: colors.rose["50"], background: colors.rose["50"],
foreground: colors.rose["50"], foreground: colors.rose["50"],
}, },
background: colors.gray["100"], background: colors.white,
foreground: colors.gray["800"], foreground: colors.gray["800"],
accent: { accent: {
DEFAULT: colors.gray["100"], DEFAULT: colors.gray["100"],
}, },
muted: { muted: {
DEFAULT: colors.gray["200"], DEFAULT: colors.gray["100"],
background: colors.gray["50"], background: colors.gray["50"],
foreground: colors.gray["500"], foreground: colors.gray["500"],
}, },

View file

@ -23,7 +23,7 @@ const buttonVariants = cva(
}, },
size: { size: {
default: "h-9 px-2.5 text-sm", default: "h-9 px-2.5 text-sm",
sm: "h-7 text-sm px-1 rounded-md", sm: "h-7 text-xs px-2 rounded-md",
lg: "h-11 text-base px-4 rounded-md", lg: "h-11 text-base px-4 rounded-md",
}, },
}, },

View file

@ -24,7 +24,7 @@ const CardHeader = React.forwardRef<
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"flex flex-col space-y-1.5 rounded-t-md border-b bg-gray-50 px-3 py-4 sm:p-6", "grid border-b border-gray-100 p-3 sm:px-5 sm:py-4",
className, className,
)} )}
{...props} {...props}
@ -39,7 +39,7 @@ const CardTitle = React.forwardRef<
<h3 <h3
ref={ref} ref={ref}
className={cn( className={cn(
"text-lg font-semibold leading-none tracking-tight", "font-semibold tracking-tight sm:text-lg sm:leading-tight",
className, className,
)} )}
{...props} {...props}
@ -63,7 +63,7 @@ const CardContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div ref={ref} className={cn("px-3 py-4 sm:p-6", className)} {...props} /> <div ref={ref} className={cn("p-3 sm:px-5 sm:py-4", className)} {...props} />
)); ));
CardContent.displayName = "CardContent"; CardContent.displayName = "CardContent";
@ -74,7 +74,7 @@ const CardFooter = React.forwardRef<
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"flex items-center gap-x-2 rounded-b-md border-t bg-gray-50 p-3 sm:px-6", "flex items-center gap-x-2 rounded-b-md border-t bg-gray-50 p-3 sm:px-5",
className, className,
)} )}
{...props} {...props}

View file

@ -42,8 +42,9 @@ const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg";
hideCloseButton?: boolean;
} }
>(({ className, children, size = "md", ...props }, ref) => ( >(({ className, children, size = "md", hideCloseButton, ...props }, ref) => (
<DialogPortal> <DialogPortal>
<DialogOverlay /> <DialogOverlay />
<DialogPrimitive.Content <DialogPrimitive.Content
@ -61,8 +62,12 @@ const DialogContent = React.forwardRef<
> >
{children} {children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none"> <DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
<XIcon className="h-4 w-4" /> {!hideCloseButton ? (
<span className="sr-only">Close</span> <>
<XIcon className="h-4 w-4" />
<span className="sr-only">Close</span>
</>
) : null}
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>

View file

@ -78,7 +78,7 @@ const FormItem = React.forwardRef<
return ( return (
<FormItemContext.Provider value={{ id }}> <FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2.5", className)} {...props} /> <div ref={ref} className={cn("grid gap-y-2.5", className)} {...props} />
</FormItemContext.Provider> </FormItemContext.Provider>
); );
}); });

View file

@ -10,7 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input <input
type={type} type={type}
className={cn( className={cn(
"border-input ring-offset-input-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded border bg-transparent px-2 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:border-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", "border-input ring-offset-input-background placeholder:text-muted-foreground focus-visible:ring-ring focus-visible:border-ring flex h-9 w-full rounded border bg-transparent px-2 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
ref={ref} ref={ref}

View file

@ -6,16 +6,17 @@
"types": "src/index.ts", "types": "src/index.ts",
"dependencies": { "dependencies": {
"@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-tooltip": "^1.0.6",
"@radix-ui/react-checkbox": "^1.0.3", "@radix-ui/react-checkbox": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.4", "@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.4", "@radix-ui/react-dropdown-menu": "^2.0.4",
"@radix-ui/react-label": "^2.0.1", "@radix-ui/react-label": "^2.0.1",
"@radix-ui/react-popover": "^1.0.5", "@radix-ui/react-popover": "^1.0.5",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-select": "^1.2.1", "@radix-ui/react-select": "^1.2.1",
"@radix-ui/react-slot": "^1.0.1", "@radix-ui/react-slot": "^1.0.1",
"@radix-ui/react-switch": "^1.0.2", "@radix-ui/react-switch": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.6",
"@rallly/icons": "*", "@rallly/icons": "*",
"@rallly/languages": "*", "@rallly/languages": "*",
"class-variance-authority": "^0.6.0", "class-variance-authority": "^0.6.0",

55
packages/ui/tabs.tsx Normal file
View file

@ -0,0 +1,55 @@
"use client";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import * as React from "react";
import { cn } from "./lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"bg-muted text-muted-foreground inline-flex items-center justify-center rounded-md border",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex h-9 items-center justify-center whitespace-nowrap rounded px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:ring-1 data-[state=active]:ring-gray-200",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"ring-offset-background focus-visible:ring-ring mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsContent, TabsList, TabsTrigger };

View file

@ -9,7 +9,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
return ( return (
<textarea <textarea
className={cn( className={cn(
"border-input ring-offset-input-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[80px] w-full rounded border bg-transparent px-3 py-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50", "border-input ring-offset-input-background placeholder:text-muted-foreground focus-visible:ring-ring focus-visible:border-ring flex min-h-[80px] w-full rounded border bg-transparent px-3 py-2 focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
ref={ref} ref={ref}

View file

@ -2947,6 +2947,21 @@
"@radix-ui/react-use-previous" "1.0.0" "@radix-ui/react-use-previous" "1.0.0"
"@radix-ui/react-use-size" "1.0.0" "@radix-ui/react-use-size" "1.0.0"
"@radix-ui/react-tabs@^1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.0.4.tgz#993608eec55a5d1deddd446fa9978d2bc1053da2"
integrity sha512-egZfYY/+wRNCflXNHx+dePvnz9FbmssDTJBtgRfDY7e8SE5oIo3Py2eCB1ckAbh1Q7cQ/6yJZThJ++sgbxibog==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.1"
"@radix-ui/react-context" "1.0.1"
"@radix-ui/react-direction" "1.0.1"
"@radix-ui/react-id" "1.0.1"
"@radix-ui/react-presence" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-roving-focus" "1.0.4"
"@radix-ui/react-use-controllable-state" "1.0.1"
"@radix-ui/react-tooltip@^1.0.6": "@radix-ui/react-tooltip@^1.0.6":
version "1.0.6" version "1.0.6"
resolved "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.6.tgz" resolved "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.6.tgz"
@ -8363,10 +8378,10 @@ quick-lru@^5.1.1:
resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz" resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz"
integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==
react-big-calendar@^1.5.0: react-big-calendar@^1.8.1:
version "1.6.8" version "1.8.1"
resolved "https://registry.npmjs.org/react-big-calendar/-/react-big-calendar-1.6.8.tgz" resolved "https://registry.yarnpkg.com/react-big-calendar/-/react-big-calendar-1.8.1.tgz#07886a66086fcae16934572c5ace8c4c433dbbed"
integrity sha512-uDuHoqH5/Wnk3tBxXnrXQD5w9FEofd1Ch1vMpLuv2LRBqrF2u7FcIruc80urizrkxcDWrTGxa7bsr0a9bby4Mw== integrity sha512-yEiScxReMrRCc0qFdZIKY/L6+argK4ZiYzLk5bck8CRYVbHjCCb/6Ictv42kPs/g3Q4RIb8+GUjDzk/uFtu76Q==
dependencies: dependencies:
"@babel/runtime" "^7.20.7" "@babel/runtime" "^7.20.7"
clsx "^1.2.1" clsx "^1.2.1"
@ -8415,6 +8430,11 @@ react-email@^1.9.1:
shelljs "0.8.5" shelljs "0.8.5"
tree-node-cli "1.6.0" tree-node-cli "1.6.0"
react-hook-form-persist@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/react-hook-form-persist/-/react-hook-form-persist-3.0.0.tgz#4aa1f7150d3f836408240cbfbfdb0fe4842f31a2"
integrity sha512-6nwW65JyFpBem9RjLYAWvIFxOLoCk0E13iB9e5yeF5jeHlwx1ua0M77FvwhPpD8eaCz7hG4ziCdOxRcnJVUSxQ==
react-hook-form@^7.42.1: react-hook-form@^7.42.1:
version "7.43.2" version "7.43.2"
resolved "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.43.2.tgz" resolved "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.43.2.tgz"