habits.andr33v.ru/app/components/OnboardingFunnel.vue

319 lines
14 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.

<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>