Updated workflow for adding and updating participants (#500)

This commit is contained in:
Luke Vella 2023-02-09 17:56:30 +00:00
parent bac7db54f2
commit 5d7db848b8
58 changed files with 659 additions and 520 deletions

View file

@ -25,7 +25,6 @@
"@radix-ui/react-popover": "^1.0.3",
"@sentry/nextjs": "^7.33.0",
"@svgr/webpack": "^6.2.1",
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.9",
"@tanstack/react-query": "^4.16.1",
"@trpc/client": "^10.0.0-rc.8",

View file

@ -97,7 +97,7 @@
"profileUser": "Perfil - {{username}}",
"requiredNameError": "El nom és obligatori",
"save": "Desa",
"saveInstruction": "Selecciona la teva disponibilitat i prem <b>{{save}}</b>",
"saveInstruction": "Selecciona la teva disponibilitat i prem <b>{{action}}</b>",
"share": "Compartir",
"shareDescription": "Envia aquest enllaç als <b>participants</b> perquè puguin votar a l'enquesta.",
"shareLink": "Comparteix via enllaç",

View file

@ -102,7 +102,7 @@
"requiredNameError": "Jméno je vyžadováno",
"resendVerificationCode": "Znovu odeslat ověřovací kód",
"save": "Uložit",
"saveInstruction": "Vyberte svou dostupnost a klikněte na <b>{{save}}</b>",
"saveInstruction": "Vyberte svou dostupnost a klikněte na <b>{{action}}</b>",
"share": "Sdílet",
"shareDescription": "Tento odkaz zašlete vašim <b>účastníkům</b>, aby mohli v anketě hlasovat.",
"shareLink": "Odkaz ke sdílení",

View file

@ -90,7 +90,7 @@
"profileUser": "Profil - {{username}}",
"requiredNameError": "Navn er påkrævet",
"save": "Gem",
"saveInstruction": "Vælg din tilgængelighed og klik <b>{{save}}</b>",
"saveInstruction": "Vælg din tilgængelighed og klik <b>{{action}}</b>",
"share": "Del",
"shareDescription": "Giv dette link til dine <b>deltagere</b> for at give dem mulighed for at stemme på din afstemning.",
"shareLink": "Del via link",

View file

@ -102,7 +102,7 @@
"requiredNameError": "Bitte gib einen Namen an",
"resendVerificationCode": "Bestätigungscode erneut senden",
"save": "Speichern",
"saveInstruction": "Wähle deine Verfügbarkeit und klicke auf <b>{{save}}</b>",
"saveInstruction": "Wähle deine Verfügbarkeit und klicke auf <b>{{action}}</b>",
"share": "Teilen",
"shareDescription": "Gib diesen Link deinen <b>Teilnehmern</b> damit sie an deiner Umfrage teilnehmen können.",
"shareLink": "Über Link teilen",

View file

@ -51,9 +51,13 @@
"linkHasExpired": "Your link has expired or is no longer valid",
"loading": "Loading…",
"loadingParticipants": "Loading participants…",
"validEmail": "Please enter a valid email",
"newParticipantFormDescription": "Fill in the form below to submit your votes.",
"optional": "optional",
"location": "Location",
"locationPlaceholder": "Joe's Coffee Shop",
"lockPoll": "Lock poll",
"response": "Response",
"login": "Login",
"logout": "Logout",
"manage": "Manage",
@ -87,9 +91,17 @@
"participantCount_few": "{{count}} participants",
"participantCount_many": "{{count}} participants",
"participantCount_one": "{{count}} participant",
"requiredString": "“{{name}}” is required",
"participantCount_other": "{{count}} participants",
"participantCount_two": "{{count}} participants",
"participantCount_zero": "{{count}} participants",
"optionCount_few": "{{count}} options",
"optionCount_many": "{{count}} options",
"optionCount_one": "{{count}} option",
"optionCount_other": "{{count}} options",
"optionCount_two": "{{count}} options",
"optionCount_zero": "{{count}} options",
"newParticipant": "New participant",
"pollHasBeenLocked": "This poll has been locked",
"pollHasBeenVerified": "Your poll has been verified",
"pollOwnerNotice": "Hey {{name}}, looks like you are the owner of this poll.",
@ -102,7 +114,7 @@
"requiredNameError": "Name is required",
"resendVerificationCode": "Resend verification code",
"save": "Save",
"saveInstruction": "Select your availability and click <b>{{save}}</b>",
"saveInstruction": "Select your availability and click <b>{{action}}</b>",
"share": "Share",
"shareDescription": "Give this link to your <b>participants</b> to allow them to vote on your poll.",
"shareLink": "Share via link",

View file

@ -102,7 +102,7 @@
"requiredNameError": "El nombre es obligatorio",
"resendVerificationCode": "Reenviar el código de verificación",
"save": "Guardar",
"saveInstruction": "Selecciona tu disponibilidad y haz clic en <b>{{save}}</b>",
"saveInstruction": "Selecciona tu disponibilidad y haz clic en <b>{{action}}</b>",
"share": "Compartir",
"shareDescription": "Da este enlace a tus <b>participantes</b> para permitirles votar en tu encuesta.",
"shareLink": "Compartir con un enlace",

View file

@ -90,7 +90,7 @@
"profileUser": "نمایه - {{username}}",
"requiredNameError": "نام الزامی است",
"save": "ذخیره",
"saveInstruction": "مشخص کنید چه زمان‌هایی برایتان مقدور است و روی <b>{{save}}</b> کلیک کنید",
"saveInstruction": "مشخص کنید چه زمان‌هایی برایتان مقدور است و روی <b>{{action}}</b> کلیک کنید",
"share": "هم‌رسانی",
"shareDescription": "این لینک را به <b>شرکت‌کنندگان</b> بدهید تا بتوانند در نظرسنجی شما شرکت کنند.",
"shareLink": "هم‌رسانی با پیوند",

View file

@ -102,7 +102,7 @@
"requiredNameError": "Nimi vaaditaan",
"resendVerificationCode": "Lähetä vahvistuskoodi uudelleen",
"save": "Tallenna",
"saveInstruction": "Valitse sinulle sopivat vaihtoehdot ja napsauta <b>{{save}}</b>",
"saveInstruction": "Valitse sinulle sopivat vaihtoehdot ja napsauta <b>{{action}}</b>",
"share": "Jaa",
"shareDescription": "Anna tämä linkki <b>osallistujille</b>, jotta he voivat äänestää kyselyssäsi.",
"shareLink": "Jaa linkin välityksellä",

View file

@ -99,7 +99,7 @@
"requiredNameError": "Le nom est obligatoire",
"resendVerificationCode": "Renvoyer le code de vérification",
"save": "Sauvegarder",
"saveInstruction": "Sélectionnez votre disponibilité et cliquez sur <b>{{save}}</b>",
"saveInstruction": "Sélectionnez votre disponibilité et cliquez sur <b>{{action}}</b>",
"share": "Partager",
"shareDescription": "Donnez ce lien à vos <b>participants</b> pour leur permettre de voter sur votre sondage.",
"shareLink": "Partager via un lien",

View file

@ -102,7 +102,7 @@
"requiredNameError": "Ime je obavezno",
"resendVerificationCode": "Ponovno pošalji poruku za potvrdu",
"save": "Pohrani",
"saveInstruction": "Odaberite termine koji vam odgovaraju i kliknite na <b>{{save}}</b>",
"saveInstruction": "Odaberite termine koji vam odgovaraju i kliknite na <b>{{action}}</b>",
"share": "Podijeli",
"shareDescription": "Pošaljite ovu poveznicu <b>sudionicima</b> kako biste im omogućili glasanje u vašoj anketi.",
"shareLink": "Podijeli putem poveznice",

View file

@ -102,7 +102,7 @@
"requiredNameError": "Név megadása kötelező",
"resendVerificationCode": "Hitelesítő kód újraküldése",
"save": "Mentés",
"saveInstruction": "Válaszd ki mikor érsz rá és kattints a <b>{{save}}</b> gombra",
"saveInstruction": "Válaszd ki mikor érsz rá és kattints a <b>{{action}}</b> gombra",
"share": "Megosztás",
"shareDescription": "Küldd el ezt a linket a <b>résztvevőidnek</b>, hogy tudjanak szavazatokat leadni.",
"shareLink": "Megosztás linkkel",

View file

@ -90,7 +90,7 @@
"profileUser": "Profilo - {{username}}",
"requiredNameError": "Il nome è obbligatorio",
"save": "Salva",
"saveInstruction": "Seleziona la tua disponibilità e clicca su <b>{{save}}</b>",
"saveInstruction": "Seleziona la tua disponibilità e clicca su <b>{{action}}</b>",
"share": "Condividi",
"shareDescription": "Dai questo link ai <b>partecipanti</b> per permette loro di votare al tuo sondaggio.",
"shareLink": "Condividi via link",

View file

@ -90,7 +90,7 @@
"profileUser": "프로필 - {{username}}",
"requiredNameError": "이름을 입력해주세요.",
"save": "저장하기",
"saveInstruction": "가능여부를 선택한 후 <b>{{save}}</b> 를 클릭하세요",
"saveInstruction": "가능여부를 선택한 후 <b>{{action}}</b> 를 클릭하세요",
"share": "공유하기",
"shareDescription": "이 링크를 <b>참여자들</b>에게 전달하여 투표하도록 하세요",
"shareLink": "링크 공유하기",

View file

@ -102,7 +102,7 @@
"requiredNameError": "Naam is verplicht",
"resendVerificationCode": "Verificatiecode opnieuw versturen",
"save": "Opslaan",
"saveInstruction": "Selecteer je beschikbaarheid en klik op <b>{{save}}</b>",
"saveInstruction": "Selecteer je beschikbaarheid en klik op <b>{{action}}</b>",
"share": "Delen",
"shareDescription": "Geef deze link aan je <b>deelnemers</b> zodat ze op je poll kunnen stemmen.",
"shareLink": "Deel via link",

View file

@ -94,7 +94,7 @@
"profileUser": "Profil - {{username}}",
"requiredNameError": "Imię jest wymagane",
"save": "Zapisz",
"saveInstruction": "Wybierz swoją dostępność i kliknij <b>{{save}}</b>",
"saveInstruction": "Wybierz swoją dostępność i kliknij <b>{{action}}</b>",
"share": "Udostępnij",
"shareDescription": "Przekaż ten link <b>uczestnikom</b>, aby mogli zagłosować na ankietę.",
"shareLink": "Udostępnij link",

View file

@ -102,7 +102,7 @@
"requiredNameError": "Nome é obrigatório",
"resendVerificationCode": "Reenviar código de verificação",
"save": "Salvar",
"saveInstruction": "Selecione sua disponibilidade e clique <b>{{save}}</b>",
"saveInstruction": "Selecione sua disponibilidade e clique <b>{{action}}</b>",
"share": "Compartilhar",
"shareDescription": "Dê este link para os seus <b>participantes</b> para permitir que eles votem na sua enquete.",
"shareLink": "Compartilhar via link",

View file

@ -90,7 +90,7 @@
"profileUser": "Perfil - {{username}}",
"requiredNameError": "O nome é obrigatório",
"save": "Guardar",
"saveInstruction": "Selecione a sua disponibilidade e clique em <b>{{save}}</b>",
"saveInstruction": "Selecione a sua disponibilidade e clique em <b>{{action}}</b>",
"share": "Partilhar",
"shareDescription": "Dê este link aos seus <b>participantes</b> para permitir que eles votem na sua sondagem.",
"shareLink": "Partilhar via link",

View file

@ -102,7 +102,7 @@
"requiredNameError": "Необходимо указать имя",
"resendVerificationCode": "Отправить код ещё раз",
"save": "Сохранить",
"saveInstruction": "Укажите когда вы доступны и нажмите <b>{{save}}</b>",
"saveInstruction": "Укажите когда вы доступны и нажмите <b>{{action}}</b>",
"share": "Поделиться",
"shareDescription": "Поделитесь этой ссылкой с вашими <b>участниками</b>, чтобы они смогли ответить на ваш опрос.",
"shareLink": "Поделиться с помощью ссылки",

View file

@ -99,7 +99,7 @@
"requiredNameError": "Požadované je meno",
"resendVerificationCode": "Znovu odoslať verifikačný kód",
"save": "Uložiť",
"saveInstruction": "Vyberte svoju dostupnosť a kliknite na <b>{{save}}</b>",
"saveInstruction": "Vyberte svoju dostupnosť a kliknite na <b>{{action}}</b>",
"share": "Zdielať",
"shareDescription": "Tento odkaz zašlite vašim <b>účastníkom</b>, aby mohli v ankete hlasovať.",
"shareLink": "Zdieľať cez odkaz",

View file

@ -102,7 +102,7 @@
"requiredNameError": "Namn är obligatoriskt",
"resendVerificationCode": "Skicka verifieringskoden igen",
"save": "Spara",
"saveInstruction": "Välj din tillgänglighet och klicka på <b>{{save}}</b>",
"saveInstruction": "Välj din tillgänglighet och klicka på <b>{{action}}</b>",
"share": "Dela",
"shareDescription": "Ge den här länken till dina <b>deltagare</b> så att de kan delta i din förfrågan.",
"shareLink": "Dela via länk",

View file

@ -69,7 +69,7 @@
"participantCount_other": "{{count}} katılımcı",
"pollOwnerNotice": "Selam {{name}}, görünüşe göre bu anketin sahibi sensin.",
"profileUser": "Profil - {{username}}",
"saveInstruction": "Uygunluk durumunuzu seçin ve <b>{{save}}</b> düğmesine tıklayın",
"saveInstruction": "Uygunluk durumunuzu seçin ve <b>{{action}}</b> düğmesine tıklayın",
"shareDescription": "Anketinizde oy kullanmalarına izin vermek için bu bağlantıyı <b>katılımcılarınıza</b> verin.",
"stepSummary": "Aşama: {{current}} / {{total}}",
"sunday": "Pazar",

View file

@ -101,7 +101,7 @@
"requiredNameError": "姓名为必填项",
"resendVerificationCode": "重新发送验证码",
"save": "保存",
"saveInstruction": "选择你有空的时间并点击 <b>{{save}}</b>",
"saveInstruction": "选择你有空的时间并点击 <b>{{action}}</b>",
"share": "分享",
"shareDescription": "其他人可以通过此链接成为<b>参与者</b>進行投票",
"shareLink": "分享链接",

View file

@ -13,7 +13,7 @@ export const LoginModal: React.VoidFunctionComponent<{
const [defaultEmail, setDefaultEmail] = React.useState("");
return (
<div className="w-[420px] max-w-full overflow-hidden rounded-lg bg-white shadow-sm">
<div className="w-[420px] max-w-full overflow-hidden bg-white shadow-sm">
<div className="bg-pattern border-b border-t-4 border-t-primary-500 bg-slate-500/5 p-4 text-center sm:p-8">
<Logo className="text-2xl" />
</div>

View file

@ -111,64 +111,69 @@ const MonthCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
);
})}
</div>
<div className="grid grow grid-cols-7 overflow-hidden rounded-lg border bg-white shadow-sm">
<div className="grid grow grid-cols-7 rounded-lg border bg-white shadow-sm">
{datepicker.days.map((day, i) => {
return (
<button
type="button"
<div
key={i}
onClick={() => {
if (
datepicker.selection.some((selectedDate) =>
dayjs(selectedDate).isSame(day.date, "day"),
)
) {
onChange(removeAllOptionsForDay(options, day.date));
} else {
const selectedDate = dayjs(day.date)
.set("hour", 12)
.toDate();
const newOption: DateTimeOption = !isTimedEvent
? {
type: "date",
date: formatDateWithoutTime(selectedDate),
}
: {
type: "timeSlot",
start: formatDateWithoutTz(selectedDate),
end: formatDateWithoutTz(
dayjs(selectedDate)
.add(duration, "minutes")
.toDate(),
),
};
onChange([...options, newOption]);
onNavigate(selectedDate);
}
if (day.outOfMonth) {
if (i < 6) {
datepicker.prev();
} else {
datepicker.next();
}
}
}}
className={clsx(
"relative flex h-12 items-center justify-center text-sm hover:bg-slate-50 focus:ring-0 focus:ring-offset-0 active:bg-slate-100",
{
"bg-slate-50 text-slate-400": day.outOfMonth,
"font-bold": day.today,
"text-primary-500": day.today && !day.selected,
"border-r": (i + 1) % 7 !== 0,
"border-b": i < datepicker.days.length - 7,
"font-normal text-white after:absolute after:-z-0 after:h-8 after:w-8 after:rounded-full after:bg-green-500 after:content-['']":
day.selected,
},
)}
className={clsx("h-12", {
"border-r": (i + 1) % 7 !== 0,
"border-b": i < datepicker.days.length - 7,
})}
>
<span className="z-10">{day.day}</span>
</button>
<button
type="button"
onClick={() => {
if (
datepicker.selection.some((selectedDate) =>
dayjs(selectedDate).isSame(day.date, "day"),
)
) {
onChange(removeAllOptionsForDay(options, day.date));
} else {
const selectedDate = dayjs(day.date)
.set("hour", 12)
.toDate();
const newOption: DateTimeOption = !isTimedEvent
? {
type: "date",
date: formatDateWithoutTime(selectedDate),
}
: {
type: "timeSlot",
start: formatDateWithoutTz(selectedDate),
end: formatDateWithoutTz(
dayjs(selectedDate)
.add(duration, "minutes")
.toDate(),
),
};
onChange([...options, newOption]);
onNavigate(selectedDate);
}
if (day.outOfMonth) {
if (i < 6) {
datepicker.prev();
} else {
datepicker.next();
}
}
}}
className={clsx(
"relative flex h-full w-full items-center justify-center text-sm hover:bg-slate-50 focus:z-10 focus:rounded active:bg-slate-100",
{
"bg-slate-50 text-slate-400": day.outOfMonth,
"font-bold": day.today,
"text-primary-500": 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-500 after:content-['']":
day.selected,
},
)}
>
<span className="z-10">{day.day}</span>
</button>
</div>
);
})}
</div>

View file

@ -29,13 +29,13 @@ const Hero: React.VoidFunctionComponent = () => {
<div className="space-x-3">
<a
href="/new"
className="rounded-lg bg-primary-500 px-5 py-3 font-semibold text-white shadow-sm transition-all hover:bg-primary-500/90 hover:text-white hover:no-underline hover:shadow-md focus:ring-2 focus:ring-primary-200 active:bg-primary-600/90"
className="rounded-lg bg-primary-500 px-5 py-3 font-semibold text-white shadow-sm transition-all hover:bg-primary-500/90 hover:text-white hover:no-underline hover:shadow-md active:bg-primary-600/90"
>
{t("getStarted")}
</a>
<a
href="/demo"
className="rounded-lg bg-slate-500 px-5 py-3 font-semibold text-white shadow-sm transition-all hover:bg-slate-500/90 hover:text-white hover:no-underline hover:shadow-md focus:ring-2 focus:ring-primary-200 active:bg-slate-600/90"
className="rounded-lg bg-slate-500 px-5 py-3 font-semibold text-white shadow-sm transition-all hover:bg-slate-500/90 hover:text-white hover:no-underline hover:shadow-md active:bg-slate-600/90"
rel="nofollow"
>
{t("liveDemo")}

View file

@ -39,7 +39,7 @@ const PollDemo: React.VoidFunctionComponent = () => {
const { dayjs } = useDayjs();
return (
<div
className="rounded-lg bg-white py-1 shadow-huge"
className="rounded-lg bg-white pb-2 shadow-huge"
style={{ width: 600 }}
>
<div className="flex">

View file

@ -25,7 +25,7 @@ const Menu: React.VoidFunctionComponent<{ className: string }> = ({
<Link
href="/"
className={clsx(
"text-gray-400 transition-colors hover:text-primary-500 hover:no-underline hover:underline-offset-2",
"rounded text-gray-400 transition-colors hover:text-primary-500 hover:no-underline hover:underline-offset-2",
{
"pointer-events-none font-bold text-gray-600": pathname === "/home",
},
@ -36,20 +36,20 @@ const Menu: React.VoidFunctionComponent<{ className: string }> = ({
<Link
href="https://blog.rallly.co"
className={clsx(
"text-gray-400 transition-colors hover:text-primary-500 hover:no-underline hover:underline-offset-2",
"rounded text-gray-400 transition-colors hover:text-primary-500 hover:no-underline hover:underline-offset-2",
)}
>
{t("blog")}
</Link>
<a
href="https://support.rallly.co"
className="text-gray-400 transition-colors hover:text-primary-500 hover:no-underline hover:underline-offset-2"
className="rounded text-gray-400 transition-colors hover:text-primary-500 hover:no-underline hover:underline-offset-2"
>
{t("support")}
</a>
<Link
href="https://github.com/lukevella/rallly"
className="text-gray-400 transition-colors hover:text-primary-500 hover:no-underline hover:underline-offset-2"
className="rounded text-gray-400 transition-colors hover:text-primary-500 hover:no-underline hover:underline-offset-2"
>
<Github className="w-6" />
</Link>
@ -66,7 +66,7 @@ const PageLayout: React.VoidFunctionComponent<PageLayoutProps> = ({
<div className="mx-auto flex max-w-7xl items-center py-8 px-8">
<div className="grow">
<div className="relative inline-block">
<Link href="/">
<Link className="inline-block rounded" href="/">
<Logo className="w-40 text-primary-500" alt="Rallly" />
</Link>
<span className="absolute -bottom-6 right-0 text-sm text-slate-400 transition-colors">
@ -74,15 +74,15 @@ const PageLayout: React.VoidFunctionComponent<PageLayoutProps> = ({
</span>
</div>
</div>
<Menu className="hidden items-center space-x-8 md:flex" />
<Menu className="hidden items-center space-x-8 sm:flex" />
<Popover>
<PopoverTrigger asChild={true}>
<button className="text-gray-400 transition-colors hover:text-primary-500 hover:no-underline hover:underline-offset-2 sm:hidden">
<DotsVertical className="w-5" />
</button>
</PopoverTrigger>
<PopoverContent align="start">
<Menu className="flex flex-col space-y-2" />
<PopoverContent align="end">
<Menu className="flex flex-col space-y-2 p-2" />
</PopoverContent>
</Popover>
</div>

View file

@ -42,7 +42,7 @@ export const MobileNavigation = (props: { className?: string }) => {
return (
<div
className={clsx(
"sticky top-0 z-40 flex h-12 w-full shrink-0 items-center justify-between border-b p-3 transition-all",
"sticky top-0 z-40 flex w-full shrink-0 items-center justify-between border-b p-2 transition-all",
{
"bg-gray-50/75 shadow-sm backdrop-blur-md ": isPinned,
"border-transparent bg-gray-50/0 shadow-none": !isPinned,
@ -56,7 +56,7 @@ export const MobileNavigation = (props: { className?: string }) => {
<button
role="button"
type="button"
className="group flex items-center rounded-md px-2 py-1 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300"
className="group flex items-center rounded px-2 py-1 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300"
>
<Menu className="mr-2 w-5 group-hover:text-primary-500" />
<Logo />
@ -69,7 +69,7 @@ export const MobileNavigation = (props: { className?: string }) => {
</div>
<div className="flex items-center">
{user ? null : (
<LoginLink className="flex w-full cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300">
<LoginLink className="flex w-full cursor-pointer items-center space-x-2 whitespace-nowrap rounded px-2 py-1 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300">
<Login className="h-5 opacity-75" />
<span className="inline-block">{t("app:login")}</span>
</LoginLink>
@ -83,7 +83,7 @@ export const MobileNavigation = (props: { className?: string }) => {
role="button"
data-testid="user"
className={clsx(
"group inline-flex w-full items-center space-x-2 rounded-lg px-2 py-1 text-left transition-colors hover:bg-slate-500/10 active:bg-slate-500/20",
"group inline-flex w-full items-center space-x-2 rounded px-2 py-1 text-left transition-colors hover:bg-slate-500/10 active:bg-slate-500/20",
{
"opacity-50": isUpdating,
},
@ -105,7 +105,7 @@ export const MobileNavigation = (props: { className?: string }) => {
<button
role="button"
type="button"
className="group flex items-center whitespace-nowrap rounded-md px-2 py-1 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300"
className="group flex items-center whitespace-nowrap rounded px-2 py-1 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300"
>
<Adjustments className="h-5 opacity-75 group-hover:text-primary-500" />
<span className="ml-2 hidden sm:block">
@ -130,14 +130,14 @@ const AppMenu: React.VoidFunctionComponent<{ className?: string }> = ({
<div className={clsx("space-y-1", className)}>
<Link
href="/"
className="flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 pr-4 font-medium text-slate-600 transition-colors hover:bg-slate-50 hover:text-slate-600 hover:no-underline active:bg-gray-300"
className="flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded px-2 py-1 pr-4 font-medium text-slate-600 transition-colors hover:bg-slate-50 hover:text-slate-600 hover:no-underline active:bg-gray-300"
>
<Home className="h-5 opacity-75 " />
<span className="inline-block">{t("app:home")}</span>
</Link>
<Link
href="/new"
className="flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 pr-4 font-medium text-slate-600 transition-colors hover:bg-slate-50 hover:text-slate-600 hover:no-underline active:bg-gray-300"
className="flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded px-2 py-1 pr-4 font-medium text-slate-600 transition-colors hover:bg-slate-50 hover:text-slate-600 hover:no-underline active:bg-gray-300"
>
<Pencil className="h-5 opacity-75 " />
<span className="inline-block">{t("app:createNew")}</span>
@ -145,7 +145,7 @@ const AppMenu: React.VoidFunctionComponent<{ className?: string }> = ({
<a
target="_blank"
href="https://support.rallly.co"
className="flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 pr-4 font-medium text-slate-600 transition-colors hover:bg-slate-50 hover:text-slate-600 hover:no-underline active:bg-gray-300"
className="flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded px-2 py-1 pr-4 font-medium text-slate-600 transition-colors hover:bg-slate-50 hover:text-slate-600 hover:no-underline active:bg-gray-300"
rel="noreferrer"
>
<Support className="h-5 opacity-75" />

View file

@ -17,6 +17,8 @@ interface ModalConfig extends ModalProps {
const ModalContext =
React.createContext<{
render: (el: ModalConfig) => void;
add: (id: string, el: React.ReactNode, config?: ModalConfig) => void;
remove: (id: string) => void;
} | null>(null);
ModalContext.displayName = "<ModalProvider />";
@ -28,11 +30,11 @@ export const useModalContext = () => {
const ModalProvider: React.VoidFunctionComponent<ModalProviderProps> = ({
children,
}) => {
const counter = React.useRef(0);
const [modals, { push, removeAt, updateAt }] = useList<ModalConfig>([]);
const [modals, { push, removeAt, updateAt }] = useList<
ModalConfig & { id: number }
>([]);
const [modalById, setModalById] = React.useState<
Record<string, { content: React.ReactNode; config?: ModalConfig }>
>({});
const removeModalAt = (index: number) => {
updateAt(index, { ...modals[index], visible: false });
@ -40,18 +42,44 @@ const ModalProvider: React.VoidFunctionComponent<ModalProviderProps> = ({
removeAt(index);
}, 500);
};
const remove = (id: string) => {
const newModalById = { ...modalById };
delete newModalById[id];
setModalById(newModalById);
};
return (
<ModalContext.Provider
value={{
/**
* @deprecated
*/
render: (props) => {
push({ ...props, id: counter.current++ });
push(props);
},
add: (id: string, content: React.ReactNode, config?: ModalConfig) => {
setModalById({ ...modalById, [id]: { content, config } });
},
remove,
}}
>
{children}
{Object.entries(modalById).map(([id, modal]) => (
<Modal
key={id}
visible={true}
{...modal.config}
content={modal.content}
footer={null}
onCancel={() => {
remove(id);
}}
/>
))}
{modals.map((props, i) => (
<Modal
key={`modal-${props.id}`}
key={i}
visible={true}
{...props}
content={

View file

@ -57,7 +57,7 @@ const Modal: React.VoidFunctionComponent<ModalProps> = ({
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-0 bg-slate-900 bg-opacity-25"
className="fixed inset-0 z-0 bg-slate-900/25"
/>
<motion.div
transition={{ duration: 0.1 }}
@ -66,11 +66,11 @@ const Modal: React.VoidFunctionComponent<ModalProps> = ({
exit={{ opacity: 0, scale: 0.9 }}
className="relative z-50 my-8 inline-block max-w-full transform text-left align-middle"
>
<div className="mx-4 max-w-full overflow-hidden rounded-xl bg-white shadow-xl xs:rounded-xl">
<div className="max-w-full overflow-hidden rounded-md bg-white shadow-huge">
{showClose ? (
<button
role="button"
className="absolute right-5 top-1 z-10 rounded-lg p-2 text-slate-400 transition-colors hover:bg-slate-500/10 hover:text-slate-500 active:bg-slate-500/20"
className="absolute top-1 right-1 z-10 rounded p-2 text-slate-400 transition-colors hover:bg-slate-500/10 hover:text-slate-500 focus:ring-0 focus:ring-offset-0 active:bg-slate-500/20"
onClick={onCancel}
>
<X className="h-4" />
@ -91,7 +91,7 @@ const Modal: React.VoidFunctionComponent<ModalProps> = ({
</div>
)}
{footer === undefined ? (
<div className="flex h-14 items-center justify-end space-x-3 rounded-br-lg rounded-bl-lg border-t bg-slate-50 px-4">
<div className="flex h-14 items-center justify-end gap-3 rounded-br-lg border-t bg-slate-50 p-3">
{cancelText ? (
<Button
onClick={() => {

View file

@ -0,0 +1,176 @@
import { VoteType } from "@prisma/client";
import clsx from "clsx";
import { useTranslation } from "next-i18next";
import { useForm } from "react-hook-form";
import { useFormValidation } from "../utils/form-validation";
import { Button } from "./button";
import { useModalContext } from "./modal/modal-provider";
import { useAddParticipantMutation } from "./poll/mutations";
import VoteIcon from "./poll/vote-icon";
import { usePoll } from "./poll-context";
import { TextInput } from "./text-input";
interface NewParticipantFormData {
name: string;
email?: string;
}
interface NewParticipantModalProps {
votes: { optionId: string; type: VoteType }[];
onSubmit?: (data: { id: string }) => void;
onCancel?: () => void;
}
const VoteSummary = ({
votes,
className,
}: {
className: string;
votes: { optionId: string; type: VoteType }[];
}) => {
const { t } = useTranslation("app");
const voteByType = votes.reduce<Record<VoteType, number>>(
(acc, vote) => {
acc[vote.type] = acc[vote.type] ? acc[vote.type] + 1 : 1;
return acc;
},
{ yes: 0, ifNeedBe: 0, no: 0 },
);
return (
<div className={clsx("space-y-1", className)}>
<div className="flex items-center gap-2">
<VoteIcon type="yes" />
<div>{t("yes")}</div>
<div className="rounded bg-white px-2 text-sm shadow-sm">
{voteByType["yes"]}
</div>
</div>
<div className="flex items-center gap-2">
<VoteIcon type="ifNeedBe" />
<div>{t("ifNeedBe")}</div>
<div className="rounded bg-white px-2 text-sm shadow-sm">
{voteByType["ifNeedBe"]}
</div>
</div>
<div className="flex items-center gap-2">
<VoteIcon type="no" />
<div>{t("no")}</div>
<div className="rounded bg-white px-2 text-sm shadow-sm">
{voteByType["no"]}
</div>
</div>
</div>
);
};
export const NewParticipantModal = (props: NewParticipantModalProps) => {
const { t } = useTranslation("app");
const { register, formState, handleSubmit } =
useForm<NewParticipantFormData>();
const { requiredString } = useFormValidation();
const { poll } = usePoll();
const addParticipant = useAddParticipantMutation();
return (
<div className="max-w-full p-4">
<div className="text-lg font-semibold text-slate-800">
{t("newParticipant")}
</div>
<div className="mb-4">{t("newParticipantFormDescription")}</div>
<form
onSubmit={handleSubmit(async (data) => {
const newParticipant = await addParticipant.mutateAsync({
name: data.name,
votes: props.votes,
pollId: poll.id,
});
props.onSubmit?.(newParticipant);
})}
className="space-y-4"
>
<fieldset>
<label htmlFor="name" className="text-slate-500">
{t("name")}
</label>
<TextInput
className="w-full"
autoFocus={true}
error={!!formState.errors.name}
disabled={formState.isSubmitting}
placeholder={t("namePlaceholder")}
{...register("name", { validate: requiredString(t("name")) })}
/>
{formState.errors.name?.message ? (
<div className="mt-2 text-sm text-rose-500">
{formState.errors.name.message}
</div>
) : null}
</fieldset>
{/* <fieldset>
<label htmlFor="email" className="text-slate-500">
{t("email")} ({t("optional")})
</label>
<TextInput
className="w-full"
error={!!formState.errors.email}
disabled={formState.isSubmitting}
placeholder={t("emailPlaceholder")}
{...register("email", {
validate: (value) => {
if (!value) return true;
return validEmail(value);
},
})}
/>
{formState.errors.email?.message ? (
<div className="mt-1 text-sm text-rose-500">
{formState.errors.email.message}
</div>
) : null}
</fieldset> */}
<fieldset>
<label className="text-slate-500">{t("response")}</label>
<VoteSummary
votes={props.votes}
className="rounded border bg-gray-50 py-2 px-3"
/>
</fieldset>
<div className="flex gap-2">
<Button onClick={props.onCancel}>{t("cancel")}</Button>
<Button
htmlType="submit"
type="primary"
loading={formState.isSubmitting}
>
{t("save")}
</Button>
</div>
</form>
</div>
);
};
export const useNewParticipantModal = () => {
const modalContext = useModalContext();
const showNewParticipantModal = (props: NewParticipantModalProps) => {
return modalContext.render({
content: function Content({ close }) {
return (
<NewParticipantModal
{...props}
onSubmit={(data) => {
props.onSubmit?.(data);
close();
}}
onCancel={close}
/>
);
},
footer: null,
});
};
return showNewParticipantModal;
};

View file

@ -49,27 +49,23 @@ export const Poll = (props: { children?: React.ReactNode }) => {
<div className="mx-auto max-w-full space-y-3 sm:space-y-4 lg:mx-0">
{props.children}
{poll.demo ? (
<div className="flex items-start gap-3 rounded-md border border-amber-200 bg-amber-100 p-3 text-amber-600 shadow-sm">
<div className="rounded-md">
<Exclamation className="w-7" />
</div>
<div className="flex items-center gap-3 rounded-md border border-amber-200 bg-amber-100 p-3 text-amber-600 shadow-sm">
<Exclamation className="w-6" />
<div>{t("demoPollNotice")}</div>
</div>
) : null}
{poll.closed ? (
<div className="flex items-center gap-3 rounded-md border border-pink-200 bg-pink-100 p-3 text-pink-600 shadow-sm">
<div className="rounded-md">
<LockClosed className="w-7" />
</div>
<LockClosed className="w-6" />
<div>{t("pollHasBeenLocked")}</div>
</div>
) : null}
<div className="rounded-md border bg-white shadow-sm md:overflow-hidden">
<div className="p-4 sm:p-6">
<div className="p-4">
<div className="space-y-3">
<div>
<div
className="mb-1 text-2xl font-semibold text-slate-800 sm:text-3xl"
className="text-xl font-semibold text-slate-800 sm:text-2xl"
data-testid="poll-title"
>
{preventWidows(poll.title)}
@ -77,14 +73,14 @@ export const Poll = (props: { children?: React.ReactNode }) => {
<PollSubheader />
</div>
{poll.description ? (
<div className="border-primary whitespace-pre-line lg:text-lg">
<div className="border-primary whitespace-pre-line">
<TruncatedLinkify>
{preventWidows(poll.description)}
</TruncatedLinkify>
</div>
) : null}
{poll.location ? (
<div className="lg:text-lg">
<div>
<div className="text-sm text-slate-500">
{t("location")}
</div>
@ -98,17 +94,15 @@ export const Poll = (props: { children?: React.ReactNode }) => {
<div className="flex items-center space-x-3">
<span className="inline-flex items-center space-x-1">
<VoteIcon type="yes" />
<span className="text-xs text-slate-500">{t("yes")}</span>
<span>{t("yes")}</span>
</span>
<span className="inline-flex items-center space-x-1">
<VoteIcon type="ifNeedBe" />
<span className="text-xs text-slate-500">
{t("ifNeedBe")}
</span>
<span>{t("ifNeedBe")}</span>
</span>
<span className="inline-flex items-center space-x-1">
<VoteIcon type="no" />
<span className="text-xs text-slate-500">{t("no")}</span>
<span>{t("no")}</span>
</span>
</div>
</div>

View file

@ -5,10 +5,9 @@ import { useMeasure } from "react-use";
import ArrowLeft from "@/components/icons/arrow-left.svg";
import ArrowRight from "@/components/icons/arrow-right.svg";
import Check from "@/components/icons/check.svg";
import Plus from "@/components/icons/plus-sm.svg";
import { Button } from "../button";
import { useNewParticipantModal } from "../new-participant-modal";
import { useParticipants } from "../participants-provider";
import { usePoll } from "../poll-context";
import TimeZonePicker from "../time-zone-picker";
@ -21,8 +20,6 @@ import {
useUpdateParticipantMutation,
} from "./mutations";
const MotionButton = motion(Button);
const minSidebarWidth = 200;
const Poll: React.VoidFunctionComponent = () => {
@ -37,17 +34,16 @@ const Poll: React.VoidFunctionComponent = () => {
const [editingParticipantId, setEditingParticipantId] =
React.useState<string | null>(null);
const actionColumnWidth = 100;
const columnWidth = 90;
const columnWidth = 80;
const numberOfVisibleColumns = Math.min(
options.length,
Math.floor((width - (minSidebarWidth + actionColumnWidth)) / columnWidth),
Math.floor((width - minSidebarWidth) / columnWidth),
);
const sidebarWidth = Math.min(
width - (numberOfVisibleColumns * columnWidth + actionColumnWidth),
300,
width - numberOfVisibleColumns * columnWidth,
275,
);
const availableSpace = Math.min(
@ -66,8 +62,7 @@ const Poll: React.VoidFunctionComponent = () => {
const [shouldShowNewParticipantForm, setShouldShowNewParticipantForm] =
React.useState(!poll.closed && !userAlreadyVoted);
const pollWidth =
sidebarWidth + options.length * columnWidth + actionColumnWidth;
const pollWidth = sidebarWidth + options.length * columnWidth;
const addParticipant = useAddParticipantMutation();
@ -88,7 +83,7 @@ const Poll: React.VoidFunctionComponent = () => {
const updateParticipant = useUpdateParticipantMutation();
const participantListContainerRef = React.useRef<HTMLDivElement>(null);
const showNewParticipantModal = useNewParticipantModal();
return (
<PollContext.Provider
value={{
@ -102,7 +97,6 @@ const Poll: React.VoidFunctionComponent = () => {
goToPreviousPage,
numberOfColumns: numberOfVisibleColumns,
availableSpace,
actionColumnWidth,
maxScrollPosition,
}}
>
@ -112,8 +106,65 @@ const Poll: React.VoidFunctionComponent = () => {
ref={ref}
>
<div className="flex flex-col overflow-hidden">
<div className="flex h-14 shrink-0 items-center justify-between border-b bg-gradient-to-b from-gray-50 to-gray-100/50 p-3">
<div className="p-1">
{shouldShowNewParticipantForm || editingParticipantId ? (
<Trans
t={t}
i18nKey="saveInstruction"
values={{
action: shouldShowNewParticipantForm
? t("continue")
: t("save"),
}}
components={{ b: <strong /> }}
/>
) : (
<div className="flex gap-2">
<div className="font-semibold text-slate-800">
{t("participantCount", { count: participants.length })}
</div>
{poll.closed ? null : (
<button
className="rounded hover:text-primary-500"
onClick={() => {
setEditingParticipantId(null);
setShouldShowNewParticipantForm(true);
}}
>
+ {t("new")}
</button>
)}
</div>
)}
</div>
<div className="flex items-center gap-3">
<div className="p-1">
{t("optionCount", { count: options.length })}
</div>
{maxScrollPosition > 0 ? (
<div className="flex gap-2">
<Button
onClick={goToPreviousPage}
disabled={scrollPosition === 0}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<Button
className="text-xs"
disabled={scrollPosition === maxScrollPosition}
onClick={() => {
goToNextPage();
}}
>
<ArrowRight className="h-4 w-4" />
</Button>
</div>
) : null}
</div>
</div>
{poll.timeZone ? (
<div className="flex h-14 shrink-0 items-center justify-end space-x-4 border-b bg-gray-50 px-4">
<div className="flex h-14 shrink-0 items-center justify-end space-x-4 border-b bg-gradient-to-b from-gray-50 to-gray-100/50 px-4">
<div className="flex grow items-center">
<div className="mr-2 text-sm font-medium text-slate-500">
{t("timeZone")}
@ -131,112 +182,82 @@ const Poll: React.VoidFunctionComponent = () => {
<div
className="flex shrink-0 items-center pl-4 pr-2 font-medium"
style={{ width: sidebarWidth }}
>
<div className="flex h-full grow items-end">
{t("participantCount", { count: participants.length })}
</div>
<AnimatePresence initial={false}>
{scrollPosition > 0 ? (
<MotionButton
transition={{ duration: 0.1 }}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
rounded={true}
onClick={goToPreviousPage}
>
<ArrowLeft className="h-4 w-4" />
</MotionButton>
) : null}
</AnimatePresence>
</div>
></div>
<PollHeader />
<div
className="flex items-center py-3 px-2"
style={{ width: actionColumnWidth }}
>
{maxScrollPosition > 0 ? (
<AnimatePresence initial={false}>
{scrollPosition < maxScrollPosition ? (
<MotionButton
transition={{ duration: 0.1 }}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="text-xs"
rounded={true}
onClick={() => {
goToNextPage();
}}
>
<ArrowRight className="h-4 w-4" />
</MotionButton>
) : null}
</AnimatePresence>
) : null}
</div>
</div>
</div>
{participants.length > 0 ? (
<div
className="min-h-0 overflow-y-auto py-2"
ref={participantListContainerRef}
>
{participants.map((participant, i) => {
return (
<ParticipantRow
key={i}
participant={participant}
editMode={editingParticipantId === participant.id}
onChangeEditMode={(isEditing) => {
setEditingParticipantId(
isEditing ? participant.id : null,
);
}}
onSubmit={async ({ name, votes }) => {
await updateParticipant.mutateAsync({
participantId: participant.id,
pollId: poll.id,
<div className="pb-2">
<AnimatePresence initial={false}>
{shouldShowNewParticipantForm &&
!poll.closed &&
!editingParticipantId ? (
<motion.div
variants={{
hidden: { height: 0, y: -50, opacity: 0 },
visible: { height: "auto", y: 0, opacity: 1 },
exit: { height: 0, opacity: 0, y: -25 },
}}
initial="hidden"
animate="visible"
>
<ParticipantRowForm
className="shrink-0"
onSubmit={async ({ votes }) => {
showNewParticipantModal({
votes,
name,
onSubmit: () => {
setShouldShowNewParticipantForm(false);
},
});
}}
/>
);
})}
</div>
) : null}
{shouldShowNewParticipantForm &&
!poll.closed &&
!editingParticipantId ? (
<ParticipantRowForm
className="shrink-0 border-t bg-gray-50"
onSubmit={async ({ name, votes }) => {
await addParticipant.mutateAsync({
name,
votes,
pollId: poll.id,
});
setShouldShowNewParticipantForm(false);
}}
/>
) : null}
{!poll.closed ? (
<div className="flex h-14 shrink-0 items-center border-t bg-gray-50 px-3">
{shouldShowNewParticipantForm || editingParticipantId ? (
<div className="flex items-center space-x-3">
<Button
key="submit"
form="participant-row-form"
htmlType="submit"
type="primary"
icon={<Check />}
loading={
addParticipant.isLoading || updateParticipant.isLoading
</motion.div>
) : null}
</AnimatePresence>
{participants.map((participant, i) => {
return (
<ParticipantRow
key={i}
className={
editingParticipantId &&
editingParticipantId !== participant.id
? "opacity-50"
: ""
}
participant={participant}
disableEditing={!!editingParticipantId}
editMode={editingParticipantId === participant.id}
onChangeEditMode={(isEditing) => {
if (isEditing) {
setShouldShowNewParticipantForm(false);
setEditingParticipantId(participant.id);
}
>
{t("save")}
</Button>
}}
onSubmit={async ({ votes }) => {
await updateParticipant.mutateAsync({
participantId: participant.id,
pollId: poll.id,
votes,
});
setEditingParticipantId(null);
}}
/>
);
})}
</div>
<AnimatePresence initial={false}>
{shouldShowNewParticipantForm || editingParticipantId ? (
<motion.div
variants={{
hidden: { height: 0, y: 30, opacity: 0 },
visible: { height: "auto", y: 0, opacity: 1 },
}}
initial="hidden"
animate="visible"
exit="hidden"
className="flex shrink-0 items-center border-t bg-gray-50"
>
<div className="flex w-full items-center justify-between gap-3 p-3">
<Button
onClick={() => {
if (editingParticipantId) {
@ -248,38 +269,21 @@ const Poll: React.VoidFunctionComponent = () => {
>
{t("cancel")}
</Button>
<div className="text-sm">
<Trans
t={t}
i18nKey="saveInstruction"
values={{
save: t("save"),
}}
components={{ b: <strong /> }}
/>
</div>
</div>
) : (
<div className="flex w-full items-center space-x-3">
<Button
key="add-participant"
onClick={() => {
setShouldShowNewParticipantForm(true);
}}
icon={<Plus />}
key="submit"
form="participant-row-form"
htmlType="submit"
type="primary"
loading={
addParticipant.isLoading || updateParticipant.isLoading
}
>
{t("addParticipant")}
{shouldShowNewParticipantForm ? t("continue") : t("save")}
</Button>
{userAlreadyVoted ? (
<div className="flex items-center text-sm text-gray-400">
<Check className="mr-1 h-5" />
<div>{t("alreadyVoted")}</div>
</div>
) : null}
</div>
)}
</div>
) : null}
</motion.div>
) : null}
</AnimatePresence>
</div>
</div>
</PollContext.Provider>

View file

@ -3,30 +3,27 @@ import { useTranslation } from "next-i18next";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import ArrowLeft from "@/components/icons/arrow-left.svg";
import ArrowRight from "@/components/icons/arrow-right.svg";
import { requiredString } from "../../../utils/form-validation";
import { Button } from "../../button";
import NameInput from "../../name-input";
import { usePoll } from "../../poll-context";
import { normalizeVotes } from "../mutations";
import { ParticipantForm, ParticipantFormSubmitted } from "../types";
import UserAvatar from "../user-avatar";
import { VoteSelector } from "../vote-selector";
import ControlledScrollArea from "./controlled-scroll-area";
import { usePollContext } from "./poll-context";
export interface ParticipantRowFormProps {
name?: string;
defaultValues?: Partial<ParticipantForm>;
onSubmit: (data: ParticipantFormSubmitted) => Promise<void>;
className?: string;
isYou?: boolean;
onCancel?: () => void;
}
const ParticipantRowForm: React.ForwardRefRenderFunction<
HTMLFormElement,
ParticipantRowFormProps
> = ({ defaultValues, onSubmit, className, onCancel }, ref) => {
> = ({ defaultValues, onSubmit, name, isYou, className, onCancel }, ref) => {
const { t } = useTranslation("app");
const {
columnWidth,
@ -34,20 +31,11 @@ const ParticipantRowForm: React.ForwardRefRenderFunction<
sidebarWidth,
numberOfColumns,
goToNextPage,
goToPreviousPage,
maxScrollPosition,
setScrollPosition,
} = usePollContext();
const { options, optionIds } = usePoll();
const {
handleSubmit,
control,
formState: { errors, submitCount },
reset,
} = useForm({
const { handleSubmit, control } = useForm({
defaultValues: {
name: "",
votes: [],
...defaultValues,
},
@ -71,49 +59,15 @@ const ParticipantRowForm: React.ForwardRefRenderFunction<
<form
id="participant-row-form"
ref={ref}
onSubmit={handleSubmit(async ({ name, votes }) => {
onSubmit={handleSubmit(async ({ votes }) => {
await onSubmit({
name,
votes: normalizeVotes(optionIds, votes),
});
reset();
})}
className={clsx("flex h-14 shrink-0", className)}
className={clsx("flex h-12 shrink-0", className)}
>
<div className="flex items-center px-2" style={{ width: sidebarWidth }}>
<Controller
name="name"
rules={{
validate: requiredString,
}}
render={({ field }) => (
<div className="w-full">
<NameInput
className={clsx("w-full", {
"input-error": errors.name && submitCount > 0,
})}
placeholder={t("yourName")}
{...field}
onKeyDown={(e) => {
if (e.code === "Tab" && scrollPosition > 0) {
e.preventDefault();
setScrollPosition(0);
setTimeout(() => {
checkboxRefs.current[0]?.focus();
}, 100);
}
}}
onKeyPress={(e) => {
if (e.code === "Enter") {
e.preventDefault();
checkboxRefs.current[0]?.focus();
}
}}
/>
</div>
)}
control={control}
/>
<div className="flex items-center px-3" style={{ width: sidebarWidth }}>
<UserAvatar name={name ?? t("you")} isYou={isYou} showName={true} />
</div>
<Controller
control={control}
@ -127,10 +81,11 @@ const ParticipantRowForm: React.ForwardRefRenderFunction<
return (
<div
key={optionId}
className="flex shrink-0 items-center justify-center px-2"
className="flex shrink-0 items-center justify-center p-1"
style={{ width: columnWidth }}
>
<VoteSelector
className="h-full w-full"
value={value?.type}
onKeyDown={(e) => {
if (
@ -161,30 +116,6 @@ const ParticipantRowForm: React.ForwardRefRenderFunction<
);
}}
/>
{maxScrollPosition > 0 ? (
<div className="flex items-center space-x-2 px-2 transition-all">
<Button
disabled={scrollPosition === 0}
className="text-xs"
rounded={true}
onClick={() => {
goToPreviousPage();
}}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<Button
disabled={scrollPosition >= maxScrollPosition}
className="text-xs"
rounded={true}
onClick={() => {
goToNextPage();
}}
>
<ArrowRight className="h-4 w-4" />
</Button>
</div>
) : null}
</form>
);
};

View file

@ -1,13 +1,15 @@
import { Participant, Vote, VoteType } from "@prisma/client";
import clsx from "clsx";
import { useTranslation } from "next-i18next";
import * as React from "react";
import CompactButton from "@/components/compact-button";
import DotsVertical from "@/components/icons/dots-vertical.svg";
import Pencil from "@/components/icons/pencil-alt.svg";
import Trash from "@/components/icons/trash.svg";
import { usePoll } from "@/components/poll-context";
import { useUser } from "@/components/user-provider";
import Dropdown, { DropdownItem } from "../../dropdown";
import { ParticipantFormSubmitted } from "../types";
import { useDeleteParticipantModal } from "../use-delete-participant-modal";
import UserAvatar from "../user-avatar";
@ -18,7 +20,9 @@ import { usePollContext } from "./poll-context";
export interface ParticipantRowProps {
participant: Participant & { votes: Vote[] };
className?: string;
editMode?: boolean;
disableEditing?: boolean;
onChangeEditMode?: (editMode: boolean) => void;
onSubmit?: (data: ParticipantFormSubmitted) => Promise<void>;
}
@ -31,6 +35,7 @@ export const ParticipantRowView: React.VoidFunctionComponent<{
onEdit?: () => void;
onDelete?: () => void;
columnWidth: number;
className?: string;
sidebarWidth: number;
isYou?: boolean;
participantId: string;
@ -39,6 +44,7 @@ export const ParticipantRowView: React.VoidFunctionComponent<{
editable,
votes,
onEdit,
className,
onDelete,
sidebarWidth,
columnWidth,
@ -46,46 +52,49 @@ export const ParticipantRowView: React.VoidFunctionComponent<{
color,
participantId,
}) => {
const { t } = useTranslation("app");
return (
<div
data-testid="participant-row"
data-participantid={participantId}
className="group flex h-14 items-center"
className={clsx("flex h-12 items-center", className)}
>
<div
className="flex shrink-0 items-center px-4"
className="flex h-full shrink-0 items-center justify-between gap-2 px-3"
style={{ width: sidebarWidth }}
>
<UserAvatar
className="mr-2"
name={name}
showName={true}
isYou={isYou}
color={color}
/>
<UserAvatar name={name} showName={true} isYou={isYou} color={color} />
{editable ? (
<div className="hidden shrink-0 items-center space-x-2 overflow-hidden group-hover:flex">
<CompactButton icon={Pencil} onClick={onEdit} />
<CompactButton icon={Trash} onClick={onDelete} />
<div className="flex">
<Dropdown
placement="bottom-start"
trigger={
<button className="text-slate-500 hover:text-slate-800">
<DotsVertical className="h-3" />
</button>
}
>
<DropdownItem icon={Pencil} onClick={onEdit} label={t("edit")} />
<DropdownItem
icon={Trash}
onClick={onDelete}
label={t("delete")}
/>
</Dropdown>
</div>
) : null}
</div>
<ControlledScrollArea>
<ControlledScrollArea className="h-full">
{votes.map((vote, i) => {
return (
<div
key={i}
className="relative flex shrink-0 items-center justify-center px-2 transition-colors"
className={clsx("relative flex h-full shrink-0 p-1")}
style={{ width: columnWidth }}
>
<div
className={clsx(
"flex h-10 w-full items-center justify-center rounded-md",
{
"bg-green-50": vote === "yes",
"bg-amber-50": vote === "ifNeedBe",
"bg-slate-50": vote === "no",
},
"flex h-full w-full items-center justify-center rounded border border-slate-200 bg-slate-50/75",
)}
>
<VoteIcon type={vote} />
@ -102,6 +111,8 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
participant,
editMode,
onSubmit,
className,
disableEditing,
onChangeEditMode,
}) => {
const { columnWidth, sidebarWidth } = usePollContext();
@ -115,20 +126,22 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
const isUnclaimed = !participant.userId;
const canEdit = !poll.closed && (admin || isYou || isUnclaimed);
const canEdit =
!disableEditing && !poll.closed && (admin || isYou || isUnclaimed);
if (editMode) {
return (
<ParticipantRowForm
name={participant.name}
defaultValues={{
name: participant.name,
votes: options.map(({ optionId }) => {
const type = getVote(participant.id, optionId);
return type ? { optionId, type } : undefined;
}),
}}
onSubmit={async ({ name, votes }) => {
await onSubmit?.({ name, votes });
isYou={isYou}
onSubmit={async ({ votes }) => {
await onSubmit?.({ votes });
onChangeEditMode?.(false);
}}
onCancel={() => onChangeEditMode?.(false)}
@ -140,6 +153,7 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
<ParticipantRowView
sidebarWidth={sidebarWidth}
columnWidth={columnWidth}
className={className}
name={participant.name}
votes={options.map(({ optionId }) => {
return getVote(participant.id, optionId);

View file

@ -11,7 +11,6 @@ export const PollContext = React.createContext<{
sidebarWidth: number;
numberOfColumns: number;
availableSpace: number | string;
actionColumnWidth: number;
goToNextPage: () => void;
goToPreviousPage: () => void;
}>({
@ -26,7 +25,6 @@ export const PollContext = React.createContext<{
availableSpace: "auto",
goToNextPage: noop,
goToPreviousPage: noop,
actionColumnWidth: 0,
});
export const usePollContext = () => React.useContext(PollContext);

View file

@ -1,31 +1,25 @@
import { Listbox } from "@headlessui/react";
import clsx from "clsx";
import { AnimatePresence, motion } from "framer-motion";
import { useTranslation } from "next-i18next";
import * as React from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import { FormProvider, useForm } from "react-hook-form";
import smoothscroll from "smoothscroll-polyfill";
import Check from "@/components/icons/check.svg";
import ChevronDown from "@/components/icons/chevron-down.svg";
import Pencil from "@/components/icons/pencil-alt.svg";
import PlusCircle from "@/components/icons/plus-circle.svg";
import Trash from "@/components/icons/trash.svg";
import { usePoll } from "@/components/poll-context";
import { You } from "@/components/you";
import { requiredString } from "../../utils/form-validation";
import { Button } from "../button";
import { styleMenuItem } from "../menu-styles";
import NameInput from "../name-input";
import { useNewParticipantModal } from "../new-participant-modal";
import { useParticipants } from "../participants-provider";
import TimeZonePicker from "../time-zone-picker";
import { isUnclaimed, useUser } from "../user-provider";
import GroupedOptions from "./mobile-poll/grouped-options";
import {
normalizeVotes,
useAddParticipantMutation,
useUpdateParticipantMutation,
} from "./mutations";
import { normalizeVotes, useUpdateParticipantMutation } from "./mutations";
import { ParticipantForm } from "./types";
import { useDeleteParticipantModal } from "./use-delete-participant-modal";
import UserAvatar from "./user-avatar";
@ -55,12 +49,11 @@ const MobilePoll: React.VoidFunctionComponent = () => {
const form = useForm<ParticipantForm>({
defaultValues: {
name: "",
votes: [],
},
});
const { reset, handleSubmit, control, formState } = form;
const { reset, handleSubmit, formState } = form;
const [selectedParticipantId, setSelectedParticipantId] =
React.useState<string | undefined>();
@ -78,36 +71,36 @@ const MobilePoll: React.VoidFunctionComponent = () => {
const updateParticipant = useUpdateParticipantMutation();
const addParticipant = useAddParticipantMutation();
const confirmDeleteParticipant = useDeleteParticipantModal();
const showNewParticipantModal = useNewParticipantModal();
return (
<FormProvider {...form}>
<form
ref={formRef}
onSubmit={handleSubmit(async ({ name, votes }) => {
onSubmit={handleSubmit(async ({ votes }) => {
if (selectedParticipant) {
await updateParticipant.mutateAsync({
pollId: poll.id,
participantId: selectedParticipant.id,
name,
votes: normalizeVotes(optionIds, votes),
});
setIsEditing(false);
} else {
const newParticipant = await addParticipant.mutateAsync({
pollId: poll.id,
name,
showNewParticipantModal({
votes: normalizeVotes(optionIds, votes),
onSubmit: async ({ id }) => {
setSelectedParticipantId(id);
setIsEditing(false);
},
});
setSelectedParticipantId(newParticipant.id);
setIsEditing(false);
}
})}
>
<div className="flex flex-col space-y-2 border-b bg-gray-50 p-2">
<div className="flex space-x-2">
{!isEditing ? (
{selectedParticipantId || !isEditing ? (
<Listbox
value={selectedParticipantId}
onChange={(participantId) => {
@ -168,22 +161,8 @@ const MobilePoll: React.VoidFunctionComponent = () => {
</div>
</Listbox>
) : (
<div className="grow">
<Controller
name="name"
control={control}
rules={{ validate: requiredString }}
render={({ field }) => (
<NameInput
autoFocus={true}
disabled={formState.isSubmitting}
className={clsx("input w-full", {
"input-error": formState.errors.name,
})}
{...field}
/>
)}
/>
<div className="flex grow items-center px-1">
<You />
</div>
)}
{isEditing ? (
@ -212,7 +191,6 @@ const MobilePoll: React.VoidFunctionComponent = () => {
onClick={() => {
setIsEditing(true);
reset({
name: selectedParticipant.name,
votes: optionIds.map((optionId) => ({
optionId,
type: getVote(selectedParticipant.id, optionId),
@ -250,7 +228,6 @@ const MobilePoll: React.VoidFunctionComponent = () => {
disabled={poll.closed}
onClick={() => {
reset({
name: "",
votes: [],
});
setIsEditing(true);
@ -296,13 +273,12 @@ const MobilePoll: React.VoidFunctionComponent = () => {
>
<div className="space-y-3 border-t bg-gray-50 p-3">
<Button
icon={<Check />}
className="w-full"
htmlType="submit"
type="primary"
loading={formState.isSubmitting}
>
{t("save")}
{selectedParticipantId ? t("save") : t("continue")}
</Button>
</div>
</motion.div>

View file

@ -102,7 +102,7 @@ const PollOptionVoteSummary: React.VoidFunctionComponent<{ optionId: string }> =
<div className="col-span-1 space-y-2">
{participantsWhoVotedYes.map(({ name }, i) => (
<div key={i} className="flex">
<div className="relative mr-2 flex h-5 w-5 items-center justify-center">
<div className="relative mr-2 flex items-center justify-center">
<UserAvatar name={name} />
<VoteIcon
type="yes"
@ -117,7 +117,7 @@ const PollOptionVoteSummary: React.VoidFunctionComponent<{ optionId: string }> =
<div className="col-span-1 space-y-2">
{participantsWhoVotedIfNeedBe.map(({ name }, i) => (
<div key={i} className="flex">
<div className="relative mr-2 flex h-5 w-5 items-center justify-center">
<div className="relative mr-2 flex items-center justify-center">
<UserAvatar name={name} />
<VoteIcon
type="ifNeedBe"
@ -130,7 +130,7 @@ const PollOptionVoteSummary: React.VoidFunctionComponent<{ optionId: string }> =
))}
{participantsWhoVotedNo.map(({ name }, i) => (
<div key={i} className="flex">
<div className="relative mr-2 flex h-5 w-5 items-center justify-center">
<div className="relative mr-2 flex items-center justify-center">
<UserAvatar name={name} />
<VoteIcon
type="no"
@ -232,7 +232,7 @@ const PollOption: React.VoidFunctionComponent<PollOptionProps> = ({
exit={{ opacity: 0, x: -10 }}
type="button"
onTouchStart={(e) => e.stopPropagation()}
className="flex min-w-0 justify-end gap-1 overflow-hidden rounded-lg p-2 active:bg-slate-500/10"
className="flex min-w-0 justify-end gap-1 overflow-hidden p-1 active:bg-slate-500/10"
onClick={(e) => {
e.stopPropagation();
setExpanded((value) => !value);
@ -262,10 +262,10 @@ const PollOption: React.VoidFunctionComponent<PollOptionProps> = ({
{editable ? (
<div className="flex h-full items-center justify-center">
<VoteSelector
className="w-9"
ref={selectorRef}
value={vote}
onChange={onChange}
className="w-9"
/>
</div>
) : (

View file

@ -24,13 +24,9 @@ export const useAddParticipantMutation = () => {
queryClient.setQueryData(
["polls.participants.list", { pollId: participant.pollId }],
(existingParticipants = []) => {
return [...existingParticipants, participant];
return [participant, ...existingParticipants];
},
);
queryClient.invalidateQueries([
"polls.participants.list",
{ pollId: participant.pollId },
]);
},
});
};

View file

@ -11,7 +11,7 @@ const PollSubheader: React.VoidFunctionComponent = () => {
const { t } = useTranslation("app");
const { dayjs } = useDayjs();
return (
<div className="text-slate-500/75 lg:text-lg">
<div className="text-slate-500/75">
<div className="md:inline">
<Trans
i18nKey="createdBy"

View file

@ -2,7 +2,7 @@ import { AnimatePresence, motion } from "framer-motion";
import * as React from "react";
import { usePrevious } from "react-use";
import User from "@/components/icons/user-solid.svg";
import CheckCircle from "@/components/icons/check-circle.svg";
export interface PopularityScoreProps {
yesScore: number;
@ -21,7 +21,7 @@ export const ScoreSummary: React.VoidFunctionComponent<PopularityScoreProps> =
data-testid="popularity-score"
className="flex items-center gap-1 text-sm font-bold tabular-nums"
>
<User className="inline-block h-4 text-slate-300 transition-opacity" />
<CheckCircle className="inline-block h-4 text-slate-300 transition-opacity" />
<AnimatePresence initial={false} exitBeforeEnter={true}>
<motion.span
transition={{

View file

@ -1,7 +1,6 @@
import { VoteType } from "@prisma/client";
export interface ParticipantForm {
name: string;
votes: Array<
| {
optionId: string;
@ -12,6 +11,5 @@ export interface ParticipantForm {
}
export interface ParticipantFormSubmitted {
name: string;
votes: Array<{ optionId: string; type: VoteType }>;
}

View file

@ -13,7 +13,7 @@ export const UnverifiedPollNotice = () => {
return (
<div className="space-y-3 rounded-md border border-amber-200 bg-amber-100 p-3 text-gray-700 shadow-sm">
<div className="p-1">
<div className="px-1">
<Trans
t={t}
i18nKey="unverifiedMessage"

View file

@ -19,15 +19,11 @@ const UserAvatarContext =
React.createContext<((name: string) => string) | null>(null);
const colors = [
"bg-fuchsia-300",
"bg-purple-400",
"bg-primary-400",
"bg-blue-400",
"bg-violet-400",
"bg-sky-400",
"bg-cyan-400",
"bg-sky-400",
"bg-blue-400",
"bg-primary-400",
"bg-indigo-400",
"bg-purple-400",
"bg-fuchsia-400",
"bg-pink-400",
@ -46,9 +42,9 @@ export const UserAvatarProvider: React.VoidFunctionComponent<{
const res = {
"": defaultColor,
};
for (let i = 0; i < names.length; i++) {
for (let i = names.length - 1; i >= 0; i--) {
const name = names[i].trim().toLowerCase();
const color = colors[(seedValue + i) % colors.length];
const color = colors[(seedValue + names.length - i) % colors.length];
res[name] = color;
}
return res;
@ -90,15 +86,14 @@ const UserAvatarInner: React.VoidFunctionComponent<UserAvaterProps> = ({
return (
<span
className={clsx(
"inline-block h-5 w-5 shrink-0 rounded-full text-center text-white",
"inline-block shrink-0 rounded-full text-center text-white",
color,
{
"h-5 w-5 text-xs leading-5": size === "default",
"h-6 w-6 text-xs leading-6": size === "default",
"h-10 w-10 leading-10": size === "large",
},
className,
)}
title={name}
>
{trimmedName[0]?.toUpperCase()}
</span>
@ -124,9 +119,7 @@ const UserAvatar: React.VoidFunctionComponent<UserAvaterProps> = ({
)}
>
<UserAvatarInner {...forwardedProps} />
<div className="min-w-0 truncate" title={forwardedProps.name}>
{forwardedProps.name}
</div>
<div className="min-w-0 truncate">{forwardedProps.name}</div>
{isYou ? <Badge>{t("you")}</Badge> : null}
</div>
);

View file

@ -1,6 +1,5 @@
import { VoteType } from "@prisma/client";
import clsx from "clsx";
import { AnimatePresence, motion } from "framer-motion";
import * as React from "react";
import VoteIcon from "./vote-icon";
@ -37,17 +36,7 @@ export const VoteSelector = React.forwardRef<
onBlur={onBlur}
onKeyDown={onKeyDown}
className={clsx(
"group relative inline-flex h-9 w-full items-center justify-center overflow-hidden rounded-md border bg-white transition-all hover:ring-4 focus-visible:border-0 focus-visible:ring-2 focus-visible:ring-primary-500",
{
"border-green-200 bg-green-50 hover:ring-green-100/50 active:bg-green-100/50":
value === "yes",
"border-amber-200 bg-amber-50 hover:ring-amber-100/50 active:bg-amber-100/50":
value === "ifNeedBe",
"border-gray-200 bg-gray-50 hover:ring-gray-100/50 active:bg-gray-100/50":
value === "no",
"border-gray-200 hover:ring-gray-100/50 active:bg-gray-100/50":
value === undefined,
},
"btn-default relative items-center justify-center overflow-hidden px-0",
className,
)}
onClick={() => {
@ -55,18 +44,7 @@ export const VoteSelector = React.forwardRef<
}}
ref={ref}
>
<AnimatePresence initial={false}>
<motion.span
className="absolute flex items-center justify-center"
transition={{ duration: 0.2 }}
initial={{ opacity: 0, scale: 1.5, y: -45 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.5, y: 45 }}
key={value}
>
<VoteIcon type={value} />
</motion.span>
</AnimatePresence>
<VoteIcon type={value} />
</button>
);
});

View file

@ -19,7 +19,7 @@ const Switch: React.VoidFunctionComponent<SwitchProps> = ({
checked={checked}
onChange={onChange}
className={clsx(
"relative inline-flex h-6 w-10 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75",
"relative inline-flex h-6 w-10 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out",
{
"bg-gray-200": !checked,
"bg-green-500": checked,

View file

@ -20,7 +20,7 @@ export const TextInput = React.forwardRef<HTMLInputElement, TextInputProps>(
ref={ref}
type="text"
className={clsx(
"appearance-none rounded-md border border-gray-300 text-slate-700 shadow-sm placeholder:text-slate-400 focus:border-primary-500 focus:ring-1 focus:ring-primary-500",
"appearance-none rounded border border-gray-300 text-slate-700 shadow-sm placeholder:text-slate-400",
className,
{
"px-2 py-1": size === "md",

8
src/components/you.tsx Normal file
View file

@ -0,0 +1,8 @@
import { useTranslation } from "next-i18next";
import UserAvatar from "./poll/user-avatar";
export const You = () => {
const { t } = useTranslation("app");
return <UserAvatar name={t("you")} showName={true} />;
};

View file

@ -15,6 +15,7 @@ import { trpcNext } from "@/utils/trpc";
import { withPageTranslations } from "@/utils/with-page-translations";
import { AdminControls } from "../../components/admin-control";
import ModalProvider from "../../components/modal/modal-provider";
const PollPageLoader: NextPage = () => {
const { query } = useRouter();
@ -35,11 +36,13 @@ const PollPageLoader: NextPage = () => {
<ParticipantsProvider pollId={poll.id}>
<StandardLayout>
<PollContextProvider poll={poll} urlId={urlId} admin={true}>
<div className="flex flex-col space-y-3 p-3 sm:space-y-4 sm:p-4">
<AdminControls>
<Poll />
</AdminControls>
</div>
<ModalProvider>
<div className="flex flex-col space-y-3 p-3 sm:space-y-4 sm:p-4">
<AdminControls>
<Poll />
</AdminControls>
</div>
</ModalProvider>
</PollContextProvider>
</StandardLayout>
</ParticipantsProvider>

View file

@ -13,6 +13,7 @@ import { trpcNext } from "@/utils/trpc";
import { withPageTranslations } from "@/utils/with-page-translations";
import { ParticipantLayout } from "../../components/layouts/participant-layout";
import ModalProvider from "../../components/modal/modal-provider";
import { DayjsProvider } from "../../utils/dayjs";
const Page: NextPage = () => {
@ -36,17 +37,19 @@ const Page: NextPage = () => {
<ParticipantsProvider pollId={poll.id}>
<ParticipantLayout>
<PollContextProvider poll={poll} urlId={urlId} admin={false}>
<div className="space-y-3 sm:space-y-4">
{user.id === poll.user.id ? (
<Link
className="btn-default"
href={`/admin/${poll.adminUrlId}`}
>
&larr; {t("goToAdmin")}
</Link>
) : null}
<Poll />
</div>
<ModalProvider>
<div className="space-y-3 sm:space-y-4">
{user.id === poll.user.id ? (
<Link
className="btn-default"
href={`/admin/${poll.adminUrlId}`}
>
&larr; {t("goToAdmin")}
</Link>
) : null}
<Poll />
</div>
</ModalProvider>
</PollContextProvider>
</ParticipantLayout>
</ParticipantsProvider>

View file

@ -20,7 +20,7 @@ export const participants = createRouter()
},
orderBy: [
{
createdAt: "asc",
createdAt: "desc",
},
{ name: "desc" },
],
@ -86,7 +86,6 @@ export const participants = createRouter()
input: z.object({
pollId: z.string(),
participantId: z.string(),
name: z.string(),
votes: z
.object({
optionId: z.string(),
@ -94,7 +93,7 @@ export const participants = createRouter()
})
.array(),
}),
resolve: async ({ input: { pollId, participantId, votes, name } }) => {
resolve: async ({ input: { pollId, participantId, votes } }) => {
const participant = await prisma.participant.update({
where: {
id: participantId,
@ -112,7 +111,6 @@ export const participants = createRouter()
})),
},
},
name,
},
include: {
votes: true,

View file

@ -1,4 +1,31 @@
import { useTranslation } from "next-i18next";
/**
* @deprecated Use form validation hook instead
*/
export const requiredString = (value: string) => !!value.trim();
/**
* @deprecated Use form validation hook instead
*/
export const validEmail = (value: string) =>
/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value);
export const useFormValidation = () => {
const { t } = useTranslation("app");
return {
requiredString: (name?: string) => (value: string) => {
if (!value.trim()) {
return t("requiredString", { name });
}
},
validEmail: (value: string) => {
const isValidEmail = validEmail(value);
if (!isValidEmail) {
return t("validEmail");
}
},
};
};

View file

@ -26,15 +26,22 @@
h2 {
@apply text-xl;
}
input {
@apply outline-none;
}
label {
@apply mb-1 block text-sm text-slate-800;
}
a,
button,
input,
select,
textarea {
@apply rounded outline-none transition-shadow focus:border-transparent focus:ring-2 focus:ring-slate-300;
}
a,
button {
@apply outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1;
@apply focus:ring-offset-2 focus:ring-offset-white;
}
#floating-ui-root {
@ -44,13 +51,13 @@
@layer components {
.text-link {
@apply rounded-sm font-medium text-primary-500 outline-none hover:text-primary-500 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1;
@apply rounded font-medium text-primary-500 outline-none hover:text-primary-500 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1;
}
.formField {
@apply mb-4;
}
.input {
@apply appearance-none rounded-md border border-gray-200 px-2 py-1 text-slate-700 placeholder:text-slate-400 focus:border-primary-500 focus:ring-1 focus:ring-primary-500;
@apply appearance-none border px-2 py-1 text-slate-700 placeholder:text-slate-400;
}
input.input {
@apply h-9;
@ -59,20 +66,20 @@
@apply input px-3 py-3;
}
.input-error {
@apply border-rose-500 ring-1 ring-rose-400 focus:border-rose-400 focus:ring-rose-500;
@apply focus:ring-rose-500;
}
.checkbox {
@apply h-4 w-4 rounded border-slate-300 text-primary-500 shadow-sm focus:ring-primary-500;
}
.btn {
@apply inline-flex h-9 select-none items-center justify-center whitespace-nowrap rounded-md border px-3 font-medium shadow-sm transition-all;
@apply inline-flex h-9 select-none items-center justify-center whitespace-nowrap border px-3 font-medium shadow-sm;
}
a.btn {
@apply cursor-pointer hover:no-underline;
}
.btn-default {
@apply btn bg-white text-slate-700 hover:bg-gray-50 active:bg-slate-100;
@apply btn bg-white text-slate-700 hover:bg-gray-50 active:bg-gray-100;
}
a.btn-default {
@ -90,7 +97,7 @@
}
.btn-primary {
text-shadow: rgb(0 0 0 / 20%) 0px 1px 1px;
@apply btn border-primary-600 bg-primary-500 text-white hover:bg-opacity-90 focus-visible:ring-primary-500 active:bg-primary-600;
@apply btn border-primary-600 bg-primary-500 text-white hover:bg-opacity-90 active:bg-primary-600;
}
a.btn-primary {
@ -102,7 +109,7 @@
}
.segment-button button {
@apply inline-flex grow items-center justify-center border-t border-b border-r bg-gray-50 px-4 transition-colors first:rounded-l first:border-l last:rounded-r hover:bg-slate-50 focus:z-10 focus-visible:ring-2 focus-visible:ring-offset-0 active:bg-slate-100;
@apply inline-flex grow items-center justify-center border-t border-b border-r bg-gray-50 px-4 transition-colors first:rounded-r-none first:border-l last:rounded-l-none hover:bg-slate-50 focus:z-10 active:bg-slate-100;
}
.segment-button .segment-button-active {

View file

@ -45,9 +45,5 @@ module.exports = {
},
},
},
plugins: [
require("@tailwindcss/forms"),
require("@tailwindcss/typography"),
require("tailwindcss-animate"),
],
plugins: [require("@tailwindcss/typography"), require("tailwindcss-animate")],
};

View file

@ -6,13 +6,16 @@ test("should be able to vote and comment on a poll", async ({ page }) => {
await expect(page.locator('text="Lunch Meeting"')).toBeVisible();
await page.click("text='New'");
await page.click('text="New"');
await page.click("data-testid=poll-option >> nth=0");
await page.click("data-testid=poll-option >> nth=1");
await page.click("data-testid=poll-option >> nth=3");
await page.type('[placeholder="Your name…"]', "Test user");
await page.click("text=Save");
await page.getByText("Continue").click();
await page.getByPlaceholder("Jessie Smith").type("Test user");
await page.getByText("Save").click();
await expect(page.locator("data-testid=user")).toBeVisible();
await expect(
page.locator("data-testid=participant-selector").locator("text=You"),

View file

@ -11,16 +11,20 @@ test("should be able to vote and comment on a poll", async ({ page }) => {
await expect(page.locator('text="Lunch Meeting"')).toBeVisible();
await page.type('[placeholder="Your name…"]', "Test user");
// There is a hidden checkbox (nth=0) that exists so that the behaviour of the form is consistent even
// when we only have a single option/checkbox.
await page.locator("data-testid=vote-selector >> nth=0").click();
await page.locator("data-testid=vote-selector >> nth=2").click();
await page.click("button >> text='Continue'");
await page.type('[placeholder="Jessie Smith"]', "Test user");
await page.click("text='Save'");
await expect(page.locator("text='Test user'")).toBeVisible();
await expect(
page.locator("data-testid=participant-row >> nth=4").locator("text=You"),
page.locator("data-testid=participant-row >> nth=0").locator("text=You"),
).toBeVisible();
await page.type(
"[placeholder='Leave a comment on this poll (visible to everyone)']",
"This is a comment!",

View file

@ -1932,13 +1932,6 @@
dependencies:
tslib "^2.4.0"
"@tailwindcss/forms@^0.5.3":
version "0.5.3"
resolved "https://registry.yarnpkg.com/@tailwindcss/forms/-/forms-0.5.3.tgz#e4d7989686cbcaf416c53f1523df5225332a86e7"
integrity sha512-y5mb86JUoiUgBjY/o6FJSFZSEttfb3Q5gllE4xoKjAAD+vBrnIhE4dViwUuow3va8mpH4s9jyUbUbrRGoRdc2Q==
dependencies:
mini-svg-data-uri "^1.2.3"
"@tailwindcss/typography@^0.5.9":
version "0.5.9"
resolved "https://registry.yarnpkg.com/@tailwindcss/typography/-/typography-0.5.9.tgz#027e4b0674929daaf7c921c900beee80dbad93e8"
@ -5868,11 +5861,6 @@ min-indent@^1.0.0:
resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869"
integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==
mini-svg-data-uri@^1.2.3:
version "1.4.3"
resolved "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.3.tgz"
integrity sha512-gSfqpMRC8IxghvMcxzzmMnWpXAChSA+vy4cia33RgerMS8Fex95akUyQZPbxJJmeBGiGmK7n/1OpUX8ksRjIdA==
minimatch@^3.0.4:
version "3.0.4"
resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz"