319 lines
14 KiB
Vue
319 lines
14 KiB
Vue
<template>
|
||
<div class="onboarding-funnel">
|
||
<!-- Progress Bar -->
|
||
<div class="progress-bar">
|
||
<div v-for="n in 5" :key="n" class="step" :class="{ 'active': currentStep === n, 'completed': currentStep > n }">
|
||
<div class="step-circle">{{ n }}</div>
|
||
<div class="step-label">{{ getStepLabel(n) }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Content Area -->
|
||
<div class="step-content">
|
||
<div class="step-container">
|
||
|
||
<!-- Step 1: Create Habit -->
|
||
<div v-if="currentStep === 1">
|
||
<h2>Шаг 1: Создайте свою первую привычку</h2>
|
||
<p>Привычки - это основа продуктивности. С чего начнем?</p>
|
||
<form @submit.prevent="handleCreateHabit">
|
||
<div class="form-group">
|
||
<label for="habit-name">Название привычки</label>
|
||
<input id="habit-name" type="text" v-model="form.habitName" placeholder="Например, Читать 15 минут" required />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Дни выполнения</label>
|
||
<div class="days-selector">
|
||
<button v-for="day in daysOfWeek" :key="day.value" type="button" :class="{ 'selected': form.selectedDays.includes(day.value) }" @click="toggleDay(day.value)">{{ day.label }}</button>
|
||
</div>
|
||
</div>
|
||
<div v-if="error" class="error-message">{{ error }}</div>
|
||
<button type="submit" :disabled="isLoading">{{ isLoading ? 'Создание...' : 'Создать и перейти далее' }}</button>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- Step 2: Complete Habit -->
|
||
<div v-if="currentStep === 2">
|
||
<h2>Шаг 2: Завершите привычку</h2>
|
||
<p>Отлично! Теперь отметьте привычку <strong>"{{ createdHabit?.name }}"</strong> как выполненную, чтобы получить награду.</p>
|
||
<div v-if="error" class="error-message">{{ error }}</div>
|
||
<HabitCard
|
||
v-if="createdHabit"
|
||
:habit="createdHabit"
|
||
:is-submitting-habit="isLoading"
|
||
:show-history-grid="false"
|
||
:force-show-action="true"
|
||
@complete="handleCompleteOnboardingHabit"
|
||
/>
|
||
</div>
|
||
|
||
<!-- NEW Step 3: Reward Screen -->
|
||
<div v-if="currentStep === 3" class="reward-step">
|
||
<div class="reward-icon">💰</div>
|
||
<h2>Вы получили {{ onboardingRewardAmount }} монет!</h2>
|
||
<p class="reward-caption">Деньги можно тратить на строительство вашей деревни.</p>
|
||
<button @click="nextStep">Продолжить</button>
|
||
</div>
|
||
|
||
<!-- Step 4: Build a House (was Step 3) -->
|
||
<div v-if="currentStep === 4">
|
||
<h2>Шаг 4: Постройте дом</h2>
|
||
<p>Вы заработали <strong>{{ onboardingRewardAmount }}</strong> золота! Нажмите на пустой участок в деревне, чтобы построить Дом (Стоимость: 50 монет) и увеличить лимит рабочих.</p>
|
||
|
||
<VillageGrid
|
||
v-if="villageData"
|
||
:village-data="villageData"
|
||
@tile-click="handleTileClickToBuild"
|
||
/>
|
||
<div v-else-if="villagePending" class="loading-placeholder">Загрузка деревни...</div>
|
||
|
||
<div v-if="error" class="error-message">{{ error }}</div>
|
||
|
||
<button @click="nextStep" :disabled="isLoading || !isHouseBuilt" class="next-button">
|
||
{{ isLoading ? 'Загрузка...' : 'Продолжить' }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Step 5: Register (was Step 4) -->
|
||
<div v-if="currentStep === 5">
|
||
<h2>Шаг 5: Сохраните свой прогресс!</h2>
|
||
<p>Ваша деревня растет! Чтобы не потерять свой прогресс и соревноваться с другими, зарегистрируйтесь.</p>
|
||
<form @submit.prevent="handleRegister">
|
||
<div class="form-group">
|
||
<label for="nickname">Ваше имя</label>
|
||
<input id="nickname" type="text" v-model="form.nickname" placeholder="Смурфик" required />
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="email">Email</label>
|
||
<input id="email" type="email" v-model="form.email" placeholder="smurf@example.com" required />
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="password">Пароль (мин. 8 символов)</label>
|
||
<input id="password" type="password" v-model="form.password" required />
|
||
</div>
|
||
<div v-if="error" class="error-message">{{ error }}</div>
|
||
<button type="submit" :disabled="isLoading">{{ isLoading ? 'Регистрация...' : 'Завершить и сохранить' }}</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, watch, computed } from 'vue';
|
||
|
||
// --- Composables ---
|
||
const api = useApi();
|
||
const { user, updateUser, register } = useAuth();
|
||
|
||
// --- Constants ---
|
||
const onboardingRewardAmount = 75;
|
||
|
||
// --- State ---
|
||
const currentStep = ref(1);
|
||
const isLoading = ref(false);
|
||
const error = ref<string | null>(null);
|
||
|
||
const form = reactive({
|
||
habitName: 'Читать 15 минут',
|
||
selectedDays: [0, 1, 2, 3, 4, 5, 6],
|
||
nickname: '',
|
||
email: '',
|
||
password: '',
|
||
});
|
||
|
||
const daysOfWeek = [
|
||
{ label: 'Пн', value: 0 }, { label: 'Вт', value: 1 }, { label: 'Ср', value: 2 },
|
||
{ label: 'Чт', value: 3 }, { label: 'Пт', value: 4 }, { label: 'Сб', value: 5 }, { label: 'Вс', value: 6 }
|
||
];
|
||
const createdHabit = ref<{ id: number; name: string; completions: any[] } | null>(null);
|
||
|
||
const villageData = ref(null);
|
||
const villagePending = ref(false);
|
||
|
||
// --- Computed ---
|
||
const isHouseBuilt = computed(() => villageData.value?.objects.some(o => o.type === 'HOUSE'));
|
||
const hasEnoughCoinsForHouse = computed(() => user.value && user.value.coins >= 50);
|
||
|
||
// --- Watchers ---
|
||
watch(currentStep, async (newStep) => {
|
||
if (newStep === 4 && !villageData.value) { // Was step 3, now 4
|
||
villagePending.value = true;
|
||
error.value = null;
|
||
try {
|
||
villageData.value = await api('/api/village');
|
||
} catch (e: any) { error.value = 'Не удалось загрузить данные деревни.'; }
|
||
finally { villagePending.value = false; }
|
||
}
|
||
});
|
||
|
||
// --- Methods ---
|
||
const getStepLabel = (step: number): string => {
|
||
switch (step) {
|
||
case 1: return 'Создать привычку';
|
||
case 2: return 'Завершить';
|
||
case 3: return 'Награда';
|
||
case 4: return 'Построить дом';
|
||
case 5: return 'Регистрация';
|
||
default: return '';
|
||
}
|
||
};
|
||
|
||
const nextStep = () => {
|
||
if (currentStep.value < 5) { // Max 5 steps now
|
||
error.value = null;
|
||
currentStep.value++;
|
||
}
|
||
};
|
||
|
||
const toggleDay = (day: number) => {
|
||
const index = form.selectedDays.indexOf(day);
|
||
if (index > -1) {
|
||
if (form.selectedDays.length > 1) form.selectedDays.splice(index, 1);
|
||
} else {
|
||
form.selectedDays.push(day);
|
||
}
|
||
};
|
||
|
||
// --- API Methods ---
|
||
const handleCreateHabit = async () => {
|
||
if (!form.habitName.trim() || form.selectedDays.length === 0) {
|
||
error.value = 'Пожалуйста, введите название и выберите хотя бы один день.';
|
||
return;
|
||
}
|
||
isLoading.value = true;
|
||
error.value = null;
|
||
try {
|
||
const response = await api('/api/habits', {
|
||
method: 'POST',
|
||
body: { name: form.habitName, daysOfWeek: form.selectedDays },
|
||
});
|
||
createdHabit.value = { ...response, completions: [] };
|
||
nextStep();
|
||
} catch (e: any) { error.value = e.data?.message || 'Не удалось создать привычку.'; }
|
||
finally { isLoading.value = false; }
|
||
};
|
||
|
||
const handleCompleteOnboardingHabit = async (habitId: number) => {
|
||
if (!createdHabit.value) {
|
||
error.value = 'Ошибка: не найдена созданная привычка.';
|
||
return;
|
||
}
|
||
isLoading.value = true;
|
||
error.value = null;
|
||
try {
|
||
const response = await api(`/api/habits/${habitId}/complete`, {
|
||
method: 'POST',
|
||
});
|
||
// Manually update the user's coins from the response
|
||
if (user.value && response.updatedCoins !== undefined) {
|
||
user.value.coins = response.updatedCoins;
|
||
}
|
||
createdHabit.value.completions.push({ date: new Date().toISOString() });
|
||
nextStep();
|
||
} catch (e: any) { error.value = e.data?.message || 'Не удалось завершить привычку.'; }
|
||
finally { isLoading.value = false; }
|
||
};
|
||
|
||
const handleTileClickToBuild = async (tile: any) => {
|
||
if (isLoading.value || isHouseBuilt.value) return;
|
||
if (tile.terrainType !== 'EMPTY' || tile.object) {
|
||
error.value = 'Выберите пустой участок для строительства.';
|
||
return;
|
||
}
|
||
if (!hasEnoughCoinsForHouse.value) {
|
||
error.value = 'Не хватает монет для постройки дома (50 монет).';
|
||
return;
|
||
}
|
||
|
||
isLoading.value = true;
|
||
error.value = null;
|
||
|
||
try {
|
||
const response = await api('/api/village/action', {
|
||
method: 'POST',
|
||
body: {
|
||
tileId: tile.id,
|
||
actionType: 'BUILD',
|
||
payload: { buildingType: 'HOUSE' },
|
||
},
|
||
});
|
||
villageData.value = response;
|
||
if (updateUser && response.user) {
|
||
updateUser(response.user);
|
||
}
|
||
} catch (e: any) { error.value = e.data?.message || 'Не удалось построить дом.'; }
|
||
finally { isLoading.value = false; }
|
||
};
|
||
|
||
const handleRegister = async () => {
|
||
if (!form.email || !form.password || !form.nickname) {
|
||
error.value = "Пожалуйста, заполните все поля.";
|
||
return;
|
||
}
|
||
if (form.password.length < 8) {
|
||
error.value = "Пароль должен быть не менее 8 символов.";
|
||
return;
|
||
}
|
||
|
||
isLoading.value = true;
|
||
error.value = null;
|
||
|
||
try {
|
||
await register(form.email, form.password, form.nickname);
|
||
} catch (e: any) {
|
||
error.value = e.data?.message || 'Ошибка регистрации. Попробуйте другой email.';
|
||
} finally {
|
||
isLoading.value = false;
|
||
}
|
||
};
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* --- Main Funnel & Progress Bar --- */
|
||
.onboarding-funnel { width: 100%; max-width: 800px; margin: 2rem auto; padding: 2rem; font-family: sans-serif; background-color: #f9f9f9; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1); }
|
||
.progress-bar { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 3rem; position: relative; }
|
||
.progress-bar::before { content: ''; position: absolute; top: 18px; left: 10%; right: 10%; height: 4px; background-color: #e0e0e0; z-index: 1; }
|
||
.step { display: flex; flex-direction: column; align-items: center; text-align: center; flex: 1; position: relative; z-index: 2; }
|
||
.step-circle { width: 36px; height: 36px; border-radius: 50%; background-color: #e0e0e0; color: #888; display: flex; justify-content: center; align-items: center; font-weight: bold; font-size: 1.2rem; border: 4px solid #e0e0e0; transition: all 0.3s ease; }
|
||
.step-label { margin-top: 0.5rem; font-size: 0.9rem; color: #888; }
|
||
.step.active .step-circle { background-color: #fff; border-color: #3b82f6; color: #3b82f6; }
|
||
.step.active .step-label { color: #3b82f6; font-weight: bold; }
|
||
.step.completed .step-circle { background-color: #16a34a; border-color: #16a34a; color: #fff; }
|
||
.step.completed .step-label { color: #333; }
|
||
.step.completed::after { content: ''; position: absolute; top: 18px; left: -50%; width: 100%; height: 4px; background-color: #16a34a; z-index: -1; }
|
||
.step:first-child.completed::after { width: 50%; left: 0; }
|
||
|
||
/* --- Content & Form Styling --- */
|
||
.step-content { margin-top: 2rem; }
|
||
.step-container { animation: fadeIn 0.5s ease; }
|
||
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||
.step-container h2 { font-size: 1.8rem; margin-bottom: 1rem; color: #333; }
|
||
.step-container p { font-size: 1.1rem; color: #666; margin-bottom: 2rem; }
|
||
.loading-placeholder { background-color: #f0f0f0; border: 2px dashed #ccc; padding: 3rem; text-align: center; color: #999; border-radius: 8px; margin-bottom: 2rem; }
|
||
button { background-color: #3b82f6; color: white; padding: 1rem 2rem; border: none; border-radius: 8px; font-size: 1.1rem; font-weight: bold; cursor: pointer; transition: background-color 0.2s; width: 100%; }
|
||
button:hover:not(:disabled) { background-color: #2563eb; }
|
||
button:disabled { background-color: #9ca3af; cursor: not-allowed; }
|
||
.form-group { margin-bottom: 1.5rem; text-align: left; }
|
||
.form-group label { display: block; margin-bottom: 0.5rem; font-weight: bold; color: #555; }
|
||
.form-group input { width: 100%; padding: 0.75rem 1rem; font-size: 1rem; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }
|
||
.days-selector { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||
.days-selector button { background-color: #e0e0e0; color: #333; font-weight: normal; font-size: 0.9rem; padding: 0.5rem 1rem; border-radius: 20px; width: auto; }
|
||
.days-selector button.selected { background-color: #3b82f6; color: white; }
|
||
.error-message { color: #ef4444; margin-bottom: 1rem; text-align: center; }
|
||
|
||
/* Step 3 (Reward) specific styles */
|
||
.reward-step { text-align: center; }
|
||
.reward-icon { font-size: 5rem; line-height: 1; margin-bottom: 1rem; }
|
||
.reward-caption { color: #888; font-size: 1rem; }
|
||
|
||
/* Step 4 (Build) specific styles */
|
||
.next-button {
|
||
margin-top: 1rem;
|
||
background-color: #16a34a; /* Green for next step */
|
||
}
|
||
.next-button:hover:not(:disabled) {
|
||
background-color: #15803d;
|
||
}
|
||
</style> |