ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / db / db (push) Failing after 1m29s
ci / ts / ts (packages + web) (push) Successful in 49s
ci / api / api (push) Successful in 1m25s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 48s
ci / recommender / recommender (push) Successful in 21s
- GET /onboarding offers the library's top artists (with album covers) and genres and says whether to show it (not done and < 10 plays); PUT /me/onboarding stores the choice or a skip (migration 00009). - The recommender seeds the taste from it while the user has fewer than 30 scored tracks: chosen artists' tracks +1, chosen genres +0.5. - Web: onboarding replaces the wave on the home page until done or skipped; artist grid with covers, genre chips, at least 3 artists. - e2e journey and the recommender DB test cover it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
192 lines
6.2 KiB
TypeScript
192 lines
6.2 KiB
TypeScript
import { unwrap } from '@pipu/core';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { useSession, useUser } from '../app/session';
|
|
import { usePlayer, usePlayerState } from '../player/context';
|
|
import { useErrorText } from '../shared/lib/errors';
|
|
import {
|
|
pauseIcon,
|
|
playIcon,
|
|
waveBedtimeIcon,
|
|
waveBoltIcon,
|
|
waveCarIcon,
|
|
waveFitnessIcon,
|
|
waveHappyIcon,
|
|
waveHeadphonesIcon,
|
|
waveRainyIcon,
|
|
waveRunIcon,
|
|
waveSpaIcon,
|
|
waveSunIcon,
|
|
waveWeekendIcon,
|
|
} from '../shared/ui/icons';
|
|
import { cx, FormError, Icon } from '../shared/ui/ui';
|
|
import { WaveButton } from '../widgets/wave/WaveButton';
|
|
import s from './home.module.css';
|
|
import { Onboarding } from './Onboarding';
|
|
|
|
/** Иконки Material Symbols по именам из /wave/options. */
|
|
const ICONS: Record<string, string> = {
|
|
directions_run: waveRunIcon,
|
|
fitness_center: waveFitnessIcon,
|
|
wb_sunny: waveSunIcon,
|
|
headphones: waveHeadphonesIcon,
|
|
directions_car: waveCarIcon,
|
|
weekend: waveWeekendIcon,
|
|
bedtime: waveBedtimeIcon,
|
|
bolt: waveBoltIcon,
|
|
sentiment_satisfied: waveHappyIcon,
|
|
spa: waveSpaIcon,
|
|
rainy: waveRainyIcon,
|
|
};
|
|
|
|
const WAVE_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
|
|
|
|
type Tr = (key: string) => string;
|
|
|
|
/** Главная: для нового пользователя без истории — сначала онбординг вкуса, затем волна. */
|
|
export function HomePage() {
|
|
const session = useSession();
|
|
const queryClient = useQueryClient();
|
|
const onboarding = useQuery({
|
|
queryKey: ['onboarding'],
|
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')),
|
|
staleTime: Infinity,
|
|
});
|
|
if (onboarding.data?.required) {
|
|
return (
|
|
<Onboarding
|
|
options={onboarding.data}
|
|
onDone={() => queryClient.setQueryData(['onboarding'], { ...onboarding.data, required: false })}
|
|
/>
|
|
);
|
|
}
|
|
return <WaveHome />;
|
|
}
|
|
|
|
/** Главная (раскладка Center): кнопка «Моя волна» и под ней выбор занятия и настроения. */
|
|
function WaveHome() {
|
|
const { t } = useTranslation();
|
|
const tr = t as unknown as Tr;
|
|
const session = useSession();
|
|
const user = useUser();
|
|
const player = usePlayer();
|
|
const st = usePlayerState();
|
|
const errorText = useErrorText();
|
|
const [error, setError] = useState<unknown>(null);
|
|
const [activity, setActivity] = useState<string | null>(st.wave?.activity ?? null);
|
|
const [mood, setMood] = useState<string | null>(st.wave?.mood ?? null);
|
|
|
|
const options = useQuery({
|
|
queryKey: ['wave-options'],
|
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/wave/options')),
|
|
staleTime: Infinity,
|
|
});
|
|
|
|
const waveOn = st.wave !== null;
|
|
const playing = waveOn && st.playing;
|
|
const track = waveOn ? st.track : null;
|
|
const colors: [string, string, string] =
|
|
track?.album.cover_colors.length === 3 ? (track.album.cover_colors as [string, string, string]) : WAVE_COLORS;
|
|
|
|
const run = (fn: () => Promise<void> | void) => {
|
|
setError(null);
|
|
void Promise.resolve(fn()).catch(setError);
|
|
};
|
|
const onWave = () => {
|
|
if (!waveOn) run(() => player.startWave(activity, mood));
|
|
else run(() => player.toggle());
|
|
};
|
|
const choose = (kind: 'activity' | 'mood', key: string) => {
|
|
const nextActivity = kind === 'activity' ? (activity === key ? null : key) : activity;
|
|
const nextMood = kind === 'mood' ? (mood === key ? null : key) : mood;
|
|
setActivity(nextActivity);
|
|
setMood(nextMood);
|
|
// Смена контекста во время игры — без остановки музыки.
|
|
if (waveOn) run(() => player.setWaveContext(nextActivity, nextMood));
|
|
};
|
|
|
|
const ctxLabel =
|
|
[activity && tr(`wave.activities.${activity}`), mood && tr(`wave.moods.${mood}`)].filter(Boolean).join(' · ') ||
|
|
t('wave.for_you');
|
|
|
|
return (
|
|
<div className={s.home}>
|
|
<WaveButton
|
|
variant={user.ui_prefs.wave_variant ?? 'orb'}
|
|
playing={playing}
|
|
bpm={track?.features?.bpm ?? undefined}
|
|
energy={track?.features?.energy ?? undefined}
|
|
colors={colors}
|
|
theme={document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'}
|
|
size={320}
|
|
label={t(playing ? 'wave.pause' : 'wave.start')}
|
|
onClick={onWave}
|
|
>
|
|
<Icon src={playing ? pauseIcon : playIcon} size={44} />
|
|
<span className={s.orbLabel}>{t('wave.title')}</span>
|
|
<span className={s.orbContext}>{ctxLabel}</span>
|
|
</WaveButton>
|
|
|
|
{st.wave?.fallback && <p className={s.note}>{t('wave.fallback')}</p>}
|
|
{st.error === 'wave.empty' && <p className={s.note}>{t('wave.empty')}</p>}
|
|
<FormError>{error ? errorText(error) : null}</FormError>
|
|
|
|
{options.data && (
|
|
<div className={s.picker}>
|
|
<Chips
|
|
title={t('wave.activity_label')}
|
|
options={options.data.activities}
|
|
value={activity}
|
|
label={(k) => tr(`wave.activities.${k}`)}
|
|
onPick={(k) => choose('activity', k)}
|
|
/>
|
|
<Chips
|
|
title={t('wave.mood_label')}
|
|
options={options.data.moods}
|
|
value={mood}
|
|
label={(k) => tr(`wave.moods.${k}`)}
|
|
onPick={(k) => choose('mood', k)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** MoodPicker: одно занятие и одно настроение; повторный клик снимает выбор. */
|
|
function Chips({
|
|
title,
|
|
options,
|
|
value,
|
|
label,
|
|
onPick,
|
|
}: {
|
|
title: string;
|
|
options: { key: string; icon: string }[];
|
|
value: string | null;
|
|
label: (key: string) => string;
|
|
onPick: (key: string) => void;
|
|
}) {
|
|
return (
|
|
<section className={s.group} aria-label={title}>
|
|
<h2 className={s.groupTitle}>{title}</h2>
|
|
<div className={s.chips}>
|
|
{options.map((o) => (
|
|
<button
|
|
key={o.key}
|
|
type="button"
|
|
className={cx(s.chip, value === o.key && s.chipOn)}
|
|
aria-pressed={value === o.key}
|
|
onClick={() => onPick(o.key)}
|
|
>
|
|
{ICONS[o.icon] && <Icon src={ICONS[o.icon]!} size={18} />}
|
|
{label(o.key)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|