mirror of
https://github.com/lukevella/rallly.git
synced 2025-05-22 05:16:23 +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?",
|
||||
"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?",
|
||||
"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 {
|
||||
BillingPlan,
|
||||
BillingPlanFooter,
|
||||
|
@ -23,15 +24,6 @@ import { linkToApp } from "@/lib/linkToApp";
|
|||
import { NextPageWithLayout } from "@/types";
|
||||
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 annualPriceUsd = 30;
|
||||
|
||||
|
@ -102,9 +94,17 @@ const Page: NextPageWithLayout = () => {
|
|||
</BillingPlan>
|
||||
<BillingPlan variant="primary">
|
||||
<BillingPlanHeader>
|
||||
<BillingPlanTitle className="text-primary m-0">
|
||||
<Trans i18nKey="pricing:planPro" defaults="Pro" />
|
||||
</BillingPlanTitle>
|
||||
<div className="flex justify-between">
|
||||
<BillingPlanTitle className="text-primary m-0">
|
||||
<Trans i18nKey="pricing:planPro" defaults="Pro" />
|
||||
</BillingPlanTitle>
|
||||
<Badge variant="secondary">
|
||||
<Trans
|
||||
i18nKey="pricing:earlyAccess"
|
||||
defaults="Early bird discount"
|
||||
/>
|
||||
</Badge>
|
||||
</div>
|
||||
{annualBilling ? (
|
||||
<>
|
||||
<BillingPlanPrice
|
||||
|
@ -144,12 +144,18 @@ const Page: NextPageWithLayout = () => {
|
|||
defaults="Unlimited participants"
|
||||
/>
|
||||
</BillingPlanPerk>
|
||||
<Perk>
|
||||
<BillingPlanPerk>
|
||||
<Trans
|
||||
i18nKey="pricing:customPollSettings"
|
||||
defaults="Customizable poll settings"
|
||||
/>
|
||||
</BillingPlanPerk>
|
||||
<BillingPlanPerk>
|
||||
<Trans
|
||||
i18nKey="pricing:finalizePolls"
|
||||
defaults="Finalize polls"
|
||||
/>
|
||||
</Perk>
|
||||
</BillingPlanPerk>
|
||||
<BillingPlanPerk>
|
||||
<Trans
|
||||
i18nKey="pricing:extendedPollLife"
|
||||
|
|
|
@ -54,8 +54,9 @@
|
|||
"php-serialize": "^4.1.1",
|
||||
"postcss": "^8.4.21",
|
||||
"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-persist": "^3.0.0",
|
||||
"react-hot-toast": "^2.4.0",
|
||||
"react-i18next": "^12.1.4",
|
||||
"react-linkify": "^1.0.0-alpha",
|
||||
|
@ -70,7 +71,6 @@
|
|||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
"@playwright/test": "^1.35.1",
|
||||
"@rallly/tsconfig": "*",
|
||||
"@types/accept-language-parser": "^1.5.3",
|
||||
|
@ -84,6 +84,7 @@
|
|||
"@typescript-eslint/eslint-plugin": "^5.21.0",
|
||||
"@typescript-eslint/parser": "^5.50.0",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^7.26.0",
|
||||
"eslint-config-next": "^13.0.1",
|
||||
"eslint-config-turbo": "^0.0.9",
|
||||
|
|
|
@ -5,15 +5,12 @@
|
|||
"alreadyRegistered": "Already registered? <a>Login →</a>",
|
||||
"applyToAllDates": "Apply to all dates",
|
||||
"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",
|
||||
"changeName": "Change name",
|
||||
"settings": "Settings",
|
||||
"changeNameDescription": "Enter a new name for this participant.",
|
||||
"changeNameInfo": "This will not affect any votes you have already made.",
|
||||
"close": "Close",
|
||||
"commentPlaceholder": "Leave a comment on this poll (visible to everyone)",
|
||||
"comments": "Comments",
|
||||
"continue": "Continue",
|
||||
"copied": "Copied",
|
||||
|
@ -69,7 +66,6 @@
|
|||
"noDatesSelected": "No dates selected",
|
||||
"notificationsDisabled": "Notifications have been disabled for <b>{title}</b>",
|
||||
"noVotes": "No one has voted for this option",
|
||||
"ok": "Ok",
|
||||
"optional": "optional",
|
||||
"preferences": "Preferences",
|
||||
"previousMonth": "Previous month",
|
||||
|
@ -125,7 +121,6 @@
|
|||
"languageDescription": "Change your preferred language",
|
||||
"dateAndTime": "Date & Time",
|
||||
"profileDescription": "Change your profile settings",
|
||||
"back": "Back",
|
||||
"dates": "Dates",
|
||||
"menu": "Menu",
|
||||
"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?",
|
||||
"yesTransfer": "Yes, transfer to current user",
|
||||
"noTransfer": "No, take me home",
|
||||
"createPollDescription": "Create an event and invite participants to vote on the best time to meet.",
|
||||
"share": "Share",
|
||||
"timeShownIn": "Times shown in {timeZone}",
|
||||
"timeShownInLocalTime": "Times shown in local time",
|
||||
|
@ -203,5 +197,21 @@
|
|||
"plan_prioritySupport": "Priority support",
|
||||
"becomeATranslator": "Help translate",
|
||||
"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 {
|
||||
BillingPlan,
|
||||
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 = ({
|
||||
annual,
|
||||
children,
|
||||
|
@ -178,9 +168,15 @@ export const ProPlan = ({
|
|||
defaults="Unlimited participants"
|
||||
/>
|
||||
</BillingPlanPerk>
|
||||
<Perk>
|
||||
<BillingPlanPerk>
|
||||
<Trans i18nKey="plan_finalizePolls" defaults="Finalize polls" />
|
||||
</Perk>
|
||||
</BillingPlanPerk>
|
||||
<BillingPlanPerk>
|
||||
<Trans
|
||||
i18nKey="planCustomizablePollSettings"
|
||||
defaults="Customizable poll settings"
|
||||
/>
|
||||
</BillingPlanPerk>
|
||||
<BillingPlanPerk>
|
||||
<Trans
|
||||
i18nKey="plan_extendedPollLife"
|
||||
|
|
|
@ -1,34 +1,23 @@
|
|||
import { trpc } from "@rallly/backend";
|
||||
import { ArrowLeftIcon } from "@rallly/icons";
|
||||
import { Button } from "@rallly/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@rallly/ui/card";
|
||||
import { Form } from "@rallly/ui/form";
|
||||
import { useRouter } from "next/router";
|
||||
import { useTranslation } from "next-i18next";
|
||||
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 { usePostHog } from "@/utils/posthog";
|
||||
|
||||
import {
|
||||
NewEventData,
|
||||
PollDetailsData,
|
||||
PollDetailsForm,
|
||||
PollOptionsData,
|
||||
PollOptionsForm,
|
||||
UserDetailsData,
|
||||
UserDetailsForm,
|
||||
} from "./forms";
|
||||
import Steps from "./steps";
|
||||
import { useUser } from "./user-provider";
|
||||
|
||||
type StepName = "eventDetails" | "options" | "userDetails";
|
||||
import { NewEventData, PollDetailsForm, PollOptionsForm } from "./forms";
|
||||
|
||||
const required = <T,>(v: T | undefined): T => {
|
||||
if (!v) {
|
||||
|
@ -46,192 +35,96 @@ export interface CreatePollPageProps {
|
|||
}
|
||||
|
||||
export const CreatePoll: React.FunctionComponent = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const session = useUser();
|
||||
|
||||
const steps: StepName[] = React.useMemo(
|
||||
() =>
|
||||
session.user.isGuest
|
||||
? ["eventDetails", "options", "userDetails"]
|
||||
: ["eventDetails", "options"],
|
||||
[session.user.isGuest],
|
||||
);
|
||||
|
||||
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 form = useForm<NewEventData>({
|
||||
defaultValues: {
|
||||
view: "month",
|
||||
options: [],
|
||||
hideScores: false,
|
||||
hideParticipants: false,
|
||||
disableComments: false,
|
||||
duration: 60,
|
||||
},
|
||||
});
|
||||
|
||||
const isBusy = isRedirecting || createPoll.isLoading;
|
||||
useFormPersist("new-poll", {
|
||||
watch: form.watch,
|
||||
setValue: form.setValue,
|
||||
});
|
||||
|
||||
const handleSubmit = async (
|
||||
data: PollDetailsData | PollOptionsData | UserDetailsData,
|
||||
) => {
|
||||
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,
|
||||
});
|
||||
};
|
||||
const posthog = usePostHog();
|
||||
const queryClient = trpc.useContext();
|
||||
const createPoll = trpc.polls.create.useMutation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="sm:p-8">
|
||||
<div className="my-4 flex justify-center">
|
||||
<Steps current={currentStepIndex} total={steps.length} />
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="pb-16"
|
||||
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>
|
||||
<Card className="mx-auto max-w-4xl rounded-none border-x-0 sm:rounded-md sm:border-x">
|
||||
<CardHeader>
|
||||
<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>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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"
|
||||
onClick={() => setIsWriting(true)}
|
||||
>
|
||||
<Trans i18nKey="commentPlaceholder" />
|
||||
<Trans
|
||||
i18nKey="commentPlaceholder"
|
||||
defaults="Leave a comment on this poll (visible to everyone)"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -9,6 +9,7 @@ import { useParticipants } from "@/components/participants-provider";
|
|||
import { PollStatusBadge } from "@/components/poll-status";
|
||||
import { TextSummary } from "@/components/text-summary";
|
||||
import { Trans } from "@/components/trans";
|
||||
import { IfParticipantsVisible } from "@/components/visibility";
|
||||
import { usePoll } from "@/contexts/poll";
|
||||
import { generateGradient } from "@/utils/color-hash";
|
||||
import { useDayjs } from "@/utils/dayjs";
|
||||
|
@ -88,15 +89,17 @@ export const EventCard = () => {
|
|||
{!poll.event ? (
|
||||
<PollSubheader />
|
||||
) : (
|
||||
<div className="mt-4">
|
||||
<div className="text-muted-foreground mb-2 text-sm">
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<Trans
|
||||
i18nKey="attendeeCount"
|
||||
defaults="{count, plural, one {# attendee} other {# attendees}}"
|
||||
values={{ count: attendees.length }}
|
||||
/>
|
||||
</div>
|
||||
<ParticipantAvatarBar participants={attendees} max={10} />
|
||||
<IfParticipantsVisible>
|
||||
<ParticipantAvatarBar participants={attendees} max={10} />
|
||||
</IfParticipantsVisible>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -3,5 +3,3 @@ export { PollDetailsForm } from "./poll-details-form";
|
|||
export type { PollOptionsData } from "./poll-options-form/poll-options-form";
|
||||
export { default as PollOptionsForm } from "./poll-options-form/poll-options-form";
|
||||
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 { Textarea } from "@rallly/ui/textarea";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import * as React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import { requiredString } from "../../utils/form-validation";
|
||||
import { PollFormProps } from "./types";
|
||||
import { Trans } from "@/components/trans";
|
||||
import { useFormValidation } from "@/utils/form-validation";
|
||||
|
||||
import { NewEventData } from "./types";
|
||||
|
||||
export interface PollDetailsData {
|
||||
title: string;
|
||||
|
@ -15,77 +16,71 @@ export interface PollDetailsData {
|
|||
description: string;
|
||||
}
|
||||
|
||||
export const PollDetailsForm: React.FunctionComponent<
|
||||
PollFormProps<PollDetailsData>
|
||||
> = ({ name, defaultValues, onSubmit, onChange, className }) => {
|
||||
export const PollDetailsForm = () => {
|
||||
const { t } = useTranslation();
|
||||
const form = useForm<PollDetailsData>({ defaultValues });
|
||||
const form = useFormContext<NewEventData>();
|
||||
|
||||
const { requiredString } = useFormValidation();
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = form;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (onChange) {
|
||||
const subscription = watch(onChange);
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}
|
||||
}, [onChange, watch]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
id={name}
|
||||
className={clsx("space-y-6", className)}
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
{/* <div className="mb-8">
|
||||
<h2 className="">
|
||||
<Trans i18nKey="eventDetails" defaults="Event Details" />
|
||||
</h2>
|
||||
<p className="leading-6 text-gray-500">
|
||||
<Trans
|
||||
i18nKey="eventDetailsDescription"
|
||||
defaults="What are you organzing?"
|
||||
<div className="grid gap-4 py-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
rules={{
|
||||
validate: requiredString(t("title")),
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel htmlFor="title">{t("title")}</FormLabel>
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
id="title"
|
||||
className={clsx("w-full", {
|
||||
"input-error": errors.title,
|
||||
})}
|
||||
placeholder={t("titlePlaceholder")}
|
||||
/>
|
||||
</p>
|
||||
</div> */}
|
||||
<FormItem>
|
||||
<FormLabel htmlFor="title">{t("title")}</FormLabel>
|
||||
<Input
|
||||
type="text"
|
||||
id="title"
|
||||
className={clsx("w-full", {
|
||||
"input-error": errors.title,
|
||||
})}
|
||||
placeholder={t("titlePlaceholder")}
|
||||
{...register("title", { validate: requiredString })}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<FormLabel>{t("location")}</FormLabel>
|
||||
<Input
|
||||
type="text"
|
||||
id="location"
|
||||
placeholder={t("locationPlaceholder")}
|
||||
{...register("location")}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<FormLabel htmlFor="description">{t("description")}</FormLabel>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder={t("descriptionPlaceholder")}
|
||||
rows={5}
|
||||
{...register("description")}
|
||||
/>
|
||||
</FormItem>
|
||||
</form>
|
||||
</Form>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormItem>
|
||||
<div>
|
||||
<FormLabel className="inline-block">{t("location")}</FormLabel>
|
||||
<span className="text-muted-foreground ml-1 text-sm">
|
||||
<Trans i18nKey="optionalLabel" defaults="(Optional)" />
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
id="location"
|
||||
placeholder={t("locationPlaceholder")}
|
||||
{...register("location")}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<div>
|
||||
<FormLabel className="inline-block" htmlFor="description">
|
||||
{t("description")}
|
||||
</FormLabel>
|
||||
<span className="text-muted-foreground ml-1 text-sm">
|
||||
<Trans i18nKey="optionalLabel" defaults="(Optional)" />
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder={t("descriptionPlaceholder")}
|
||||
rows={5}
|
||||
{...register("description")}
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { ChevronLeftIcon, ChevronRightIcon } from "@rallly/icons";
|
||||
import { Button } from "@rallly/ui/button";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import * as React from "react";
|
||||
|
||||
|
@ -15,22 +16,20 @@ const DateNavigationToolbar: React.FunctionComponent<
|
|||
> = ({ year, label, onPrevious, onToday, onNext }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex h-14 w-full shrink-0 items-center border-b px-4">
|
||||
<div className="grow">
|
||||
<span className="mr-2 text-sm font-bold text-gray-400">{year}</span>
|
||||
<span className="text-lg font-bold text-gray-700">{label}</span>
|
||||
<div className="flex h-14 w-full shrink-0 items-center px-4">
|
||||
<div className="grow font-semibold tracking-tight">
|
||||
<span className="mr-2 text-sm font-normal text-gray-500">{year}</span>
|
||||
<span className="font-semibold">{label}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="segment-button">
|
||||
<button type="button" onClick={onPrevious}>
|
||||
<ChevronLeftIcon className="h-5" />
|
||||
</button>
|
||||
<button type="button" onClick={onToday}>
|
||||
{t("today")}
|
||||
</button>
|
||||
<button type="button" onClick={onNext}>
|
||||
<ChevronRightIcon className="h-5" />
|
||||
</button>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button type="button" onClick={onPrevious}>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button onClick={onToday}>{t("today")}</Button>
|
||||
<Button onClick={onNext}>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
SparklesIcon,
|
||||
XIcon,
|
||||
} from "@rallly/icons";
|
||||
import { cn } from "@rallly/ui";
|
||||
import { Button } from "@rallly/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
@ -16,6 +17,7 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@rallly/ui/dropdown-menu";
|
||||
import { Switch } from "@rallly/ui/switch";
|
||||
import clsx from "clsx";
|
||||
import dayjs from "dayjs";
|
||||
import { useTranslation } from "next-i18next";
|
||||
|
@ -31,7 +33,6 @@ import {
|
|||
import CompactButton from "../../../compact-button";
|
||||
import DateCard from "../../../date-card";
|
||||
import { useHeadlessDatePicker } from "../../../headless-date-picker";
|
||||
import Switch from "../../../switch";
|
||||
import { DateTimeOption } from "..";
|
||||
import { DateTimePickerProps } from "../types";
|
||||
import { formatDateWithoutTime, formatDateWithoutTz } from "../utils";
|
||||
|
@ -88,8 +89,8 @@ const MonthCalendar: React.FunctionComponent<DateTimePickerProps> = ({
|
|||
});
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden lg:flex">
|
||||
<div className="shrink-0 border-b p-3 sm:p-4 lg:w-[440px] lg:border-b-0 lg:border-r">
|
||||
<div className="overflow-hidden md:flex">
|
||||
<div className="border-b p-3 sm:p-4 md:w-[400px] md:border-b-0 md:border-r">
|
||||
<div>
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="mb-3 flex items-center justify-center space-x-4">
|
||||
|
@ -98,7 +99,7 @@ const MonthCalendar: React.FunctionComponent<DateTimePickerProps> = ({
|
|||
title={t("previousMonth")}
|
||||
onClick={datepicker.prev}
|
||||
/>
|
||||
<div className="grow text-center text-lg font-medium">
|
||||
<div className="grow text-center font-semibold tracking-tight">
|
||||
{datepicker.label}
|
||||
</div>
|
||||
<Button
|
||||
|
@ -119,12 +120,12 @@ const MonthCalendar: React.FunctionComponent<DateTimePickerProps> = ({
|
|||
);
|
||||
})}
|
||||
</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) => {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={clsx("h-12", {
|
||||
className={clsx("h-11", {
|
||||
"border-r": (i + 1) % 7 !== 0,
|
||||
"border-b": i < datepicker.days.length - 7,
|
||||
})}
|
||||
|
@ -170,16 +171,25 @@ const MonthCalendar: React.FunctionComponent<DateTimePickerProps> = ({
|
|||
}
|
||||
}}
|
||||
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,
|
||||
"font-bold": day.today,
|
||||
"text-primary-600": day.today && !day.selected,
|
||||
"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.selected,
|
||||
"bg-gray-100 text-gray-400": day.isPast,
|
||||
"text-rose-600": day.today && !day.selected,
|
||||
"bg-gray-50 text-gray-500":
|
||||
day.outOfMonth && !day.isPast,
|
||||
"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>
|
||||
</button>
|
||||
</div>
|
||||
|
@ -209,11 +219,11 @@ const MonthCalendar: React.FunctionComponent<DateTimePickerProps> = ({
|
|||
<Switch
|
||||
data-testid="specify-times-switch"
|
||||
checked={isTimedEvent}
|
||||
onChange={(checked) => {
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
// convert dates to time slots
|
||||
onChange(
|
||||
options.map((option) => {
|
||||
options.map<DateTimeOption>((option) => {
|
||||
if (option.type === "timeSlot") {
|
||||
throw new Error(
|
||||
"Expected option to be a date but received timeSlot",
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
import { CalendarIcon, TableIcon } from "@rallly/icons";
|
||||
import { Form, FormItem } from "@rallly/ui/form";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from "@rallly/ui/card";
|
||||
import { FormField, FormMessage } from "@rallly/ui/form";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@rallly/ui/tabs";
|
||||
import { Trans, useTranslation } from "next-i18next";
|
||||
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 { useModal } from "../../modal";
|
||||
import TimeZonePicker from "../../time-zone-picker";
|
||||
import { PollFormProps } from "../types";
|
||||
import { NewEventData } from "../types";
|
||||
import MonthCalendar from "./month-calendar";
|
||||
import { DateTimeOption } from "./types";
|
||||
import WeekCalendar from "./week-calendar";
|
||||
|
@ -21,32 +23,11 @@ export type PollOptionsData = {
|
|||
options: DateTimeOption[];
|
||||
};
|
||||
|
||||
const PollOptionsForm: React.FunctionComponent<
|
||||
PollFormProps<PollOptionsData> & { title?: string }
|
||||
> = ({ name, defaultValues, onSubmit, onChange, title, className }) => {
|
||||
const PollOptionsForm = ({ children }: React.PropsWithChildren) => {
|
||||
const { t } = useTranslation();
|
||||
const form = useForm<PollOptionsData>({
|
||||
defaultValues: {
|
||||
options: [],
|
||||
duration: 30,
|
||||
timeZone: "",
|
||||
navigationDate: new Date().toISOString(),
|
||||
...defaultValues,
|
||||
},
|
||||
resolver: (values) => {
|
||||
return {
|
||||
values,
|
||||
errors:
|
||||
values.options.length === 0
|
||||
? {
|
||||
options: true,
|
||||
}
|
||||
: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
const form = useFormContext<NewEventData>();
|
||||
|
||||
const { control, handleSubmit, watch, setValue, formState } = form;
|
||||
const { watch, setValue, formState } = form;
|
||||
|
||||
const views = React.useMemo(() => {
|
||||
const res = [
|
||||
|
@ -71,7 +52,8 @@ const PollOptionsForm: React.FunctionComponent<
|
|||
[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 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(() => {
|
||||
if (watchOptions.length > 1) {
|
||||
const optionType = watchOptions[0].type;
|
||||
|
@ -124,115 +93,117 @@ const PollOptionsForm: React.FunctionComponent<
|
|||
}, [watchOptions, openDateOrTimeRangeModal]);
|
||||
|
||||
const watchNavigationDate = watch("navigationDate");
|
||||
const navigationDate = new Date(watchNavigationDate);
|
||||
|
||||
const [calendarHelpModal, openHelpModal] = useModal({
|
||||
overlayClosable: true,
|
||||
title: t("calendarHelpTitle"),
|
||||
description: t("calendarHelp"),
|
||||
okText: t("ok"),
|
||||
});
|
||||
const navigationDate = new Date(watchNavigationDate ?? Date.now());
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
id={name}
|
||||
className={clsx("w-full", className)}
|
||||
onSubmit={handleSubmit(onSubmit, openHelpModal)}
|
||||
>
|
||||
{calendarHelpModal}
|
||||
{dateOrTimeRangeModal}
|
||||
{/* <div className="mb-8">
|
||||
<h2 className="">
|
||||
<Trans i18nKey="dates" defaults="Dates" />
|
||||
</h2>
|
||||
<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>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col justify-between gap-4 sm:flex-row">
|
||||
<div>
|
||||
<CardTitle>
|
||||
<Trans i18nKey="calendar">Calendar</Trans>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<Trans i18nKey="selectPotentialDates">
|
||||
Select potential dates for your event
|
||||
</Trans>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
<selectedView.Component
|
||||
title={title}
|
||||
options={watchOptions}
|
||||
date={navigationDate}
|
||||
onNavigate={(date) => {
|
||||
setValue("navigationDate", date.toISOString());
|
||||
}}
|
||||
onChange={(options) => {
|
||||
setValue("options", options);
|
||||
if (
|
||||
options.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);
|
||||
}}
|
||||
<div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="view"
|
||||
render={({ field }) => (
|
||||
<Tabs value={field.value} onValueChange={field.onChange}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger className="grow" value="month">
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
<Trans i18nKey="monthView" />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className="grow" value="week">
|
||||
<TableIcon className="mr-2 h-4 w-4" />
|
||||
<Trans i18nKey="weekView" />
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</FormItem>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{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 {
|
||||
title?: string;
|
||||
options: DateTimeOption[];
|
||||
date: Date;
|
||||
date?: Date;
|
||||
onNavigate: (date: Date) => void;
|
||||
onChange: (options: DateTimeOption[]) => void;
|
||||
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 React from "react";
|
||||
import { Calendar } from "react-big-calendar";
|
||||
import { useMount } from "react-use";
|
||||
import { createBreakpoint } from "react-use";
|
||||
|
||||
import { getDuration } from "../../../utils/date-time-utils";
|
||||
import DateNavigationToolbar from "./date-navigation-toolbar";
|
||||
import dayjsLocalizer from "./dayjs-localizer";
|
||||
import { DateTimeOption, DateTimePickerProps } from "./types";
|
||||
import { formatDateWithoutTime, formatDateWithoutTz } from "./utils";
|
||||
import { formatDateWithoutTz } from "./utils";
|
||||
|
||||
const localizer = dayjsLocalizer(dayjs);
|
||||
|
||||
const useDevice = createBreakpoint({ desktop: 720, mobile: 360 });
|
||||
|
||||
const WeekCalendar: React.FunctionComponent<DateTimePickerProps> = ({
|
||||
title,
|
||||
options,
|
||||
onNavigate,
|
||||
date,
|
||||
onChange,
|
||||
duration,
|
||||
duration = 60,
|
||||
onChangeDuration,
|
||||
}) => {
|
||||
const [scrollToTime, setScrollToTime] = React.useState<Date>();
|
||||
const scrollToTime =
|
||||
options.length > 0
|
||||
? options[0].type === "timeSlot"
|
||||
? new Date(options[0].start)
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
useMount(() => {
|
||||
// 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());
|
||||
});
|
||||
const defaultView = useDevice() === "mobile" ? "day" : "week";
|
||||
|
||||
return (
|
||||
<Calendar
|
||||
events={options.map((option) => {
|
||||
if (option.type === "date") {
|
||||
return { title, start: new Date(option.date) };
|
||||
} else {
|
||||
return {
|
||||
title,
|
||||
start: new Date(option.start),
|
||||
end: new Date(option.end),
|
||||
};
|
||||
}
|
||||
})}
|
||||
culture="default"
|
||||
onNavigate={onNavigate}
|
||||
date={date}
|
||||
className="h-[calc(100vh-220px)] max-h-[800px] min-h-[400px] w-full"
|
||||
defaultView="week"
|
||||
views={["week"]}
|
||||
selectable={true}
|
||||
localizer={localizer}
|
||||
onSelectEvent={(event) => {
|
||||
onChange(
|
||||
options.filter(
|
||||
(option) =>
|
||||
!(
|
||||
option.type === "timeSlot" &&
|
||||
option.start === formatDateWithoutTz(event.start) &&
|
||||
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");
|
||||
}}
|
||||
/>
|
||||
<div className="relative flex h-[600px]">
|
||||
<Calendar
|
||||
className="absolute inset-0"
|
||||
events={options.map((option) => {
|
||||
if (option.type === "date") {
|
||||
return { start: new Date(option.date) };
|
||||
} else {
|
||||
return {
|
||||
start: new Date(option.start),
|
||||
end: new Date(option.end),
|
||||
};
|
||||
}
|
||||
})}
|
||||
culture="default"
|
||||
onNavigate={onNavigate}
|
||||
date={date}
|
||||
defaultView={defaultView}
|
||||
views={["week", "day"]}
|
||||
selectable={true}
|
||||
localizer={localizer}
|
||||
onSelectEvent={(event) => {
|
||||
onChange(
|
||||
options.filter(
|
||||
(option) =>
|
||||
!(
|
||||
option.type === "timeSlot" &&
|
||||
option.start === formatDateWithoutTz(event.start) &&
|
||||
event.end &&
|
||||
option.end === formatDateWithoutTz(event.end)
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
eventWrapper: function EventWraper(props) {
|
||||
const start = dayjs(props.event.start);
|
||||
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;
|
||||
});
|
||||
}}
|
||||
components={{
|
||||
toolbar: function Toolbar(props) {
|
||||
return (
|
||||
<span
|
||||
onClick={() => {
|
||||
if (!selectedOption) {
|
||||
onChange([
|
||||
...options,
|
||||
{
|
||||
type: "date",
|
||||
date: formatDateWithoutTime(date),
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
onChange(
|
||||
options.filter((option) => option !== selectedOption),
|
||||
);
|
||||
}
|
||||
<DateNavigationToolbar
|
||||
year={props.date.getFullYear()}
|
||||
label={props.label}
|
||||
onPrevious={() => {
|
||||
props.onNavigate("PREV");
|
||||
}}
|
||||
className={clsx(
|
||||
"inline-flex w-full items-center justify-center rounded-md py-2 text-sm hover:bg-gray-50 hover:text-gray-700",
|
||||
{
|
||||
"bg-green-50 text-green-600 hover:bg-green-50 hover:bg-opacity-75 hover:text-green-600":
|
||||
!!selectedOption,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<span className="mr-1 font-normal opacity-50">
|
||||
{dayjs(date).format("ddd")}
|
||||
</span>
|
||||
<span className="font-medium">{dayjs(date).format("DD")}</span>
|
||||
</span>
|
||||
onToday={() => {
|
||||
props.onNavigate("TODAY");
|
||||
}}
|
||||
onNext={() => {
|
||||
props.onNavigate("NEXT");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
timeSlotWrapper: function TimeSlotWrapper({
|
||||
children,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return <div className="h-8 text-xs text-gray-500">{children}</div>;
|
||||
},
|
||||
}}
|
||||
step={15}
|
||||
onSelectSlot={({ start, end, action }) => {
|
||||
// on select slot
|
||||
const startDate = new Date(start);
|
||||
const endDate = new Date(end);
|
||||
eventWrapper: function EventWraper(props) {
|
||||
const start = dayjs(props.event.start);
|
||||
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="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={{
|
||||
top: `calc(${props.style?.top}% + 4px)`,
|
||||
height: `calc(${props.style?.height}% - 8px)`,
|
||||
left: `${props.style?.xOffset}%`,
|
||||
width: `calc(${props.style?.width}%)`,
|
||||
}}
|
||||
>
|
||||
<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 = {
|
||||
type: "timeSlot",
|
||||
start: formatDateWithoutTz(startDate),
|
||||
duration: dayjs(endDate).diff(endDate, "minutes"),
|
||||
end: formatDateWithoutTz(endDate),
|
||||
};
|
||||
const newEvent: DateTimeOption = {
|
||||
type: "timeSlot",
|
||||
start: formatDateWithoutTz(startDate),
|
||||
duration: dayjs(endDate).diff(endDate, "minutes"),
|
||||
end: formatDateWithoutTz(endDate),
|
||||
};
|
||||
|
||||
if (action === "select") {
|
||||
const diff = dayjs(endDate).diff(startDate, "minutes");
|
||||
if (diff < 60 * 24) {
|
||||
onChangeDuration(diff);
|
||||
if (action === "select") {
|
||||
const diff = dayjs(endDate).diff(startDate, "minutes");
|
||||
if (diff < 60 * 24) {
|
||||
onChangeDuration(diff);
|
||||
}
|
||||
} else {
|
||||
newEvent.end = formatDateWithoutTz(
|
||||
dayjs(startDate).add(duration, "minutes").toDate(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
newEvent.end = formatDateWithoutTz(
|
||||
dayjs(startDate).add(duration, "minutes").toDate(),
|
||||
|
||||
const alreadyExists = options.some(
|
||||
(option) =>
|
||||
option.type === "timeSlot" &&
|
||||
option.start === newEvent.start &&
|
||||
option.end === newEvent.end,
|
||||
);
|
||||
}
|
||||
|
||||
const alreadyExists = options.some(
|
||||
(option) =>
|
||||
option.type === "timeSlot" &&
|
||||
option.start === newEvent.start &&
|
||||
option.end === newEvent.end,
|
||||
);
|
||||
|
||||
if (!alreadyExists) {
|
||||
onChange([...options, newEvent]);
|
||||
}
|
||||
}}
|
||||
scrollToTime={scrollToTime}
|
||||
/>
|
||||
if (!alreadyExists) {
|
||||
onChange([...options, newEvent]);
|
||||
}
|
||||
}}
|
||||
scrollToTime={scrollToTime}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
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 { PollOptionsData } from "./poll-options-form/poll-options-form";
|
||||
import { UserDetailsData } from "./user-details-form";
|
||||
|
||||
export interface NewEventData {
|
||||
currentStep: number;
|
||||
eventDetails?: Partial<PollDetailsData>;
|
||||
options?: Partial<PollOptionsData>;
|
||||
userDetails?: Partial<UserDetailsData>;
|
||||
}
|
||||
export type NewEventData = PollDetailsData &
|
||||
PollOptionsData &
|
||||
PollSettingsFormData;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export interface PollFormProps<T extends Record<string, any>> {
|
||||
onSubmit: (data: T) => void;
|
||||
onSubmit?: (data: T) => void;
|
||||
onChange?: (data: Partial<T>) => void;
|
||||
defaultValues?: Partial<T>;
|
||||
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;
|
||||
outOfMonth: boolean;
|
||||
today: boolean;
|
||||
isPast: boolean;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
|
@ -61,6 +62,7 @@ export const useHeadlessDatePicker = (
|
|||
outOfMonth: d.month() !== currentMonth,
|
||||
today: d.isSame(today, "day"),
|
||||
selected: selection.some((selectedDate) => d.isSame(selectedDate, "day")),
|
||||
isPast: d.isBefore(today, "day"),
|
||||
});
|
||||
i++;
|
||||
reachedEnd =
|
||||
|
|
|
@ -7,7 +7,7 @@ export const styleMenuItem = ({
|
|||
active: boolean;
|
||||
selected: boolean;
|
||||
}) =>
|
||||
clsx("menu-item", {
|
||||
clsx("menu-item text-sm", {
|
||||
"font-medium": selected,
|
||||
"bg-blue-50": active,
|
||||
});
|
||||
|
|
|
@ -2,6 +2,9 @@ import { trpc } from "@rallly/backend";
|
|||
import { Participant, Vote, VoteType } from "@rallly/database";
|
||||
import * as React from "react";
|
||||
|
||||
import { useVisibility } from "@/components/visibility";
|
||||
import { usePermissions } from "@/contexts/permissions";
|
||||
|
||||
import { useRequiredContext } from "./use-required-context";
|
||||
|
||||
const ParticipantsContext = React.createContext<{
|
||||
|
@ -40,9 +43,6 @@ export const ParticipantsProvider: React.FunctionComponent<{
|
|||
});
|
||||
});
|
||||
};
|
||||
|
||||
// TODO (Luke Vella) [2022-05-18]: Add mutations here
|
||||
|
||||
if (!participants) {
|
||||
return null;
|
||||
}
|
||||
|
@ -53,3 +53,20 @@ export const ParticipantsProvider: React.FunctionComponent<{
|
|||
</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}>
|
||||
<PollComponent />
|
||||
</Card>
|
||||
<hr className="my-4" />
|
||||
<Card fullWidthOnMobile={false}>
|
||||
<Discussion />
|
||||
</Card>
|
||||
{poll.disableComments ? null : (
|
||||
<>
|
||||
<hr className="my-4" />
|
||||
<Card fullWidthOnMobile={false}>
|
||||
<Discussion />
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -15,7 +15,10 @@ import { useRole } from "@/contexts/role";
|
|||
import { TimePreferences } from "@/contexts/time-preferences";
|
||||
|
||||
import { useNewParticipantModal } from "../new-participant-modal";
|
||||
import { useParticipants } from "../participants-provider";
|
||||
import {
|
||||
useParticipants,
|
||||
useVisibleParticipants,
|
||||
} from "../participants-provider";
|
||||
import { usePoll } from "../poll-context";
|
||||
import ParticipantRow from "./desktop-poll/participant-row";
|
||||
import ParticipantRowForm from "./desktop-poll/participant-row-form";
|
||||
|
@ -101,6 +104,8 @@ const Poll: React.FunctionComponent = () => {
|
|||
|
||||
const updateParticipant = useUpdateParticipantMutation();
|
||||
const showNewParticipantModal = useNewParticipantModal();
|
||||
|
||||
const visibleParticipants = useVisibleParticipants();
|
||||
return (
|
||||
<PollContext.Provider
|
||||
value={{
|
||||
|
@ -218,9 +223,9 @@ const Poll: React.FunctionComponent = () => {
|
|||
}}
|
||||
/>
|
||||
) : null}
|
||||
{participants.length > 0 ? (
|
||||
{visibleParticipants.length > 0 ? (
|
||||
<div className="py-2">
|
||||
{participants.map((participant, i) => {
|
||||
{visibleParticipants.map((participant, i) => {
|
||||
return (
|
||||
<ParticipantRow
|
||||
key={i}
|
||||
|
|
|
@ -48,9 +48,7 @@ const PollHeader: React.FunctionComponent = () => {
|
|||
{option.type === "timeSlot" ? (
|
||||
<TimeRange start={option.startTime} end={option.endTime} />
|
||||
) : null}
|
||||
<div className="flex justify-center">
|
||||
<ConnectedScoreSummary optionId={option.optionId} />
|
||||
</div>
|
||||
<ConnectedScoreSummary optionId={option.optionId} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
@ -2,6 +2,7 @@ import {
|
|||
ChevronDownIcon,
|
||||
DownloadIcon,
|
||||
PencilIcon,
|
||||
Settings2Icon,
|
||||
SettingsIcon,
|
||||
TableIcon,
|
||||
TrashIcon,
|
||||
|
@ -57,6 +58,13 @@ const ManagePoll: React.FunctionComponent<{
|
|||
</DropdownMenuItemIconLabel>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/poll/${poll.id}/edit-settings`}>
|
||||
<DropdownMenuItemIconLabel icon={Settings2Icon}>
|
||||
<Trans i18nKey="editSettings" defaults="Edit settings" />
|
||||
</DropdownMenuItemIconLabel>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={exportToCsv}>
|
||||
<DropdownMenuItemIconLabel icon={DownloadIcon}>
|
||||
|
|
|
@ -15,7 +15,10 @@ import { TimePreferences } from "@/contexts/time-preferences";
|
|||
|
||||
import { styleMenuItem } from "../menu-styles";
|
||||
import { useNewParticipantModal } from "../new-participant-modal";
|
||||
import { useParticipants } from "../participants-provider";
|
||||
import {
|
||||
useParticipants,
|
||||
useVisibleParticipants,
|
||||
} from "../participants-provider";
|
||||
import { useUser } from "../user-provider";
|
||||
import GroupedOptions from "./mobile-poll/grouped-options";
|
||||
import { normalizeVotes, useUpdateParticipantMutation } from "./mutations";
|
||||
|
@ -59,6 +62,7 @@ const MobilePoll: React.FunctionComponent = () => {
|
|||
}
|
||||
});
|
||||
|
||||
const visibleParticipants = useVisibleParticipants();
|
||||
const selectedParticipant = selectedParticipantId
|
||||
? getParticipantById(selectedParticipantId)
|
||||
: undefined;
|
||||
|
@ -149,7 +153,7 @@ const MobilePoll: React.FunctionComponent = () => {
|
|||
<Listbox.Option value={undefined} className={styleMenuItem}>
|
||||
{t("participantCount", { count: participants.length })}
|
||||
</Listbox.Option>
|
||||
{participants.map((participant) => (
|
||||
{visibleParticipants.map((participant) => (
|
||||
<Listbox.Option
|
||||
key={participant.id}
|
||||
value={participant.id}
|
||||
|
|
|
@ -5,6 +5,8 @@ import { AnimatePresence, m } from "framer-motion";
|
|||
import { useTranslation } from "next-i18next";
|
||||
import * as React from "react";
|
||||
|
||||
import { IfParticipantsVisible } from "@/components/visibility";
|
||||
|
||||
import { useParticipants } from "../../participants-provider";
|
||||
import { ConnectedScoreSummary } from "../score-summary";
|
||||
import UserAvatar from "../user-avatar";
|
||||
|
@ -212,17 +214,19 @@ const PollOption: React.FunctionComponent<PollOptionProps> = ({
|
|||
setExpanded((value) => !value);
|
||||
}}
|
||||
>
|
||||
{participants.length > 0 ? (
|
||||
<SummarizedParticipantList participants={participants} />
|
||||
) : null}
|
||||
<ChevronDownIcon
|
||||
className={clsx(
|
||||
"h-5 shrink-0 text-gray-500 transition-transform",
|
||||
{
|
||||
"-rotate-180": expanded,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
<IfParticipantsVisible>
|
||||
{participants.length > 0 ? (
|
||||
<SummarizedParticipantList participants={participants} />
|
||||
) : null}
|
||||
<ChevronDownIcon
|
||||
className={clsx(
|
||||
"h-5 shrink-0 text-gray-500 transition-transform",
|
||||
{
|
||||
"-rotate-180": expanded,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
</IfParticipantsVisible>
|
||||
</m.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
|
|
@ -5,6 +5,7 @@ import * as React from "react";
|
|||
import { usePrevious } from "react-use";
|
||||
|
||||
import { usePoll } from "@/components/poll-context";
|
||||
import { IfScoresVisible } from "@/components/visibility";
|
||||
|
||||
export interface PopularityScoreProps {
|
||||
yesScore: number;
|
||||
|
@ -20,12 +21,14 @@ export const ConnectedScoreSummary: React.FunctionComponent<{
|
|||
const { yes, ifNeedBe } = getScore(optionId);
|
||||
const score = yes + ifNeedBe;
|
||||
return (
|
||||
<ScoreSummary
|
||||
yesScore={yes}
|
||||
ifNeedBeScore={ifNeedBe}
|
||||
highScore={highScore}
|
||||
highlight={score === highScore && score > 1}
|
||||
/>
|
||||
<IfScoresVisible>
|
||||
<ScoreSummary
|
||||
yesScore={yes}
|
||||
ifNeedBeScore={ifNeedBe}
|
||||
highScore={highScore}
|
||||
highlight={score === highScore && score > 1}
|
||||
/>
|
||||
</IfScoresVisible>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -109,7 +109,7 @@ export const YouAvatar = () => {
|
|||
const you = t("you");
|
||||
return (
|
||||
<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]}
|
||||
</span>
|
||||
{t("you")}
|
||||
|
|
|
@ -202,7 +202,7 @@ const TimeZonePicker: React.FunctionComponent<{
|
|||
}}
|
||||
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">
|
||||
{!query ? selectedTimeZone.label : null}
|
||||
</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) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (role === "admin" && user.id === poll.userId) {
|
||||
return true;
|
||||
}
|
||||
|
@ -37,7 +36,7 @@ export const usePermissions = () => {
|
|||
if (
|
||||
participant &&
|
||||
(participant.userId === user.id ||
|
||||
participant.userId === context.userId)
|
||||
(context.userId && participant.userId === context.userId))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
|
|
@ -37,6 +37,9 @@ export const TimePreferences = () => {
|
|||
};
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.userPreferences.get.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
if (data === undefined) {
|
||||
|
|
|
@ -10,6 +10,7 @@ import { Poll } from "@/components/poll";
|
|||
import { LegacyPollContextProvider } from "@/components/poll/poll-context-provider";
|
||||
import { Trans } from "@/components/trans";
|
||||
import { useUser } from "@/components/user-provider";
|
||||
import { VisibilityProvider } from "@/components/visibility";
|
||||
import { PermissionsContext } from "@/contexts/permissions";
|
||||
import { usePoll } from "@/contexts/poll";
|
||||
import { getStaticTranslations } from "@/utils/with-page-translations";
|
||||
|
@ -82,52 +83,54 @@ const Page = () => {
|
|||
return (
|
||||
<Prefetch>
|
||||
<LegacyPollContextProvider>
|
||||
<div className="">
|
||||
<svg
|
||||
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
|
||||
id="1f932ae7-37de-4c0a-a8b0-a6e3b4d44b84"
|
||||
width={240}
|
||||
height={240}
|
||||
x="50%"
|
||||
y={-1}
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<path d="M.5 240V.5H240" fill="none" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
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">
|
||||
<GoToApp />
|
||||
<Poll />
|
||||
<div className="mt-4 space-y-4 text-center text-gray-500">
|
||||
<div className="py-8">
|
||||
<Trans
|
||||
defaults="Powered by <a>{name}</a>"
|
||||
i18nKey="poweredByRallly"
|
||||
values={{ name: "rallly.co" }}
|
||||
components={{
|
||||
a: (
|
||||
<Link
|
||||
className="hover:text-primary-600 rounded-none border-b border-b-gray-500 font-semibold"
|
||||
href="https://rallly.co"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<VisibilityProvider>
|
||||
<div className="">
|
||||
<svg
|
||||
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
|
||||
id="1f932ae7-37de-4c0a-a8b0-a6e3b4d44b84"
|
||||
width={240}
|
||||
height={240}
|
||||
x="50%"
|
||||
y={-1}
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<path d="M.5 240V.5H240" fill="none" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
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">
|
||||
<GoToApp />
|
||||
<Poll />
|
||||
<div className="mt-4 space-y-4 text-center text-gray-500">
|
||||
<div className="py-8">
|
||||
<Trans
|
||||
defaults="Powered by <a>{name}</a>"
|
||||
i18nKey="poweredByRallly"
|
||||
values={{ name: "rallly.co" }}
|
||||
components={{
|
||||
a: (
|
||||
<Link
|
||||
className="hover:text-primary-600 rounded-none border-b border-b-gray-500 font-semibold"
|
||||
href="https://rallly.co"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VisibilityProvider>
|
||||
</LegacyPollContextProvider>
|
||||
</Prefetch>
|
||||
);
|
||||
|
|
|
@ -7,9 +7,15 @@ import {
|
|||
CardHeader,
|
||||
CardTitle,
|
||||
} 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 { PollDetailsForm } from "@/components/forms/poll-details-form";
|
||||
import {
|
||||
PollDetailsData,
|
||||
PollDetailsForm,
|
||||
} from "@/components/forms/poll-details-form";
|
||||
import { getPollLayout } from "@/components/layouts/poll-layout";
|
||||
import { useUpdatePollMutation } from "@/components/poll/mutations";
|
||||
import { usePoll } from "@/components/poll-context";
|
||||
|
@ -23,53 +29,61 @@ const Page: NextPageWithLayout = () => {
|
|||
const { mutate: updatePollMutation, isLoading: isUpdating } =
|
||||
useUpdatePollMutation();
|
||||
const router = useRouter();
|
||||
|
||||
const pollLink = `/poll/${poll.id}`;
|
||||
|
||||
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 (
|
||||
<Card className="mx-auto max-w-4xl">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey="editDetails" defaults="Edit details" />
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<Trans
|
||||
i18nKey="editDetailsDescription"
|
||||
defaults="Change the details of your event."
|
||||
/>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PollDetailsForm
|
||||
name="updateDetails"
|
||||
defaultValues={{
|
||||
title: poll.title,
|
||||
location: poll.location ?? "",
|
||||
description: poll.description ?? "",
|
||||
}}
|
||||
onSubmit={(data) => {
|
||||
//submit
|
||||
updatePollMutation(
|
||||
{ urlId, ...data },
|
||||
{ onSuccess: redirectBackToPoll },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-between">
|
||||
<Button onClick={redirectBackToPoll}>
|
||||
<Trans i18nKey="cancel" />
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isUpdating}
|
||||
form="updateDetails"
|
||||
variant="primary"
|
||||
>
|
||||
<Trans i18nKey="save" />
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="mx-auto max-w-3xl"
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
//submit
|
||||
updatePollMutation(
|
||||
{ urlId, ...data },
|
||||
{ onSuccess: redirectBackToPoll },
|
||||
);
|
||||
})}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey="editDetails" defaults="Edit details" />
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<Trans
|
||||
i18nKey="editDetailsDescription"
|
||||
defaults="Change the details of your event."
|
||||
/>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PollDetailsForm />
|
||||
</CardContent>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,16 +1,19 @@
|
|||
import { Button } from "@rallly/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@rallly/ui/card";
|
||||
import { Form } from "@rallly/ui/form";
|
||||
import dayjs from "dayjs";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
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 { getPollLayout } from "@/components/layouts/poll-layout";
|
||||
import { useModalContext } from "@/components/modal/modal-provider";
|
||||
|
@ -37,116 +40,116 @@ const Page: NextPageWithLayout = () => {
|
|||
const { t } = useTranslation();
|
||||
const modalContext = useModalContext();
|
||||
const router = useRouter();
|
||||
const pollLink = `/poll/${poll.id}`;
|
||||
|
||||
const redirectBackToPoll = () => {
|
||||
router.replace(`/poll/${poll.id}`);
|
||||
router.push(pollLink);
|
||||
};
|
||||
return (
|
||||
<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>
|
||||
<CardContent>
|
||||
<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();
|
||||
const form = useForm<PollOptionsData>({
|
||||
defaultValues: {
|
||||
navigationDate: dayjs(poll.options[0].start).utc().format("YYYY-MM-DD"),
|
||||
view: "month",
|
||||
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"),
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-between">
|
||||
<Button onClick={redirectBackToPoll}>
|
||||
<Trans i18nKey="cancel" />
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isUpdating}
|
||||
form="pollOptions"
|
||||
variant="primary"
|
||||
>
|
||||
<Trans i18nKey="save" />
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
: {
|
||||
type: "date",
|
||||
date: start.format("YYYY-MM-DD"),
|
||||
};
|
||||
}),
|
||||
timeZone: poll.timeZone ?? "",
|
||||
duration: poll.options[0].duration || 60,
|
||||
},
|
||||
});
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((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();
|
||||
}
|
||||
})}
|
||||
>
|
||||
<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,
|
||||
select,
|
||||
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 {
|
||||
|
@ -88,7 +88,7 @@
|
|||
}
|
||||
.btn.btn-disabled {
|
||||
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 {
|
||||
text-shadow: rgb(0 0 0 / 20%) 0px 1px 1px;
|
||||
|
@ -163,7 +163,7 @@
|
|||
}
|
||||
|
||||
.rbc-today {
|
||||
@apply bg-blue-50 bg-opacity-50;
|
||||
@apply bg-gray-50 bg-opacity-50;
|
||||
}
|
||||
|
||||
.rbc-day-slot .rbc-time-slot {
|
||||
|
@ -176,7 +176,7 @@
|
|||
.rbc-time-header.rbc-overflowing,
|
||||
.rbc-time-header-content,
|
||||
.rbc-header {
|
||||
@apply border-gray-200;
|
||||
@apply border-gray-100;
|
||||
}
|
||||
|
||||
.rbc-time-content {
|
||||
|
@ -187,10 +187,6 @@
|
|||
@apply hidden;
|
||||
}
|
||||
|
||||
.rbc-label.rbc-time-header-gutter {
|
||||
@apply border-b;
|
||||
}
|
||||
|
||||
.rbc-current-time-indicator {
|
||||
@apply bg-rose-400;
|
||||
}
|
||||
|
@ -198,22 +194,35 @@
|
|||
.rbc-header + .rbc-header {
|
||||
@apply border-l-0;
|
||||
}
|
||||
.rbc-time-slot {
|
||||
@apply pl-2 pt-1;
|
||||
}
|
||||
|
||||
.rbc-header a {
|
||||
@apply block h-full w-full p-1 no-underline hover:text-gray-800;
|
||||
.rbc-timeslot-group {
|
||||
@apply border-gray-100;
|
||||
}
|
||||
|
||||
.rbc-day-slot .rbc-time-slot {
|
||||
@apply border-dashed border-gray-50;
|
||||
}
|
||||
|
||||
.rbc-day-slot .rbc-events-container {
|
||||
@apply mr-2;
|
||||
}
|
||||
.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 {
|
||||
@apply bg-white text-rose-600;
|
||||
}
|
||||
|
||||
.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 { data } = trpc.userPreferences.get.useQuery();
|
||||
|
||||
useAsync(async () => {
|
||||
const state = useAsync(async () => {
|
||||
const locale = await localeConfig.import();
|
||||
const localeTimeFormat = localeConfig.timeFormat;
|
||||
const timeFormat = data?.timeFormat ?? localeConfig.timeFormat;
|
||||
|
@ -205,6 +205,11 @@ export const DayjsProvider: React.FunctionComponent<{
|
|||
|
||||
const preferredTimeZone = data?.timeZone ?? locale.timeZone;
|
||||
|
||||
if (state.loading) {
|
||||
// wait for locale to load before rendering
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DayjsContext.Provider
|
||||
value={{
|
||||
|
|
|
@ -16,7 +16,7 @@ export const useFormValidation = () => {
|
|||
|
||||
return {
|
||||
requiredString: (name?: string) => (value: string) => {
|
||||
if (!value.trim()) {
|
||||
if (!value || !value.trim()) {
|
||||
return t("requiredString", { name });
|
||||
}
|
||||
},
|
||||
|
|
|
@ -23,11 +23,11 @@ test.describe.serial(() => {
|
|||
|
||||
await expect(page.getByTestId("poll-title")).toHaveText("Monthly Meetup");
|
||||
|
||||
const { email } = await mailServer.captureOne("john.doe@example.com", {
|
||||
wait: 5000,
|
||||
});
|
||||
// const { email } = await mailServer.captureOne("john.doe@example.com", {
|
||||
// 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
|
||||
|
@ -41,6 +41,6 @@ test.describe.serial(() => {
|
|||
|
||||
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 page = await context.newPage();
|
||||
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 }) => {
|
||||
const context = await browser.newContext({ locale: "mt" });
|
||||
const page = await context.newPage();
|
||||
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.click('text="Continue"');
|
||||
|
||||
await page.click('[title="Next month"]');
|
||||
|
||||
// Select a few days
|
||||
|
@ -35,14 +33,6 @@ export class NewPollPage {
|
|||
await page.click("text=/^10$/");
|
||||
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"');
|
||||
|
||||
return new PollPage(page);
|
||||
|
|
|
@ -38,6 +38,19 @@ const getPollIdFromAdminUrlId = async (urlId: string) => {
|
|||
return res.id;
|
||||
};
|
||||
|
||||
const getPro = async (userId: string) => {
|
||||
return Boolean(
|
||||
await prisma.userPaymentData.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
endDate: {
|
||||
gt: new Date(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const polls = router({
|
||||
demo,
|
||||
participants,
|
||||
|
@ -51,12 +64,9 @@ export const polls = router({
|
|||
timeZone: z.string().optional(),
|
||||
location: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
user: z
|
||||
.object({
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
hideParticipants: z.boolean().optional(),
|
||||
hideScores: z.boolean().optional(),
|
||||
disableComments: z.boolean().optional(),
|
||||
options: z
|
||||
.object({
|
||||
startDate: z.string(),
|
||||
|
@ -70,27 +80,8 @@ export const polls = router({
|
|||
const adminToken = nanoid();
|
||||
const participantUrlId = 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) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
email = user.email;
|
||||
name = user.name;
|
||||
}
|
||||
const isPro = await getPro(ctx.user.id);
|
||||
|
||||
const poll = await prisma.poll.create({
|
||||
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}`);
|
||||
|
||||
if (email && name) {
|
||||
await sendEmail("NewPollEmail", {
|
||||
to: email,
|
||||
subject: `Let's find a date for ${poll.title}`,
|
||||
props: {
|
||||
title: poll.title,
|
||||
name,
|
||||
adminLink: pollLink,
|
||||
participantLink,
|
||||
},
|
||||
if (ctx.user.isGuest === false) {
|
||||
const user = await prisma.user.findUnique({
|
||||
select: { email: true, name: true },
|
||||
where: { id: ctx.user.id },
|
||||
});
|
||||
|
||||
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 };
|
||||
|
@ -168,11 +173,16 @@ export const polls = router({
|
|||
optionsToDelete: z.string().array().optional(),
|
||||
optionsToAdd: z.string().array().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 isPro = await getPro(ctx.user.id);
|
||||
|
||||
if (input.optionsToDelete && input.optionsToDelete.length > 0) {
|
||||
await prisma.option.deleteMany({
|
||||
where: {
|
||||
|
@ -215,6 +225,13 @@ export const polls = router({
|
|||
description: input.description,
|
||||
timeZone: input.timeZone,
|
||||
closed: input.closed,
|
||||
...(isPro
|
||||
? {
|
||||
hideScores: input.hideScores,
|
||||
hideParticipants: input.hideParticipants,
|
||||
disableComments: input.disableComments,
|
||||
}
|
||||
: undefined),
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
@ -378,6 +395,9 @@ export const polls = router({
|
|||
participantUrlId: true,
|
||||
closed: true,
|
||||
legacy: true,
|
||||
hideParticipants: true,
|
||||
disableComments: true,
|
||||
hideScores: true,
|
||||
demo: true,
|
||||
options: {
|
||||
orderBy: {
|
||||
|
|
|
@ -12,11 +12,12 @@ export const comments = router({
|
|||
.input(
|
||||
z.object({
|
||||
pollId: z.string(),
|
||||
hideParticipants: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input: { pollId } }) => {
|
||||
.query(async ({ input: { pollId, hideParticipants }, ctx }) => {
|
||||
return await prisma.comment.findMany({
|
||||
where: { pollId },
|
||||
where: { pollId, userId: hideParticipants ? ctx.user.id : undefined },
|
||||
orderBy: [
|
||||
{
|
||||
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")
|
||||
}
|
||||
|
||||
enum ParticipantVisibility {
|
||||
full
|
||||
scoresOnly
|
||||
limited
|
||||
|
||||
@@map("participant_visibility")
|
||||
}
|
||||
|
||||
model Poll {
|
||||
id String @id @unique @map("id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
@ -88,6 +96,9 @@ model Poll {
|
|||
adminUrlId String @unique @map("admin_url_id")
|
||||
eventId String? @map("event_id")
|
||||
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)
|
||||
@@map("polls")
|
||||
|
|
|
@ -45,13 +45,13 @@ module.exports = {
|
|||
background: colors.rose["50"],
|
||||
foreground: colors.rose["50"],
|
||||
},
|
||||
background: colors.gray["100"],
|
||||
background: colors.white,
|
||||
foreground: colors.gray["800"],
|
||||
accent: {
|
||||
DEFAULT: colors.gray["100"],
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: colors.gray["200"],
|
||||
DEFAULT: colors.gray["100"],
|
||||
background: colors.gray["50"],
|
||||
foreground: colors.gray["500"],
|
||||
},
|
||||
|
|
|
@ -23,7 +23,7 @@ const buttonVariants = cva(
|
|||
},
|
||||
size: {
|
||||
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",
|
||||
},
|
||||
},
|
||||
|
|
|
@ -24,7 +24,7 @@ const CardHeader = React.forwardRef<
|
|||
<div
|
||||
ref={ref}
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
|
@ -39,7 +39,7 @@ const CardTitle = React.forwardRef<
|
|||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
"font-semibold tracking-tight sm:text-lg sm:leading-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
@ -63,7 +63,7 @@ const CardContent = React.forwardRef<
|
|||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ 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";
|
||||
|
||||
|
@ -74,7 +74,7 @@ const CardFooter = React.forwardRef<
|
|||
<div
|
||||
ref={ref}
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
@ -42,8 +42,9 @@ const DialogContent = React.forwardRef<
|
|||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
size?: "sm" | "md" | "lg";
|
||||
hideCloseButton?: boolean;
|
||||
}
|
||||
>(({ className, children, size = "md", ...props }, ref) => (
|
||||
>(({ className, children, size = "md", hideCloseButton, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
|
@ -61,8 +62,12 @@ const DialogContent = React.forwardRef<
|
|||
>
|
||||
{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">
|
||||
<XIcon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
{!hideCloseButton ? (
|
||||
<>
|
||||
<XIcon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</>
|
||||
) : null}
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
|
|
|
@ -78,7 +78,7 @@ const FormItem = React.forwardRef<
|
|||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -10,7 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||
<input
|
||||
type={type}
|
||||
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,
|
||||
)}
|
||||
ref={ref}
|
||||
|
|
|
@ -6,16 +6,17 @@
|
|||
"types": "src/index.ts",
|
||||
"dependencies": {
|
||||
"@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-dialog": "^1.0.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.4",
|
||||
"@radix-ui/react-label": "^2.0.1",
|
||||
"@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-slot": "^1.0.1",
|
||||
"@radix-ui/react-switch": "^1.0.2",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.6",
|
||||
"@rallly/icons": "*",
|
||||
"@rallly/languages": "*",
|
||||
"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 (
|
||||
<textarea
|
||||
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,
|
||||
)}
|
||||
ref={ref}
|
||||
|
|
28
yarn.lock
28
yarn.lock
|
@ -2947,6 +2947,21 @@
|
|||
"@radix-ui/react-use-previous" "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":
|
||||
version "1.0.6"
|
||||
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"
|
||||
integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==
|
||||
|
||||
react-big-calendar@^1.5.0:
|
||||
version "1.6.8"
|
||||
resolved "https://registry.npmjs.org/react-big-calendar/-/react-big-calendar-1.6.8.tgz"
|
||||
integrity sha512-uDuHoqH5/Wnk3tBxXnrXQD5w9FEofd1Ch1vMpLuv2LRBqrF2u7FcIruc80urizrkxcDWrTGxa7bsr0a9bby4Mw==
|
||||
react-big-calendar@^1.8.1:
|
||||
version "1.8.1"
|
||||
resolved "https://registry.yarnpkg.com/react-big-calendar/-/react-big-calendar-1.8.1.tgz#07886a66086fcae16934572c5ace8c4c433dbbed"
|
||||
integrity sha512-yEiScxReMrRCc0qFdZIKY/L6+argK4ZiYzLk5bck8CRYVbHjCCb/6Ictv42kPs/g3Q4RIb8+GUjDzk/uFtu76Q==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.20.7"
|
||||
clsx "^1.2.1"
|
||||
|
@ -8415,6 +8430,11 @@ react-email@^1.9.1:
|
|||
shelljs "0.8.5"
|
||||
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:
|
||||
version "7.43.2"
|
||||
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