mirror of
https://github.com/lukevella/rallly.git
synced 2025-06-02 18:51:52 +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",
|
||||
"@sentry/nextjs": "^7.33.0",
|
||||
"@svgr/webpack": "^6.2.1",
|
||||
"@tailwindcss/forms": "^0.5.3",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@tanstack/react-query": "^4.16.1",
|
||||
"@trpc/client": "^10.0.0-rc.8",
|
||||
|
|
|
@ -97,7 +97,7 @@
|
|||
"profileUser": "Perfil - {{username}}",
|
||||
"requiredNameError": "El nom és obligatori",
|
||||
"save": "Desa",
|
||||
"saveInstruction": "Selecciona la teva disponibilitat i prem <b>{{save}}</b>",
|
||||
"saveInstruction": "Selecciona la teva disponibilitat i prem <b>{{action}}</b>",
|
||||
"share": "Compartir",
|
||||
"shareDescription": "Envia aquest enllaç als <b>participants</b> perquè puguin votar a l'enquesta.",
|
||||
"shareLink": "Comparteix via enllaç",
|
||||
|
|
|
@ -102,7 +102,7 @@
|
|||
"requiredNameError": "Jméno je vyžadováno",
|
||||
"resendVerificationCode": "Znovu odeslat ověřovací kód",
|
||||
"save": "Uložit",
|
||||
"saveInstruction": "Vyberte svou dostupnost a klikněte na <b>{{save}}</b>",
|
||||
"saveInstruction": "Vyberte svou dostupnost a klikněte na <b>{{action}}</b>",
|
||||
"share": "Sdílet",
|
||||
"shareDescription": "Tento odkaz zašlete vašim <b>účastníkům</b>, aby mohli v anketě hlasovat.",
|
||||
"shareLink": "Odkaz ke sdílení",
|
||||
|
|
|
@ -90,7 +90,7 @@
|
|||
"profileUser": "Profil - {{username}}",
|
||||
"requiredNameError": "Navn er påkrævet",
|
||||
"save": "Gem",
|
||||
"saveInstruction": "Vælg din tilgængelighed og klik <b>{{save}}</b>",
|
||||
"saveInstruction": "Vælg din tilgængelighed og klik <b>{{action}}</b>",
|
||||
"share": "Del",
|
||||
"shareDescription": "Giv dette link til dine <b>deltagere</b> for at give dem mulighed for at stemme på din afstemning.",
|
||||
"shareLink": "Del via link",
|
||||
|
|
|
@ -102,7 +102,7 @@
|
|||
"requiredNameError": "Bitte gib einen Namen an",
|
||||
"resendVerificationCode": "Bestätigungscode erneut senden",
|
||||
"save": "Speichern",
|
||||
"saveInstruction": "Wähle deine Verfügbarkeit und klicke auf <b>{{save}}</b>",
|
||||
"saveInstruction": "Wähle deine Verfügbarkeit und klicke auf <b>{{action}}</b>",
|
||||
"share": "Teilen",
|
||||
"shareDescription": "Gib diesen Link deinen <b>Teilnehmern</b> damit sie an deiner Umfrage teilnehmen können.",
|
||||
"shareLink": "Über Link teilen",
|
||||
|
|
|
@ -51,9 +51,13 @@
|
|||
"linkHasExpired": "Your link has expired or is no longer valid",
|
||||
"loading": "Loading…",
|
||||
"loadingParticipants": "Loading participants…",
|
||||
"validEmail": "Please enter a valid email",
|
||||
"newParticipantFormDescription": "Fill in the form below to submit your votes.",
|
||||
"optional": "optional",
|
||||
"location": "Location",
|
||||
"locationPlaceholder": "Joe's Coffee Shop",
|
||||
"lockPoll": "Lock poll",
|
||||
"response": "Response",
|
||||
"login": "Login",
|
||||
"logout": "Logout",
|
||||
"manage": "Manage",
|
||||
|
@ -87,9 +91,17 @@
|
|||
"participantCount_few": "{{count}} participants",
|
||||
"participantCount_many": "{{count}} participants",
|
||||
"participantCount_one": "{{count}} participant",
|
||||
"requiredString": "“{{name}}” is required",
|
||||
"participantCount_other": "{{count}} participants",
|
||||
"participantCount_two": "{{count}} participants",
|
||||
"participantCount_zero": "{{count}} participants",
|
||||
"optionCount_few": "{{count}} options",
|
||||
"optionCount_many": "{{count}} options",
|
||||
"optionCount_one": "{{count}} option",
|
||||
"optionCount_other": "{{count}} options",
|
||||
"optionCount_two": "{{count}} options",
|
||||
"optionCount_zero": "{{count}} options",
|
||||
"newParticipant": "New participant",
|
||||
"pollHasBeenLocked": "This poll has been locked",
|
||||
"pollHasBeenVerified": "Your poll has been verified",
|
||||
"pollOwnerNotice": "Hey {{name}}, looks like you are the owner of this poll.",
|
||||
|
@ -102,7 +114,7 @@
|
|||
"requiredNameError": "Name is required",
|
||||
"resendVerificationCode": "Resend verification code",
|
||||
"save": "Save",
|
||||
"saveInstruction": "Select your availability and click <b>{{save}}</b>",
|
||||
"saveInstruction": "Select your availability and click <b>{{action}}</b>",
|
||||
"share": "Share",
|
||||
"shareDescription": "Give this link to your <b>participants</b> to allow them to vote on your poll.",
|
||||
"shareLink": "Share via link",
|
||||
|
|
|
@ -102,7 +102,7 @@
|
|||
"requiredNameError": "El nombre es obligatorio",
|
||||
"resendVerificationCode": "Reenviar el código de verificación",
|
||||
"save": "Guardar",
|
||||
"saveInstruction": "Selecciona tu disponibilidad y haz clic en <b>{{save}}</b>",
|
||||
"saveInstruction": "Selecciona tu disponibilidad y haz clic en <b>{{action}}</b>",
|
||||
"share": "Compartir",
|
||||
"shareDescription": "Da este enlace a tus <b>participantes</b> para permitirles votar en tu encuesta.",
|
||||
"shareLink": "Compartir con un enlace",
|
||||
|
|
|
@ -90,7 +90,7 @@
|
|||
"profileUser": "نمایه - {{username}}",
|
||||
"requiredNameError": "نام الزامی است",
|
||||
"save": "ذخیره",
|
||||
"saveInstruction": "مشخص کنید چه زمانهایی برایتان مقدور است و روی <b>{{save}}</b> کلیک کنید",
|
||||
"saveInstruction": "مشخص کنید چه زمانهایی برایتان مقدور است و روی <b>{{action}}</b> کلیک کنید",
|
||||
"share": "همرسانی",
|
||||
"shareDescription": "این لینک را به <b>شرکتکنندگان</b> بدهید تا بتوانند در نظرسنجی شما شرکت کنند.",
|
||||
"shareLink": "همرسانی با پیوند",
|
||||
|
|
|
@ -102,7 +102,7 @@
|
|||
"requiredNameError": "Nimi vaaditaan",
|
||||
"resendVerificationCode": "Lähetä vahvistuskoodi uudelleen",
|
||||
"save": "Tallenna",
|
||||
"saveInstruction": "Valitse sinulle sopivat vaihtoehdot ja napsauta <b>{{save}}</b>",
|
||||
"saveInstruction": "Valitse sinulle sopivat vaihtoehdot ja napsauta <b>{{action}}</b>",
|
||||
"share": "Jaa",
|
||||
"shareDescription": "Anna tämä linkki <b>osallistujille</b>, jotta he voivat äänestää kyselyssäsi.",
|
||||
"shareLink": "Jaa linkin välityksellä",
|
||||
|
|
|
@ -99,7 +99,7 @@
|
|||
"requiredNameError": "Le nom est obligatoire",
|
||||
"resendVerificationCode": "Renvoyer le code de vérification",
|
||||
"save": "Sauvegarder",
|
||||
"saveInstruction": "Sélectionnez votre disponibilité et cliquez sur <b>{{save}}</b>",
|
||||
"saveInstruction": "Sélectionnez votre disponibilité et cliquez sur <b>{{action}}</b>",
|
||||
"share": "Partager",
|
||||
"shareDescription": "Donnez ce lien à vos <b>participants</b> pour leur permettre de voter sur votre sondage.",
|
||||
"shareLink": "Partager via un lien",
|
||||
|
|
|
@ -102,7 +102,7 @@
|
|||
"requiredNameError": "Ime je obavezno",
|
||||
"resendVerificationCode": "Ponovno pošalji poruku za potvrdu",
|
||||
"save": "Pohrani",
|
||||
"saveInstruction": "Odaberite termine koji vam odgovaraju i kliknite na <b>{{save}}</b>",
|
||||
"saveInstruction": "Odaberite termine koji vam odgovaraju i kliknite na <b>{{action}}</b>",
|
||||
"share": "Podijeli",
|
||||
"shareDescription": "Pošaljite ovu poveznicu <b>sudionicima</b> kako biste im omogućili glasanje u vašoj anketi.",
|
||||
"shareLink": "Podijeli putem poveznice",
|
||||
|
|
|
@ -102,7 +102,7 @@
|
|||
"requiredNameError": "Név megadása kötelező",
|
||||
"resendVerificationCode": "Hitelesítő kód újraküldése",
|
||||
"save": "Mentés",
|
||||
"saveInstruction": "Válaszd ki mikor érsz rá és kattints a <b>{{save}}</b> gombra",
|
||||
"saveInstruction": "Válaszd ki mikor érsz rá és kattints a <b>{{action}}</b> gombra",
|
||||
"share": "Megosztás",
|
||||
"shareDescription": "Küldd el ezt a linket a <b>résztvevőidnek</b>, hogy tudjanak szavazatokat leadni.",
|
||||
"shareLink": "Megosztás linkkel",
|
||||
|
|
|
@ -90,7 +90,7 @@
|
|||
"profileUser": "Profilo - {{username}}",
|
||||
"requiredNameError": "Il nome è obbligatorio",
|
||||
"save": "Salva",
|
||||
"saveInstruction": "Seleziona la tua disponibilità e clicca su <b>{{save}}</b>",
|
||||
"saveInstruction": "Seleziona la tua disponibilità e clicca su <b>{{action}}</b>",
|
||||
"share": "Condividi",
|
||||
"shareDescription": "Dai questo link ai <b>partecipanti</b> per permette loro di votare al tuo sondaggio.",
|
||||
"shareLink": "Condividi via link",
|
||||
|
|
|
@ -90,7 +90,7 @@
|
|||
"profileUser": "프로필 - {{username}}",
|
||||
"requiredNameError": "이름을 입력해주세요.",
|
||||
"save": "저장하기",
|
||||
"saveInstruction": "가능여부를 선택한 후 <b>{{save}}</b> 를 클릭하세요",
|
||||
"saveInstruction": "가능여부를 선택한 후 <b>{{action}}</b> 를 클릭하세요",
|
||||
"share": "공유하기",
|
||||
"shareDescription": "이 링크를 <b>참여자들</b>에게 전달하여 투표하도록 하세요",
|
||||
"shareLink": "링크 공유하기",
|
||||
|
|
|
@ -102,7 +102,7 @@
|
|||
"requiredNameError": "Naam is verplicht",
|
||||
"resendVerificationCode": "Verificatiecode opnieuw versturen",
|
||||
"save": "Opslaan",
|
||||
"saveInstruction": "Selecteer je beschikbaarheid en klik op <b>{{save}}</b>",
|
||||
"saveInstruction": "Selecteer je beschikbaarheid en klik op <b>{{action}}</b>",
|
||||
"share": "Delen",
|
||||
"shareDescription": "Geef deze link aan je <b>deelnemers</b> zodat ze op je poll kunnen stemmen.",
|
||||
"shareLink": "Deel via link",
|
||||
|
|
|
@ -94,7 +94,7 @@
|
|||
"profileUser": "Profil - {{username}}",
|
||||
"requiredNameError": "Imię jest wymagane",
|
||||
"save": "Zapisz",
|
||||
"saveInstruction": "Wybierz swoją dostępność i kliknij <b>{{save}}</b>",
|
||||
"saveInstruction": "Wybierz swoją dostępność i kliknij <b>{{action}}</b>",
|
||||
"share": "Udostępnij",
|
||||
"shareDescription": "Przekaż ten link <b>uczestnikom</b>, aby mogli zagłosować na ankietę.",
|
||||
"shareLink": "Udostępnij link",
|
||||
|
|
|
@ -102,7 +102,7 @@
|
|||
"requiredNameError": "Nome é obrigatório",
|
||||
"resendVerificationCode": "Reenviar código de verificação",
|
||||
"save": "Salvar",
|
||||
"saveInstruction": "Selecione sua disponibilidade e clique <b>{{save}}</b>",
|
||||
"saveInstruction": "Selecione sua disponibilidade e clique <b>{{action}}</b>",
|
||||
"share": "Compartilhar",
|
||||
"shareDescription": "Dê este link para os seus <b>participantes</b> para permitir que eles votem na sua enquete.",
|
||||
"shareLink": "Compartilhar via link",
|
||||
|
|
|
@ -90,7 +90,7 @@
|
|||
"profileUser": "Perfil - {{username}}",
|
||||
"requiredNameError": "O nome é obrigatório",
|
||||
"save": "Guardar",
|
||||
"saveInstruction": "Selecione a sua disponibilidade e clique em <b>{{save}}</b>",
|
||||
"saveInstruction": "Selecione a sua disponibilidade e clique em <b>{{action}}</b>",
|
||||
"share": "Partilhar",
|
||||
"shareDescription": "Dê este link aos seus <b>participantes</b> para permitir que eles votem na sua sondagem.",
|
||||
"shareLink": "Partilhar via link",
|
||||
|
|
|
@ -102,7 +102,7 @@
|
|||
"requiredNameError": "Необходимо указать имя",
|
||||
"resendVerificationCode": "Отправить код ещё раз",
|
||||
"save": "Сохранить",
|
||||
"saveInstruction": "Укажите когда вы доступны и нажмите <b>{{save}}</b>",
|
||||
"saveInstruction": "Укажите когда вы доступны и нажмите <b>{{action}}</b>",
|
||||
"share": "Поделиться",
|
||||
"shareDescription": "Поделитесь этой ссылкой с вашими <b>участниками</b>, чтобы они смогли ответить на ваш опрос.",
|
||||
"shareLink": "Поделиться с помощью ссылки",
|
||||
|
|
|
@ -99,7 +99,7 @@
|
|||
"requiredNameError": "Požadované je meno",
|
||||
"resendVerificationCode": "Znovu odoslať verifikačný kód",
|
||||
"save": "Uložiť",
|
||||
"saveInstruction": "Vyberte svoju dostupnosť a kliknite na <b>{{save}}</b>",
|
||||
"saveInstruction": "Vyberte svoju dostupnosť a kliknite na <b>{{action}}</b>",
|
||||
"share": "Zdielať",
|
||||
"shareDescription": "Tento odkaz zašlite vašim <b>účastníkom</b>, aby mohli v ankete hlasovať.",
|
||||
"shareLink": "Zdieľať cez odkaz",
|
||||
|
|
|
@ -102,7 +102,7 @@
|
|||
"requiredNameError": "Namn är obligatoriskt",
|
||||
"resendVerificationCode": "Skicka verifieringskoden igen",
|
||||
"save": "Spara",
|
||||
"saveInstruction": "Välj din tillgänglighet och klicka på <b>{{save}}</b>",
|
||||
"saveInstruction": "Välj din tillgänglighet och klicka på <b>{{action}}</b>",
|
||||
"share": "Dela",
|
||||
"shareDescription": "Ge den här länken till dina <b>deltagare</b> så att de kan delta i din förfrågan.",
|
||||
"shareLink": "Dela via länk",
|
||||
|
|
|
@ -69,7 +69,7 @@
|
|||
"participantCount_other": "{{count}} katılımcı",
|
||||
"pollOwnerNotice": "Selam {{name}}, görünüşe göre bu anketin sahibi sensin.",
|
||||
"profileUser": "Profil - {{username}}",
|
||||
"saveInstruction": "Uygunluk durumunuzu seçin ve <b>{{save}}</b> düğmesine tıklayın",
|
||||
"saveInstruction": "Uygunluk durumunuzu seçin ve <b>{{action}}</b> düğmesine tıklayın",
|
||||
"shareDescription": "Anketinizde oy kullanmalarına izin vermek için bu bağlantıyı <b>katılımcılarınıza</b> verin.",
|
||||
"stepSummary": "Aşama: {{current}} / {{total}}",
|
||||
"sunday": "Pazar",
|
||||
|
|
|
@ -101,7 +101,7 @@
|
|||
"requiredNameError": "姓名为必填项",
|
||||
"resendVerificationCode": "重新发送验证码",
|
||||
"save": "保存",
|
||||
"saveInstruction": "选择你有空的时间并点击 <b>{{save}}</b>",
|
||||
"saveInstruction": "选择你有空的时间并点击 <b>{{action}}</b>",
|
||||
"share": "分享",
|
||||
"shareDescription": "其他人可以通过此链接成为<b>参与者</b>進行投票",
|
||||
"shareLink": "分享链接",
|
||||
|
|
|
@ -13,7 +13,7 @@ export const LoginModal: React.VoidFunctionComponent<{
|
|||
const [defaultEmail, setDefaultEmail] = React.useState("");
|
||||
|
||||
return (
|
||||
<div className="w-[420px] max-w-full overflow-hidden rounded-lg bg-white shadow-sm">
|
||||
<div className="w-[420px] max-w-full overflow-hidden bg-white shadow-sm">
|
||||
<div className="bg-pattern border-b border-t-4 border-t-primary-500 bg-slate-500/5 p-4 text-center sm:p-8">
|
||||
<Logo className="text-2xl" />
|
||||
</div>
|
||||
|
|
|
@ -111,64 +111,69 @@ const MonthCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="grid grow grid-cols-7 overflow-hidden rounded-lg border bg-white shadow-sm">
|
||||
<div className="grid grow grid-cols-7 rounded-lg border bg-white shadow-sm">
|
||||
{datepicker.days.map((day, i) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => {
|
||||
if (
|
||||
datepicker.selection.some((selectedDate) =>
|
||||
dayjs(selectedDate).isSame(day.date, "day"),
|
||||
)
|
||||
) {
|
||||
onChange(removeAllOptionsForDay(options, day.date));
|
||||
} else {
|
||||
const selectedDate = dayjs(day.date)
|
||||
.set("hour", 12)
|
||||
.toDate();
|
||||
const newOption: DateTimeOption = !isTimedEvent
|
||||
? {
|
||||
type: "date",
|
||||
date: formatDateWithoutTime(selectedDate),
|
||||
}
|
||||
: {
|
||||
type: "timeSlot",
|
||||
start: formatDateWithoutTz(selectedDate),
|
||||
end: formatDateWithoutTz(
|
||||
dayjs(selectedDate)
|
||||
.add(duration, "minutes")
|
||||
.toDate(),
|
||||
),
|
||||
};
|
||||
|
||||
onChange([...options, newOption]);
|
||||
onNavigate(selectedDate);
|
||||
}
|
||||
if (day.outOfMonth) {
|
||||
if (i < 6) {
|
||||
datepicker.prev();
|
||||
} else {
|
||||
datepicker.next();
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={clsx(
|
||||
"relative flex h-12 items-center justify-center text-sm hover:bg-slate-50 focus:ring-0 focus:ring-offset-0 active:bg-slate-100",
|
||||
{
|
||||
"bg-slate-50 text-slate-400": day.outOfMonth,
|
||||
"font-bold": day.today,
|
||||
"text-primary-500": day.today && !day.selected,
|
||||
"border-r": (i + 1) % 7 !== 0,
|
||||
"border-b": i < datepicker.days.length - 7,
|
||||
"font-normal text-white after:absolute after:-z-0 after:h-8 after:w-8 after:rounded-full after:bg-green-500 after:content-['']":
|
||||
day.selected,
|
||||
},
|
||||
)}
|
||||
className={clsx("h-12", {
|
||||
"border-r": (i + 1) % 7 !== 0,
|
||||
"border-b": i < datepicker.days.length - 7,
|
||||
})}
|
||||
>
|
||||
<span className="z-10">{day.day}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (
|
||||
datepicker.selection.some((selectedDate) =>
|
||||
dayjs(selectedDate).isSame(day.date, "day"),
|
||||
)
|
||||
) {
|
||||
onChange(removeAllOptionsForDay(options, day.date));
|
||||
} else {
|
||||
const selectedDate = dayjs(day.date)
|
||||
.set("hour", 12)
|
||||
.toDate();
|
||||
const newOption: DateTimeOption = !isTimedEvent
|
||||
? {
|
||||
type: "date",
|
||||
date: formatDateWithoutTime(selectedDate),
|
||||
}
|
||||
: {
|
||||
type: "timeSlot",
|
||||
start: formatDateWithoutTz(selectedDate),
|
||||
end: formatDateWithoutTz(
|
||||
dayjs(selectedDate)
|
||||
.add(duration, "minutes")
|
||||
.toDate(),
|
||||
),
|
||||
};
|
||||
|
||||
onChange([...options, newOption]);
|
||||
onNavigate(selectedDate);
|
||||
}
|
||||
if (day.outOfMonth) {
|
||||
if (i < 6) {
|
||||
datepicker.prev();
|
||||
} else {
|
||||
datepicker.next();
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={clsx(
|
||||
"relative flex h-full w-full items-center justify-center text-sm hover:bg-slate-50 focus:z-10 focus:rounded active:bg-slate-100",
|
||||
{
|
||||
"bg-slate-50 text-slate-400": day.outOfMonth,
|
||||
"font-bold": day.today,
|
||||
"text-primary-500": day.today && !day.selected,
|
||||
"font-normal text-white after:absolute after:-z-0 after:h-8 after:w-8 after:rounded-full after:bg-green-500 after:content-['']":
|
||||
day.selected,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<span className="z-10">{day.day}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
|
@ -29,13 +29,13 @@ const Hero: React.VoidFunctionComponent = () => {
|
|||
<div className="space-x-3">
|
||||
<a
|
||||
href="/new"
|
||||
className="rounded-lg bg-primary-500 px-5 py-3 font-semibold text-white shadow-sm transition-all hover:bg-primary-500/90 hover:text-white hover:no-underline hover:shadow-md focus:ring-2 focus:ring-primary-200 active:bg-primary-600/90"
|
||||
className="rounded-lg bg-primary-500 px-5 py-3 font-semibold text-white shadow-sm transition-all hover:bg-primary-500/90 hover:text-white hover:no-underline hover:shadow-md active:bg-primary-600/90"
|
||||
>
|
||||
{t("getStarted")}
|
||||
</a>
|
||||
<a
|
||||
href="/demo"
|
||||
className="rounded-lg bg-slate-500 px-5 py-3 font-semibold text-white shadow-sm transition-all hover:bg-slate-500/90 hover:text-white hover:no-underline hover:shadow-md focus:ring-2 focus:ring-primary-200 active:bg-slate-600/90"
|
||||
className="rounded-lg bg-slate-500 px-5 py-3 font-semibold text-white shadow-sm transition-all hover:bg-slate-500/90 hover:text-white hover:no-underline hover:shadow-md active:bg-slate-600/90"
|
||||
rel="nofollow"
|
||||
>
|
||||
{t("liveDemo")}
|
||||
|
|
|
@ -39,7 +39,7 @@ const PollDemo: React.VoidFunctionComponent = () => {
|
|||
const { dayjs } = useDayjs();
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg bg-white py-1 shadow-huge"
|
||||
className="rounded-lg bg-white pb-2 shadow-huge"
|
||||
style={{ width: 600 }}
|
||||
>
|
||||
<div className="flex">
|
||||
|
|
|
@ -25,7 +25,7 @@ const Menu: React.VoidFunctionComponent<{ className: string }> = ({
|
|||
<Link
|
||||
href="/"
|
||||
className={clsx(
|
||||
"text-gray-400 transition-colors hover:text-primary-500 hover:no-underline hover:underline-offset-2",
|
||||
"rounded text-gray-400 transition-colors hover:text-primary-500 hover:no-underline hover:underline-offset-2",
|
||||
{
|
||||
"pointer-events-none font-bold text-gray-600": pathname === "/home",
|
||||
},
|
||||
|
@ -36,20 +36,20 @@ const Menu: React.VoidFunctionComponent<{ className: string }> = ({
|
|||
<Link
|
||||
href="https://blog.rallly.co"
|
||||
className={clsx(
|
||||
"text-gray-400 transition-colors hover:text-primary-500 hover:no-underline hover:underline-offset-2",
|
||||
"rounded text-gray-400 transition-colors hover:text-primary-500 hover:no-underline hover:underline-offset-2",
|
||||
)}
|
||||
>
|
||||
{t("blog")}
|
||||
</Link>
|
||||
<a
|
||||
href="https://support.rallly.co"
|
||||
className="text-gray-400 transition-colors hover:text-primary-500 hover:no-underline hover:underline-offset-2"
|
||||
className="rounded text-gray-400 transition-colors hover:text-primary-500 hover:no-underline hover:underline-offset-2"
|
||||
>
|
||||
{t("support")}
|
||||
</a>
|
||||
<Link
|
||||
href="https://github.com/lukevella/rallly"
|
||||
className="text-gray-400 transition-colors hover:text-primary-500 hover:no-underline hover:underline-offset-2"
|
||||
className="rounded text-gray-400 transition-colors hover:text-primary-500 hover:no-underline hover:underline-offset-2"
|
||||
>
|
||||
<Github className="w-6" />
|
||||
</Link>
|
||||
|
@ -66,7 +66,7 @@ const PageLayout: React.VoidFunctionComponent<PageLayoutProps> = ({
|
|||
<div className="mx-auto flex max-w-7xl items-center py-8 px-8">
|
||||
<div className="grow">
|
||||
<div className="relative inline-block">
|
||||
<Link href="/">
|
||||
<Link className="inline-block rounded" href="/">
|
||||
<Logo className="w-40 text-primary-500" alt="Rallly" />
|
||||
</Link>
|
||||
<span className="absolute -bottom-6 right-0 text-sm text-slate-400 transition-colors">
|
||||
|
@ -74,15 +74,15 @@ const PageLayout: React.VoidFunctionComponent<PageLayoutProps> = ({
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Menu className="hidden items-center space-x-8 md:flex" />
|
||||
<Menu className="hidden items-center space-x-8 sm:flex" />
|
||||
<Popover>
|
||||
<PopoverTrigger asChild={true}>
|
||||
<button className="text-gray-400 transition-colors hover:text-primary-500 hover:no-underline hover:underline-offset-2 sm:hidden">
|
||||
<DotsVertical className="w-5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start">
|
||||
<Menu className="flex flex-col space-y-2" />
|
||||
<PopoverContent align="end">
|
||||
<Menu className="flex flex-col space-y-2 p-2" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
|
|
@ -42,7 +42,7 @@ export const MobileNavigation = (props: { className?: string }) => {
|
|||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"sticky top-0 z-40 flex h-12 w-full shrink-0 items-center justify-between border-b p-3 transition-all",
|
||||
"sticky top-0 z-40 flex w-full shrink-0 items-center justify-between border-b p-2 transition-all",
|
||||
{
|
||||
"bg-gray-50/75 shadow-sm backdrop-blur-md ": isPinned,
|
||||
"border-transparent bg-gray-50/0 shadow-none": !isPinned,
|
||||
|
@ -56,7 +56,7 @@ export const MobileNavigation = (props: { className?: string }) => {
|
|||
<button
|
||||
role="button"
|
||||
type="button"
|
||||
className="group flex items-center rounded-md px-2 py-1 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300"
|
||||
className="group flex items-center rounded px-2 py-1 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300"
|
||||
>
|
||||
<Menu className="mr-2 w-5 group-hover:text-primary-500" />
|
||||
<Logo />
|
||||
|
@ -69,7 +69,7 @@ export const MobileNavigation = (props: { className?: string }) => {
|
|||
</div>
|
||||
<div className="flex items-center">
|
||||
{user ? null : (
|
||||
<LoginLink className="flex w-full cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300">
|
||||
<LoginLink className="flex w-full cursor-pointer items-center space-x-2 whitespace-nowrap rounded px-2 py-1 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300">
|
||||
<Login className="h-5 opacity-75" />
|
||||
<span className="inline-block">{t("app:login")}</span>
|
||||
</LoginLink>
|
||||
|
@ -83,7 +83,7 @@ export const MobileNavigation = (props: { className?: string }) => {
|
|||
role="button"
|
||||
data-testid="user"
|
||||
className={clsx(
|
||||
"group inline-flex w-full items-center space-x-2 rounded-lg px-2 py-1 text-left transition-colors hover:bg-slate-500/10 active:bg-slate-500/20",
|
||||
"group inline-flex w-full items-center space-x-2 rounded px-2 py-1 text-left transition-colors hover:bg-slate-500/10 active:bg-slate-500/20",
|
||||
{
|
||||
"opacity-50": isUpdating,
|
||||
},
|
||||
|
@ -105,7 +105,7 @@ export const MobileNavigation = (props: { className?: string }) => {
|
|||
<button
|
||||
role="button"
|
||||
type="button"
|
||||
className="group flex items-center whitespace-nowrap rounded-md px-2 py-1 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300"
|
||||
className="group flex items-center whitespace-nowrap rounded px-2 py-1 font-medium text-slate-600 transition-colors hover:bg-gray-200 hover:text-slate-600 hover:no-underline active:bg-gray-300"
|
||||
>
|
||||
<Adjustments className="h-5 opacity-75 group-hover:text-primary-500" />
|
||||
<span className="ml-2 hidden sm:block">
|
||||
|
@ -130,14 +130,14 @@ const AppMenu: React.VoidFunctionComponent<{ className?: string }> = ({
|
|||
<div className={clsx("space-y-1", className)}>
|
||||
<Link
|
||||
href="/"
|
||||
className="flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 pr-4 font-medium text-slate-600 transition-colors hover:bg-slate-50 hover:text-slate-600 hover:no-underline active:bg-gray-300"
|
||||
className="flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded px-2 py-1 pr-4 font-medium text-slate-600 transition-colors hover:bg-slate-50 hover:text-slate-600 hover:no-underline active:bg-gray-300"
|
||||
>
|
||||
<Home className="h-5 opacity-75 " />
|
||||
<span className="inline-block">{t("app:home")}</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/new"
|
||||
className="flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 pr-4 font-medium text-slate-600 transition-colors hover:bg-slate-50 hover:text-slate-600 hover:no-underline active:bg-gray-300"
|
||||
className="flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded px-2 py-1 pr-4 font-medium text-slate-600 transition-colors hover:bg-slate-50 hover:text-slate-600 hover:no-underline active:bg-gray-300"
|
||||
>
|
||||
<Pencil className="h-5 opacity-75 " />
|
||||
<span className="inline-block">{t("app:createNew")}</span>
|
||||
|
@ -145,7 +145,7 @@ const AppMenu: React.VoidFunctionComponent<{ className?: string }> = ({
|
|||
<a
|
||||
target="_blank"
|
||||
href="https://support.rallly.co"
|
||||
className="flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded-md px-2 py-1 pr-4 font-medium text-slate-600 transition-colors hover:bg-slate-50 hover:text-slate-600 hover:no-underline active:bg-gray-300"
|
||||
className="flex cursor-pointer items-center space-x-2 whitespace-nowrap rounded px-2 py-1 pr-4 font-medium text-slate-600 transition-colors hover:bg-slate-50 hover:text-slate-600 hover:no-underline active:bg-gray-300"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Support className="h-5 opacity-75" />
|
||||
|
|
|
@ -17,6 +17,8 @@ interface ModalConfig extends ModalProps {
|
|||
const ModalContext =
|
||||
React.createContext<{
|
||||
render: (el: ModalConfig) => void;
|
||||
add: (id: string, el: React.ReactNode, config?: ModalConfig) => void;
|
||||
remove: (id: string) => void;
|
||||
} | null>(null);
|
||||
|
||||
ModalContext.displayName = "<ModalProvider />";
|
||||
|
@ -28,11 +30,11 @@ export const useModalContext = () => {
|
|||
const ModalProvider: React.VoidFunctionComponent<ModalProviderProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
const counter = React.useRef(0);
|
||||
const [modals, { push, removeAt, updateAt }] = useList<ModalConfig>([]);
|
||||
|
||||
const [modals, { push, removeAt, updateAt }] = useList<
|
||||
ModalConfig & { id: number }
|
||||
>([]);
|
||||
const [modalById, setModalById] = React.useState<
|
||||
Record<string, { content: React.ReactNode; config?: ModalConfig }>
|
||||
>({});
|
||||
|
||||
const removeModalAt = (index: number) => {
|
||||
updateAt(index, { ...modals[index], visible: false });
|
||||
|
@ -40,18 +42,44 @@ const ModalProvider: React.VoidFunctionComponent<ModalProviderProps> = ({
|
|||
removeAt(index);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const remove = (id: string) => {
|
||||
const newModalById = { ...modalById };
|
||||
delete newModalById[id];
|
||||
setModalById(newModalById);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalContext.Provider
|
||||
value={{
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
render: (props) => {
|
||||
push({ ...props, id: counter.current++ });
|
||||
push(props);
|
||||
},
|
||||
add: (id: string, content: React.ReactNode, config?: ModalConfig) => {
|
||||
setModalById({ ...modalById, [id]: { content, config } });
|
||||
},
|
||||
remove,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{Object.entries(modalById).map(([id, modal]) => (
|
||||
<Modal
|
||||
key={id}
|
||||
visible={true}
|
||||
{...modal.config}
|
||||
content={modal.content}
|
||||
footer={null}
|
||||
onCancel={() => {
|
||||
remove(id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{modals.map((props, i) => (
|
||||
<Modal
|
||||
key={`modal-${props.id}`}
|
||||
key={i}
|
||||
visible={true}
|
||||
{...props}
|
||||
content={
|
||||
|
|
|
@ -57,7 +57,7 @@ const Modal: React.VoidFunctionComponent<ModalProps> = ({
|
|||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-0 bg-slate-900 bg-opacity-25"
|
||||
className="fixed inset-0 z-0 bg-slate-900/25"
|
||||
/>
|
||||
<motion.div
|
||||
transition={{ duration: 0.1 }}
|
||||
|
@ -66,11 +66,11 @@ const Modal: React.VoidFunctionComponent<ModalProps> = ({
|
|||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="relative z-50 my-8 inline-block max-w-full transform text-left align-middle"
|
||||
>
|
||||
<div className="mx-4 max-w-full overflow-hidden rounded-xl bg-white shadow-xl xs:rounded-xl">
|
||||
<div className="max-w-full overflow-hidden rounded-md bg-white shadow-huge">
|
||||
{showClose ? (
|
||||
<button
|
||||
role="button"
|
||||
className="absolute right-5 top-1 z-10 rounded-lg p-2 text-slate-400 transition-colors hover:bg-slate-500/10 hover:text-slate-500 active:bg-slate-500/20"
|
||||
className="absolute top-1 right-1 z-10 rounded p-2 text-slate-400 transition-colors hover:bg-slate-500/10 hover:text-slate-500 focus:ring-0 focus:ring-offset-0 active:bg-slate-500/20"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<X className="h-4" />
|
||||
|
@ -91,7 +91,7 @@ const Modal: React.VoidFunctionComponent<ModalProps> = ({
|
|||
</div>
|
||||
)}
|
||||
{footer === undefined ? (
|
||||
<div className="flex h-14 items-center justify-end space-x-3 rounded-br-lg rounded-bl-lg border-t bg-slate-50 px-4">
|
||||
<div className="flex h-14 items-center justify-end gap-3 rounded-br-lg border-t bg-slate-50 p-3">
|
||||
{cancelText ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
|
|
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">
|
||||
{props.children}
|
||||
{poll.demo ? (
|
||||
<div className="flex items-start gap-3 rounded-md border border-amber-200 bg-amber-100 p-3 text-amber-600 shadow-sm">
|
||||
<div className="rounded-md">
|
||||
<Exclamation className="w-7" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-md border border-amber-200 bg-amber-100 p-3 text-amber-600 shadow-sm">
|
||||
<Exclamation className="w-6" />
|
||||
<div>{t("demoPollNotice")}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{poll.closed ? (
|
||||
<div className="flex items-center gap-3 rounded-md border border-pink-200 bg-pink-100 p-3 text-pink-600 shadow-sm">
|
||||
<div className="rounded-md">
|
||||
<LockClosed className="w-7" />
|
||||
</div>
|
||||
<LockClosed className="w-6" />
|
||||
<div>{t("pollHasBeenLocked")}</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="rounded-md border bg-white shadow-sm md:overflow-hidden">
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="p-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div
|
||||
className="mb-1 text-2xl font-semibold text-slate-800 sm:text-3xl"
|
||||
className="text-xl font-semibold text-slate-800 sm:text-2xl"
|
||||
data-testid="poll-title"
|
||||
>
|
||||
{preventWidows(poll.title)}
|
||||
|
@ -77,14 +73,14 @@ export const Poll = (props: { children?: React.ReactNode }) => {
|
|||
<PollSubheader />
|
||||
</div>
|
||||
{poll.description ? (
|
||||
<div className="border-primary whitespace-pre-line lg:text-lg">
|
||||
<div className="border-primary whitespace-pre-line">
|
||||
<TruncatedLinkify>
|
||||
{preventWidows(poll.description)}
|
||||
</TruncatedLinkify>
|
||||
</div>
|
||||
) : null}
|
||||
{poll.location ? (
|
||||
<div className="lg:text-lg">
|
||||
<div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{t("location")}
|
||||
</div>
|
||||
|
@ -98,17 +94,15 @@ export const Poll = (props: { children?: React.ReactNode }) => {
|
|||
<div className="flex items-center space-x-3">
|
||||
<span className="inline-flex items-center space-x-1">
|
||||
<VoteIcon type="yes" />
|
||||
<span className="text-xs text-slate-500">{t("yes")}</span>
|
||||
<span>{t("yes")}</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center space-x-1">
|
||||
<VoteIcon type="ifNeedBe" />
|
||||
<span className="text-xs text-slate-500">
|
||||
{t("ifNeedBe")}
|
||||
</span>
|
||||
<span>{t("ifNeedBe")}</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center space-x-1">
|
||||
<VoteIcon type="no" />
|
||||
<span className="text-xs text-slate-500">{t("no")}</span>
|
||||
<span>{t("no")}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -5,10 +5,9 @@ import { useMeasure } from "react-use";
|
|||
|
||||
import ArrowLeft from "@/components/icons/arrow-left.svg";
|
||||
import ArrowRight from "@/components/icons/arrow-right.svg";
|
||||
import Check from "@/components/icons/check.svg";
|
||||
import Plus from "@/components/icons/plus-sm.svg";
|
||||
|
||||
import { Button } from "../button";
|
||||
import { useNewParticipantModal } from "../new-participant-modal";
|
||||
import { useParticipants } from "../participants-provider";
|
||||
import { usePoll } from "../poll-context";
|
||||
import TimeZonePicker from "../time-zone-picker";
|
||||
|
@ -21,8 +20,6 @@ import {
|
|||
useUpdateParticipantMutation,
|
||||
} from "./mutations";
|
||||
|
||||
const MotionButton = motion(Button);
|
||||
|
||||
const minSidebarWidth = 200;
|
||||
|
||||
const Poll: React.VoidFunctionComponent = () => {
|
||||
|
@ -37,17 +34,16 @@ const Poll: React.VoidFunctionComponent = () => {
|
|||
const [editingParticipantId, setEditingParticipantId] =
|
||||
React.useState<string | null>(null);
|
||||
|
||||
const actionColumnWidth = 100;
|
||||
const columnWidth = 90;
|
||||
const columnWidth = 80;
|
||||
|
||||
const numberOfVisibleColumns = Math.min(
|
||||
options.length,
|
||||
Math.floor((width - (minSidebarWidth + actionColumnWidth)) / columnWidth),
|
||||
Math.floor((width - minSidebarWidth) / columnWidth),
|
||||
);
|
||||
|
||||
const sidebarWidth = Math.min(
|
||||
width - (numberOfVisibleColumns * columnWidth + actionColumnWidth),
|
||||
300,
|
||||
width - numberOfVisibleColumns * columnWidth,
|
||||
275,
|
||||
);
|
||||
|
||||
const availableSpace = Math.min(
|
||||
|
@ -66,8 +62,7 @@ const Poll: React.VoidFunctionComponent = () => {
|
|||
const [shouldShowNewParticipantForm, setShouldShowNewParticipantForm] =
|
||||
React.useState(!poll.closed && !userAlreadyVoted);
|
||||
|
||||
const pollWidth =
|
||||
sidebarWidth + options.length * columnWidth + actionColumnWidth;
|
||||
const pollWidth = sidebarWidth + options.length * columnWidth;
|
||||
|
||||
const addParticipant = useAddParticipantMutation();
|
||||
|
||||
|
@ -88,7 +83,7 @@ const Poll: React.VoidFunctionComponent = () => {
|
|||
|
||||
const updateParticipant = useUpdateParticipantMutation();
|
||||
|
||||
const participantListContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
const showNewParticipantModal = useNewParticipantModal();
|
||||
return (
|
||||
<PollContext.Provider
|
||||
value={{
|
||||
|
@ -102,7 +97,6 @@ const Poll: React.VoidFunctionComponent = () => {
|
|||
goToPreviousPage,
|
||||
numberOfColumns: numberOfVisibleColumns,
|
||||
availableSpace,
|
||||
actionColumnWidth,
|
||||
maxScrollPosition,
|
||||
}}
|
||||
>
|
||||
|
@ -112,8 +106,65 @@ const Poll: React.VoidFunctionComponent = () => {
|
|||
ref={ref}
|
||||
>
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<div className="flex h-14 shrink-0 items-center justify-between border-b bg-gradient-to-b from-gray-50 to-gray-100/50 p-3">
|
||||
<div className="p-1">
|
||||
{shouldShowNewParticipantForm || editingParticipantId ? (
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey="saveInstruction"
|
||||
values={{
|
||||
action: shouldShowNewParticipantForm
|
||||
? t("continue")
|
||||
: t("save"),
|
||||
}}
|
||||
components={{ b: <strong /> }}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<div className="font-semibold text-slate-800">
|
||||
{t("participantCount", { count: participants.length })}
|
||||
</div>
|
||||
{poll.closed ? null : (
|
||||
<button
|
||||
className="rounded hover:text-primary-500"
|
||||
onClick={() => {
|
||||
setEditingParticipantId(null);
|
||||
setShouldShowNewParticipantForm(true);
|
||||
}}
|
||||
>
|
||||
+ {t("new")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-1">
|
||||
{t("optionCount", { count: options.length })}
|
||||
</div>
|
||||
{maxScrollPosition > 0 ? (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={goToPreviousPage}
|
||||
disabled={scrollPosition === 0}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
className="text-xs"
|
||||
disabled={scrollPosition === maxScrollPosition}
|
||||
onClick={() => {
|
||||
goToNextPage();
|
||||
}}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{poll.timeZone ? (
|
||||
<div className="flex h-14 shrink-0 items-center justify-end space-x-4 border-b bg-gray-50 px-4">
|
||||
<div className="flex h-14 shrink-0 items-center justify-end space-x-4 border-b bg-gradient-to-b from-gray-50 to-gray-100/50 px-4">
|
||||
<div className="flex grow items-center">
|
||||
<div className="mr-2 text-sm font-medium text-slate-500">
|
||||
{t("timeZone")}
|
||||
|
@ -131,112 +182,82 @@ const Poll: React.VoidFunctionComponent = () => {
|
|||
<div
|
||||
className="flex shrink-0 items-center pl-4 pr-2 font-medium"
|
||||
style={{ width: sidebarWidth }}
|
||||
>
|
||||
<div className="flex h-full grow items-end">
|
||||
{t("participantCount", { count: participants.length })}
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{scrollPosition > 0 ? (
|
||||
<MotionButton
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
rounded={true}
|
||||
onClick={goToPreviousPage}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</MotionButton>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
></div>
|
||||
<PollHeader />
|
||||
<div
|
||||
className="flex items-center py-3 px-2"
|
||||
style={{ width: actionColumnWidth }}
|
||||
>
|
||||
{maxScrollPosition > 0 ? (
|
||||
<AnimatePresence initial={false}>
|
||||
{scrollPosition < maxScrollPosition ? (
|
||||
<MotionButton
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
className="text-xs"
|
||||
rounded={true}
|
||||
onClick={() => {
|
||||
goToNextPage();
|
||||
}}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</MotionButton>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{participants.length > 0 ? (
|
||||
<div
|
||||
className="min-h-0 overflow-y-auto py-2"
|
||||
ref={participantListContainerRef}
|
||||
>
|
||||
{participants.map((participant, i) => {
|
||||
return (
|
||||
<ParticipantRow
|
||||
key={i}
|
||||
participant={participant}
|
||||
editMode={editingParticipantId === participant.id}
|
||||
onChangeEditMode={(isEditing) => {
|
||||
setEditingParticipantId(
|
||||
isEditing ? participant.id : null,
|
||||
);
|
||||
}}
|
||||
onSubmit={async ({ name, votes }) => {
|
||||
await updateParticipant.mutateAsync({
|
||||
participantId: participant.id,
|
||||
pollId: poll.id,
|
||||
<div className="pb-2">
|
||||
<AnimatePresence initial={false}>
|
||||
{shouldShowNewParticipantForm &&
|
||||
!poll.closed &&
|
||||
!editingParticipantId ? (
|
||||
<motion.div
|
||||
variants={{
|
||||
hidden: { height: 0, y: -50, opacity: 0 },
|
||||
visible: { height: "auto", y: 0, opacity: 1 },
|
||||
exit: { height: 0, opacity: 0, y: -25 },
|
||||
}}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<ParticipantRowForm
|
||||
className="shrink-0"
|
||||
onSubmit={async ({ votes }) => {
|
||||
showNewParticipantModal({
|
||||
votes,
|
||||
name,
|
||||
onSubmit: () => {
|
||||
setShouldShowNewParticipantForm(false);
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{shouldShowNewParticipantForm &&
|
||||
!poll.closed &&
|
||||
!editingParticipantId ? (
|
||||
<ParticipantRowForm
|
||||
className="shrink-0 border-t bg-gray-50"
|
||||
onSubmit={async ({ name, votes }) => {
|
||||
await addParticipant.mutateAsync({
|
||||
name,
|
||||
votes,
|
||||
pollId: poll.id,
|
||||
});
|
||||
setShouldShowNewParticipantForm(false);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{!poll.closed ? (
|
||||
<div className="flex h-14 shrink-0 items-center border-t bg-gray-50 px-3">
|
||||
{shouldShowNewParticipantForm || editingParticipantId ? (
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button
|
||||
key="submit"
|
||||
form="participant-row-form"
|
||||
htmlType="submit"
|
||||
type="primary"
|
||||
icon={<Check />}
|
||||
loading={
|
||||
addParticipant.isLoading || updateParticipant.isLoading
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
{participants.map((participant, i) => {
|
||||
return (
|
||||
<ParticipantRow
|
||||
key={i}
|
||||
className={
|
||||
editingParticipantId &&
|
||||
editingParticipantId !== participant.id
|
||||
? "opacity-50"
|
||||
: ""
|
||||
}
|
||||
participant={participant}
|
||||
disableEditing={!!editingParticipantId}
|
||||
editMode={editingParticipantId === participant.id}
|
||||
onChangeEditMode={(isEditing) => {
|
||||
if (isEditing) {
|
||||
setShouldShowNewParticipantForm(false);
|
||||
setEditingParticipantId(participant.id);
|
||||
}
|
||||
>
|
||||
{t("save")}
|
||||
</Button>
|
||||
}}
|
||||
onSubmit={async ({ votes }) => {
|
||||
await updateParticipant.mutateAsync({
|
||||
participantId: participant.id,
|
||||
pollId: poll.id,
|
||||
votes,
|
||||
});
|
||||
setEditingParticipantId(null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{shouldShowNewParticipantForm || editingParticipantId ? (
|
||||
<motion.div
|
||||
variants={{
|
||||
hidden: { height: 0, y: 30, opacity: 0 },
|
||||
visible: { height: "auto", y: 0, opacity: 1 },
|
||||
}}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="hidden"
|
||||
className="flex shrink-0 items-center border-t bg-gray-50"
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-3 p-3">
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (editingParticipantId) {
|
||||
|
@ -248,38 +269,21 @@ const Poll: React.VoidFunctionComponent = () => {
|
|||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<div className="text-sm">
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey="saveInstruction"
|
||||
values={{
|
||||
save: t("save"),
|
||||
}}
|
||||
components={{ b: <strong /> }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full items-center space-x-3">
|
||||
<Button
|
||||
key="add-participant"
|
||||
onClick={() => {
|
||||
setShouldShowNewParticipantForm(true);
|
||||
}}
|
||||
icon={<Plus />}
|
||||
key="submit"
|
||||
form="participant-row-form"
|
||||
htmlType="submit"
|
||||
type="primary"
|
||||
loading={
|
||||
addParticipant.isLoading || updateParticipant.isLoading
|
||||
}
|
||||
>
|
||||
{t("addParticipant")}
|
||||
{shouldShowNewParticipantForm ? t("continue") : t("save")}
|
||||
</Button>
|
||||
{userAlreadyVoted ? (
|
||||
<div className="flex items-center text-sm text-gray-400">
|
||||
<Check className="mr-1 h-5" />
|
||||
<div>{t("alreadyVoted")}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</PollContext.Provider>
|
||||
|
|
|
@ -3,30 +3,27 @@ import { useTranslation } from "next-i18next";
|
|||
import * as React from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import ArrowLeft from "@/components/icons/arrow-left.svg";
|
||||
import ArrowRight from "@/components/icons/arrow-right.svg";
|
||||
|
||||
import { requiredString } from "../../../utils/form-validation";
|
||||
import { Button } from "../../button";
|
||||
import NameInput from "../../name-input";
|
||||
import { usePoll } from "../../poll-context";
|
||||
import { normalizeVotes } from "../mutations";
|
||||
import { ParticipantForm, ParticipantFormSubmitted } from "../types";
|
||||
import UserAvatar from "../user-avatar";
|
||||
import { VoteSelector } from "../vote-selector";
|
||||
import ControlledScrollArea from "./controlled-scroll-area";
|
||||
import { usePollContext } from "./poll-context";
|
||||
|
||||
export interface ParticipantRowFormProps {
|
||||
name?: string;
|
||||
defaultValues?: Partial<ParticipantForm>;
|
||||
onSubmit: (data: ParticipantFormSubmitted) => Promise<void>;
|
||||
className?: string;
|
||||
isYou?: boolean;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
const ParticipantRowForm: React.ForwardRefRenderFunction<
|
||||
HTMLFormElement,
|
||||
ParticipantRowFormProps
|
||||
> = ({ defaultValues, onSubmit, className, onCancel }, ref) => {
|
||||
> = ({ defaultValues, onSubmit, name, isYou, className, onCancel }, ref) => {
|
||||
const { t } = useTranslation("app");
|
||||
const {
|
||||
columnWidth,
|
||||
|
@ -34,20 +31,11 @@ const ParticipantRowForm: React.ForwardRefRenderFunction<
|
|||
sidebarWidth,
|
||||
numberOfColumns,
|
||||
goToNextPage,
|
||||
goToPreviousPage,
|
||||
maxScrollPosition,
|
||||
setScrollPosition,
|
||||
} = usePollContext();
|
||||
|
||||
const { options, optionIds } = usePoll();
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors, submitCount },
|
||||
reset,
|
||||
} = useForm({
|
||||
const { handleSubmit, control } = useForm({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
votes: [],
|
||||
...defaultValues,
|
||||
},
|
||||
|
@ -71,49 +59,15 @@ const ParticipantRowForm: React.ForwardRefRenderFunction<
|
|||
<form
|
||||
id="participant-row-form"
|
||||
ref={ref}
|
||||
onSubmit={handleSubmit(async ({ name, votes }) => {
|
||||
onSubmit={handleSubmit(async ({ votes }) => {
|
||||
await onSubmit({
|
||||
name,
|
||||
votes: normalizeVotes(optionIds, votes),
|
||||
});
|
||||
reset();
|
||||
})}
|
||||
className={clsx("flex h-14 shrink-0", className)}
|
||||
className={clsx("flex h-12 shrink-0", className)}
|
||||
>
|
||||
<div className="flex items-center px-2" style={{ width: sidebarWidth }}>
|
||||
<Controller
|
||||
name="name"
|
||||
rules={{
|
||||
validate: requiredString,
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<div className="w-full">
|
||||
<NameInput
|
||||
className={clsx("w-full", {
|
||||
"input-error": errors.name && submitCount > 0,
|
||||
})}
|
||||
placeholder={t("yourName")}
|
||||
{...field}
|
||||
onKeyDown={(e) => {
|
||||
if (e.code === "Tab" && scrollPosition > 0) {
|
||||
e.preventDefault();
|
||||
setScrollPosition(0);
|
||||
setTimeout(() => {
|
||||
checkboxRefs.current[0]?.focus();
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
onKeyPress={(e) => {
|
||||
if (e.code === "Enter") {
|
||||
e.preventDefault();
|
||||
checkboxRefs.current[0]?.focus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
control={control}
|
||||
/>
|
||||
<div className="flex items-center px-3" style={{ width: sidebarWidth }}>
|
||||
<UserAvatar name={name ?? t("you")} isYou={isYou} showName={true} />
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
|
@ -127,10 +81,11 @@ const ParticipantRowForm: React.ForwardRefRenderFunction<
|
|||
return (
|
||||
<div
|
||||
key={optionId}
|
||||
className="flex shrink-0 items-center justify-center px-2"
|
||||
className="flex shrink-0 items-center justify-center p-1"
|
||||
style={{ width: columnWidth }}
|
||||
>
|
||||
<VoteSelector
|
||||
className="h-full w-full"
|
||||
value={value?.type}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
|
@ -161,30 +116,6 @@ const ParticipantRowForm: React.ForwardRefRenderFunction<
|
|||
);
|
||||
}}
|
||||
/>
|
||||
{maxScrollPosition > 0 ? (
|
||||
<div className="flex items-center space-x-2 px-2 transition-all">
|
||||
<Button
|
||||
disabled={scrollPosition === 0}
|
||||
className="text-xs"
|
||||
rounded={true}
|
||||
onClick={() => {
|
||||
goToPreviousPage();
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
disabled={scrollPosition >= maxScrollPosition}
|
||||
className="text-xs"
|
||||
rounded={true}
|
||||
onClick={() => {
|
||||
goToNextPage();
|
||||
}}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
import { Participant, Vote, VoteType } from "@prisma/client";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import * as React from "react";
|
||||
|
||||
import CompactButton from "@/components/compact-button";
|
||||
import DotsVertical from "@/components/icons/dots-vertical.svg";
|
||||
import Pencil from "@/components/icons/pencil-alt.svg";
|
||||
import Trash from "@/components/icons/trash.svg";
|
||||
import { usePoll } from "@/components/poll-context";
|
||||
import { useUser } from "@/components/user-provider";
|
||||
|
||||
import Dropdown, { DropdownItem } from "../../dropdown";
|
||||
import { ParticipantFormSubmitted } from "../types";
|
||||
import { useDeleteParticipantModal } from "../use-delete-participant-modal";
|
||||
import UserAvatar from "../user-avatar";
|
||||
|
@ -18,7 +20,9 @@ import { usePollContext } from "./poll-context";
|
|||
|
||||
export interface ParticipantRowProps {
|
||||
participant: Participant & { votes: Vote[] };
|
||||
className?: string;
|
||||
editMode?: boolean;
|
||||
disableEditing?: boolean;
|
||||
onChangeEditMode?: (editMode: boolean) => void;
|
||||
onSubmit?: (data: ParticipantFormSubmitted) => Promise<void>;
|
||||
}
|
||||
|
@ -31,6 +35,7 @@ export const ParticipantRowView: React.VoidFunctionComponent<{
|
|||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
columnWidth: number;
|
||||
className?: string;
|
||||
sidebarWidth: number;
|
||||
isYou?: boolean;
|
||||
participantId: string;
|
||||
|
@ -39,6 +44,7 @@ export const ParticipantRowView: React.VoidFunctionComponent<{
|
|||
editable,
|
||||
votes,
|
||||
onEdit,
|
||||
className,
|
||||
onDelete,
|
||||
sidebarWidth,
|
||||
columnWidth,
|
||||
|
@ -46,46 +52,49 @@ export const ParticipantRowView: React.VoidFunctionComponent<{
|
|||
color,
|
||||
participantId,
|
||||
}) => {
|
||||
const { t } = useTranslation("app");
|
||||
return (
|
||||
<div
|
||||
data-testid="participant-row"
|
||||
data-participantid={participantId}
|
||||
className="group flex h-14 items-center"
|
||||
className={clsx("flex h-12 items-center", className)}
|
||||
>
|
||||
<div
|
||||
className="flex shrink-0 items-center px-4"
|
||||
className="flex h-full shrink-0 items-center justify-between gap-2 px-3"
|
||||
style={{ width: sidebarWidth }}
|
||||
>
|
||||
<UserAvatar
|
||||
className="mr-2"
|
||||
name={name}
|
||||
showName={true}
|
||||
isYou={isYou}
|
||||
color={color}
|
||||
/>
|
||||
<UserAvatar name={name} showName={true} isYou={isYou} color={color} />
|
||||
{editable ? (
|
||||
<div className="hidden shrink-0 items-center space-x-2 overflow-hidden group-hover:flex">
|
||||
<CompactButton icon={Pencil} onClick={onEdit} />
|
||||
<CompactButton icon={Trash} onClick={onDelete} />
|
||||
<div className="flex">
|
||||
<Dropdown
|
||||
placement="bottom-start"
|
||||
trigger={
|
||||
<button className="text-slate-500 hover:text-slate-800">
|
||||
<DotsVertical className="h-3" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<DropdownItem icon={Pencil} onClick={onEdit} label={t("edit")} />
|
||||
<DropdownItem
|
||||
icon={Trash}
|
||||
onClick={onDelete}
|
||||
label={t("delete")}
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<ControlledScrollArea>
|
||||
<ControlledScrollArea className="h-full">
|
||||
{votes.map((vote, i) => {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="relative flex shrink-0 items-center justify-center px-2 transition-colors"
|
||||
className={clsx("relative flex h-full shrink-0 p-1")}
|
||||
style={{ width: columnWidth }}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
"flex h-10 w-full items-center justify-center rounded-md",
|
||||
{
|
||||
"bg-green-50": vote === "yes",
|
||||
"bg-amber-50": vote === "ifNeedBe",
|
||||
"bg-slate-50": vote === "no",
|
||||
},
|
||||
"flex h-full w-full items-center justify-center rounded border border-slate-200 bg-slate-50/75",
|
||||
)}
|
||||
>
|
||||
<VoteIcon type={vote} />
|
||||
|
@ -102,6 +111,8 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
|
|||
participant,
|
||||
editMode,
|
||||
onSubmit,
|
||||
className,
|
||||
disableEditing,
|
||||
onChangeEditMode,
|
||||
}) => {
|
||||
const { columnWidth, sidebarWidth } = usePollContext();
|
||||
|
@ -115,20 +126,22 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
|
|||
|
||||
const isUnclaimed = !participant.userId;
|
||||
|
||||
const canEdit = !poll.closed && (admin || isYou || isUnclaimed);
|
||||
const canEdit =
|
||||
!disableEditing && !poll.closed && (admin || isYou || isUnclaimed);
|
||||
|
||||
if (editMode) {
|
||||
return (
|
||||
<ParticipantRowForm
|
||||
name={participant.name}
|
||||
defaultValues={{
|
||||
name: participant.name,
|
||||
votes: options.map(({ optionId }) => {
|
||||
const type = getVote(participant.id, optionId);
|
||||
return type ? { optionId, type } : undefined;
|
||||
}),
|
||||
}}
|
||||
onSubmit={async ({ name, votes }) => {
|
||||
await onSubmit?.({ name, votes });
|
||||
isYou={isYou}
|
||||
onSubmit={async ({ votes }) => {
|
||||
await onSubmit?.({ votes });
|
||||
onChangeEditMode?.(false);
|
||||
}}
|
||||
onCancel={() => onChangeEditMode?.(false)}
|
||||
|
@ -140,6 +153,7 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
|
|||
<ParticipantRowView
|
||||
sidebarWidth={sidebarWidth}
|
||||
columnWidth={columnWidth}
|
||||
className={className}
|
||||
name={participant.name}
|
||||
votes={options.map(({ optionId }) => {
|
||||
return getVote(participant.id, optionId);
|
||||
|
|
|
@ -11,7 +11,6 @@ export const PollContext = React.createContext<{
|
|||
sidebarWidth: number;
|
||||
numberOfColumns: number;
|
||||
availableSpace: number | string;
|
||||
actionColumnWidth: number;
|
||||
goToNextPage: () => void;
|
||||
goToPreviousPage: () => void;
|
||||
}>({
|
||||
|
@ -26,7 +25,6 @@ export const PollContext = React.createContext<{
|
|||
availableSpace: "auto",
|
||||
goToNextPage: noop,
|
||||
goToPreviousPage: noop,
|
||||
actionColumnWidth: 0,
|
||||
});
|
||||
|
||||
export const usePollContext = () => React.useContext(PollContext);
|
||||
|
|
|
@ -1,31 +1,25 @@
|
|||
import { Listbox } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import * as React from "react";
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import smoothscroll from "smoothscroll-polyfill";
|
||||
|
||||
import Check from "@/components/icons/check.svg";
|
||||
import ChevronDown from "@/components/icons/chevron-down.svg";
|
||||
import Pencil from "@/components/icons/pencil-alt.svg";
|
||||
import PlusCircle from "@/components/icons/plus-circle.svg";
|
||||
import Trash from "@/components/icons/trash.svg";
|
||||
import { usePoll } from "@/components/poll-context";
|
||||
import { You } from "@/components/you";
|
||||
|
||||
import { requiredString } from "../../utils/form-validation";
|
||||
import { Button } from "../button";
|
||||
import { styleMenuItem } from "../menu-styles";
|
||||
import NameInput from "../name-input";
|
||||
import { useNewParticipantModal } from "../new-participant-modal";
|
||||
import { useParticipants } from "../participants-provider";
|
||||
import TimeZonePicker from "../time-zone-picker";
|
||||
import { isUnclaimed, useUser } from "../user-provider";
|
||||
import GroupedOptions from "./mobile-poll/grouped-options";
|
||||
import {
|
||||
normalizeVotes,
|
||||
useAddParticipantMutation,
|
||||
useUpdateParticipantMutation,
|
||||
} from "./mutations";
|
||||
import { normalizeVotes, useUpdateParticipantMutation } from "./mutations";
|
||||
import { ParticipantForm } from "./types";
|
||||
import { useDeleteParticipantModal } from "./use-delete-participant-modal";
|
||||
import UserAvatar from "./user-avatar";
|
||||
|
@ -55,12 +49,11 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
|||
|
||||
const form = useForm<ParticipantForm>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
votes: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { reset, handleSubmit, control, formState } = form;
|
||||
const { reset, handleSubmit, formState } = form;
|
||||
const [selectedParticipantId, setSelectedParticipantId] =
|
||||
React.useState<string | undefined>();
|
||||
|
||||
|
@ -78,36 +71,36 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
|||
|
||||
const updateParticipant = useUpdateParticipantMutation();
|
||||
|
||||
const addParticipant = useAddParticipantMutation();
|
||||
const confirmDeleteParticipant = useDeleteParticipantModal();
|
||||
|
||||
const showNewParticipantModal = useNewParticipantModal();
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={handleSubmit(async ({ name, votes }) => {
|
||||
onSubmit={handleSubmit(async ({ votes }) => {
|
||||
if (selectedParticipant) {
|
||||
await updateParticipant.mutateAsync({
|
||||
pollId: poll.id,
|
||||
participantId: selectedParticipant.id,
|
||||
name,
|
||||
votes: normalizeVotes(optionIds, votes),
|
||||
});
|
||||
setIsEditing(false);
|
||||
} else {
|
||||
const newParticipant = await addParticipant.mutateAsync({
|
||||
pollId: poll.id,
|
||||
name,
|
||||
showNewParticipantModal({
|
||||
votes: normalizeVotes(optionIds, votes),
|
||||
onSubmit: async ({ id }) => {
|
||||
setSelectedParticipantId(id);
|
||||
setIsEditing(false);
|
||||
},
|
||||
});
|
||||
setSelectedParticipantId(newParticipant.id);
|
||||
setIsEditing(false);
|
||||
}
|
||||
})}
|
||||
>
|
||||
<div className="flex flex-col space-y-2 border-b bg-gray-50 p-2">
|
||||
<div className="flex space-x-2">
|
||||
{!isEditing ? (
|
||||
{selectedParticipantId || !isEditing ? (
|
||||
<Listbox
|
||||
value={selectedParticipantId}
|
||||
onChange={(participantId) => {
|
||||
|
@ -168,22 +161,8 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
|||
</div>
|
||||
</Listbox>
|
||||
) : (
|
||||
<div className="grow">
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
rules={{ validate: requiredString }}
|
||||
render={({ field }) => (
|
||||
<NameInput
|
||||
autoFocus={true}
|
||||
disabled={formState.isSubmitting}
|
||||
className={clsx("input w-full", {
|
||||
"input-error": formState.errors.name,
|
||||
})}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="flex grow items-center px-1">
|
||||
<You />
|
||||
</div>
|
||||
)}
|
||||
{isEditing ? (
|
||||
|
@ -212,7 +191,6 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
|||
onClick={() => {
|
||||
setIsEditing(true);
|
||||
reset({
|
||||
name: selectedParticipant.name,
|
||||
votes: optionIds.map((optionId) => ({
|
||||
optionId,
|
||||
type: getVote(selectedParticipant.id, optionId),
|
||||
|
@ -250,7 +228,6 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
|||
disabled={poll.closed}
|
||||
onClick={() => {
|
||||
reset({
|
||||
name: "",
|
||||
votes: [],
|
||||
});
|
||||
setIsEditing(true);
|
||||
|
@ -296,13 +273,12 @@ const MobilePoll: React.VoidFunctionComponent = () => {
|
|||
>
|
||||
<div className="space-y-3 border-t bg-gray-50 p-3">
|
||||
<Button
|
||||
icon={<Check />}
|
||||
className="w-full"
|
||||
htmlType="submit"
|
||||
type="primary"
|
||||
loading={formState.isSubmitting}
|
||||
>
|
||||
{t("save")}
|
||||
{selectedParticipantId ? t("save") : t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
|
|
@ -102,7 +102,7 @@ const PollOptionVoteSummary: React.VoidFunctionComponent<{ optionId: string }> =
|
|||
<div className="col-span-1 space-y-2">
|
||||
{participantsWhoVotedYes.map(({ name }, i) => (
|
||||
<div key={i} className="flex">
|
||||
<div className="relative mr-2 flex h-5 w-5 items-center justify-center">
|
||||
<div className="relative mr-2 flex items-center justify-center">
|
||||
<UserAvatar name={name} />
|
||||
<VoteIcon
|
||||
type="yes"
|
||||
|
@ -117,7 +117,7 @@ const PollOptionVoteSummary: React.VoidFunctionComponent<{ optionId: string }> =
|
|||
<div className="col-span-1 space-y-2">
|
||||
{participantsWhoVotedIfNeedBe.map(({ name }, i) => (
|
||||
<div key={i} className="flex">
|
||||
<div className="relative mr-2 flex h-5 w-5 items-center justify-center">
|
||||
<div className="relative mr-2 flex items-center justify-center">
|
||||
<UserAvatar name={name} />
|
||||
<VoteIcon
|
||||
type="ifNeedBe"
|
||||
|
@ -130,7 +130,7 @@ const PollOptionVoteSummary: React.VoidFunctionComponent<{ optionId: string }> =
|
|||
))}
|
||||
{participantsWhoVotedNo.map(({ name }, i) => (
|
||||
<div key={i} className="flex">
|
||||
<div className="relative mr-2 flex h-5 w-5 items-center justify-center">
|
||||
<div className="relative mr-2 flex items-center justify-center">
|
||||
<UserAvatar name={name} />
|
||||
<VoteIcon
|
||||
type="no"
|
||||
|
@ -232,7 +232,7 @@ const PollOption: React.VoidFunctionComponent<PollOptionProps> = ({
|
|||
exit={{ opacity: 0, x: -10 }}
|
||||
type="button"
|
||||
onTouchStart={(e) => e.stopPropagation()}
|
||||
className="flex min-w-0 justify-end gap-1 overflow-hidden rounded-lg p-2 active:bg-slate-500/10"
|
||||
className="flex min-w-0 justify-end gap-1 overflow-hidden p-1 active:bg-slate-500/10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded((value) => !value);
|
||||
|
@ -262,10 +262,10 @@ const PollOption: React.VoidFunctionComponent<PollOptionProps> = ({
|
|||
{editable ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<VoteSelector
|
||||
className="w-9"
|
||||
ref={selectorRef}
|
||||
value={vote}
|
||||
onChange={onChange}
|
||||
className="w-9"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
@ -24,13 +24,9 @@ export const useAddParticipantMutation = () => {
|
|||
queryClient.setQueryData(
|
||||
["polls.participants.list", { pollId: participant.pollId }],
|
||||
(existingParticipants = []) => {
|
||||
return [...existingParticipants, participant];
|
||||
return [participant, ...existingParticipants];
|
||||
},
|
||||
);
|
||||
queryClient.invalidateQueries([
|
||||
"polls.participants.list",
|
||||
{ pollId: participant.pollId },
|
||||
]);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
@ -11,7 +11,7 @@ const PollSubheader: React.VoidFunctionComponent = () => {
|
|||
const { t } = useTranslation("app");
|
||||
const { dayjs } = useDayjs();
|
||||
return (
|
||||
<div className="text-slate-500/75 lg:text-lg">
|
||||
<div className="text-slate-500/75">
|
||||
<div className="md:inline">
|
||||
<Trans
|
||||
i18nKey="createdBy"
|
||||
|
|
|
@ -2,7 +2,7 @@ import { AnimatePresence, motion } from "framer-motion";
|
|||
import * as React from "react";
|
||||
import { usePrevious } from "react-use";
|
||||
|
||||
import User from "@/components/icons/user-solid.svg";
|
||||
import CheckCircle from "@/components/icons/check-circle.svg";
|
||||
|
||||
export interface PopularityScoreProps {
|
||||
yesScore: number;
|
||||
|
@ -21,7 +21,7 @@ export const ScoreSummary: React.VoidFunctionComponent<PopularityScoreProps> =
|
|||
data-testid="popularity-score"
|
||||
className="flex items-center gap-1 text-sm font-bold tabular-nums"
|
||||
>
|
||||
<User className="inline-block h-4 text-slate-300 transition-opacity" />
|
||||
<CheckCircle className="inline-block h-4 text-slate-300 transition-opacity" />
|
||||
<AnimatePresence initial={false} exitBeforeEnter={true}>
|
||||
<motion.span
|
||||
transition={{
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { VoteType } from "@prisma/client";
|
||||
|
||||
export interface ParticipantForm {
|
||||
name: string;
|
||||
votes: Array<
|
||||
| {
|
||||
optionId: string;
|
||||
|
@ -12,6 +11,5 @@ export interface ParticipantForm {
|
|||
}
|
||||
|
||||
export interface ParticipantFormSubmitted {
|
||||
name: string;
|
||||
votes: Array<{ optionId: string; type: VoteType }>;
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ export const UnverifiedPollNotice = () => {
|
|||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border border-amber-200 bg-amber-100 p-3 text-gray-700 shadow-sm">
|
||||
<div className="p-1">
|
||||
<div className="px-1">
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey="unverifiedMessage"
|
||||
|
|
|
@ -19,15 +19,11 @@ const UserAvatarContext =
|
|||
React.createContext<((name: string) => string) | null>(null);
|
||||
|
||||
const colors = [
|
||||
"bg-fuchsia-300",
|
||||
"bg-purple-400",
|
||||
"bg-primary-400",
|
||||
"bg-blue-400",
|
||||
"bg-violet-400",
|
||||
"bg-sky-400",
|
||||
"bg-cyan-400",
|
||||
"bg-sky-400",
|
||||
"bg-blue-400",
|
||||
"bg-primary-400",
|
||||
"bg-indigo-400",
|
||||
"bg-purple-400",
|
||||
"bg-fuchsia-400",
|
||||
"bg-pink-400",
|
||||
|
@ -46,9 +42,9 @@ export const UserAvatarProvider: React.VoidFunctionComponent<{
|
|||
const res = {
|
||||
"": defaultColor,
|
||||
};
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
for (let i = names.length - 1; i >= 0; i--) {
|
||||
const name = names[i].trim().toLowerCase();
|
||||
const color = colors[(seedValue + i) % colors.length];
|
||||
const color = colors[(seedValue + names.length - i) % colors.length];
|
||||
res[name] = color;
|
||||
}
|
||||
return res;
|
||||
|
@ -90,15 +86,14 @@ const UserAvatarInner: React.VoidFunctionComponent<UserAvaterProps> = ({
|
|||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
"inline-block h-5 w-5 shrink-0 rounded-full text-center text-white",
|
||||
"inline-block shrink-0 rounded-full text-center text-white",
|
||||
color,
|
||||
{
|
||||
"h-5 w-5 text-xs leading-5": size === "default",
|
||||
"h-6 w-6 text-xs leading-6": size === "default",
|
||||
"h-10 w-10 leading-10": size === "large",
|
||||
},
|
||||
className,
|
||||
)}
|
||||
title={name}
|
||||
>
|
||||
{trimmedName[0]?.toUpperCase()}
|
||||
</span>
|
||||
|
@ -124,9 +119,7 @@ const UserAvatar: React.VoidFunctionComponent<UserAvaterProps> = ({
|
|||
)}
|
||||
>
|
||||
<UserAvatarInner {...forwardedProps} />
|
||||
<div className="min-w-0 truncate" title={forwardedProps.name}>
|
||||
{forwardedProps.name}
|
||||
</div>
|
||||
<div className="min-w-0 truncate">{forwardedProps.name}</div>
|
||||
{isYou ? <Badge>{t("you")}</Badge> : null}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { VoteType } from "@prisma/client";
|
||||
import clsx from "clsx";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import * as React from "react";
|
||||
|
||||
import VoteIcon from "./vote-icon";
|
||||
|
@ -37,17 +36,7 @@ export const VoteSelector = React.forwardRef<
|
|||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
className={clsx(
|
||||
"group relative inline-flex h-9 w-full items-center justify-center overflow-hidden rounded-md border bg-white transition-all hover:ring-4 focus-visible:border-0 focus-visible:ring-2 focus-visible:ring-primary-500",
|
||||
{
|
||||
"border-green-200 bg-green-50 hover:ring-green-100/50 active:bg-green-100/50":
|
||||
value === "yes",
|
||||
"border-amber-200 bg-amber-50 hover:ring-amber-100/50 active:bg-amber-100/50":
|
||||
value === "ifNeedBe",
|
||||
"border-gray-200 bg-gray-50 hover:ring-gray-100/50 active:bg-gray-100/50":
|
||||
value === "no",
|
||||
"border-gray-200 hover:ring-gray-100/50 active:bg-gray-100/50":
|
||||
value === undefined,
|
||||
},
|
||||
"btn-default relative items-center justify-center overflow-hidden px-0",
|
||||
className,
|
||||
)}
|
||||
onClick={() => {
|
||||
|
@ -55,18 +44,7 @@ export const VoteSelector = React.forwardRef<
|
|||
}}
|
||||
ref={ref}
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
<motion.span
|
||||
className="absolute flex items-center justify-center"
|
||||
transition={{ duration: 0.2 }}
|
||||
initial={{ opacity: 0, scale: 1.5, y: -45 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.5, y: 45 }}
|
||||
key={value}
|
||||
>
|
||||
<VoteIcon type={value} />
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
<VoteIcon type={value} />
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -19,7 +19,7 @@ const Switch: React.VoidFunctionComponent<SwitchProps> = ({
|
|||
checked={checked}
|
||||
onChange={onChange}
|
||||
className={clsx(
|
||||
"relative inline-flex h-6 w-10 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75",
|
||||
"relative inline-flex h-6 w-10 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out",
|
||||
{
|
||||
"bg-gray-200": !checked,
|
||||
"bg-green-500": checked,
|
||||
|
|
|
@ -20,7 +20,7 @@ export const TextInput = React.forwardRef<HTMLInputElement, TextInputProps>(
|
|||
ref={ref}
|
||||
type="text"
|
||||
className={clsx(
|
||||
"appearance-none rounded-md border border-gray-300 text-slate-700 shadow-sm placeholder:text-slate-400 focus:border-primary-500 focus:ring-1 focus:ring-primary-500",
|
||||
"appearance-none rounded border border-gray-300 text-slate-700 shadow-sm placeholder:text-slate-400",
|
||||
className,
|
||||
{
|
||||
"px-2 py-1": size === "md",
|
||||
|
|
8
src/components/you.tsx
Normal file
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 { AdminControls } from "../../components/admin-control";
|
||||
import ModalProvider from "../../components/modal/modal-provider";
|
||||
|
||||
const PollPageLoader: NextPage = () => {
|
||||
const { query } = useRouter();
|
||||
|
@ -35,11 +36,13 @@ const PollPageLoader: NextPage = () => {
|
|||
<ParticipantsProvider pollId={poll.id}>
|
||||
<StandardLayout>
|
||||
<PollContextProvider poll={poll} urlId={urlId} admin={true}>
|
||||
<div className="flex flex-col space-y-3 p-3 sm:space-y-4 sm:p-4">
|
||||
<AdminControls>
|
||||
<Poll />
|
||||
</AdminControls>
|
||||
</div>
|
||||
<ModalProvider>
|
||||
<div className="flex flex-col space-y-3 p-3 sm:space-y-4 sm:p-4">
|
||||
<AdminControls>
|
||||
<Poll />
|
||||
</AdminControls>
|
||||
</div>
|
||||
</ModalProvider>
|
||||
</PollContextProvider>
|
||||
</StandardLayout>
|
||||
</ParticipantsProvider>
|
||||
|
|
|
@ -13,6 +13,7 @@ import { trpcNext } from "@/utils/trpc";
|
|||
import { withPageTranslations } from "@/utils/with-page-translations";
|
||||
|
||||
import { ParticipantLayout } from "../../components/layouts/participant-layout";
|
||||
import ModalProvider from "../../components/modal/modal-provider";
|
||||
import { DayjsProvider } from "../../utils/dayjs";
|
||||
|
||||
const Page: NextPage = () => {
|
||||
|
@ -36,17 +37,19 @@ const Page: NextPage = () => {
|
|||
<ParticipantsProvider pollId={poll.id}>
|
||||
<ParticipantLayout>
|
||||
<PollContextProvider poll={poll} urlId={urlId} admin={false}>
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{user.id === poll.user.id ? (
|
||||
<Link
|
||||
className="btn-default"
|
||||
href={`/admin/${poll.adminUrlId}`}
|
||||
>
|
||||
← {t("goToAdmin")}
|
||||
</Link>
|
||||
) : null}
|
||||
<Poll />
|
||||
</div>
|
||||
<ModalProvider>
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{user.id === poll.user.id ? (
|
||||
<Link
|
||||
className="btn-default"
|
||||
href={`/admin/${poll.adminUrlId}`}
|
||||
>
|
||||
← {t("goToAdmin")}
|
||||
</Link>
|
||||
) : null}
|
||||
<Poll />
|
||||
</div>
|
||||
</ModalProvider>
|
||||
</PollContextProvider>
|
||||
</ParticipantLayout>
|
||||
</ParticipantsProvider>
|
||||
|
|
|
@ -20,7 +20,7 @@ export const participants = createRouter()
|
|||
},
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: "asc",
|
||||
createdAt: "desc",
|
||||
},
|
||||
{ name: "desc" },
|
||||
],
|
||||
|
@ -86,7 +86,6 @@ export const participants = createRouter()
|
|||
input: z.object({
|
||||
pollId: z.string(),
|
||||
participantId: z.string(),
|
||||
name: z.string(),
|
||||
votes: z
|
||||
.object({
|
||||
optionId: z.string(),
|
||||
|
@ -94,7 +93,7 @@ export const participants = createRouter()
|
|||
})
|
||||
.array(),
|
||||
}),
|
||||
resolve: async ({ input: { pollId, participantId, votes, name } }) => {
|
||||
resolve: async ({ input: { pollId, participantId, votes } }) => {
|
||||
const participant = await prisma.participant.update({
|
||||
where: {
|
||||
id: participantId,
|
||||
|
@ -112,7 +111,6 @@ export const participants = createRouter()
|
|||
})),
|
||||
},
|
||||
},
|
||||
name,
|
||||
},
|
||||
include: {
|
||||
votes: true,
|
||||
|
|
|
@ -1,4 +1,31 @@
|
|||
import { useTranslation } from "next-i18next";
|
||||
|
||||
/**
|
||||
* @deprecated Use form validation hook instead
|
||||
*/
|
||||
export const requiredString = (value: string) => !!value.trim();
|
||||
|
||||
/**
|
||||
* @deprecated Use form validation hook instead
|
||||
*/
|
||||
export const validEmail = (value: string) =>
|
||||
/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value);
|
||||
|
||||
export const useFormValidation = () => {
|
||||
const { t } = useTranslation("app");
|
||||
|
||||
return {
|
||||
requiredString: (name?: string) => (value: string) => {
|
||||
if (!value.trim()) {
|
||||
return t("requiredString", { name });
|
||||
}
|
||||
},
|
||||
|
||||
validEmail: (value: string) => {
|
||||
const isValidEmail = validEmail(value);
|
||||
if (!isValidEmail) {
|
||||
return t("validEmail");
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
29
style.css
29
style.css
|
@ -26,15 +26,22 @@
|
|||
h2 {
|
||||
@apply text-xl;
|
||||
}
|
||||
input {
|
||||
@apply outline-none;
|
||||
}
|
||||
|
||||
label {
|
||||
@apply mb-1 block text-sm text-slate-800;
|
||||
}
|
||||
|
||||
a,
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
@apply rounded outline-none transition-shadow focus:border-transparent focus:ring-2 focus:ring-slate-300;
|
||||
}
|
||||
|
||||
a,
|
||||
button {
|
||||
@apply outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1;
|
||||
@apply focus:ring-offset-2 focus:ring-offset-white;
|
||||
}
|
||||
|
||||
#floating-ui-root {
|
||||
|
@ -44,13 +51,13 @@
|
|||
|
||||
@layer components {
|
||||
.text-link {
|
||||
@apply rounded-sm font-medium text-primary-500 outline-none hover:text-primary-500 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1;
|
||||
@apply rounded font-medium text-primary-500 outline-none hover:text-primary-500 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1;
|
||||
}
|
||||
.formField {
|
||||
@apply mb-4;
|
||||
}
|
||||
.input {
|
||||
@apply appearance-none rounded-md border border-gray-200 px-2 py-1 text-slate-700 placeholder:text-slate-400 focus:border-primary-500 focus:ring-1 focus:ring-primary-500;
|
||||
@apply appearance-none border px-2 py-1 text-slate-700 placeholder:text-slate-400;
|
||||
}
|
||||
input.input {
|
||||
@apply h-9;
|
||||
|
@ -59,20 +66,20 @@
|
|||
@apply input px-3 py-3;
|
||||
}
|
||||
.input-error {
|
||||
@apply border-rose-500 ring-1 ring-rose-400 focus:border-rose-400 focus:ring-rose-500;
|
||||
@apply focus:ring-rose-500;
|
||||
}
|
||||
.checkbox {
|
||||
@apply h-4 w-4 rounded border-slate-300 text-primary-500 shadow-sm focus:ring-primary-500;
|
||||
}
|
||||
.btn {
|
||||
@apply inline-flex h-9 select-none items-center justify-center whitespace-nowrap rounded-md border px-3 font-medium shadow-sm transition-all;
|
||||
@apply inline-flex h-9 select-none items-center justify-center whitespace-nowrap border px-3 font-medium shadow-sm;
|
||||
}
|
||||
a.btn {
|
||||
@apply cursor-pointer hover:no-underline;
|
||||
}
|
||||
|
||||
.btn-default {
|
||||
@apply btn bg-white text-slate-700 hover:bg-gray-50 active:bg-slate-100;
|
||||
@apply btn bg-white text-slate-700 hover:bg-gray-50 active:bg-gray-100;
|
||||
}
|
||||
|
||||
a.btn-default {
|
||||
|
@ -90,7 +97,7 @@
|
|||
}
|
||||
.btn-primary {
|
||||
text-shadow: rgb(0 0 0 / 20%) 0px 1px 1px;
|
||||
@apply btn border-primary-600 bg-primary-500 text-white hover:bg-opacity-90 focus-visible:ring-primary-500 active:bg-primary-600;
|
||||
@apply btn border-primary-600 bg-primary-500 text-white hover:bg-opacity-90 active:bg-primary-600;
|
||||
}
|
||||
|
||||
a.btn-primary {
|
||||
|
@ -102,7 +109,7 @@
|
|||
}
|
||||
|
||||
.segment-button button {
|
||||
@apply inline-flex grow items-center justify-center border-t border-b border-r bg-gray-50 px-4 transition-colors first:rounded-l first:border-l last:rounded-r hover:bg-slate-50 focus:z-10 focus-visible:ring-2 focus-visible:ring-offset-0 active:bg-slate-100;
|
||||
@apply inline-flex grow items-center justify-center border-t border-b border-r bg-gray-50 px-4 transition-colors first:rounded-r-none first:border-l last:rounded-l-none hover:bg-slate-50 focus:z-10 active:bg-slate-100;
|
||||
}
|
||||
|
||||
.segment-button .segment-button-active {
|
||||
|
|
|
@ -45,9 +45,5 @@ module.exports = {
|
|||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require("@tailwindcss/forms"),
|
||||
require("@tailwindcss/typography"),
|
||||
require("tailwindcss-animate"),
|
||||
],
|
||||
plugins: [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 page.click("text='New'");
|
||||
await page.click('text="New"');
|
||||
await page.click("data-testid=poll-option >> nth=0");
|
||||
await page.click("data-testid=poll-option >> nth=1");
|
||||
await page.click("data-testid=poll-option >> nth=3");
|
||||
await page.type('[placeholder="Your name…"]', "Test user");
|
||||
|
||||
await page.click("text=Save");
|
||||
await page.getByText("Continue").click();
|
||||
|
||||
await page.getByPlaceholder("Jessie Smith").type("Test user");
|
||||
await page.getByText("Save").click();
|
||||
|
||||
await expect(page.locator("data-testid=user")).toBeVisible();
|
||||
await expect(
|
||||
page.locator("data-testid=participant-selector").locator("text=You"),
|
||||
|
|
|
@ -11,16 +11,20 @@ test("should be able to vote and comment on a poll", async ({ page }) => {
|
|||
|
||||
await expect(page.locator('text="Lunch Meeting"')).toBeVisible();
|
||||
|
||||
await page.type('[placeholder="Your name…"]', "Test user");
|
||||
// There is a hidden checkbox (nth=0) that exists so that the behaviour of the form is consistent even
|
||||
// when we only have a single option/checkbox.
|
||||
await page.locator("data-testid=vote-selector >> nth=0").click();
|
||||
await page.locator("data-testid=vote-selector >> nth=2").click();
|
||||
await page.click("button >> text='Continue'");
|
||||
|
||||
await page.type('[placeholder="Jessie Smith"]', "Test user");
|
||||
await page.click("text='Save'");
|
||||
|
||||
await expect(page.locator("text='Test user'")).toBeVisible();
|
||||
await expect(
|
||||
page.locator("data-testid=participant-row >> nth=4").locator("text=You"),
|
||||
page.locator("data-testid=participant-row >> nth=0").locator("text=You"),
|
||||
).toBeVisible();
|
||||
|
||||
await page.type(
|
||||
"[placeholder='Leave a comment on this poll (visible to everyone)']",
|
||||
"This is a comment!",
|
||||
|
|
12
yarn.lock
12
yarn.lock
|
@ -1932,13 +1932,6 @@
|
|||
dependencies:
|
||||
tslib "^2.4.0"
|
||||
|
||||
"@tailwindcss/forms@^0.5.3":
|
||||
version "0.5.3"
|
||||
resolved "https://registry.yarnpkg.com/@tailwindcss/forms/-/forms-0.5.3.tgz#e4d7989686cbcaf416c53f1523df5225332a86e7"
|
||||
integrity sha512-y5mb86JUoiUgBjY/o6FJSFZSEttfb3Q5gllE4xoKjAAD+vBrnIhE4dViwUuow3va8mpH4s9jyUbUbrRGoRdc2Q==
|
||||
dependencies:
|
||||
mini-svg-data-uri "^1.2.3"
|
||||
|
||||
"@tailwindcss/typography@^0.5.9":
|
||||
version "0.5.9"
|
||||
resolved "https://registry.yarnpkg.com/@tailwindcss/typography/-/typography-0.5.9.tgz#027e4b0674929daaf7c921c900beee80dbad93e8"
|
||||
|
@ -5868,11 +5861,6 @@ min-indent@^1.0.0:
|
|||
resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869"
|
||||
integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==
|
||||
|
||||
mini-svg-data-uri@^1.2.3:
|
||||
version "1.4.3"
|
||||
resolved "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.3.tgz"
|
||||
integrity sha512-gSfqpMRC8IxghvMcxzzmMnWpXAChSA+vy4cia33RgerMS8Fex95akUyQZPbxJJmeBGiGmK7n/1OpUX8ksRjIdA==
|
||||
|
||||
minimatch@^3.0.4:
|
||||
version "3.0.4"
|
||||
resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue