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

527 lines
17 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">
<!-- New, Modern Progress Bar -->
<div class="onboarding-funnel__progress">
<div class="onboarding-funnel__progress-line"></div>
<div
class="onboarding-funnel__progress-line --completed"
:style="{ width: `${(currentStep - 1) * 25}%` }"
></div>
<div v-for="n in 5" :key="n" class="onboarding-funnel__step" :class="{ 'active': currentStep === n, 'completed': currentStep > n }">
<div class="onboarding-funnel__step-dot"></div>
<div class="onboarding-funnel__step-label">{{ getStepLabel(n) }}</div>
</div>
</div>
<!-- Content Area -->
<div class="onboarding-funnel__content">
<div class="step-card">
<!-- Key added for transitions -->
<div :key="currentStep" class="step-container">
<!-- Step 1: Create Habit -->
<div v-if="currentStep === 1">
<h2>Создайте первую привычку</h2>
<p>Привычки - это основа продуктивности. С чего начнем?</p>
<form @submit.prevent="handleCreateHabit" class="onboarding-form">
<div class="form-group">
<label for="habit-name" class="form-label">Название привычки</label>
<input id="habit-name" type="text" v-model="form.habitName" placeholder="Например, Читать 15 минут" required class="form-control" />
</div>
<div class="form-group">
<label class="form-label">Дни выполнения</label>
<div class="days-selector">
<button v-for="day in daysOfWeek" :key="day.value" type="button" class="btn-toggle" :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" class="btn btn-primary" :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>
<div class="habit-display-card">
<h3>{{ createdHabit?.name }}</h3>
<p class="habit-schedule-text">{{ getScheduleText(createdHabit) }}</p>
<button @click="handleCompleteOnboardingHabit(createdHabit.id)" :disabled="isLoading" class="btn btn-primary">
{{ isLoading ? 'Выполнение...' : 'Выполнить' }}
</button>
</div>
</div>
<!-- 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" class="btn btn-primary">Продолжить</button>
</div>
<!-- Step 4: Build a House -->
<div v-if="currentStep === 4">
<h2>Шаг 4: Постройте дом</h2>
<p>Вы заработали <strong>{{ onboardingRewardAmount }}</strong> монет! Нажмите на пустой участок, чтобы построить Дом (Стоимость: 50 монет).</p>
<VillageGrid
v-if="villageData"
:village-data="villageData"
:is-onboarding="true"
@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="btn btn-primary next-button">
{{ isLoading ? 'Загрузка...' : 'Продолжить' }}
</button>
</div>
<!-- Step 5: Register -->
<div v-if="currentStep === 5">
<h2>Шаг 5: Сохраните прогресс!</h2>
<p>Ваша деревня растет! Чтобы не потерять свой прогресс и соревноваться с другими, зарегистрируйтесь.</p>
<form @submit.prevent="handleRegister" class="onboarding-form">
<div class="form-group">
<label for="nickname" class="form-label">Ваше имя</label>
<input id="nickname" type="text" v-model="form.nickname" placeholder="Смурфик" required class="form-control" />
</div>
<div class="form-group">
<label for="email" class="form-label">Email</label>
<input id="email" type="email" v-model="form.email" placeholder="smurf@example.com" required class="form-control" />
</div>
<div class="form-group">
<label for="password" class="form-label">Пароль (мин. 8 символов)</label>
<input id="password" type="password" v-model="form.password" required class="form-control"/>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<button type="submit" class="btn btn-primary" :disabled="isLoading">{{ isLoading ? 'Регистрация...' : 'Завершить и сохранить' }}</button>
</form>
</div>
</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 getScheduleText = (habit: any) => {
if (!habit || !habit.daysOfWeek) return '';
if (habit.daysOfWeek.length === 7) {
return 'каждый день';
}
const dayMap = { 0: 'Пн', 1: 'Вт', 2: 'Ср', 3: 'Чт', 4: 'Пт', 5: 'Сб', 6: 'Вс' };
return habit.daysOfWeek.sort((a: number, b: number) => a - b).map((dayIndex: number) => dayMap[dayIndex]).join(', ');
};
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 Layout --- */
.onboarding-funnel {
width: 100%;
max-width: 720px;
margin: 40px auto;
padding: 0 24px;
}
/* --- New Progress Bar --- */
.onboarding-funnel__progress {
display: flex;
justify-content: space-between;
position: relative;
margin-bottom: 2rem;
padding: 0 10%;
}
.onboarding-funnel__progress-line {
position: absolute;
top: 8px;
left: 10%;
right: 10%;
height: 4px;
background-color: var(--border-color);
z-index: 1;
transition: width 0.4s ease-in-out;
}
.onboarding-funnel__progress-line.--completed {
background-color: var(--secondary-color);
}
.onboarding-funnel__step {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
position: relative;
z-index: 2;
}
.onboarding-funnel__step-dot {
width: 20px;
height: 20px;
border-radius: 50%;
background-color: var(--container-bg-color);
border: 4px solid var(--border-color);
transition: all 0.3s ease;
}
.onboarding-funnel__step-label {
margin-top: 0.75rem;
font-size: 0.875rem;
color: var(--text-color-light);
font-weight: 500;
}
.onboarding-funnel__step.active .onboarding-funnel__step-dot {
border-color: var(--primary-color);
transform: scale(1.2);
}
.onboarding-funnel__step.active .onboarding-funnel__step-label {
color: var(--primary-color);
font-weight: 700;
}
.onboarding-funnel__step.completed .onboarding-funnel__step-dot {
border-color: var(--secondary-color);
background-color: var(--secondary-color);
}
/* --- Content Card --- */
.step-card {
background-color: var(--container-bg-color);
border-radius: 16px;
padding: 2.5rem 3rem;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
margin-top: 1rem;
}
.step-container {
animation: fadeIn 0.5s ease;
text-align: center;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(15px); }
to { opacity: 1; transform: translateY(0); }
}
.step-container h2 {
font-size: 1.75rem;
margin-bottom: 0.75rem;
color: var(--text-color);
font-weight: 700;
}
.step-container p {
font-size: 1.125rem;
color: var(--text-color-light);
margin-bottom: 2.5rem;
max-width: 500px;
margin-left: auto;
margin-right: auto;
}
/* --- Form & Interactive Elements --- */
.onboarding-form {
max-width: 450px;
margin: 0 auto;
text-align: left;
}
.days-selector {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: center;
margin-bottom: 2rem;
}
.btn-toggle {
background-color: transparent;
color: var(--text-color-light);
font-weight: 600;
font-size: 0.9rem;
padding: 0.6rem 1.2rem;
border-radius: 10px;
border: 2px solid var(--border-color);
cursor: pointer;
transition: all 0.2s ease;
}
.btn-toggle:hover {
background-color: #f7f7f7;
border-color: var(--secondary-color);
}
.btn-toggle.selected {
background-color: var(--secondary-color);
border-color: var(--secondary-color);
color: white;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.error-message {
color: var(--danger-color);
margin: -1rem 0 1.5rem 0;
text-align: center;
font-weight: 500;
}
.loading-placeholder {
background-color: rgba(0,0,0,0.02);
border: 2px dashed var(--border-color);
padding: 3rem;
text-align: center;
color: var(--text-color-light);
border-radius: 12px;
margin: 0 auto 2rem auto;
}
/* --- Specific Step Styles --- */
.habit-card-wrapper {
max-width: 400px;
margin: 0 auto;
}
.habit-display-card {
background: var(--container-bg-color);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
max-width: 400px;
margin: 0 auto 24px auto; /* Centered with margin-bottom */
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.habit-display-card h3 {
margin-top: 0;
margin-bottom: 8px;
font-size: 1.25rem;
color: var(--text-color);
}
.habit-schedule-text {
font-size: 0.95rem;
color: var(--text-color-light);
margin-bottom: 20px;
}
.reward-step { text-align: center; }
.reward-icon { font-size: 6rem; line-height: 1; margin-bottom: 1.5rem; animation: tada 1s ease; }
@keyframes tada {
from { transform: scale3d(1, 1, 1); }
10%, 20% { transform: scale3d(0.9, 0.9, 0.9) rotate3d(0, 0, 1, -3deg); }
30%, 50%, 70%, 90% { transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg); }
40%, 60%, 80% { transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg); }
to { transform: scale3d(1, 1, 1); }
}
.reward-caption { margin-bottom: 2.5rem; }
.next-button { margin-top: 2rem; }
.next-button:disabled {
background-color: var(--text-color-light);
border-color: var(--text-color-light);
}
/* --- Responsive Styles --- */
@media (max-width: 768px) {
.onboarding-funnel {
padding: 0;
margin: 24px auto;
}
.onboarding-funnel__progress {
padding: 0 1rem; /* Reduced padding */
}
.onboarding-funnel__progress-line {
left: 1rem;
right: 1rem;
}
.onboarding-funnel__step-label {
display: none; /* Hide labels on mobile to save space */
}
.step-card {
padding: 2rem 1.5rem;
}
.step-container h2 {
font-size: 1.5rem;
}
.step-container p {
font-size: 1rem;
margin-bottom: 2rem;
}
/* Make main buttons full-width */
.step-container .btn-primary {
width: 100%;
}
.days-selector {
gap: 0.5rem;
}
.btn-toggle {
padding: 0.5rem 0.7rem;
}
}
</style>