ci / ts / ts (packages + web) (push) Successful in 3m19s
ci / db / db (push) Successful in 53s
ci / module boundaries / boundaries (push) Successful in 7s
ci / api / api (push) Successful in 51s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m3s
ci / recommender / recommender (push) Failing after 16s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m15s
131 lines
5.9 KiB
TypeScript
131 lines
5.9 KiB
TypeScript
import type { components } from '@pipu/api-client';
|
|
import { AGE_GROUPS, GENDERS, unwrap } from '@pipu/core';
|
|
import { useMutation } from '@tanstack/react-query';
|
|
import { useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { useSession } from '../app/session';
|
|
import { Cover } from '../entities/catalog';
|
|
import { useErrorText } from '../shared/lib/errors';
|
|
import { checkIcon } from '../shared/ui/icons';
|
|
import { Button, cx, FormError, Icon } from '../shared/ui/ui';
|
|
import s from './onboarding.module.css';
|
|
|
|
type Options = components['schemas']['Onboarding'];
|
|
|
|
/** Минимум исполнителей, чтобы волне было от чего оттолкнуться. */
|
|
const MIN_ARTISTS = 3;
|
|
|
|
/**
|
|
* Онбординг вкуса (docs/recommendations.md#7): исполнители и жанры библиотеки — начальный вкус «Моей волны»,
|
|
* пока своей истории почти нет. Пропуск тоже запоминается: второй раз не спросим.
|
|
*/
|
|
export function Onboarding({ options, onDone, editing = false }: { options: Options; onDone: () => void; editing?: boolean }) {
|
|
const { t } = useTranslation();
|
|
const session = useSession();
|
|
const errorText = useErrorText();
|
|
const [artists, setArtists] = useState<Set<string>>(new Set(options.choice?.artist_ids));
|
|
const [genres, setGenres] = useState<Set<string>>(new Set(options.choice?.genres));
|
|
const [gender, setGender] = useState(options.choice?.gender ?? null);
|
|
const [ageGroup, setAgeGroup] = useState(options.choice?.age_group ?? null);
|
|
const save = useMutation({
|
|
mutationFn: async (skip: boolean) =>
|
|
unwrap(
|
|
await session.client.PUT('/api/v1/me/onboarding', {
|
|
body: skip ? { artist_ids: [], genres: [], gender: null, age_group: null } : { artist_ids: [...artists], genres: [...genres], gender, age_group: ageGroup },
|
|
}),
|
|
),
|
|
onSuccess: onDone,
|
|
});
|
|
// Функциональное обновление: быстрые нажатия подряд не теряют выбор.
|
|
const toggle = (update: (fn: (prev: Set<string>) => Set<string>) => void, key: string) =>
|
|
update((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(key)) next.delete(key);
|
|
else next.add(key);
|
|
return next;
|
|
});
|
|
const enough = editing || artists.size >= MIN_ARTISTS || (artists.size > 0 && genres.size > 0) || !!gender || !!ageGroup || options.artists.length === 0;
|
|
|
|
return (
|
|
<div className={s.page}>
|
|
<header className={s.head}>
|
|
<h1>{t('onboarding.title')}</h1>
|
|
<p>{t('onboarding.subtitle')}</p>
|
|
</header>
|
|
|
|
<section aria-label={t('onboarding.gender')}>
|
|
<h2 className={s.label}>{t('onboarding.gender')}</h2>
|
|
<div className={s.genres}>
|
|
<button type="button" className={s.genre} aria-pressed={!gender} onClick={() => setGender(null)}>{t('onboarding.unspecified')}</button>
|
|
{GENDERS.map((g) => <button type="button" key={g} className={s.genre} aria-pressed={gender === g} onClick={() => setGender(g)}>{t(`onboarding.genders.${g}`)}</button>)}
|
|
</div>
|
|
</section>
|
|
<section aria-label={t('onboarding.age_group')}>
|
|
<h2 className={s.label}>{t('onboarding.age_group')}</h2>
|
|
<p>{t('onboarding.age_hint')}</p>
|
|
<div className={s.genres}>
|
|
<button type="button" className={s.genre} aria-pressed={!ageGroup} onClick={() => setAgeGroup(null)}>{t('onboarding.unspecified')}</button>
|
|
{AGE_GROUPS.map((g) => <button type="button" key={g} className={s.genre} aria-pressed={ageGroup === g} onClick={() => setAgeGroup(g)}>{t(`onboarding.ages.${g}`)}</button>)}
|
|
</div>
|
|
</section>
|
|
<section aria-label={t('onboarding.artists')}>
|
|
<h2 className={s.label}>{t('onboarding.artists')}</h2>
|
|
<div className={s.artists}>
|
|
{options.artists.map((a) => {
|
|
const on = artists.has(a.id);
|
|
return (
|
|
<button
|
|
key={a.id}
|
|
type="button"
|
|
className={cx(s.artist, on && s.on)}
|
|
aria-pressed={on}
|
|
onClick={() => toggle(setArtists, a.id)}
|
|
>
|
|
<span className={s.avatar}>
|
|
<Cover album={{ id: a.id, cover_url: a.cover_url, cover_colors: a.cover_colors }} size={96} round />
|
|
{on && (
|
|
<span className={s.check}>
|
|
<Icon src={checkIcon} size={22} />
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span className={s.name}>{a.name}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
|
|
{options.genres.length > 0 && (
|
|
<section aria-label={t('onboarding.genres')}>
|
|
<h2 className={s.label}>{t('onboarding.genres')}</h2>
|
|
<div className={s.genres}>
|
|
{options.genres.map((g) => (
|
|
<button
|
|
key={g.name}
|
|
type="button"
|
|
className={s.genre}
|
|
aria-pressed={genres.has(g.name)}
|
|
onClick={() => toggle(setGenres, g.name)}
|
|
>
|
|
{g.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
|
<div className={s.actions}>
|
|
<Button variant="ghost" disabled={save.isPending} busy={save.isPending && save.variables} onClick={() => editing ? onDone() : save.mutate(true)}>
|
|
{t(editing ? 'form.cancel' : 'onboarding.skip')}
|
|
</Button>
|
|
<Button variant="primary" busy={save.isPending && !save.variables} disabled={!enough || save.isPending} onClick={() => save.mutate(false)}>
|
|
{enough ? t(editing ? 'settings.save' : 'onboarding.done') : t('onboarding.pick_more', { count: MIN_ARTISTS - artists.size })}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|