habits.andr33v.ru/app/pages/index.vue

489 lines
13 KiB
Vue
Raw Permalink 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> <!-- Removed .page-container for a more open layout -->
<!-- ================================= -->
<!-- Authenticated User Dashboard -->
<!-- ================================= -->
<div v-if="isAuthenticated && user" class="dashboard-content page-container">
<h1>Привет, {{ user.nickname }}!</h1>
<div class="habits-section">
<div v-if="habitsPending">Загрузка привычек...</div>
<div v-else-if="habitsError">Не удалось загрузить привычки.</div>
<div v-else-if="habits && habits.length > 0">
<HabitCard
v-for="habit in habits"
:key="habit.id"
:habit="habit"
:is-submitting-habit="isSubmittingHabit"
:exploding-habit-id="explodingHabitId"
@complete="completeHabit"
/>
</div>
<div v-else>
<p>У вас еще нет привычек. Перейдите на страницу <NuxtLink to="/habits">Мои привычки</NuxtLink>, чтобы создать их.</p>
</div>
</div>
<p class="text-color-light">Ваши цели обновляются раз в сутки. Получаемые бонусы усиливаются, если посещать сайт ежедневно.</p>
<div class="streak-section">
<div class="streak-card" :class="{ 'active-streak': user.dailyStreak === 1 }">
<h2>x1</h2>
<p>Базовые</p>
</div>
<div class="streak-card" :class="{ 'active-streak': user.dailyStreak === 2 }">
<h2>x2</h2>
<p>Двойные</p>
</div>
<div class="streak-card" :class="{ 'active-streak': user.dailyStreak >= 3 }">
<h2>x3</h2>
<p>Тройные</p>
</div>
</div>
</div>
<!-- ================================= -->
<!-- Anonymous User Onboarding Funnel -->
<!-- ================================= -->
<div v-else-if="isAnonymous" class="onboarding-container">
<OnboardingFunnel />
</div>
<!-- ================================= -->
<!-- New/Unidentified User Welcome -->
<!-- ================================= -->
<div v-else class="welcome-content">
<h1>Трекер привычек с геймификацией</h1>
<p class="subtitle">Создавайте и отслеживайте свои привычки и развивайте свою деревню.</p>
<div class="auth-buttons">
<button @click="startOnboarding" class="btn btn-primary">Начать!</button>
<NuxtLink to="/login" class="btn btn-secondary">Уже есть аккаунт</NuxtLink>
</div>
<!-- HR replaced with spacing via CSS -->
<div class="marketing-divider"></div>
<div class="marketing-container">
<h3>Попробуйте сами!</h3>
<div class="demo-habit-section">
<div class="arrow-with-text to-button">
<span>Отслеживайте выполнение привычек...</span>
</div>
<div class="demo-habit-card">
<span :class="{ 'completed': isHabitCompleting }">{{ currentHabitText }}</span>
<button @click="advanceMarketingDemo" class="btn btn-sm btn-success" :disabled="isHabitCompleting">Готово</button>
</div>
</div>
<div class="demo-village-section">
<div class="arrow-with-text to-village" :class="{ 'visible': showVillageArrow }">
<span>...и стройте деревню! &darr;</span>
</div>
<div class="demo-village-grid">
<div
v-for="(content, index) in villageSquares"
:key="index"
class="village-square-demo"
:class="{
'initial-obstacle': villageInitialState[index] && content === villageInitialState[index],
'built': content && content !== villageInitialState[index],
'empty': !content
}"
>
<span class="emoji-content">{{ content }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
// Use the refactored auth composable
const { user, isAuthenticated, isAnonymous, updateUser, startOnboarding } = useAuth();
const api = useApi();
// --- Habits Data (This part only runs for authenticated users but is fine to leave here) ---
const { data: habits, pending: habitsPending, error: habitsError, refresh: refreshHabits } = await useFetch('/api/habits', {
lazy: true,
server: false,
});
// --- Marketing Demo State (for anonymous users) ---
const demoHabits = ref([
'Читать 20 минут в день',
'Делать 50 приседаний',
'Выпить стакан воды',
'Позвонить маме',
'Учить новый язык (15 минут)',
'Медитировать 5 минут',
'Прогуляться на свежем воздухе',
'Запланировать завтрашний день',
'Начать сначала!',
]);
const currentHabitText = ref(demoHabits.value[0]);
const isHabitCompleting = ref(false);
const marketingStep = ref(0);
const showVillageArrow = ref(false);
const villageInitialState = ['', '', '', '', '🌳', '🪨'];
const villageSquares = ref([...villageInitialState]);
const marketingActions = [
(sq) => { sq[0] = '🏠'; return sq; },
(sq) => { sq[1] = '🌱'; return sq; },
(sq) => { sq[2] = '💧'; return sq; },
(sq) => { sq[3] = '🪚'; return sq; },
(sq) => { sq[4] = ''; return sq; }, // Clear forest
(sq) => { sq[4] = '⛏️'; return sq; }, // Build quarry
(sq) => { sq[5] = ''; return sq; }, // Removes stone
(sq) => { sq[5] = '🌱'; return sq; }, // Adds field
];
const advanceMarketingDemo = () => {
if (isHabitCompleting.value) return;
// Handle reset click
if (marketingStep.value >= marketingActions.length) {
isHabitCompleting.value = true;
setTimeout(() => {
marketingStep.value = 0;
showVillageArrow.value = false;
villageSquares.value = [...villageInitialState];
currentHabitText.value = demoHabits.value[0];
isHabitCompleting.value = false;
}, 400);
return;
}
isHabitCompleting.value = true;
if (marketingStep.value === 0) {
showVillageArrow.value = true;
}
// Apply current action
const action = marketingActions[marketingStep.value];
villageSquares.value = action([...villageSquares.value]);
// Set up next state
setTimeout(() => {
marketingStep.value++;
currentHabitText.value = demoHabits.value[marketingStep.value];
isHabitCompleting.value = false;
}, 400);
};
// --- Actions & UI State ---
const isSubmittingHabit = ref(false);
const explodingHabitId = ref(null);
const completeHabit = async (habitId) => { // Removed event param since it's handled by HabitCard
if (isSubmittingHabit.value) return;
isSubmittingHabit.value = true;
try {
const gameDay = new Date().toISOString().slice(0, 10);
const response = await api(`/api/habits/${habitId}/complete`, {
method: 'POST',
body: { gameDay }
});
if (updateUser && response) {
updateUser({
coins: response.updatedCoins,
exp: response.updatedExp,
});
}
const habit = habits.value.find(h => h.id === habitId);
if (habit) {
// Optimistically update the completions. This assumes the API call is successful.
if (!habit.completions) {
habit.completions = [];
}
habit.completions.push({
id: Math.random(), // Temporary ID for reactivity
habitId: habitId,
date: gameDay, // Use the same gameDay string
});
}
explodingHabitId.value = habitId;
setTimeout(() => {
explodingHabitId.value = null;
}, 1000);
} catch (err) {
alert(err.data?.message || 'Failed to complete habit.');
} finally {
isSubmittingHabit.value = false;
}
};
</script>
<style scoped>
.dashboard-content, .onboarding-container {
text-align: center;
}
.welcome-content {
padding: 80px 24px;
max-width: 700px;
margin: 0 auto;
text-align: center;
}
.subtitle {
font-size: 1.25rem; /* 20px */
color: var(--text-color-light);
max-width: 480px;
margin: 0 auto 32px auto;
}
.streak-section {
display: flex;
justify-content: center;
gap: 16px;
margin: 24px 0 32px 0;
}
.streak-card {
background: #f8f9fa;
border: 2px solid var(--border-color);
border-radius: 12px;
padding: 16px 20px;
width: 110px;
transition: all 0.3s ease;
}
.streak-card h2 {
margin: 0 0 5px 0;
font-size: 2em;
color: var(--text-color-light);
}
.streak-card p {
margin: 0;
font-size: 0.9em;
color: var(--text-color-light);
}
.active-streak {
border-color: var(--primary-color);
background-color: #fff;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
transform: translateY(-4px);
}
.active-streak h2 {
color: var(--primary-color);
}
.active-streak p {
color: var(--text-color);
font-weight: 600;
}
.habits-section {
margin: 40px 0;
}
.auth-buttons {
display: flex;
justify-content: center;
gap: 16px;
margin-top: 32px;
}
/* --- Marketing Demo --- */
.marketing-divider {
height: 1px;
width: 100px;
background-color: var(--border-color);
margin: 80px auto;
}
.marketing-container h3 {
font-size: 1.75rem; /* 28px */
margin-bottom: 48px;
}
.demo-habit-section, .demo-village-section {
position: relative;
max-width: 500px;
margin: 0 auto;
}
.demo-habit-section {
margin-bottom: 64px;
display: flex;
justify-content: center;
align-items: center;
height: 60px;
}
.demo-habit-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: #fff;
border-radius: 16px;
border: 1px solid var(--border-color);
width: 320px;
box-shadow: 0 4px 15px rgba(0,0,0,0.07);
flex-shrink: 0;
}
.demo-habit-card span {
font-weight: 500;
transition: all 0.3s ease;
}
.demo-habit-card span.completed {
text-decoration: line-through;
color: var(--text-color-light);
font-style: italic;
}
.demo-habit-card .btn-success {
background-color: var(--secondary-color);
border-color: var(--secondary-color);
color: #fff;
}
.demo-habit-card .btn-success:hover {
background-color: var(--secondary-color-hover);
border-color: var(--secondary-color-hover);
}
.arrow-with-text {
font-size: 1rem;
color: var(--danger-color-hover);
font-style: italic;
}
.arrow-with-text.to-button {
margin-right: 16px;
order: -1;
}
.arrow-with-text.to-village {
margin-bottom: 16px;
opacity: 0;
transition: opacity 0.5s ease-in-out;
font-weight: 600;
font-size: 1.1rem;
}
.arrow-with-text.to-village.visible {
opacity: 1;
}
.demo-village-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
max-width: 324px; /* 3 * 100 + 2 * 12 */
margin: 0 auto;
}
.village-square-demo {
width: 100px;
height: 100px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 16px;
transition: all 0.3s ease;
color: var(--text-color);
position: relative;
}
.village-square-demo .emoji-content {
font-size: 2.8em;
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.village-square-demo.empty {
background-color: rgba(255,255,255,0.5);
border: 2px dashed var(--border-color);
box-shadow: inset 0 2px 4px rgba(0,0,0,0.04);
}
.village-square-demo.initial-obstacle {
background-color: #f0e9d6;
border: 1px solid #e0d9c6;
}
.village-square-demo.built {
background-color: #ffffff;
border: 1px solid #fff;
box-shadow: 0 8px 25px rgba(0,0,0,0.1);
}
.village-square-demo.built .emoji-content {
transform: scale(1.2);
}
/* --- Responsive Styles --- */
@media (max-width: 768px) {
.welcome-content {
padding: 40px 16px;
}
.subtitle {
font-size: 1.125rem;
}
.auth-buttons {
flex-direction: column;
gap: 12px;
padding: 0 16px;
}
.marketing-divider {
margin: 60px auto;
}
.marketing-container h3 {
font-size: 1.5rem;
margin-bottom: 32px;
}
.demo-habit-section {
flex-direction: column;
height: auto;
gap: 12px;
margin-bottom: 10px;
}
.arrow-with-text.to-button {
order: 0; /* Reset order */
margin: 0 0 8px 0;
text-align: center;
}
.demo-habit-card {
width: 100%;
max-width: 350px;
padding: 12px 16px;
}
.demo-village-grid {
max-width: 100%;
gap: 8px;
}
.village-square-demo {
width: auto;
/* height: auto; */
aspect-ratio: 1 / 1;
border-radius: 12px;
}
.village-square-demo .emoji-content {
font-size: 2.2rem;
}
}
</style>