Files
dating-app-frontend/src/views/onboarding/ProfileSetupView.vue
2026-06-08 13:23:20 +03:00

459 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { reactive, ref, computed, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useVuelidate } from '@vuelidate/core';
import { required, helpers } from '@vuelidate/validators';
import { useAuthStore } from '@/stores/auth.store';
import { useUiStore } from '@/stores/ui.store';
import { apiClient } from '@/api/client';
import type { CreateProfileDto } from '@/api/api';
import type { UserProfile } from '@/stores/auth.store';
import type { District } from '@/stores/ui.store';
import AppInput from '@/components/common/AppInput.vue';
import AppButton from '@/components/common/AppButton.vue';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
const router = useRouter();
const authStore = useAuthStore();
const uiStore = useUiStore();
const step = ref(1);
const totalSteps = 4;
const loading = ref(false);
const form = reactive<CreateProfileDto & { confirmStep?: number }>({
name: '',
birthDate: '',
gender: 'female',
cityId: '',
districtId: '',
description: '',
nation: '',
height: undefined,
weight: undefined,
tagIds: [],
});
const selectedTags = ref<string[]>([]);
const districts = ref<District[]>([]);
const loadingDistricts = ref(false);
// Load districts when city changes
watch(() => form.cityId, async (cityId) => {
if (!cityId) { districts.value = []; return; }
if (uiStore.districts[cityId]) { districts.value = uiStore.districts[cityId]; return; }
loadingDistricts.value = true;
try {
const res = await apiClient.api.citiesControllerFindDistricts(cityId) as unknown as District[];
uiStore.setDistricts(cityId, res);
districts.value = res;
} finally {
loadingDistricts.value = false;
}
});
const step1Rules = {
name: { required: helpers.withMessage('Введите имя', required) },
birthDate: { required: helpers.withMessage('Введите дату рождения', required) },
gender: { required: helpers.withMessage('Выберите пол', required) },
};
const v$ = useVuelidate(step1Rules, form);
const progress = computed(() => ((step.value - 1) / totalSteps) * 100);
async function nextStep() {
if (step.value === 1) {
const valid = await v$.value.$validate();
if (!valid) return;
}
if (step.value < totalSteps) step.value++;
else await finish();
}
function prevStep() {
if (step.value > 1) step.value--;
}
function toggleTag(tagId: string) {
const idx = selectedTags.value.indexOf(tagId);
if (idx === -1) selectedTags.value.push(tagId);
else selectedTags.value.splice(idx, 1);
}
async function finish() {
loading.value = true;
try {
form.tagIds = selectedTags.value;
const profile = await apiClient.api.profilesControllerCreate(form) as unknown as UserProfile;
authStore.addProfile(profile);
uiStore.addToast('Профиль создан', 'success');
router.replace('/feed');
} catch (err: unknown) {
const message = (err as { response?: { data?: { message?: string } } })
?.response?.data?.message ?? 'Не удалось создать профиль';
uiStore.addToast(message, 'error');
} finally {
loading.value = false;
}
}
function skip() {
router.replace('/feed');
}
</script>
<template>
<div class="setup">
<div class="setup__grain" aria-hidden="true" />
<div class="setup__card">
<!-- Progress -->
<div class="setup__progress">
<div class="setup__progress-bar" :style="{ width: `${progress}%` }" />
</div>
<div class="setup__header">
<span class="meta">Шаг {{ step }} из {{ totalSteps }}</span>
<h1 class="setup__title">
<template v-if="step === 1">Расскажите о себе</template>
<template v-if="step === 2">Где вы находитесь?</template>
<template v-if="step === 3">Ваши интересы</template>
<template v-if="step === 4">Добавьте фото</template>
</h1>
</div>
<!-- Step 1: Basic info -->
<div v-if="step === 1" class="setup__step">
<AppInput
v-model="form.name"
label="Имя"
placeholder="Как вас зовут?"
name="name"
required
:error="v$.name.$errors.map(e => e.$message as string)"
@blur="v$.name.$touch()"
/>
<AppInput
v-model="form.birthDate"
label="Дата рождения"
placeholder="1995-06-15"
type="date"
name="birthDate"
required
:error="v$.birthDate.$errors.map(e => e.$message as string)"
@blur="v$.birthDate.$touch()"
/>
<div class="setup__gender">
<span class="field__label label">Пол</span>
<div class="setup__gender-options">
<button
type="button"
class="setup__gender-btn"
:class="{ 'setup__gender-btn--active': form.gender === 'female' }"
@click="form.gender = 'female'"
>Женщина</button>
<button
type="button"
class="setup__gender-btn"
:class="{ 'setup__gender-btn--active': form.gender === 'male' }"
@click="form.gender = 'male'"
>Мужчина</button>
</div>
</div>
<AppInput
v-model="form.description"
label="О себе"
placeholder="Расскажите что-нибудь..."
name="description"
/>
</div>
<!-- Step 2: Location -->
<div v-if="step === 2" class="setup__step">
<div class="field">
<label class="field__label label" for="city-select">Город</label>
<select id="city-select" v-model="form.cityId" class="field__select">
<option value="">Выберите город</option>
<option v-for="city in uiStore.cities" :key="city.id" :value="city.id">{{ city.name }}</option>
</select>
</div>
<div v-if="form.cityId" class="field">
<label class="field__label label" for="district-select">Район</label>
<div v-if="loadingDistricts" class="setup__loading">
<LoadingSpinner size="sm" />
</div>
<select v-else id="district-select" v-model="form.districtId" class="field__select">
<option value="">Выберите район</option>
<option v-for="d in districts" :key="d.id" :value="d.id">{{ d.name }}</option>
</select>
</div>
<AppInput v-model="form.nation" label="Национальность" placeholder="Необязательно" name="nation" />
<div class="setup__row">
<AppInput v-model.number="(form.height as unknown as string)" label="Рост (см)" placeholder="170" type="number" name="height" />
<AppInput v-model.number="(form.weight as unknown as string)" label="Вес (кг)" placeholder="60" type="number" name="weight" />
</div>
</div>
<!-- Step 3: Tags/interests -->
<div v-if="step === 3" class="setup__step">
<p class="setup__hint">Выберите теги, которые вас описывают</p>
<div class="setup__tags">
<button
v-for="tag in uiStore.tags"
:key="tag.id"
type="button"
class="setup__tag"
:class="{ 'setup__tag--active': selectedTags.includes(tag.id) }"
@click="toggleTag(tag.id)"
>
{{ tag.name }}
</button>
</div>
<p v-if="uiStore.tags.length === 0" class="setup__hint setup__hint--muted">Теги загружаются...</p>
</div>
<!-- Step 4: Photo upload reminder -->
<div v-if="step === 4" class="setup__step">
<div class="setup__photo-hint">
<svg viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="1.5" width="64" height="64">
<rect x="4" y="12" width="56" height="40" rx="4"/>
<circle cx="32" cy="32" r="10"/>
<circle cx="50" cy="20" r="3"/>
</svg>
<p>После создания профиля вы сможете добавить фото в разделе <strong>Мой профиль</strong></p>
</div>
</div>
<!-- Navigation -->
<div class="setup__nav">
<AppButton
v-if="step > 1"
variant="ghost"
@click="prevStep"
>Назад</AppButton>
<span v-else />
<div class="setup__nav-right">
<AppButton v-if="step < totalSteps" variant="ghost" @click="skip">Пропустить</AppButton>
<AppButton
variant="primary"
size="md"
:loading="loading"
@click="nextStep"
>
{{ step === totalSteps ? 'Готово' : 'Далее' }}
</AppButton>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.setup {
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-base);
padding: 24px;
position: relative;
&__grain {
position: absolute;
inset: 0;
background-image: url('@/assets/grain.svg');
background-repeat: repeat;
opacity: 0.3;
pointer-events: none;
}
&__card {
position: relative;
z-index: 1;
width: 100%;
max-width: 520px;
animation: fade-up var(--transition-base) both;
}
&__progress {
height: 2px;
background: var(--color-dim);
margin-bottom: 40px;
border-radius: var(--radius-full);
overflow: hidden;
&-bar {
height: 100%;
background: var(--color-signal);
border-radius: var(--radius-full);
transition: width var(--transition-base);
}
}
&__header {
margin-bottom: 32px;
}
&__title {
font-family: var(--font-display);
font-size: 2rem;
color: var(--color-cream);
margin: 8px 0 0;
}
&__step {
display: flex;
flex-direction: column;
gap: 20px;
min-height: 260px;
animation: fade-up var(--transition-base) both;
}
&__nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 36px;
&-right {
display: flex;
gap: 8px;
}
}
&__gender {
display: flex;
flex-direction: column;
gap: 8px;
&-options {
display: flex;
gap: 8px;
}
&-btn {
flex: 1;
height: 44px;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-muted);
font-family: var(--font-mono);
font-size: 0.875rem;
cursor: pointer;
transition: all var(--transition-fast);
&--active {
background: var(--color-signal-bg);
border-color: var(--color-signal);
color: var(--color-cream);
}
&:hover:not(.setup__gender-btn--active) {
border-color: var(--color-border-strong);
color: var(--color-cream);
}
}
}
&__row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
&__tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
&__tag {
padding: 6px 14px;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
color: var(--color-muted);
font-family: var(--font-mono);
font-size: 0.75rem;
cursor: pointer;
transition: all var(--transition-fast);
&--active {
background: var(--color-signal-bg);
border-color: var(--color-signal);
color: var(--color-cream);
}
&:hover:not(.setup__tag--active) {
border-color: var(--color-border-strong);
color: var(--color-cream);
}
}
&__hint {
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--color-muted);
margin: 0;
&--muted { color: var(--color-dim); }
}
&__loading {
height: 44px;
display: flex;
align-items: center;
}
&__photo-hint {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 20px;
padding: 32px 0;
color: var(--color-muted);
svg { opacity: 0.4; }
p {
font-family: var(--font-mono);
font-size: 0.875rem;
color: var(--color-muted);
max-width: 36ch;
margin: 0;
line-height: 1.6;
strong { color: var(--color-cream); }
}
}
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
&__label { color: var(--color-muted); }
&__select {
height: 44px;
padding: 0 14px;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-cream);
font-family: var(--font-mono);
font-size: 0.875rem;
cursor: pointer;
transition: border-color var(--transition-fast);
outline: none;
appearance: none;
&:focus { border-color: var(--color-signal); }
}
}
</style>