527 lines
17 KiB
Vue
527 lines
17 KiB
Vue
<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 1rem;
|
||
}
|
||
}
|
||
</style> |