This commit is contained in:
Oscar
2026-06-08 13:23:20 +03:00
commit 637dddf656
160 changed files with 56097 additions and 0 deletions

View File

@@ -0,0 +1,458 @@
<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>