459 lines
13 KiB
Vue
459 lines
13 KiB
Vue
<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>
|