Allow users to customize poll behaviour (#785)

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

View file

@ -23,5 +23,7 @@
"howToUpgrade": "How do I upgrade to a paid plan?",
"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"
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
const form = useForm<NewEventData>({
defaultValues: {
view: "month",
options: [],
hideScores: false,
hideParticipants: false,
disableComments: false,
duration: 60,
},
});
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);
useFormPersist("new-poll", {
watch: form.watch,
setValue: form.setValue,
});
const posthog = usePostHog();
const queryClient = trpc.useContext();
const createPoll = trpc.polls.create.useMutation({
const createPoll = trpc.polls.create.useMutation();
return (
<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) => {
setIsRedirecting(true);
posthog?.capture("created poll", {
pollId: res.id,
numberOfOptions: formData.options?.options?.length,
optionsView: formData?.options?.view,
numberOfOptions: formData.options?.length,
optionsView: formData?.view,
});
queryClient.polls.list.invalidate();
router.replace(`/poll/${res.id}`);
},
});
const isBusy = isRedirecting || createPoll.isLoading;
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,
});
};
return (
<div>
<div className="sm:p-8">
<div className="my-4 flex justify-center">
<Steps current={currentStepIndex} total={steps.length} />
</div>
<Card className="mx-auto max-w-4xl rounded-none border-x-0 sm:rounded-md sm:border-x">
},
);
})}
>
<div className="mx-auto max-w-4xl space-y-4 p-2 sm:p-8">
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey="newPoll" />
<Trans i18nKey="event" defaults="Event" />
</CardTitle>
<CardDescription>
<Trans
i18nKey="createPollDescription"
defaults="Create an event and invite participants to vote on the best time to meet."
i18nKey="describeYourEvent"
defaults="Describe what your event is about"
/>
</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 className="space-y-4">
<PollDetailsForm />
</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>
<PollOptionsForm />
<PollSettingsForm />
<hr />
<Button
loading={form.formState.isSubmitting}
size="lg"
type="submit"
className="w-full"
variant="primary"
>
<Trans i18nKey="createPoll" />
</Button>
</div>
</div>
</form>
</Form>
);
};

View file

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

View file

@ -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>
<IfParticipantsVisible>
<ParticipantAvatarBar participants={attendees} max={10} />
</IfParticipantsVisible>
</div>
)}
</div>

View file

@ -3,5 +3,3 @@ export { PollDetailsForm } from "./poll-details-form";
export type { PollOptionsData } from "./poll-options-form/poll-options-form";
export { 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";

View file

@ -1,13 +1,14 @@
import { Form, FormItem, FormLabel } from "@rallly/ui/form";
import { FormField, FormItem, FormLabel, FormMessage } from "@rallly/ui/form";
import { Input } from "@rallly/ui/input";
import { 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,60 +16,48 @@ 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?"
/>
</p>
</div> */}
<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")}
{...register("title", { validate: requiredString })}
/>
<FormMessage />
</FormItem>
)}
/>
<FormItem>
<FormLabel>{t("location")}</FormLabel>
<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"
@ -77,7 +66,14 @@ export const PollDetailsForm: React.FunctionComponent<
/>
</FormItem>
<FormItem>
<FormLabel htmlFor="description">{t("description")}</FormLabel>
<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")}
@ -85,7 +81,6 @@ export const PollDetailsForm: React.FunctionComponent<
{...register("description")}
/>
</FormItem>
</form>
</Form>
</div>
);
};

View file

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

View file

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

View file

@ -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,92 +93,71 @@ 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"
<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>
<FormField
control={form.control}
name="view"
render={({ field }) => (
<TimeZonePicker
value={field.value}
onBlur={field.onBlur}
onChange={(timeZone) => {
setValue("timeZone", timeZone, { shouldTouch: true });
}}
disabled={datesOnly}
/>
<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>
<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");
</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.",
});
},
}}
type="button"
>
<CalendarIcon className="mr-2 h-4 w-4" /> {t("monthView")}
</button>
<button
className={clsx({
"segment-button-active": selectedView.value === "week",
})}
type="button"
onClick={() => {
setValue("view", "week");
}}
>
<TableIcon className="mr-2 h-4 w-4" /> {t("weekView")}
</button>
</div>
</div>
</div>
<div className="rounded-md border">
render={({ field }) => (
<div>
<selectedView.Component
title={title}
options={watchOptions}
options={field.value}
date={navigationDate}
onNavigate={(date) => {
setValue("navigationDate", date.toISOString());
}}
onChange={(options) => {
setValue("options", options);
field.onChange(options);
if (
options.length === 0 ||
length === 0 ||
options.every((option) => option.type === "date")
) {
// unset the timeZone if we only have date option
@ -229,10 +177,33 @@ const PollOptionsForm: React.FunctionComponent<
setValue("duration", duration);
}}
/>
{formState.errors.options ? (
<div className="border-t bg-red-50 p-3 text-center">
<FormMessage />
</div>
</FormItem>
</form>
</Form>
) : null}
</div>
)}
/>
</div>
<div className="grow border-t bg-gray-50 px-5 py-3">
<FormField
control={form.control}
name="timeZone"
render={({ field }) => (
<TimeZonePicker
value={field.value}
onBlur={field.onBlur}
onChange={(timeZone) => {
setValue("timeZone", timeZone, { shouldTouch: true });
}}
disabled={datesOnly}
/>
)}
/>
</div>
{children}
</Card>
);
};

View file

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

View file

@ -1,41 +1,47 @@
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 (
<div className="relative flex h-[600px]">
<Calendar
className="absolute inset-0"
events={options.map((option) => {
if (option.type === "date") {
return { title, start: new Date(option.date) };
return { start: new Date(option.date) };
} else {
return {
title,
start: new Date(option.start),
end: new Date(option.end),
};
@ -44,9 +50,8 @@ const WeekCalendar: React.FunctionComponent<DateTimePickerProps> = ({
culture="default"
onNavigate={onNavigate}
date={date}
className="h-[calc(100vh-220px)] max-h-[800px] min-h-[400px] w-full"
defaultView="week"
views={["week"]}
defaultView={defaultView}
views={["week", "day"]}
selectable={true}
localizer={localizer}
onSelectEvent={(event) => {
@ -87,7 +92,7 @@ const WeekCalendar: React.FunctionComponent<DateTimePickerProps> = ({
<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"
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)`,
@ -95,47 +100,30 @@ const WeekCalendar: React.FunctionComponent<DateTimePickerProps> = ({
width: `calc(${props.style?.width}%)`,
}}
>
<div>{start.format("LT")}</div>
<div className="font-semibold">{getDuration(start, end)}</div>
<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) {
const dateString = formatDateWithoutTime(date);
const selectedOption = options.find((option) => {
return option.type === "date" && option.date === dateString;
});
return (
<span
onClick={() => {
if (!selectedOption) {
onChange([
...options,
{
type: "date",
date: formatDateWithoutTime(date),
},
]);
} else {
onChange(
options.filter((option) => option !== selectedOption),
);
}
}}
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">
<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 className="font-medium">
{dayjs(date).format("DD")}
</span>
</span>
);
},
@ -145,7 +133,11 @@ const WeekCalendar: React.FunctionComponent<DateTimePickerProps> = ({
}: {
children?: React.ReactNode;
}) {
return <div className="h-8 text-xs text-gray-500">{children}</div>;
return (
<div className="h-6 text-xs leading-none text-gray-500">
{children}
</div>
);
},
}}
step={15}
@ -185,6 +177,7 @@ const WeekCalendar: React.FunctionComponent<DateTimePickerProps> = ({
}}
scrollToTime={scrollToTime}
/>
</div>
);
};

View file

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

View file

@ -1,16 +1,15 @@
import { PollSettingsFormData } from "@/components/forms/poll-settings";
import { PollDetailsData } from "./poll-details-form";
import { 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;

View file

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

View file

@ -7,6 +7,7 @@ interface DayProps {
weekend: boolean;
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 =

View file

@ -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,
});

View file

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

View file

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

View file

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

View file

@ -48,10 +48,8 @@ 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>
</div>
);
})}
</ControlledScrollArea>

View file

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

View file

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

View file

@ -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,6 +214,7 @@ const PollOption: React.FunctionComponent<PollOptionProps> = ({
setExpanded((value) => !value);
}}
>
<IfParticipantsVisible>
{participants.length > 0 ? (
<SummarizedParticipantList participants={participants} />
) : null}
@ -223,6 +226,7 @@ const PollOption: React.FunctionComponent<PollOptionProps> = ({
},
)}
/>
</IfParticipantsVisible>
</m.button>
)}
</AnimatePresence>

View file

@ -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 (
<IfScoresVisible>
<ScoreSummary
yesScore={yes}
ifNeedBeScore={ifNeedBe}
highScore={highScore}
highlight={score === highScore && score > 1}
/>
</IfScoresVisible>
);
};

View file

@ -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")}

View file

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

View file

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

View file

@ -25,7 +25,6 @@ export const usePermissions = () => {
if (isClosed) {
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;
}

View file

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

View file

@ -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,6 +83,7 @@ const Page = () => {
return (
<Prefetch>
<LegacyPollContextProvider>
<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"
@ -128,6 +130,7 @@ const Page = () => {
</div>
</div>
</div>
</VisibilityProvider>
</LegacyPollContextProvider>
</Prefetch>
);

View file

@ -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,11 +29,34 @@ 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">
<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" />
@ -40,36 +69,21 @@ const Page: NextPageWithLayout = () => {
</CardDescription>
</CardHeader>
<CardContent>
<PollDetailsForm
name="updateDetails"
defaultValues={{
title: poll.title,
location: poll.location ?? "",
description: poll.description ?? "",
}}
onSubmit={(data) => {
//submit
updatePollMutation(
{ urlId, ...data },
{ onSuccess: redirectBackToPoll },
);
}}
/>
<PollDetailsForm />
</CardContent>
<CardFooter className="justify-between">
<Button onClick={redirectBackToPoll}>
<Button asChild>
<Link href={pollLink}>
<Trans i18nKey="cancel" />
</Link>
</Button>
<Button
type="submit"
loading={isUpdating}
form="updateDetails"
variant="primary"
>
<Button type="submit" loading={isUpdating} variant="primary">
<Trans i18nKey="save" />
</Button>
</CardFooter>
</Card>
</form>
</Form>
);
};

View file

@ -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,30 +40,15 @@ 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"),
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
@ -78,8 +66,13 @@ const Page: NextPageWithLayout = () => {
};
}),
timeZone: poll.timeZone ?? "",
}}
onSubmit={(data) => {
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));
@ -107,8 +100,7 @@ const Page: NextPageWithLayout = () => {
};
const optionsToDeleteThatHaveVotes = optionsToDelete.filter(
(option) =>
getParticipantsWhoVotedForOption(option.id).length > 0,
(option) => getParticipantsWhoVotedForOption(option.id).length > 0,
);
if (optionsToDeleteThatHaveVotes.length > 0) {
@ -130,23 +122,34 @@ const Page: NextPageWithLayout = () => {
} else {
onOk();
}
}}
/>
</CardContent>
<CardFooter className="justify-between">
<Button onClick={redirectBackToPoll}>
<Trans i18nKey="cancel" />
</Button>
<Button
type="submit"
loading={isUpdating}
form="pollOptions"
variant="primary"
})}
>
<Card className="mx-auto max-w-4xl">
<CardHeader>
<CardTitle>
<Trans i18nKey="editOptions" />
</CardTitle>
<CardDescription>
<Trans
i18nKey="editOptionsDescription"
defaults="Change the options available in your poll."
/>
</CardDescription>
</CardHeader>
<PollOptionsForm />
<CardFooter className="justify-between">
<Button asChild>
<Link href={pollLink}>
<Trans i18nKey="cancel" />
</Link>
</Button>
<Button type="submit" loading={isUpdating} variant="primary">
<Trans i18nKey="save" />
</Button>
</CardFooter>
</Card>
</form>
</Form>
);
};

View file

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

View file

@ -39,7 +39,7 @@
input,
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;
}

View file

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

View file

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

View file

@ -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");
});
});

View file

@ -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();
});

View file

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

View file

@ -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,18 +140,25 @@ export const polls = router({
const participantLink = shortUrl(`/invite/${pollId}`);
if (email && name) {
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: email,
to: user.email,
subject: `Let's find a date for ${poll.title}`,
props: {
title: poll.title,
name,
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: {

View file

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

View file

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

View file

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

View file

@ -62,6 +62,14 @@ model UserPreferences {
@@map("user_preferences")
}
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")

View file

@ -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"],
},

View file

@ -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",
},
},

View file

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

View file

@ -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">
{!hideCloseButton ? (
<>
<XIcon className="h-4 w-4" />
<span className="sr-only">Close</span>
</>
) : null}
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>

View file

@ -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>
);
});

View file

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

View file

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

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

View file

@ -9,7 +9,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
return (
<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}

View file

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