mirror of
https://github.com/lukevella/rallly.git
synced 2025-05-21 12:56:21 +02:00
✨ Allow users to customize poll behaviour (#785)
This commit is contained in:
parent
14cfa2bd50
commit
b1e3f63a2e
58 changed files with 1361 additions and 1042 deletions
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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";
|
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
176
apps/web/src/components/forms/poll-settings.tsx
Normal file
176
apps/web/src/components/forms/poll-settings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -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 =
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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")}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
63
apps/web/src/components/visibility.tsx
Normal file
63
apps/web/src/components/visibility.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,6 +37,9 @@ export const TimePreferences = () => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.userPreferences.get.invalidate();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data === undefined) {
|
if (data === undefined) {
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
82
apps/web/src/pages/poll/[urlId]/edit-settings.tsx
Normal file
82
apps/web/src/pages/poll/[urlId]/edit-settings.tsx
Normal 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;
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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={{
|
||||||
|
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "polls" ADD COLUMN "hide_participants" BOOLEAN NOT NULL DEFAULT false;
|
|
@ -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;
|
|
@ -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")
|
||||||
|
|
|
@ -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"],
|
||||||
},
|
},
|
||||||
|
|
|
@ -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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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
55
packages/ui/tabs.tsx
Normal 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 };
|
|
@ -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}
|
||||||
|
|
28
yarn.lock
28
yarn.lock
|
@ -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"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue