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", "@radix-ui/react-popover": "^1.0.3",
"@sentry/nextjs": "^7.33.0", "@sentry/nextjs": "^7.33.0",
"@svgr/webpack": "^6.2.1", "@svgr/webpack": "^6.2.1",
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.9", "@tailwindcss/typography": "^0.5.9",
"@tanstack/react-query": "^4.16.1", "@tanstack/react-query": "^4.16.1",
"@trpc/client": "^10.0.0-rc.8", "@trpc/client": "^10.0.0-rc.8",

View file

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

View file

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

View file

@ -90,7 +90,7 @@
"profileUser": "Profil - {{username}}", "profileUser": "Profil - {{username}}",
"requiredNameError": "Navn er påkrævet", "requiredNameError": "Navn er påkrævet",
"save": "Gem", "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", "share": "Del",
"shareDescription": "Giv dette link til dine <b>deltagere</b> for at give dem mulighed for at stemme på din afstemning.", "shareDescription": "Giv dette link til dine <b>deltagere</b> for at give dem mulighed for at stemme på din afstemning.",
"shareLink": "Del via link", "shareLink": "Del via link",

View file

@ -102,7 +102,7 @@
"requiredNameError": "Bitte gib einen Namen an", "requiredNameError": "Bitte gib einen Namen an",
"resendVerificationCode": "Bestätigungscode erneut senden", "resendVerificationCode": "Bestätigungscode erneut senden",
"save": "Speichern", "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", "share": "Teilen",
"shareDescription": "Gib diesen Link deinen <b>Teilnehmern</b> damit sie an deiner Umfrage teilnehmen können.", "shareDescription": "Gib diesen Link deinen <b>Teilnehmern</b> damit sie an deiner Umfrage teilnehmen können.",
"shareLink": "Über Link teilen", "shareLink": "Über Link teilen",

View file

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

View file

@ -102,7 +102,7 @@
"requiredNameError": "El nombre es obligatorio", "requiredNameError": "El nombre es obligatorio",
"resendVerificationCode": "Reenviar el código de verificación", "resendVerificationCode": "Reenviar el código de verificación",
"save": "Guardar", "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", "share": "Compartir",
"shareDescription": "Da este enlace a tus <b>participantes</b> para permitirles votar en tu encuesta.", "shareDescription": "Da este enlace a tus <b>participantes</b> para permitirles votar en tu encuesta.",
"shareLink": "Compartir con un enlace", "shareLink": "Compartir con un enlace",

View file

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

View file

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

View file

@ -99,7 +99,7 @@
"requiredNameError": "Le nom est obligatoire", "requiredNameError": "Le nom est obligatoire",
"resendVerificationCode": "Renvoyer le code de vérification", "resendVerificationCode": "Renvoyer le code de vérification",
"save": "Sauvegarder", "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", "share": "Partager",
"shareDescription": "Donnez ce lien à vos <b>participants</b> pour leur permettre de voter sur votre sondage.", "shareDescription": "Donnez ce lien à vos <b>participants</b> pour leur permettre de voter sur votre sondage.",
"shareLink": "Partager via un lien", "shareLink": "Partager via un lien",

View file

@ -102,7 +102,7 @@
"requiredNameError": "Ime je obavezno", "requiredNameError": "Ime je obavezno",
"resendVerificationCode": "Ponovno pošalji poruku za potvrdu", "resendVerificationCode": "Ponovno pošalji poruku za potvrdu",
"save": "Pohrani", "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", "share": "Podijeli",
"shareDescription": "Pošaljite ovu poveznicu <b>sudionicima</b> kako biste im omogućili glasanje u vašoj anketi.", "shareDescription": "Pošaljite ovu poveznicu <b>sudionicima</b> kako biste im omogućili glasanje u vašoj anketi.",
"shareLink": "Podijeli putem poveznice", "shareLink": "Podijeli putem poveznice",

View file

@ -102,7 +102,7 @@
"requiredNameError": "Név megadása kötelező", "requiredNameError": "Név megadása kötelező",
"resendVerificationCode": "Hitelesítő kód újraküldése", "resendVerificationCode": "Hitelesítő kód újraküldése",
"save": "Mentés", "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", "share": "Megosztás",
"shareDescription": "Küldd el ezt a linket a <b>résztvevőidnek</b>, hogy tudjanak szavazatokat leadni.", "shareDescription": "Küldd el ezt a linket a <b>résztvevőidnek</b>, hogy tudjanak szavazatokat leadni.",
"shareLink": "Megosztás linkkel", "shareLink": "Megosztás linkkel",

View file

@ -90,7 +90,7 @@
"profileUser": "Profilo - {{username}}", "profileUser": "Profilo - {{username}}",
"requiredNameError": "Il nome è obbligatorio", "requiredNameError": "Il nome è obbligatorio",
"save": "Salva", "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", "share": "Condividi",
"shareDescription": "Dai questo link ai <b>partecipanti</b> per permette loro di votare al tuo sondaggio.", "shareDescription": "Dai questo link ai <b>partecipanti</b> per permette loro di votare al tuo sondaggio.",
"shareLink": "Condividi via link", "shareLink": "Condividi via link",

View file

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

View file

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

View file

@ -94,7 +94,7 @@
"profileUser": "Profil - {{username}}", "profileUser": "Profil - {{username}}",
"requiredNameError": "Imię jest wymagane", "requiredNameError": "Imię jest wymagane",
"save": "Zapisz", "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", "share": "Udostępnij",
"shareDescription": "Przekaż ten link <b>uczestnikom</b>, aby mogli zagłosować na ankietę.", "shareDescription": "Przekaż ten link <b>uczestnikom</b>, aby mogli zagłosować na ankietę.",
"shareLink": "Udostępnij link", "shareLink": "Udostępnij link",

View file

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

View file

@ -90,7 +90,7 @@
"profileUser": "Perfil - {{username}}", "profileUser": "Perfil - {{username}}",
"requiredNameError": "O nome é obrigatório", "requiredNameError": "O nome é obrigatório",
"save": "Guardar", "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", "share": "Partilhar",
"shareDescription": "Dê este link aos seus <b>participantes</b> para permitir que eles votem na sua sondagem.", "shareDescription": "Dê este link aos seus <b>participantes</b> para permitir que eles votem na sua sondagem.",
"shareLink": "Partilhar via link", "shareLink": "Partilhar via link",

View file

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

View file

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

View file

@ -102,7 +102,7 @@
"requiredNameError": "Namn är obligatoriskt", "requiredNameError": "Namn är obligatoriskt",
"resendVerificationCode": "Skicka verifieringskoden igen", "resendVerificationCode": "Skicka verifieringskoden igen",
"save": "Spara", "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", "share": "Dela",
"shareDescription": "Ge den här länken till dina <b>deltagare</b> så att de kan delta i din förfrågan.", "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", "shareLink": "Dela via länk",

View file

@ -69,7 +69,7 @@
"participantCount_other": "{{count}} katılımcı", "participantCount_other": "{{count}} katılımcı",
"pollOwnerNotice": "Selam {{name}}, görünüşe göre bu anketin sahibi sensin.", "pollOwnerNotice": "Selam {{name}}, görünüşe göre bu anketin sahibi sensin.",
"profileUser": "Profil - {{username}}", "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.", "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}}", "stepSummary": "Aşama: {{current}} / {{total}}",
"sunday": "Pazar", "sunday": "Pazar",

View file

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

View file

@ -13,7 +13,7 @@ export const LoginModal: React.VoidFunctionComponent<{
const [defaultEmail, setDefaultEmail] = React.useState(""); const [defaultEmail, setDefaultEmail] = React.useState("");
return ( 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"> <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" /> <Logo className="text-2xl" />
</div> </div>

View file

@ -111,12 +111,18 @@ const MonthCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
); );
})} })}
</div> </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) => { {datepicker.days.map((day, i) => {
return ( return (
<div
key={i}
className={clsx("h-12", {
"border-r": (i + 1) % 7 !== 0,
"border-b": i < datepicker.days.length - 7,
})}
>
<button <button
type="button" type="button"
key={i}
onClick={() => { onClick={() => {
if ( if (
datepicker.selection.some((selectedDate) => datepicker.selection.some((selectedDate) =>
@ -155,13 +161,11 @@ const MonthCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
} }
}} }}
className={clsx( 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", "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, "bg-slate-50 text-slate-400": day.outOfMonth,
"font-bold": day.today, "font-bold": day.today,
"text-primary-500": day.today && !day.selected, "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-['']": "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, day.selected,
}, },
@ -169,6 +173,7 @@ const MonthCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
> >
<span className="z-10">{day.day}</span> <span className="z-10">{day.day}</span>
</button> </button>
</div>
); );
})} })}
</div> </div>

View file

@ -29,13 +29,13 @@ const Hero: React.VoidFunctionComponent = () => {
<div className="space-x-3"> <div className="space-x-3">
<a <a
href="/new" 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")} {t("getStarted")}
</a> </a>
<a <a
href="/demo" 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" rel="nofollow"
> >
{t("liveDemo")} {t("liveDemo")}

View file

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

View file

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

View file

@ -42,7 +42,7 @@ export const MobileNavigation = (props: { className?: string }) => {
return ( return (
<div <div
className={clsx( 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, "bg-gray-50/75 shadow-sm backdrop-blur-md ": isPinned,
"border-transparent bg-gray-50/0 shadow-none": !isPinned, "border-transparent bg-gray-50/0 shadow-none": !isPinned,
@ -56,7 +56,7 @@ export const MobileNavigation = (props: { className?: string }) => {
<button <button
role="button" role="button"
type="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" /> <Menu className="mr-2 w-5 group-hover:text-primary-500" />
<Logo /> <Logo />
@ -69,7 +69,7 @@ export const MobileNavigation = (props: { className?: string }) => {
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
{user ? null : ( {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" /> <Login className="h-5 opacity-75" />
<span className="inline-block">{t("app:login")}</span> <span className="inline-block">{t("app:login")}</span>
</LoginLink> </LoginLink>
@ -83,7 +83,7 @@ export const MobileNavigation = (props: { className?: string }) => {
role="button" role="button"
data-testid="user" data-testid="user"
className={clsx( 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, "opacity-50": isUpdating,
}, },
@ -105,7 +105,7 @@ export const MobileNavigation = (props: { className?: string }) => {
<button <button
role="button" role="button"
type="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" /> <Adjustments className="h-5 opacity-75 group-hover:text-primary-500" />
<span className="ml-2 hidden sm:block"> <span className="ml-2 hidden sm:block">
@ -130,14 +130,14 @@ const AppMenu: React.VoidFunctionComponent<{ className?: string }> = ({
<div className={clsx("space-y-1", className)}> <div className={clsx("space-y-1", className)}>
<Link <Link
href="/" 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 " /> <Home className="h-5 opacity-75 " />
<span className="inline-block">{t("app:home")}</span> <span className="inline-block">{t("app:home")}</span>
</Link> </Link>
<Link <Link
href="/new" 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 " /> <Pencil className="h-5 opacity-75 " />
<span className="inline-block">{t("app:createNew")}</span> <span className="inline-block">{t("app:createNew")}</span>
@ -145,7 +145,7 @@ const AppMenu: React.VoidFunctionComponent<{ className?: string }> = ({
<a <a
target="_blank" target="_blank"
href="https://support.rallly.co" 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" rel="noreferrer"
> >
<Support className="h-5 opacity-75" /> <Support className="h-5 opacity-75" />

View file

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

View file

@ -57,7 +57,7 @@ const Modal: React.VoidFunctionComponent<ModalProps> = ({
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} 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 <motion.div
transition={{ duration: 0.1 }} transition={{ duration: 0.1 }}
@ -66,11 +66,11 @@ const Modal: React.VoidFunctionComponent<ModalProps> = ({
exit={{ opacity: 0, scale: 0.9 }} exit={{ opacity: 0, scale: 0.9 }}
className="relative z-50 my-8 inline-block max-w-full transform text-left align-middle" 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 ? ( {showClose ? (
<button <button
role="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} onClick={onCancel}
> >
<X className="h-4" /> <X className="h-4" />
@ -91,7 +91,7 @@ const Modal: React.VoidFunctionComponent<ModalProps> = ({
</div> </div>
)} )}
{footer === undefined ? ( {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 ? ( {cancelText ? (
<Button <Button
onClick={() => { 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"> <div className="mx-auto max-w-full space-y-3 sm:space-y-4 lg:mx-0">
{props.children} {props.children}
{poll.demo ? ( {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="flex items-center 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-6" />
<Exclamation className="w-7" />
</div>
<div>{t("demoPollNotice")}</div> <div>{t("demoPollNotice")}</div>
</div> </div>
) : null} ) : null}
{poll.closed ? ( {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="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-6" />
<LockClosed className="w-7" />
</div>
<div>{t("pollHasBeenLocked")}</div> <div>{t("pollHasBeenLocked")}</div>
</div> </div>
) : null} ) : null}
<div className="rounded-md border bg-white shadow-sm md:overflow-hidden"> <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 className="space-y-3">
<div> <div>
<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" data-testid="poll-title"
> >
{preventWidows(poll.title)} {preventWidows(poll.title)}
@ -77,14 +73,14 @@ export const Poll = (props: { children?: React.ReactNode }) => {
<PollSubheader /> <PollSubheader />
</div> </div>
{poll.description ? ( {poll.description ? (
<div className="border-primary whitespace-pre-line lg:text-lg"> <div className="border-primary whitespace-pre-line">
<TruncatedLinkify> <TruncatedLinkify>
{preventWidows(poll.description)} {preventWidows(poll.description)}
</TruncatedLinkify> </TruncatedLinkify>
</div> </div>
) : null} ) : null}
{poll.location ? ( {poll.location ? (
<div className="lg:text-lg"> <div>
<div className="text-sm text-slate-500"> <div className="text-sm text-slate-500">
{t("location")} {t("location")}
</div> </div>
@ -98,17 +94,15 @@ export const Poll = (props: { children?: React.ReactNode }) => {
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<span className="inline-flex items-center space-x-1"> <span className="inline-flex items-center space-x-1">
<VoteIcon type="yes" /> <VoteIcon type="yes" />
<span className="text-xs text-slate-500">{t("yes")}</span> <span>{t("yes")}</span>
</span> </span>
<span className="inline-flex items-center space-x-1"> <span className="inline-flex items-center space-x-1">
<VoteIcon type="ifNeedBe" /> <VoteIcon type="ifNeedBe" />
<span className="text-xs text-slate-500"> <span>{t("ifNeedBe")}</span>
{t("ifNeedBe")}
</span>
</span> </span>
<span className="inline-flex items-center space-x-1"> <span className="inline-flex items-center space-x-1">
<VoteIcon type="no" /> <VoteIcon type="no" />
<span className="text-xs text-slate-500">{t("no")}</span> <span>{t("no")}</span>
</span> </span>
</div> </div>
</div> </div>

View file

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

View file

@ -3,30 +3,27 @@ import { useTranslation } from "next-i18next";
import * as React from "react"; import * as React from "react";
import { Controller, useForm } from "react-hook-form"; 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 { usePoll } from "../../poll-context";
import { normalizeVotes } from "../mutations"; import { normalizeVotes } from "../mutations";
import { ParticipantForm, ParticipantFormSubmitted } from "../types"; import { ParticipantForm, ParticipantFormSubmitted } from "../types";
import UserAvatar from "../user-avatar";
import { VoteSelector } from "../vote-selector"; import { VoteSelector } from "../vote-selector";
import ControlledScrollArea from "./controlled-scroll-area"; import ControlledScrollArea from "./controlled-scroll-area";
import { usePollContext } from "./poll-context"; import { usePollContext } from "./poll-context";
export interface ParticipantRowFormProps { export interface ParticipantRowFormProps {
name?: string;
defaultValues?: Partial<ParticipantForm>; defaultValues?: Partial<ParticipantForm>;
onSubmit: (data: ParticipantFormSubmitted) => Promise<void>; onSubmit: (data: ParticipantFormSubmitted) => Promise<void>;
className?: string; className?: string;
isYou?: boolean;
onCancel?: () => void; onCancel?: () => void;
} }
const ParticipantRowForm: React.ForwardRefRenderFunction< const ParticipantRowForm: React.ForwardRefRenderFunction<
HTMLFormElement, HTMLFormElement,
ParticipantRowFormProps ParticipantRowFormProps
> = ({ defaultValues, onSubmit, className, onCancel }, ref) => { > = ({ defaultValues, onSubmit, name, isYou, className, onCancel }, ref) => {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const { const {
columnWidth, columnWidth,
@ -34,20 +31,11 @@ const ParticipantRowForm: React.ForwardRefRenderFunction<
sidebarWidth, sidebarWidth,
numberOfColumns, numberOfColumns,
goToNextPage, goToNextPage,
goToPreviousPage,
maxScrollPosition,
setScrollPosition,
} = usePollContext(); } = usePollContext();
const { options, optionIds } = usePoll(); const { options, optionIds } = usePoll();
const { const { handleSubmit, control } = useForm({
handleSubmit,
control,
formState: { errors, submitCount },
reset,
} = useForm({
defaultValues: { defaultValues: {
name: "",
votes: [], votes: [],
...defaultValues, ...defaultValues,
}, },
@ -71,49 +59,15 @@ const ParticipantRowForm: React.ForwardRefRenderFunction<
<form <form
id="participant-row-form" id="participant-row-form"
ref={ref} ref={ref}
onSubmit={handleSubmit(async ({ name, votes }) => { onSubmit={handleSubmit(async ({ votes }) => {
await onSubmit({ await onSubmit({
name,
votes: normalizeVotes(optionIds, votes), 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 }}> <div className="flex items-center px-3" style={{ width: sidebarWidth }}>
<Controller <UserAvatar name={name ?? t("you")} isYou={isYou} showName={true} />
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> </div>
<Controller <Controller
control={control} control={control}
@ -127,10 +81,11 @@ const ParticipantRowForm: React.ForwardRefRenderFunction<
return ( return (
<div <div
key={optionId} 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 }} style={{ width: columnWidth }}
> >
<VoteSelector <VoteSelector
className="h-full w-full"
value={value?.type} value={value?.type}
onKeyDown={(e) => { onKeyDown={(e) => {
if ( 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> </form>
); );
}; };

View file

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

View file

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

View file

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

View file

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

View file

@ -24,13 +24,9 @@ export const useAddParticipantMutation = () => {
queryClient.setQueryData( queryClient.setQueryData(
["polls.participants.list", { pollId: participant.pollId }], ["polls.participants.list", { pollId: participant.pollId }],
(existingParticipants = []) => { (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 { t } = useTranslation("app");
const { dayjs } = useDayjs(); const { dayjs } = useDayjs();
return ( return (
<div className="text-slate-500/75 lg:text-lg"> <div className="text-slate-500/75">
<div className="md:inline"> <div className="md:inline">
<Trans <Trans
i18nKey="createdBy" i18nKey="createdBy"

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,5 @@
import { VoteType } from "@prisma/client"; import { VoteType } from "@prisma/client";
import clsx from "clsx"; import clsx from "clsx";
import { AnimatePresence, motion } from "framer-motion";
import * as React from "react"; import * as React from "react";
import VoteIcon from "./vote-icon"; import VoteIcon from "./vote-icon";
@ -37,36 +36,15 @@ export const VoteSelector = React.forwardRef<
onBlur={onBlur} onBlur={onBlur}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
className={clsx( 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", "btn-default relative items-center justify-center overflow-hidden px-0",
{
"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,
},
className, className,
)} )}
onClick={() => { onClick={() => {
onChange?.(value ? getNext(value) : orderedVoteTypes[0]); onChange?.(value ? getNext(value) : orderedVoteTypes[0]);
}} }}
ref={ref} 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} /> <VoteIcon type={value} />
</motion.span>
</AnimatePresence>
</button> </button>
); );
}); });

View file

@ -19,7 +19,7 @@ const Switch: React.VoidFunctionComponent<SwitchProps> = ({
checked={checked} checked={checked}
onChange={onChange} onChange={onChange}
className={clsx( 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-gray-200": !checked,
"bg-green-500": checked, "bg-green-500": checked,

View file

@ -20,7 +20,7 @@ export const TextInput = React.forwardRef<HTMLInputElement, TextInputProps>(
ref={ref} ref={ref}
type="text" type="text"
className={clsx( 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, className,
{ {
"px-2 py-1": size === "md", "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 { withPageTranslations } from "@/utils/with-page-translations";
import { AdminControls } from "../../components/admin-control"; import { AdminControls } from "../../components/admin-control";
import ModalProvider from "../../components/modal/modal-provider";
const PollPageLoader: NextPage = () => { const PollPageLoader: NextPage = () => {
const { query } = useRouter(); const { query } = useRouter();
@ -35,11 +36,13 @@ const PollPageLoader: NextPage = () => {
<ParticipantsProvider pollId={poll.id}> <ParticipantsProvider pollId={poll.id}>
<StandardLayout> <StandardLayout>
<PollContextProvider poll={poll} urlId={urlId} admin={true}> <PollContextProvider poll={poll} urlId={urlId} admin={true}>
<ModalProvider>
<div className="flex flex-col space-y-3 p-3 sm:space-y-4 sm:p-4"> <div className="flex flex-col space-y-3 p-3 sm:space-y-4 sm:p-4">
<AdminControls> <AdminControls>
<Poll /> <Poll />
</AdminControls> </AdminControls>
</div> </div>
</ModalProvider>
</PollContextProvider> </PollContextProvider>
</StandardLayout> </StandardLayout>
</ParticipantsProvider> </ParticipantsProvider>

View file

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

View file

@ -20,7 +20,7 @@ export const participants = createRouter()
}, },
orderBy: [ orderBy: [
{ {
createdAt: "asc", createdAt: "desc",
}, },
{ name: "desc" }, { name: "desc" },
], ],
@ -86,7 +86,6 @@ export const participants = createRouter()
input: z.object({ input: z.object({
pollId: z.string(), pollId: z.string(),
participantId: z.string(), participantId: z.string(),
name: z.string(),
votes: z votes: z
.object({ .object({
optionId: z.string(), optionId: z.string(),
@ -94,7 +93,7 @@ export const participants = createRouter()
}) })
.array(), .array(),
}), }),
resolve: async ({ input: { pollId, participantId, votes, name } }) => { resolve: async ({ input: { pollId, participantId, votes } }) => {
const participant = await prisma.participant.update({ const participant = await prisma.participant.update({
where: { where: {
id: participantId, id: participantId,
@ -112,7 +111,6 @@ export const participants = createRouter()
})), })),
}, },
}, },
name,
}, },
include: { include: {
votes: true, 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(); export const requiredString = (value: string) => !!value.trim();
/**
* @deprecated Use form validation hook instead
*/
export const validEmail = (value: string) => export const validEmail = (value: string) =>
/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value); /^[^@\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 { h2 {
@apply text-xl; @apply text-xl;
} }
input {
@apply outline-none;
}
label { label {
@apply mb-1 block text-sm text-slate-800; @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 { 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 { #floating-ui-root {
@ -44,13 +51,13 @@
@layer components { @layer components {
.text-link { .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 { .formField {
@apply mb-4; @apply mb-4;
} }
.input { .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 { input.input {
@apply h-9; @apply h-9;
@ -59,20 +66,20 @@
@apply input px-3 py-3; @apply input px-3 py-3;
} }
.input-error { .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 { .checkbox {
@apply h-4 w-4 rounded border-slate-300 text-primary-500 shadow-sm focus:ring-primary-500; @apply h-4 w-4 rounded border-slate-300 text-primary-500 shadow-sm focus:ring-primary-500;
} }
.btn { .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 { a.btn {
@apply cursor-pointer hover:no-underline; @apply cursor-pointer hover:no-underline;
} }
.btn-default { .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 { a.btn-default {
@ -90,7 +97,7 @@
} }
.btn-primary { .btn-primary {
text-shadow: rgb(0 0 0 / 20%) 0px 1px 1px; 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 { a.btn-primary {
@ -102,7 +109,7 @@
} }
.segment-button button { .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 { .segment-button .segment-button-active {

View file

@ -45,9 +45,5 @@ module.exports = {
}, },
}, },
}, },
plugins: [ plugins: [require("@tailwindcss/typography"), require("tailwindcss-animate")],
require("@tailwindcss/forms"),
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 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=0");
await page.click("data-testid=poll-option >> nth=1"); await page.click("data-testid=poll-option >> nth=1");
await page.click("data-testid=poll-option >> nth=3"); 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=user")).toBeVisible();
await expect( await expect(
page.locator("data-testid=participant-selector").locator("text=You"), 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 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 // 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. // 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=0").click();
await page.locator("data-testid=vote-selector >> nth=2").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 page.click("text='Save'");
await expect(page.locator("text='Test user'")).toBeVisible(); await expect(page.locator("text='Test user'")).toBeVisible();
await expect( 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(); ).toBeVisible();
await page.type( await page.type(
"[placeholder='Leave a comment on this poll (visible to everyone)']", "[placeholder='Leave a comment on this poll (visible to everyone)']",
"This is a comment!", "This is a comment!",

View file

@ -1932,13 +1932,6 @@
dependencies: dependencies:
tslib "^2.4.0" 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": "@tailwindcss/typography@^0.5.9":
version "0.5.9" version "0.5.9"
resolved "https://registry.yarnpkg.com/@tailwindcss/typography/-/typography-0.5.9.tgz#027e4b0674929daaf7c921c900beee80dbad93e8" 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" resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869"
integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== 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: minimatch@^3.0.4:
version "3.0.4" version "3.0.4"
resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz" resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz"