init
This commit is contained in:
458
src/views/onboarding/ProfileSetupView.vue
Normal file
458
src/views/onboarding/ProfileSetupView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user