Skip to content

Commit cf7d558

Browse files
authored
enhance: add possibility to select avatar from selection for temporary accounts without login (#4730)
1 parent ad07caf commit cf7d558

14 files changed

Lines changed: 310 additions & 104 deletions

File tree

apps/frontend-pwa/src/components/liveQuiz/AccountSelector.tsx

Lines changed: 225 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,57 @@ import { useMutation, useQuery } from '@apollo/client'
22
import { faKeyboard } from '@fortawesome/free-regular-svg-icons'
33
import {
44
faArrowLeft,
5+
faArrowRight,
6+
faCheck,
57
faUserSecret,
68
faUserTie,
79
} from '@fortawesome/free-solid-svg-icons'
10+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
811
import {
912
LoginTemporaryParticipantDocument,
1013
SelfDocument,
1114
UserRole,
1215
} from '@klicker-uzh/graphql/dist/ops'
1316
import { useLocalStorage } from '@uidotdev/usehooks'
14-
import { Button, FormikTextField, Modal, toast } from '@uzh-bf/design-system'
17+
import {
18+
Button,
19+
Carousel,
20+
CarouselApi,
21+
CarouselContent,
22+
CarouselNext,
23+
CarouselPrevious,
24+
FormikTextField,
25+
Modal,
26+
toast,
27+
} from '@uzh-bf/design-system'
1528
import { Form, Formik } from 'formik'
1629
import { useTranslations } from 'next-intl'
30+
import Image from 'next/image'
1731
import { useRouter } from 'next/router'
1832
import { useEffect, useState } from 'react'
33+
import { twMerge } from 'tailwind-merge'
1934
import * as Yup from 'yup'
35+
import CarouselMonitor from './CarouselMonitor'
36+
37+
const AVAILABLE_AVATARS = [
38+
'',
39+
'43de5cc3e88371b82515e365b61ca4f56b3fff76',
40+
'd8bced94c310832dc36fc22ad3a8fd7fbde69010',
41+
'eda64b05916c5d5d219a840befc63e269978a1da',
42+
'2d1cfed9ead85a12389badd09e48e0ded97540f9',
43+
'a9178a73231f5583c39316b23579dc8fb2f6ba2e',
44+
'5bbb97018ce7d9de56c93feb71c20c4741d80205',
45+
'1668749bf2981ef2d432d16aabe01151ddb774a2',
46+
'b877b984af00e8d901f714a470d50ba1282a6e00',
47+
'212fa161b03cc873b03eee91fce1e6b123305700',
48+
'ba359939f81458694d3ae1ead5d42a9c52204ba9',
49+
'b8c64871872753fb5e6f61c3e93c89f4aeceb7d7',
50+
'af8392c87388febc5814c15d97630138b419b2ed',
51+
'cabe95f3e740b9503e1bf771972d230600a4528c',
52+
'f60344fc3fef0b4e4e1d7a9b17950b9102a89ecf',
53+
'075bcfa2a31a58e38badb2b81381787e2c7386c5',
54+
'77749da96b827d1fa055e916d6e7a0d26b42bdff',
55+
]
2056

2157
function AccountSelector({
2258
quizId,
@@ -29,10 +65,11 @@ function AccountSelector({
2965
const router = useRouter()
3066

3167
const [open, setOpen] = useState(false)
32-
const [definePseudonym, setDefinePseudonym] = useState(false)
68+
const [step, setStep] = useState<'choice' | 'pseudonym' | 'avatar'>('choice')
3369
const [loginState, setLoginState] = useLocalStorage<
3470
'anonymous' | 'temporary' | 'loggedIn' | undefined
3571
>(`login-state-${quizId}`, undefined)
72+
const [api, setApi] = useState<CarouselApi>()
3673

3774
const [loginTemporaryParticipant, { loading: loggingIn }] = useMutation(
3875
LoginTemporaryParticipantDocument
@@ -77,7 +114,7 @@ function AccountSelector({
77114
open={open && isGamificationEnabled}
78115
onClose={() => {
79116
setOpen(false)
80-
setDefinePseudonym(false)
117+
setStep('choice')
81118
}}
82119
title={
83120
<div>
@@ -88,7 +125,7 @@ function AccountSelector({
88125
}
89126
className={{ title: 'mb-4 text-center', content: 'max-w-[25rem] pb-2' }}
90127
>
91-
{!definePseudonym ? (
128+
{step === 'choice' ? (
92129
<>
93130
<div className="mb-4 text-sm">
94131
{t.rich('pwa.liveQuiz.loginSelectionHint', {
@@ -113,7 +150,7 @@ function AccountSelector({
113150
</Button>
114151
<Button
115152
className={{ root: 'justify-start' }}
116-
onClick={() => setDefinePseudonym(true)}
153+
onClick={() => setStep('pseudonym')}
117154
data={{ cy: 'create-temporary-pseudonym' }}
118155
>
119156
<Button.Icon icon={faKeyboard} />
@@ -125,7 +162,7 @@ function AccountSelector({
125162
className={{ root: 'justify-start' }}
126163
onClick={() => {
127164
setLoginState('anonymous')
128-
setDefinePseudonym(false)
165+
setStep('choice')
129166
setOpen(false)
130167
}}
131168
data={{ cy: 'participate-anonymously' }}
@@ -137,99 +174,192 @@ function AccountSelector({
137174
</Button>
138175
</div>
139176
</>
140-
) : (
141-
<>
142-
<Button
143-
basic
144-
className={{ root: 'px-2 py-1 text-sm' }}
145-
onClick={() => setDefinePseudonym(false)}
146-
data={{ cy: 'cancel-define-pseudonym' }}
147-
>
148-
<Button.Icon icon={faArrowLeft} />
149-
<Button.Label>{t('pwa.liveQuiz.changeLoginMode')}</Button.Label>
150-
</Button>
151-
<div className="mb-2 mt-1 text-sm">
152-
{t.rich('pwa.liveQuiz.pseudonymExplanation', {
153-
b: (text) => <b>{text}</b>,
154-
})}
155-
</div>
156-
<Formik
157-
initialValues={{ pseudonym: '' }}
158-
validationSchema={Yup.object({
159-
pseudonym: Yup.string()
160-
.required(t('pwa.liveQuiz.pseudonymRequired'))
161-
.min(5, t('pwa.liveQuiz.pseudonymMinLength', { length: '5' }))
162-
.max(
163-
15,
164-
t('pwa.liveQuiz.pseudonymMaxLength', { length: '15' })
165-
),
166-
})}
167-
onSubmit={async (values) => {
168-
try {
169-
const { data } = await loginTemporaryParticipant({
170-
variables: {
171-
liveQuizId: quizId,
177+
) : null}
178+
<Formik
179+
initialValues={{ pseudonym: '', avatar: '' }}
180+
validationSchema={Yup.object({
181+
pseudonym: Yup.string()
182+
.required(t('pwa.liveQuiz.pseudonymRequired'))
183+
.min(5, t('pwa.liveQuiz.pseudonymMinLength', { length: '5' }))
184+
.max(15, t('pwa.liveQuiz.pseudonymMaxLength', { length: '15' })),
185+
avatar: Yup.string(),
186+
})}
187+
onSubmit={async (values) => {
188+
try {
189+
const { data } = await loginTemporaryParticipant({
190+
variables: {
191+
liveQuizId: quizId,
192+
pseudonym: values.pseudonym,
193+
avatar: values.avatar !== '' ? values.avatar : undefined,
194+
},
195+
refetchQueries: [{ query: SelfDocument }],
196+
})
197+
198+
if (data?.loginTemporaryParticipant) {
199+
setLoginState('temporary')
200+
setOpen(false)
201+
202+
toast({
203+
type: 'success',
204+
message: t.rich(
205+
'pwa.liveQuiz.joinedSuccessfullyWithPseudonym',
206+
{
172207
pseudonym: values.pseudonym,
173-
},
174-
})
208+
b: (text) => <b>{text}</b>,
209+
}
210+
),
211+
options: { duration: 5000 },
212+
})
175213

176-
if (data?.loginTemporaryParticipant) {
177-
setLoginState('temporary')
178-
setOpen(false)
214+
await refetch() // refetch the self query to update the user data
215+
} else {
216+
toast({
217+
type: 'error',
218+
message: t('pwa.liveQuiz.pseudonymAlreadyExists'),
219+
options: { duration: 5000 },
220+
})
221+
}
222+
} catch (error) {
223+
console.error('Error logging in as temporary participant:', error)
224+
toast({
225+
type: 'error',
226+
message: t('pwa.liveQuiz.pseudonymCreationFailed'),
227+
options: { duration: 5000 },
228+
})
229+
}
230+
}}
231+
>
232+
{({ values, setFieldValue }) => (
233+
<Form className="flex flex-col">
234+
{step === 'pseudonym' ? (
235+
<>
236+
<Button
237+
basic
238+
className={{ root: 'w-max px-2 py-1 text-sm' }}
239+
onClick={() => setStep('choice')}
240+
data={{ cy: 'cancel-define-pseudonym' }}
241+
>
242+
<Button.Icon icon={faArrowLeft} />
243+
<Button.Label>
244+
{t('pwa.liveQuiz.changeLoginMode')}
245+
</Button.Label>
246+
</Button>
247+
<div className="my-1 text-sm">
248+
{t.rich('pwa.liveQuiz.pseudonymExplanation', {
249+
b: (text) => <b>{text}</b>,
250+
})}
251+
</div>
179252

180-
toast({
181-
type: 'success',
182-
message: t.rich(
183-
'pwa.liveQuiz.joinedSuccessfullyWithPseudonym',
184-
{
185-
pseudonym: values.pseudonym,
186-
b: (text) => <b>{text}</b>,
187-
}
188-
),
189-
options: { duration: 5000 },
190-
})
253+
<FormikTextField
254+
required
255+
name="pseudonym"
256+
label={t('shared.generic.pseudonym')}
257+
placeholder="klicker123"
258+
className={{ label: 'text-sm' }}
259+
/>
260+
<Button
261+
type="button"
262+
disabled={
263+
!values.pseudonym ||
264+
values.pseudonym.length < 5 ||
265+
values.pseudonym.length > 15
266+
}
267+
onClick={() => setStep('avatar')}
268+
className={{ root: 'mt-2 self-end' }}
269+
>
270+
<Button.Icon icon={faArrowRight} />
271+
<Button.Label>{t('shared.generic.next')}</Button.Label>
272+
</Button>
273+
</>
274+
) : null}
275+
{step === 'avatar' ? (
276+
<>
277+
<Button
278+
basic
279+
className={{ root: 'w-max px-2 py-1 text-sm' }}
280+
onClick={() => setStep('pseudonym')}
281+
data={{ cy: 'cancel-choose-avatar' }}
282+
>
283+
<Button.Icon icon={faArrowLeft} />
284+
<Button.Label>
285+
{t('pwa.liveQuiz.pseudonymSelection')}
286+
</Button.Label>
287+
</Button>
288+
<div className="my-1 text-sm">
289+
{t.rich('pwa.liveQuiz.avatarExplanation', {
290+
b: (text) => <b>{text}</b>,
291+
})}
292+
</div>
191293

192-
await refetch() // refetch the self query to update the user data
193-
} else {
194-
toast({
195-
type: 'error',
196-
message: t('pwa.liveQuiz.pseudonymAlreadyExists'),
197-
options: { duration: 5000 },
198-
})
199-
}
200-
} catch (error) {
201-
console.error(
202-
'Error logging in as temporary participant:',
203-
error
204-
)
205-
toast({
206-
type: 'error',
207-
message: t('pwa.liveQuiz.pseudonymCreationFailed'),
208-
options: { duration: 5000 },
209-
})
210-
}
211-
}}
212-
>
213-
<Form className="flex flex-col gap-2">
214-
<FormikTextField
215-
required
216-
name="pseudonym"
217-
label={t('shared.generic.pseudonym')}
218-
placeholder="klicker123"
219-
className={{ label: 'text-sm' }}
220-
/>
221-
<Button
222-
primary
223-
type="submit"
224-
loading={loggingIn}
225-
className={{ root: 'self-end' }}
226-
>
227-
{t('shared.generic.submit')}
228-
</Button>
229-
</Form>
230-
</Formik>
231-
</>
232-
)}
294+
<CarouselMonitor
295+
avatars={AVAILABLE_AVATARS}
296+
api={api}
297+
setFieldValue={setFieldValue}
298+
/>
299+
<Carousel
300+
opts={{ loop: true }}
301+
className="w-full overflow-visible"
302+
setApi={setApi}
303+
>
304+
<CarouselContent className="mt-4 overflow-visible px-10">
305+
{AVAILABLE_AVATARS.map((avatar, index) => (
306+
<div
307+
key={avatar}
308+
className={twMerge(
309+
'relative flex justify-center overflow-visible pb-10',
310+
index === AVAILABLE_AVATARS.length - 1 && '-mr-10'
311+
)}
312+
>
313+
<div
314+
className={twMerge(
315+
'flex h-40 w-40 cursor-pointer items-center justify-center',
316+
avatar === '' && 'p-5 pt-14'
317+
)}
318+
onClick={() => setFieldValue('avatar', avatar)}
319+
>
320+
<Image
321+
src={
322+
avatar && avatar !== ''
323+
? `${process.env.NEXT_PUBLIC_AVATAR_BASE_PATH}/${avatar}.svg`
324+
: '/user-solid.svg'
325+
}
326+
alt="Avatar option"
327+
width={140}
328+
height={140}
329+
className={twMerge(
330+
'h-auto w-full rounded-full',
331+
avatar ? '' : 'p-2'
332+
)}
333+
/>
334+
</div>
335+
{values.avatar === avatar && (
336+
<div className="absolute bottom-0 left-0 right-0 z-10 mx-auto flex w-fit flex-row items-center gap-2.5 rounded-md border-2 border-green-600 bg-white px-2 py-1 text-sm">
337+
<FontAwesomeIcon
338+
icon={faCheck}
339+
className="h-4 w-4 font-bold text-green-600"
340+
/>
341+
{t('shared.generic.selected')}
342+
</div>
343+
)}
344+
</div>
345+
))}
346+
</CarouselContent>
347+
<CarouselPrevious type="button" className="left-0" />
348+
<CarouselNext type="button" className="right-0" />
349+
</Carousel>
350+
<Button
351+
primary
352+
type="submit"
353+
loading={loggingIn}
354+
className={{ root: 'mt-2 self-end' }}
355+
>
356+
{t('shared.generic.submit')}
357+
</Button>
358+
</>
359+
) : null}
360+
</Form>
361+
)}
362+
</Formik>
233363
</Modal>
234364
)
235365
}

0 commit comments

Comments
 (0)