mirror of
https://github.com/lukevella/rallly.git
synced 2025-06-12 07:31:54 +02:00
✨ Updated workflow for adding and updating participants (#500)
This commit is contained in:
parent
bac7db54f2
commit
5d7db848b8
58 changed files with 659 additions and 520 deletions
|
@ -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",
|
||||||
|
|
|
@ -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ç",
|
||||||
|
|
|
@ -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í",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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": "همرسانی با پیوند",
|
||||||
|
|
|
@ -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ä",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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": "링크 공유하기",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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": "Поделиться с помощью ссылки",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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": "分享链接",
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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")}
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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" />
|
||||||
|
|
|
@ -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={
|
||||||
|
|
|
@ -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={() => {
|
||||||
|
|
176
src/components/new-participant-modal.tsx
Normal file
176
src/components/new-participant-modal.tsx
Normal 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;
|
||||||
|
};
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
@ -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 },
|
|
||||||
]);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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={{
|
||||||
|
|
|
@ -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 }>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
8
src/components/you.tsx
Normal 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} />;
|
||||||
|
};
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
29
style.css
29
style.css
|
@ -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 {
|
||||||
|
|
|
@ -45,9 +45,5 @@ module.exports = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [require("@tailwindcss/typography"), require("tailwindcss-animate")],
|
||||||
require("@tailwindcss/forms"),
|
|
||||||
require("@tailwindcss/typography"),
|
|
||||||
require("tailwindcss-animate"),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -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"),
|
||||||
|
|
|
@ -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!",
|
||||||
|
|
12
yarn.lock
12
yarn.lock
|
@ -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"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue