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
86 lines
5.3 KiB
TypeScript
86 lines
5.3 KiB
TypeScript
import type { components } from '@pipu/api-client';
|
|
import { AGE_GROUPS, GENDERS, unwrap } from '@pipu/core';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useState, type ReactNode } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
|
|
|
|
import { useSession, useSessionCtx, useUser } from '../lib/session';
|
|
import { useColors } from '../theme';
|
|
import { Button, ErrorBox, H1, Muted, Screen, useErrorText } from './kit';
|
|
|
|
type Options = components['schemas']['Onboarding'];
|
|
type Choice = components['schemas']['OnboardingChoice'];
|
|
|
|
function useOnboarding() {
|
|
const session = useSession();
|
|
const user = useUser();
|
|
const { server } = useSessionCtx();
|
|
return useQuery({ queryKey: ['onboarding', server, user.id],
|
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')),
|
|
staleTime: 0, refetchInterval: 30_000 });
|
|
}
|
|
|
|
export function OnboardingGate({ children }: { children: ReactNode }) {
|
|
const query = useOnboarding();
|
|
const client = useQueryClient();
|
|
if (query.isPending) return <Screen><ActivityIndicator /></Screen>;
|
|
if (!query.data?.required) return <>{children}</>;
|
|
return <Screen><ScrollView contentContainerStyle={{ padding: 16 }}>
|
|
<Onboarding options={query.data} onDone={() => void client.invalidateQueries({ queryKey: ['onboarding'] })} />
|
|
</ScrollView></Screen>;
|
|
}
|
|
|
|
export function RecommendationSettings() {
|
|
const { t } = useTranslation();
|
|
const query = useOnboarding();
|
|
const client = useQueryClient();
|
|
const [editing, setEditing] = useState(false);
|
|
const errorText = useErrorText();
|
|
return <View>
|
|
{editing && query.data ? <Onboarding key={JSON.stringify(query.data.choice)} options={query.data} editing onDone={() => {
|
|
setEditing(false); void client.invalidateQueries({ queryKey: ['onboarding'] });
|
|
}} /> : <Button title={t('onboarding.edit')} variant="secondary" busy={query.isFetching} onPress={() => { void query.refetch().then((result) => { if (result.data && !result.error) setEditing(true); }); }} />}
|
|
<ErrorBox>{query.error ? errorText(query.error) : null}</ErrorBox>
|
|
</View>;
|
|
}
|
|
|
|
function Onboarding({ options, onDone, editing = false }: { options: Options; onDone: () => void; editing?: boolean }) {
|
|
const { t } = useTranslation();
|
|
const session = useSession();
|
|
const errorText = useErrorText();
|
|
const [choice, setChoice] = useState<Choice>(options.choice ?? { artist_ids: [], genres: [], gender: null, 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 } : choice,
|
|
})), onSuccess: onDone });
|
|
const toggle = (key: 'artist_ids' | 'genres', value: string) => setChoice((old) => ({ ...old,
|
|
[key]: old[key].includes(value) ? old[key].filter((x) => x !== value) : [...old[key], value],
|
|
}));
|
|
return <View style={{ gap: 20 }}>
|
|
<H1>{t('onboarding.title')}</H1><Muted>{t('onboarding.subtitle')}</Muted>
|
|
<Choices title={t('onboarding.gender')} items={[{ value: '', label: t('onboarding.unspecified') }, ...GENDERS.map((g) => ({ value: g, label: t(`onboarding.genders.${g}`) }))]}
|
|
selected={[choice.gender ?? '']} onPick={(v) => setChoice((old) => ({ ...old, gender: (v || null) as Choice['gender'] }))} />
|
|
<Choices title={t('onboarding.age_group')} items={[{ value: '', label: t('onboarding.unspecified') }, ...AGE_GROUPS.map((g) => ({ value: g, label: t(`onboarding.ages.${g}`) }))]}
|
|
selected={[choice.age_group ?? '']} onPick={(v) => setChoice((old) => ({ ...old, age_group: (v || null) as Choice['age_group'] }))} />
|
|
<Muted>{t('onboarding.age_hint')}</Muted>
|
|
<Choices title={t('onboarding.artists')} items={options.artists.map((a) => ({ value: a.id, label: a.name }))} selected={choice.artist_ids} onPick={(v) => toggle('artist_ids', v)} />
|
|
<Choices title={t('onboarding.genres')} items={options.genres.map((g) => ({ value: g.name, label: g.name }))} selected={choice.genres} onPick={(v) => toggle('genres', v)} />
|
|
<ErrorBox>{save.error ? errorText(save.error) : null}</ErrorBox>
|
|
<Button title={t(editing ? 'settings.save' : 'onboarding.done')} busy={save.isPending} onPress={() => save.mutate(false)} />
|
|
<Button title={t(editing ? 'form.cancel' : 'onboarding.skip')} variant="ghost" disabled={save.isPending} onPress={() => editing ? onDone() : save.mutate(true)} />
|
|
</View>;
|
|
}
|
|
|
|
function Choices({ title, items, selected, onPick }: { title: string; items: { value: string; label: string }[]; selected: string[]; onPick: (value: string) => void }) {
|
|
const colors = useColors();
|
|
return <View style={{ gap: 10 }}><Text style={{ color: colors.text, fontWeight: '600' }}>{title}</Text>
|
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>{items.map((item) => {
|
|
const on = selected.includes(item.value);
|
|
return <Pressable key={item.value} accessibilityRole="button" accessibilityState={{ selected: on }} onPress={() => onPick(item.value)}
|
|
style={{ padding: 12, borderRadius: 20, backgroundColor: on ? colors['accent-fill'] : colors.surface2 }}>
|
|
<Text style={{ color: on ? '#fff' : colors.text }}>{item.label}</Text>
|
|
</Pressable>;
|
|
})}</View>
|
|
</View>;
|
|
}
|