Files
pipu-music/apps/mobile/src/ui/Onboarding.tsx
T
mrleo1nid fff91c446b
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
feat(recommendations): collect demographics and advance age cohorts
2026-10-22 14:19:52 +03:00

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>;
}