Небольшие правки по UI. сейчас приложение стабильно

This commit is contained in:
Alexander Andreev 2026-01-08 13:07:47 +03:00
parent 495c81e60e
commit c9bf46e309
15 changed files with 1136 additions and 377 deletions

10
.gemini/settings.json Normal file
View File

@ -0,0 +1,10 @@
{
"mcpServers": {
"chrome-devtools": {
"command": "npx",
"args": [
"chrome-devtools-mcp@latest"
]
}
}
}

7
TODO.md Normal file
View File

@ -0,0 +1,7 @@
Начисление бонусов происходит про посещении страницы /village . А надо на уровне роута сделать, у залогиненного пользователя, что бы при посещении любой страницы происходила расчистка, и если расчистка закончена, выбирался новый тайл для расчистки.
У нас в БД lastExpAt и CleaningStartAt это UTC дата Стрингом. А выполнение привычки идёт как Date. И стрик тоже Date считается. Сделать так же UTC, однотипно.
Так как дата UTC, не учитывается часовой пояс. как то это надо пофиксить
На лидербордах не показывать пользователей анонимусов (они без имени)

View File

@ -1,18 +1,10 @@
<template> <template>
<div class="habit-card"> <div class="habit-card">
<div class="habit-header"> <div class="habit-header">
<div class="habit-details"> <div class="habit-details" style="flex-grow: 1;">
<h3>{{ habit.name }}</h3> <h3>{{ habit.name }}</h3>
<p class="habit-schedule">{{ getScheduleText(habit) }}</p> <p class="habit-schedule">{{ getScheduleText(habit) }}</p>
</div> </div>
<div class="habit-action">
<div v-if="isScheduledForToday(habit) || forceShowAction">
<span v-if="isCompleted(habit, today)" class="completed-text">Выполнено!</span>
<button v-else @click="emitComplete" :disabled="isSubmittingHabit" class="btn btn-primary btn-sm">
Выполнить
</button>
</div>
</div>
</div> </div>
<!-- Calendar / History Grid (only for full user dashboard, not onboarding) --> <!-- Calendar / History Grid (only for full user dashboard, not onboarding) -->
@ -22,6 +14,15 @@
</div> </div>
</div> </div>
<div class="habit-action">
<div v-if="isScheduledForToday(habit) || forceShowAction">
<span v-if="isCompleted(habit, today)" class="completed-text">Выполнено!</span>
<button v-else @click="emitComplete" :disabled="isSubmittingHabit" class="btn btn-primary btn-sm">
Выполнить
</button>
</div>
</div>
<div v-if="exploding" class="confetti-container"> <div v-if="exploding" class="confetti-container">
<div v-for="i in 15" :key="i" class="confetti-particle"></div> <div v-for="i in 15" :key="i" class="confetti-particle"></div>
</div> </div>
@ -172,13 +173,18 @@ const emitComplete = () => {
.habit-header { .habit-header {
display: flex; display: flex;
justify-content: space-between; justify-content: flex-start; /* Adjust as needed */
align-items: center; align-items: center;
margin-bottom: 20px;
} }
.habit-details { .habit-details {
text-align: left; text-align: left;
flex-grow: 1; /* Allow details to take available space */
}
.habit-action {
margin-top: 20px; /* Add some space above the action button */
text-align: center; /* Center the button */
} }
.habit-details h3 { .habit-details h3 {
@ -248,6 +254,7 @@ const emitComplete = () => {
.day-label { .day-label {
font-size: 0.85em; font-size: 0.85em;
line-height: 0.8;
} }
/* Confetti Animation */ /* Confetti Animation */
@ -299,4 +306,11 @@ const emitComplete = () => {
.confetti-particle:nth-child(13) { background-color: #ebcb8b; --x-end: 40px; animation-delay: 0.18s; } .confetti-particle:nth-child(13) { background-color: #ebcb8b; --x-end: 40px; animation-delay: 0.18s; }
.confetti-particle:nth-child(14) { background-color: #81a1c1; --x-end: -40px; animation-delay: 0.22s; } .confetti-particle:nth-child(14) { background-color: #81a1c1; --x-end: -40px; animation-delay: 0.22s; }
.confetti-particle:nth-child(15) { background-color: #b48ead; --x-end: 0px; animation-delay: 0.28s; } .confetti-particle:nth-child(15) { background-color: #b48ead; --x-end: 0px; animation-delay: 0.28s; }
/* Responsive Styles for the action button */
@media (max-width: 768px) {
.habit-action button {
width: 100%;
}
}
</style> </style>

View File

@ -1,34 +1,41 @@
<template> <template>
<div class="onboarding-funnel"> <div class="onboarding-funnel">
<!-- Progress Bar --> <!-- New, Modern Progress Bar -->
<div class="progress-bar"> <div class="onboarding-funnel__progress">
<div v-for="n in 5" :key="n" class="step" :class="{ 'active': currentStep === n, 'completed': currentStep > n }"> <div class="onboarding-funnel__progress-line"></div>
<div class="step-circle">{{ n }}</div> <div
<div class="step-label">{{ getStepLabel(n) }}</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>
</div> </div>
<!-- Content Area --> <!-- Content Area -->
<div class="step-content"> <div class="onboarding-funnel__content">
<div class="step-container"> <div class="step-card">
<!-- Key added for transitions -->
<div :key="currentStep" class="step-container">
<!-- Step 1: Create Habit --> <!-- Step 1: Create Habit -->
<div v-if="currentStep === 1"> <div v-if="currentStep === 1">
<h2>Шаг 1: Создайте свою первую привычку</h2> <h2>Создайте первую привычку</h2>
<p>Привычки - это основа продуктивности. С чего начнем?</p> <p>Привычки - это основа продуктивности. С чего начнем?</p>
<form @submit.prevent="handleCreateHabit"> <form @submit.prevent="handleCreateHabit" class="onboarding-form">
<div class="form-group"> <div class="form-group">
<label for="habit-name">Название привычки</label> <label for="habit-name" class="form-label">Название привычки</label>
<input id="habit-name" type="text" v-model="form.habitName" placeholder="Например, Читать 15 минут" required /> <input id="habit-name" type="text" v-model="form.habitName" placeholder="Например, Читать 15 минут" required class="form-control" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Дни выполнения</label> <label class="form-label">Дни выполнения</label>
<div class="days-selector"> <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> <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> </div>
<div v-if="error" class="error-message">{{ error }}</div> <div v-if="error" class="error-message">{{ error }}</div>
<button type="submit" :disabled="isLoading">{{ isLoading ? 'Создание...' : 'Создать и перейти далее' }}</button> <button type="submit" class="btn btn-primary" :disabled="isLoading">{{ isLoading ? 'Создание...' : 'Создать и перейти далее' }}</button>
</form> </form>
</div> </div>
@ -37,67 +44,67 @@
<h2>Шаг 2: Завершите привычку</h2> <h2>Шаг 2: Завершите привычку</h2>
<p>Отлично! Теперь отметьте привычку <strong>"{{ createdHabit?.name }}"</strong> как выполненную, чтобы получить награду.</p> <p>Отлично! Теперь отметьте привычку <strong>"{{ createdHabit?.name }}"</strong> как выполненную, чтобы получить награду.</p>
<div v-if="error" class="error-message">{{ error }}</div> <div v-if="error" class="error-message">{{ error }}</div>
<HabitCard <div class="habit-display-card">
v-if="createdHabit" <h3>{{ createdHabit?.name }}</h3>
:habit="createdHabit" <p class="habit-schedule-text">{{ getScheduleText(createdHabit) }}</p>
:is-submitting-habit="isLoading" <button @click="handleCompleteOnboardingHabit(createdHabit.id)" :disabled="isLoading" class="btn btn-primary">
:show-history-grid="false" {{ isLoading ? 'Выполнение...' : 'Выполнить' }}
:force-show-action="true" </button>
@complete="handleCompleteOnboardingHabit" </div>
/>
</div> </div>
<!-- NEW Step 3: Reward Screen --> <!-- Step 3: Reward Screen -->
<div v-if="currentStep === 3" class="reward-step"> <div v-if="currentStep === 3" class="reward-step">
<div class="reward-icon">💰</div> <div class="reward-icon">🎉</div>
<h2>Вы получили {{ onboardingRewardAmount }} монет!</h2> <h2>Вы получили {{ onboardingRewardAmount }} монет!</h2>
<p class="reward-caption">Деньги можно тратить на строительство вашей деревни.</p> <p class="reward-caption">Монеты можно тратить на строительство вашей деревни.</p>
<button @click="nextStep">Продолжить</button> <button @click="nextStep" class="btn btn-primary">Продолжить</button>
</div> </div>
<!-- Step 4: Build a House (was Step 3) --> <!-- Step 4: Build a House -->
<div v-if="currentStep === 4"> <div v-if="currentStep === 4">
<h2>Шаг 4: Постройте дом</h2> <h2>Шаг 4: Постройте дом</h2>
<p>Вы заработали <strong>{{ onboardingRewardAmount }}</strong> золота! Нажмите на пустой участок в деревне, чтобы построить Дом (Стоимость: 50 монет) и увеличить лимит рабочих.</p> <p>Вы заработали <strong>{{ onboardingRewardAmount }}</strong> монет! Нажмите на пустой участок, чтобы построить Дом (Стоимость: 50 монет).</p>
<VillageGrid <VillageGrid
v-if="villageData" v-if="villageData"
:village-data="villageData" :village-data="villageData"
:is-onboarding="true"
@tile-click="handleTileClickToBuild" @tile-click="handleTileClickToBuild"
/> /> <div v-else-if="villagePending" class="loading-placeholder">Загрузка деревни...</div>
<div v-else-if="villagePending" class="loading-placeholder">Загрузка деревни...</div>
<div v-if="error" class="error-message">{{ error }}</div> <div v-if="error" class="error-message">{{ error }}</div>
<button @click="nextStep" :disabled="isLoading || !isHouseBuilt" class="next-button"> <button @click="nextStep" :disabled="isLoading || !isHouseBuilt" class="btn btn-primary next-button">
{{ isLoading ? 'Загрузка...' : 'Продолжить' }} {{ isLoading ? 'Загрузка...' : 'Продолжить' }}
</button> </button>
</div> </div>
<!-- Step 5: Register (was Step 4) --> <!-- Step 5: Register -->
<div v-if="currentStep === 5"> <div v-if="currentStep === 5">
<h2>Шаг 5: Сохраните свой прогресс!</h2> <h2>Шаг 5: Сохраните прогресс!</h2>
<p>Ваша деревня растет! Чтобы не потерять свой прогресс и соревноваться с другими, зарегистрируйтесь.</p> <p>Ваша деревня растет! Чтобы не потерять свой прогресс и соревноваться с другими, зарегистрируйтесь.</p>
<form @submit.prevent="handleRegister"> <form @submit.prevent="handleRegister" class="onboarding-form">
<div class="form-group"> <div class="form-group">
<label for="nickname">Ваше имя</label> <label for="nickname" class="form-label">Ваше имя</label>
<input id="nickname" type="text" v-model="form.nickname" placeholder="Смурфик" required /> <input id="nickname" type="text" v-model="form.nickname" placeholder="Смурфик" required class="form-control" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="email">Email</label> <label for="email" class="form-label">Email</label>
<input id="email" type="email" v-model="form.email" placeholder="smurf@example.com" required /> <input id="email" type="email" v-model="form.email" placeholder="smurf@example.com" required class="form-control" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">Пароль (мин. 8 символов)</label> <label for="password" class="form-label">Пароль (мин. 8 символов)</label>
<input id="password" type="password" v-model="form.password" required /> <input id="password" type="password" v-model="form.password" required class="form-control"/>
</div> </div>
<div v-if="error" class="error-message">{{ error }}</div> <div v-if="error" class="error-message">{{ error }}</div>
<button type="submit" :disabled="isLoading">{{ isLoading ? 'Регистрация...' : 'Завершить и сохранить' }}</button> <button type="submit" class="btn btn-primary" :disabled="isLoading">{{ isLoading ? 'Регистрация...' : 'Завершить и сохранить' }}</button>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -151,15 +158,24 @@ watch(currentStep, async (newStep) => {
// --- Methods --- // --- Methods ---
const getStepLabel = (step: number): string => { const getStepLabel = (step: number): string => {
switch (step) { switch (step) {
case 1: return 'Создать привычку'; case 1: return 'Привычка';
case 2: return 'Завершить'; case 2: return 'Выполнение';
case 3: return 'Награда'; case 3: return 'Награда';
case 4: return 'Построить дом'; case 4: return 'Стройка';
case 5: return 'Регистрация'; case 5: return 'Регистрация';
default: 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 = () => { const nextStep = () => {
if (currentStep.value < 5) { // Max 5 steps now if (currentStep.value < 5) { // Max 5 steps now
error.value = null; error.value = null;
@ -271,49 +287,241 @@ const handleRegister = async () => {
</script> </script>
<style scoped> <style scoped>
/* --- Main Funnel & Progress Bar --- */ /* --- Main Funnel Layout --- */
.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); } .onboarding-funnel {
.progress-bar { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 3rem; position: relative; } width: 100%;
.progress-bar::before { content: ''; position: absolute; top: 18px; left: 10%; right: 10%; height: 4px; background-color: #e0e0e0; z-index: 1; } max-width: 720px;
.step { display: flex; flex-direction: column; align-items: center; text-align: center; flex: 1; position: relative; z-index: 2; } margin: 40px auto;
.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; } padding: 0 24px;
.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; } /* --- New Progress Bar --- */
.step.completed .step-circle { background-color: #16a34a; border-color: #16a34a; color: #fff; } .onboarding-funnel__progress {
.step.completed .step-label { color: #333; } display: flex;
.step.completed::after { content: ''; position: absolute; top: 18px; left: -50%; width: 100%; height: 4px; background-color: #16a34a; z-index: -1; } justify-content: space-between;
.step:first-child.completed::after { width: 50%; left: 0; } position: relative;
margin-bottom: 2rem;
/* --- Content & Form Styling --- */ padding: 0 10%;
.step-content { margin-top: 2rem; } }
.step-container { animation: fadeIn 0.5s ease; } .onboarding-funnel__progress-line {
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } position: absolute;
.step-container h2 { font-size: 1.8rem; margin-bottom: 1rem; color: #333; } top: 8px;
.step-container p { font-size: 1.1rem; color: #666; margin-bottom: 2rem; } left: 10%;
.loading-placeholder { background-color: #f0f0f0; border: 2px dashed #ccc; padding: 3rem; text-align: center; color: #999; border-radius: 8px; margin-bottom: 2rem; } right: 10%;
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%; } height: 4px;
button:hover:not(:disabled) { background-color: #2563eb; } background-color: var(--border-color);
button:disabled { background-color: #9ca3af; cursor: not-allowed; } z-index: 1;
.form-group { margin-bottom: 1.5rem; text-align: left; } transition: width 0.4s ease-in-out;
.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; } .onboarding-funnel__progress-line.--completed {
.days-selector { display: flex; gap: 0.5rem; flex-wrap: wrap; } background-color: var(--secondary-color);
.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; } .onboarding-funnel__step {
.error-message { color: #ef4444; margin-bottom: 1rem; text-align: center; } display: flex;
flex-direction: column;
/* Step 3 (Reward) specific styles */ align-items: center;
.reward-step { text-align: center; } text-align: center;
.reward-icon { font-size: 5rem; line-height: 1; margin-bottom: 1rem; } position: relative;
.reward-caption { color: #888; font-size: 1rem; } z-index: 2;
}
/* Step 4 (Build) specific styles */ .onboarding-funnel__step-dot {
.next-button { width: 20px;
margin-top: 1rem; height: 20px;
background-color: #16a34a; /* Green for next step */ 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;
} }
.next-button:hover:not(:disabled) {
background-color: #15803d;
} }
</style> </style>

View File

@ -1,6 +1,6 @@
<template> <template>
<div v-if="villageData && villageData.tiles" class="village-container"> <div v-if="villageData && villageData.tiles" class="village-container">
<div class="village-grid-wrapper" :style="gridWrapperStyle"> <div :class="villageGridWrapperClass" :style="gridWrapperStyle">
<!-- Empty corner for alignment --> <!-- Empty corner for alignment -->
<div class="empty-corner"></div> <div class="empty-corner"></div>
@ -43,8 +43,17 @@ const props = defineProps({
type: Object, type: Object,
required: true, required: true,
}, },
isOnboarding: {
type: Boolean,
default: false,
},
}); });
const villageGridWrapperClass = computed(() => ({
'village-grid-wrapper': true,
'is-onboarding': props.isOnboarding,
}));
defineEmits(['tile-click']); defineEmits(['tile-click']);
// --- Grid Dimensions --- // --- Grid Dimensions ---
@ -105,6 +114,7 @@ const tileClasses = (tile) => {
'tile-blocked': tile.terrainType === 'BLOCKED_TREE' || tile.terrainType === 'BLOCKED_STONE', 'tile-blocked': tile.terrainType === 'BLOCKED_TREE' || tile.terrainType === 'BLOCKED_STONE',
'tile-object': !!tile.object, 'tile-object': !!tile.object,
'tile-empty': tile.terrainType === 'EMPTY' && !tile.object, 'tile-empty': tile.terrainType === 'EMPTY' && !tile.object,
'tile-clearing': tile.terrainState === 'CLEARING',
}; };
}; };
</script> </script>
@ -121,48 +131,60 @@ const tileClasses = (tile) => {
.village-grid-wrapper { .village-grid-wrapper {
display: grid; display: grid;
gap: 4px; gap: 8px; /* Increased gap */
padding: 4px; border-radius: 16px; /* Rounded wrapper */
background-color: var(--container-bg-color);
padding: 12px; /* Increased padding */
width: fit-content; width: fit-content;
margin: 0 auto; margin: 0 auto;
box-shadow: 0 8px 30px rgba(0,0,0,0.06);
overflow-x: auto;
overflow-y: hidden;
} }
.empty-corner { .empty-corner, .col-labels, .row-labels {
grid-column: 1; opacity: 0.6;
grid-row: 8; font-size: 0.8rem;
} }
.col-labels { .col-labels {
grid-column: 2 / -1; grid-column: 2 / -1; /* Dynamic spanning */
grid-row: 8; grid-row: 8;
display: flex; display: flex;
justify-content: space-around; justify-content: space-around;
color: #999; align-items: center;
height: 20px;
}
.col-label {
width: var(--tile-size);
text-align: center;
line-height: 20px;
} }
.row-labels { .row-labels {
grid-column: 1; grid-column: 1;
grid-row: 1 / 8; grid-row: 1 / -1; /* Dynamic spanning */
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-around; justify-content: space-around;
align-items: center; align-items: center;
color: #999; width: 20px;
} }
.col-label, .row-label { .row-label {
height: var(--tile-size);
display: flex; display: flex;
justify-content: center;
align-items: center; align-items: center;
font-size: 0.8rem; justify-content: center;
line-height: 20px;
} }
.village-grid { .village-grid {
grid-column: 2 / -1; grid-column: 2 / -1; /* Dynamic spanning */
grid-row: 1 / 8; grid-row: 1 / -1; /* Dynamic spanning */
display: grid; display: grid;
gap: 4px; gap: 8px; /* Increased gap */
border: 1px solid #e0e0e0;
} }
.tile { .tile {
@ -171,27 +193,105 @@ const tileClasses = (tile) => {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
border: 1px solid #e0e0e0; border-radius: 12px;
border-radius: 4px;
background-color: #f9f9f9;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s ease;
position: relative; /* For selection pseudo-elements */
}
.tile.tile-empty {
background-color: rgba(0,0,0,0.02);
border: 2px dashed var(--border-color);
box-shadow: inset 0 2px 4px rgba(0,0,0,0.03);
}
.tile.tile-empty:hover {
background-color: rgba(0,0,0,0.04);
border-color: var(--primary-color-light);
} }
.tile.tile-blocked { .tile.tile-blocked {
background-color: #f3f4f6; background-color: #EBE5D7; /* A soft, earthy tone */
border: 1px solid #DCD5C6;
}
.tile.tile-blocked:hover {
transform: translateY(-2px);
} }
.tile.tile-object { .tile.tile-object {
background-color: #ecfdf5; background-color: #c8ffcf;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.07);
border: 1px solid #39af50;
}
.tile.tile-object:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0,0,0,0.1);
} }
.tile.tile-empty:hover { .tile.tile-clearing {
background-color: #fefce8; background-color: #ffe0b2; /* A soft orange/yellow for "in progress" */
border-color: #facc15; border: 1px solid #ffcc80;
}
.tile.selected {
box-shadow: 0 0 0 3px var(--primary-color);
transform: scale(1.05);
} }
.tile-content { .tile-content {
font-size: calc(var(--tile-size) * 0.4); font-size: calc(var(--tile-size) * 0.5); /* Make emoji scale with tile */
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.village-grid-wrapper.is-onboarding {
padding: 8px;
gap: 4px;
}
.village-grid-wrapper.is-onboarding .col-label,
.village-grid-wrapper.is-onboarding .row-label {
font-size: 0.7rem;
}
@media (max-width: 480px) {
.village-grid-wrapper {
gap: 4px;
padding: 8px;
}
.village-grid {
gap: 4px;
}
}
@media (max-width: 768px) {
.village-grid-wrapper {
width: 100%;
margin: 0;
box-sizing: border-box;
padding-left: 0;
padding-right: 0;
position: relative; /* Needed for pseudo-element */
}
.village-grid-wrapper::after {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 20px;
background: linear-gradient(to left, rgba(0,0,0,0.05), transparent);
pointer-events: none; /* Let clicks pass through */
opacity: 0;
transition: opacity 0.2s;
}
.village-grid-wrapper:hover::after {
opacity: 1;
}
.tile {
aspect-ratio: 1 / 1; /* Keep tiles square */
height: auto; /* Let aspect ratio control the height */
}
} }
</style> </style>

View File

@ -2,10 +2,9 @@
<div class="app-container"> <div class="app-container">
<header v-if="isAuthenticated" class="top-bar"> <header v-if="isAuthenticated" class="top-bar">
<div class="user-info-top"> <div class="user-info-top">
<span>{{ user.nickname }}</span>
<span>💰 {{ displayedCoins }}</span> <span>💰 {{ displayedCoins }}</span>
<span> {{ displayedExp }}</span> <span> {{ displayedExp }}</span>
<button @click="handleLogout" class="btn btn-danger btn-sm">Выйти</button> <button @click="handleLogout" class="btn btn-danger btn-sm">Выход</button>
</div> </div>
</header> </header>

View File

@ -2,6 +2,46 @@
<div class="page-container"> <div class="page-container">
<h1>Мои Привычки</h1> <h1>Мои Привычки</h1>
<!-- Habits List -->
<div class="habits-list">
<p v-if="loading.fetch">Загрузка привычек...</p>
<div v-for="habit in habits" :key="habit.id" class="habit-card">
<!-- Viewing Mode -->
<div v-if="editingHabitId !== habit.id" class="habit-view-content">
<div class="habit-info">
<h3>{{ habit.name }}</h3>
<div class="habit-days">
<span v-for="day in habit.daysOfWeek" :key="day" class="day-chip">{{ dayMap[day] }}</span>
</div>
</div>
<div class="habit-actions">
<button @click="startEditing(habit)" class="btn btn-secondary btn-sm">Редактировать</button>
<button @click="promptForDelete(habit.id)" :disabled="loading.delete" class="btn btn-danger btn-sm">Удалить</button>
</div>
</div>
<!-- Editing Mode -->
<form v-else @submit.prevent="saveHabit(habit.id)" class="habit-edit-form">
<div class="form-group">
<input v-model="editHabitName" type="text" class="form-control" required />
</div>
<div class="form-group">
<div class="days-selector edit-days">
<label v-for="day in dayOptions" :key="day.value" class="day-label">
<input type="checkbox" :value="day.name" v-model="editHabitDays" />
<span>{{ day.name }}</span>
</label>
</div>
</div>
<div class="edit-actions">
<button type="submit" :disabled="loading.edit" class="btn btn-primary btn-sm">Сохранить</button>
<button type="button" @click="cancelEditing" class="btn btn-secondary btn-sm">Отмена</button>
</div>
</form>
</div>
<p v-if="!loading.fetch && habits.length === 0">Пока нет привычек. Добавьте одну!</p>
</div>
<!-- Create Habit Form --> <!-- Create Habit Form -->
<div class="form-container"> <div class="form-container">
<h2>Создать новую привычку</h2> <h2>Создать новую привычку</h2>
@ -29,46 +69,6 @@
</form> </form>
</div> </div>
<!-- Habits List -->
<div class="habits-list">
<h2>Список привычек</h2>
<p v-if="loading.fetch">Загрузка привычек...</p>
<div v-for="habit in habits" :key="habit.id" class="habit-card">
<!-- Viewing Mode -->
<div v-if="editingHabitId !== habit.id">
<div class="habit-info">
<h3>{{ habit.name }}</h3>
<div class="habit-days">
<span v-for="day in habit.daysOfWeek" :key="day" class="day-chip">{{ dayMap[day] }}</span>
</div>
</div>
<div class="habit-actions">
<button @click="startEditing(habit)" class="btn btn-secondary btn-sm">Редактировать</button>
<button @click="promptForDelete(habit.id)" :disabled="loading.delete" class="btn btn-danger btn-sm">Удалить</button>
</div>
</div>
<!-- Editing Mode -->
<form v-else @submit.prevent="saveHabit(habit.id)" class="habit-edit-form">
<div class="form-group">
<input v-model="editHabitName" type="text" class="form-control" required />
</div>
<div class="form-group">
<div class="days-selector edit-days">
<label v-for="day in dayOptions" :key="day.value" class="day-label">
<input type="checkbox" :value="day.name" v-model="editHabitDays" />
<span>{{ day.name }}</span>
</label>
</div>
</div>
<div class="edit-actions">
<button type="submit" :disabled="loading.edit" class="btn btn-primary btn-sm">Сохранить</button>
<button type="button" @click="cancelEditing" class="btn btn-secondary btn-sm">Отмена</button>
</div>
</form>
</div>
<p v-if="!loading.fetch && habits.length === 0">Пока нет привычек. Добавьте одну!</p>
</div>
<!-- Deletion Confirmation Dialog --> <!-- Deletion Confirmation Dialog -->
<ConfirmDialog <ConfirmDialog
@ -288,8 +288,8 @@ onMounted(fetchHabits);
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
width: 40px; width: 33px;
height: 40px; height: 33px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 50%; border-radius: 50%;
font-size: 0.9em; font-size: 0.9em;
@ -312,6 +312,7 @@ onMounted(fetchHabits);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
margin-bottom: 45px;
} }
.habit-card { .habit-card {
@ -325,15 +326,17 @@ onMounted(fetchHabits);
box-shadow: 0 2px 4px rgba(0,0,0,0.05); box-shadow: 0 2px 4px rgba(0,0,0,0.05);
} }
.habit-card > div { .habit-view-content {
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
align-items: center; gap: 15px; /* Spacing between name, days, and actions */
width: 100%; width: 100%;
justify-content: flex-start;
align-items: flex-start;
} }
.habit-info h3 { .habit-info h3 {
margin: 0 0 10px 0; margin: 0;
font-size: 1.15rem; font-size: 1.15rem;
} }
@ -341,6 +344,8 @@ onMounted(fetchHabits);
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px; gap: 6px;
justify-content: flex-start;
padding: 15px 0 5px;
} }
.day-chip { .day-chip {
@ -364,6 +369,8 @@ onMounted(fetchHabits);
.habit-actions { .habit-actions {
display: flex; display: flex;
gap: 10px; gap: 10px;
justify-content: flex-start;
width: 100%;
} }
/* Edit Form Specific Styles */ /* Edit Form Specific Styles */

View File

@ -1,30 +1,13 @@
<template> <template>
<div class="page-container"> <div> <!-- Removed .page-container for a more open layout -->
<!-- ================================= --> <!-- ================================= -->
<!-- Authenticated User Dashboard --> <!-- Authenticated User Dashboard -->
<!-- ================================= --> <!-- ================================= -->
<div v-if="isAuthenticated && user" class="dashboard-content"> <div v-if="isAuthenticated && user" class="dashboard-content page-container">
<h1>Ваши цели на сегодня</h1> <h1>Привет, {{ user.nickname }}!</h1>
<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 class="habits-section"> <div class="habits-section">
<h2>Привычки</h2>
<div v-if="habitsPending">Загрузка привычек...</div> <div v-if="habitsPending">Загрузка привычек...</div>
<div v-else-if="habitsError">Не удалось загрузить привычки.</div> <div v-else-if="habitsError">Не удалось загрузить привычки.</div>
<div v-else-if="habits && habits.length > 0"> <div v-else-if="habits && habits.length > 0">
@ -42,6 +25,25 @@
</div> </div>
</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> </div>
<!-- ================================= --> <!-- ================================= -->
@ -55,11 +57,48 @@
<!-- New/Unidentified User Welcome --> <!-- New/Unidentified User Welcome -->
<!-- ================================= --> <!-- ================================= -->
<div v-else class="welcome-content"> <div v-else class="welcome-content">
<h1>Добро пожаловать в SmurfHabits!</h1> <h1>Трекер привычек с геймификацией</h1>
<p class="text-color-light">Отслеживайте свои привычки и развивайте свою деревню.</p> <p class="subtitle">Создавайте и отслеживайте свои привычки и развивайте свою деревню.</p>
<div class="auth-buttons"> <div class="auth-buttons">
<button @click="startOnboarding" class="btn btn-primary">Начать онбординг</button> <button @click="startOnboarding" class="btn btn-primary">Начать!</button>
<NuxtLink to="/login" class="btn btn-secondary">У меня уже есть аккаунт</NuxtLink> <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> </div>
</div> </div>
@ -78,6 +117,73 @@ const { data: habits, pending: habitsPending, error: habitsError, refresh: refre
server: false, 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 --- // --- Actions & UI State ---
const isSubmittingHabit = ref(false); const isSubmittingHabit = ref(false);
const explodingHabitId = ref(null); const explodingHabitId = ref(null);
@ -122,12 +228,22 @@ const completeHabit = async (habitId) => { // Removed event param since it's han
</script> </script>
<style scoped> <style scoped>
.dashboard-content, .welcome-content, .onboarding-container { .dashboard-content, .onboarding-container {
text-align: center; text-align: center;
} }
.welcome-content { .welcome-content {
padding: 40px 0; 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 { .streak-section {
@ -160,7 +276,7 @@ const completeHabit = async (habitId) => { // Removed event param since it's han
.active-streak { .active-streak {
border-color: var(--primary-color); border-color: var(--primary-color);
background-color: #f0f5ff; background-color: #fff;
box-shadow: 0 4px 12px rgba(0,0,0,0.1); box-shadow: 0 4px 12px rgba(0,0,0,0.1);
transform: translateY(-4px); transform: translateY(-4px);
} }
@ -184,4 +300,185 @@ const completeHabit = async (habitId) => { // Removed event param since it's han
gap: 16px; gap: 16px;
margin-top: 32px; 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> </style>

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="page-container"> <div class="page-container">
<h1>Monthly Leaderboard</h1> <h1>Доска почёта</h1>
<div v-if="pending" class="loading">Loading leaderboard...</div> <div v-if="pending" class="loading">Loading leaderboard...</div>
<div v-else-if="error" class="error-container"> <div v-else-if="error" class="error-container">
<p>An error occurred while fetching the leaderboard. Please try again.</p> <p>An error occurred while fetching the leaderboard. Please try again.</p>
@ -9,8 +9,8 @@
<table class="table table-striped table-hover"> <table class="table table-striped table-hover">
<thead> <thead>
<tr> <tr>
<th>Rank</th> <th>Место</th>
<th>Nickname</th> <th>Имя</th>
<th>EXP</th> <th>EXP</th>
</tr> </tr>
</thead> </thead>

View File

@ -18,8 +18,8 @@
</form> </form>
<div class="switch-link"> <div class="switch-link">
<p> <p>
Нет аккаунта?
<NuxtLink to="/register">Зарегистрироваться</NuxtLink> <NuxtLink to="/">Вернуться назад</NuxtLink>
</p> </p>
</div> </div>
</div> </div>

View File

@ -63,10 +63,7 @@
<h5>{{ getBuildingName(action.buildingType) }}</h5> <h5>{{ getBuildingName(action.buildingType) }}</h5>
<p class="building-description">{{ getBuildingDescription(action.buildingType) }}</p> <p class="building-description">{{ getBuildingDescription(action.buildingType) }}</p>
<div class="building-footer"> <div class="building-footer">
<div class="cost"> <button :disabled="!action.isEnabled || isSubmitting" @click="handleActionClick(action)" class="btn btn-primary btn-sm btn-full-width">
{{ action.cost }} монет
</div>
<button :disabled="!action.isEnabled || isSubmitting" @click="handleActionClick(action)" class="btn btn-primary btn-sm">
{{ getActionLabel(action) }} {{ getActionLabel(action) }}
</button> </button>
</div> </div>
@ -95,25 +92,17 @@
<!-- Event Log --> <!-- Event Log -->
<div v-if="villageEvents?.length" class="event-log-container"> <div v-if="villageEvents?.length" class="event-log-container">
<h2>Журнал событий</h2> <h2>Журнал событий</h2>
<div class="table-responsive"> <div class="event-list">
<table class="table table-striped table-hover"> <div v-for="event in villageEvents" :key="event.id" class="event-card">
<thead> <div class="event-card-header">
<tr> <span class="event-date">{{ new Date(event.createdAt).toLocaleString() }}</span>
<th>Дата</th> <div class="event-rewards">
<th>Событие</th> <span v-if="event.coins" class="event-reward-tag coins">{{ event.coins }} 💰</span>
<th>Монеты</th> <span v-if="event.exp" class="event-reward-tag exp">{{ event.exp }} </span>
<th>EXP</th> </div>
</tr> </div>
</thead> <p class="event-message">{{ formatMessageCoordinates(event.message) }}</p>
<tbody> </div>
<tr v-for="event in villageEvents" :key="event.id">
<td>{{ new Date(event.createdAt).toLocaleString() }}</td>
<td class="event-message">{{ formatMessageCoordinates(event.message) }}</td>
<td>{{ event.coins }}</td>
<td>{{ event.exp }}</td>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>
</div> </div>
@ -164,6 +153,7 @@ const tileClasses = (tile) => {
'tile-blocked': tile.terrainType === 'BLOCKED_TREE' || tile.terrainType === 'BLOCKED_STONE', 'tile-blocked': tile.terrainType === 'BLOCKED_TREE' || tile.terrainType === 'BLOCKED_STONE',
'tile-object': !!tile.object, 'tile-object': !!tile.object,
'tile-empty': tile.terrainType === 'EMPTY' && !tile.object, 'tile-empty': tile.terrainType === 'EMPTY' && !tile.object,
'tile-clearing': tile.terrainState === 'CLEARING', // Add this line
}; };
}; };
@ -173,7 +163,7 @@ const selectTile = (tile) => {
const getActionLabel = (action) => { const getActionLabel = (action) => {
if (action.type === 'BUILD') { if (action.type === 'BUILD') {
return `Построить`; return `${action.cost} монет`; // Return cost instead of "Построить"
} }
return action.type; return action.type;
}; };
@ -330,7 +320,7 @@ const handleAddCoins = () => handleAdminAction('/api/admin/user/add-coins');
<style scoped> <style scoped>
.village-page-layout { .village-page-layout {
--tile-size: clamp(50px, 12vw, 65px); --tile-size: clamp(55px, 12vw, 70px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -344,105 +334,126 @@ const handleAddCoins = () => handleAdminAction('/api/admin/user/add-coins');
} }
.village-container { .village-container {
display: flex; max-width: fit-content; /* Changed from width: 100%; display: flex; justify-content: center; */
justify-content: center; margin: 0 auto; /* Centered horizontally */
width: 100%;
padding: 0 10px; /* Add some padding on mobile */
margin-top: 20px; margin-top: 20px;
} }
/* --- New Demo-Inspired Tile Styles --- */
.tile { .tile {
width: var(--tile-size); width: var(--tile-size);
height: var(--tile-size); height: var(--tile-size);
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
border: 1px solid var(--border-color); border-radius: 12px;
border-radius: 4px;
background-color: var(--container-bg-color);
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s ease;
position: relative; /* For selection pseudo-elements */
}
.tile.tile-empty {
background-color: rgba(0,0,0,0.02);
border: 2px dashed var(--border-color);
box-shadow: inset 0 2px 4px rgba(0,0,0,0.03);
}
.tile.tile-empty:hover {
background-color: rgba(0,0,0,0.04);
border-color: var(--primary-color-light);
} }
.tile.tile-blocked { .tile.tile-blocked {
background-color: #f3f4f6; background-color: #EBE5D7; /* A soft, earthy tone */
border: 1px solid #DCD5C6;
}
.tile.tile-blocked:hover {
transform: translateY(-2px);
} }
.tile.tile-object { .tile.tile-object {
background-color: #ecfdf5; background-color: #c8ffcf;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.07);
border: 1px solid #39af50;
}
.tile.tile-object:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0,0,0,0.1);
} }
.tile:hover { .tile.tile-clearing {
background-color: #fefce8; background-color: #ffe0b2; /* A soft orange/yellow for "in progress" */
border-color: #facc15; border: 1px solid #ffcc80;
} }
.tile.selected { .tile.selected {
border: 2px solid var(--primary-color); box-shadow: 0 0 0 3px var(--primary-color);
box-shadow: 0 0 10px rgb(59 130 246 / 50%);
transform: scale(1.05); transform: scale(1.05);
} }
.tile-content { .tile-content {
font-size: calc(var(--tile-size) * 0.4); /* Make emoji scale with tile */ font-size: calc(var(--tile-size) * 0.5); /* Make emoji scale with tile */
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
} }
.tile.tile-object .tile-content {
transform: scale(1.1);
}
/* Styles for the grid with labels */ /* Styles for the grid with labels */
.village-grid-wrapper { .village-grid-wrapper {
display: grid; display: grid;
grid-template-columns: 20px repeat(5, var(--tile-size)); grid-template-columns: 20px repeat(5, var(--tile-size));
grid-template-rows: repeat(7, var(--tile-size)) 20px; /* Grid first, then labels */ grid-template-rows: repeat(7, var(--tile-size)) 20px;
gap: 4px; gap: 8px; /* Increased gap */
border: 2px solid var(--border-color); border-radius: 16px; /* Rounded wrapper */
border-radius: 8px; background-color: var(--container-bg-color);
background-color: var(--background-color); padding: 12px; /* Increased padding */
padding: 4px;
width: fit-content; width: fit-content;
margin: 0 auto; margin: 0 auto;
overflow-x: auto; /* Allow horizontal scroll if needed */ box-shadow: 0 8px 30px rgba(0,0,0,0.06);
overflow-y: hidden; /* Prevent vertical scroll */ /* Removed overflow-x: auto; and overflow-y: hidden; */
}
.empty-corner, .col-labels, .row-labels {
opacity: 0.6;
font-size: 0.8rem;
} }
.empty-corner { .empty-corner {
grid-column: 1; grid-column: 1;
grid-row: 8; /* Position at the bottom-left */ grid-row: 8;
width: 20px; width: 20px;
height: 20px; height: 20px;
} }
.col-labels { .col-labels {
grid-column: 2 / span 5; grid-column: 2 / span 5;
grid-row: 8; /* Position labels at the bottom (8th row) */ grid-row: 8;
display: flex; display: flex;
justify-content: space-around; justify-content: space-around;
align-items: center; align-items: center;
height: 20px; height: 20px;
color: var(--text-color-light);
font-weight: normal; /* Muted */
opacity: 0.7; /* Muted */
} }
.col-label { .col-label {
width: var(--tile-size); /* Match new tile width */ width: var(--tile-size);
text-align: center; text-align: center;
line-height: 20px; line-height: 20px;
} }
.row-labels { .row-labels {
grid-column: 1; grid-column: 1;
grid-row: 1 / span 7; /* Position labels on the left of the grid */ grid-row: 1 / span 7;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-around; justify-content: space-around;
align-items: center; align-items: center;
width: 20px; width: 20px;
color: var(--text-color-light);
font-weight: normal; /* Muted */
opacity: 0.7; /* Muted */
} }
.row-label { .row-label {
height: var(--tile-size); /* Match new tile height */ height: var(--tile-size);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -451,12 +462,11 @@ const handleAddCoins = () => handleAdminAction('/api/admin/user/add-coins');
.village-grid { .village-grid {
grid-column: 2 / span 5; grid-column: 2 / span 5;
grid-row: 1 / span 7; /* Position grid at the top */ grid-row: 1 / span 7;
display: grid; display: grid;
grid-template-columns: repeat(5, var(--tile-size)); grid-template-columns: repeat(5, var(--tile-size));
grid-template-rows: repeat(7, var(--tile-size)); grid-template-rows: repeat(7, var(--tile-size));
gap: 4px; gap: 8px; /* Increased gap */
background-color: var(--background-color);
} }
@ -473,7 +483,6 @@ const handleAddCoins = () => handleAdminAction('/api/admin/user/add-coins');
justify-content: center; justify-content: center;
align-items: flex-end; align-items: flex-end;
z-index: 1000; z-index: 1000;
padding: 10px;
} }
.tile-overlay-panel { .tile-overlay-panel {
@ -506,7 +515,7 @@ const handleAddCoins = () => handleAdminAction('/api/admin/user/add-coins');
.tile-description { .tile-description {
text-align: center; text-align: center;
margin-top: -10px; margin: -10px;
color: var(--text-color-light); color: var(--text-color-light);
font-style: italic; font-style: italic;
} }
@ -578,6 +587,7 @@ const handleAddCoins = () => handleAdminAction('/api/admin/user/add-coins');
cursor: not-allowed; cursor: not-allowed;
} }
.event-log-container { .event-log-container {
width: 100%; width: 100%;
max-width: 800px; max-width: 800px;
@ -585,27 +595,65 @@ const handleAddCoins = () => handleAdminAction('/api/admin/user/add-coins');
.event-log-container h2 { .event-log-container h2 {
text-align: center; text-align: center;
margin-bottom: 16px;
} }
.table-responsive { .event-list {
overflow-x: auto;
width: 100%;
max-height: 400px; max-height: 400px;
overflow-y: auto; overflow-y: auto;
border: 1px solid var(--border-color); padding-right: 10px; /* For scrollbar spacing */
display: flex;
flex-direction: column;
gap: 12px;
}
.event-card {
background-color: #fff;
border-radius: 8px; border-radius: 8px;
padding: 12px 16px;
border: 1px solid var(--border-color);
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
} }
.table td, .table th { .event-card-header {
white-space: nowrap; display: flex;
padding: 12px 15px; justify-content: space-between;
vertical-align: middle; align-items: center;
margin-bottom: 8px;
flex-wrap: wrap;
gap: 8px;
} }
.table .event-message { .event-date {
white-space: normal; font-size: 0.8rem;
min-width: 300px; color: var(--text-color-light);
max-width: 500px; }
.event-rewards {
display: flex;
gap: 8px;
}
.event-reward-tag {
font-size: 0.9rem;
font-weight: 600;
padding: 2px 6px;
border-radius: 6px;
}
.event-reward-tag.coins {
background-color: var(--warning-color-light);
color: var(--warning-color-dark);
}
.event-reward-tag.exp {
background-color: var(--info-color-light);
color: var(--info-color-dark);
}
.event-message {
font-size: 0.95rem;
line-height: 1.5;
} }
.build-card-grid { .build-card-grid {
@ -659,12 +707,16 @@ const handleAddCoins = () => handleAdminAction('/api/admin/user/add-coins');
.building-footer { .building-footer {
display: flex; display: flex;
justify-content: space-between; justify-content: center;
align-items: center; align-items: center;
width: 100%; width: 100%;
margin-top: auto; margin-top: auto;
} }
.building-footer .btn-full-width {
width: 100%;
}
.building-footer .cost { .building-footer .cost {
font-weight: 600; font-weight: 600;
font-size: 0.9em; font-size: 0.9em;
@ -691,4 +743,49 @@ const handleAddCoins = () => handleAdminAction('/api/admin/user/add-coins');
.disabled-overlay span { .disabled-overlay span {
font-size: 0.9em; font-size: 0.9em;
} }
@media (max-width: 480px) {
.village-grid-wrapper {
gap: 4px;
padding: 8px;
}
.village-grid {
gap: 4px;
}
}
/* --- Responsive styles for mobile --- */
@media (max-width: 768px) {
/* No changes to village-grid-wrapper or village-grid for 'fr' units,
as the --tile-size clamping already handles mobile scaling without fr.
Removed margin/padding adjustments to keep it centered and scaled.
Removed position: relative and ::after pseudo-element for scroll shadow.
*/
.bottom-content {
width: 100%;
padding: 0 16px;
box-sizing: border-box;
}
.tile-overlay-panel {
padding: 16px; /* Reduce padding on mobile */
margin-bottom: 60px;
gap: 12px;
max-width: 95%; /* Make it almost full width */
border-top-left-radius: 15px;
border-top-right-radius: 15px;
}
.build-card-grid {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: center;
}
.building-card {
width: 130px; /* A fixed width that allows for 2 cards per row on most phones */
}
}
</style> </style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
assets/bugs/village_bug.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

View File

@ -1,17 +1,21 @@
/* assets/css/main.css */ /* assets/css/main.css */
:root { :root {
--primary-color: #3b82f6; /* A friendly blue */ /* New playful color palette */
--primary-color-hover: #2563eb; --primary-color: #FF6B6B; /* Coral Red for high-contrast CTA */
--secondary-color: #6b7280; /* A neutral gray */ --primary-color-hover: #FF4F4F;
--secondary-color-hover: #4b5563; --secondary-color: #4ECDC4; /* Teal for accents */
--danger-color: #ef4444; /* A soft red */ --secondary-color-hover: #3DB8AE;
--danger-color: #ef4444;
--danger-color-hover: #dc2626; --danger-color-hover: #dc2626;
--background-color: #f9fafb; /* Very light gray for page backgrounds */
/* Softer backgrounds and text colors */
--background-color: #FDF8E9; /* Soft Cream */
--container-bg-color: #ffffff; --container-bg-color: #ffffff;
--text-color: #1f2937; --text-color: #333333; /* Darker grey for better contrast on cream */
--text-color-light: #6b7280; --text-color-light: #757575;
--border-color: #e5e7eb; --border-color: #797979;
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, --font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif; 'Helvetica Neue', Arial, sans-serif;
} }
@ -37,26 +41,26 @@ body {
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
color: var(--text-color); color: var(--text-color);
font-weight: 600; font-weight: 700; /* Bolder headings */
margin-top: 0; margin-top: 0;
} }
h1 { h1 {
font-size: 2rem; /* 32px */ font-size: 2.5rem; /* 40px */
margin-bottom: 1.5rem; /* 24px */ margin-bottom: 1rem;
text-align: center; text-align: center;
} }
h2 { h2 {
font-size: 1.5rem; /* 24px */ font-size: 1.75rem; /* 28px */
margin-bottom: 1rem; /* 16px */ margin-bottom: 1rem;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
} }
h3 { h3 {
font-size: 1.25rem; /* 20px */ font-size: 1.5rem; /* 24px */
margin-bottom: 0.75rem; /* 12px */ margin-bottom: 0.75rem;
} }
p { p {
@ -64,7 +68,7 @@ p {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
/* --- Buttons --- */ /* --- Buttons (New Style) --- */
.btn { .btn {
display: inline-block; display: inline-block;
font-weight: 600; font-weight: 600;
@ -72,18 +76,32 @@ p {
white-space: nowrap; white-space: nowrap;
vertical-align: middle; vertical-align: middle;
user-select: none; user-select: none;
border: 1px solid transparent; border: 2px solid transparent;
padding: 0.75rem 1.5rem; padding: 0.875rem 1.75rem; /* Increased padding */
font-size: 1rem; font-size: 1rem;
line-height: 1.5; line-height: 1.5;
border-radius: 0.375rem; border-radius: 12px; /* Rounded corners */
cursor: pointer; cursor: pointer;
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; transition: all 0.2s ease-in-out;
text-decoration: none; /* For NuxtLink styled as button */
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.btn:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
} }
.btn:disabled { .btn:disabled {
opacity: 0.65; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
transform: none;
box-shadow: none;
} }
.btn-primary { .btn-primary {
@ -97,15 +115,17 @@ p {
border-color: var(--primary-color-hover); border-color: var(--primary-color-hover);
} }
/* Secondary button is now an outline button */
.btn-secondary { .btn-secondary {
color: #fff; color: var(--primary-color);
background-color: var(--secondary-color); background-color: transparent;
border-color: var(--secondary-color); border-color: var(--primary-color);
} }
.btn-secondary:hover { .btn-secondary:hover {
background-color: var(--secondary-color-hover); background-color: var(--primary-color);
border-color: var(--secondary-color-hover); color: #fff;
border-color: var(--primary-color);
} }
.btn-danger { .btn-danger {
@ -120,9 +140,9 @@ p {
} }
.btn-sm { .btn-sm {
padding: 0.25rem 0.5rem; padding: 0.5rem 1rem;
font-size: 0.875rem; font-size: 0.875rem;
border-radius: 0.2rem; border-radius: 8px;
} }
@ -147,14 +167,15 @@ p {
background-color: #fff; background-color: #fff;
background-clip: padding-box; background-clip: padding-box;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 0.375rem; border-radius: 8px; /* More rounded */
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
box-sizing: border-box; /* Add this line */
} }
.form-control:focus { .form-control:focus {
outline: 0; outline: 0;
border-color: var(--primary-color); border-color: var(--secondary-color); /* Use accent color for focus */
box-shadow: 0 0 0 0.25rem rgb(59 130 246 / 25%); box-shadow: 0 0 0 0.25rem rgb(78 205 196 / 25%);
} }
.form-select { .form-select {
@ -165,14 +186,14 @@ p {
font-size: 1rem; font-size: 1rem;
font-weight: 400; font-weight: 400;
line-height: 1.5; line-height: 1.5;
color: #212529; color: var(--text-color);
background-color: #fff; background-color: #fff;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e"); background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23333333' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: right 1rem center; background-position: right 1rem center;
background-size: 16px 12px; background-size: 16px 12px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 0.375rem; border-radius: 8px; /* More rounded */
transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out; transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out;
-webkit-appearance: none; -webkit-appearance: none;
-moz-appearance: none; -moz-appearance: none;
@ -204,14 +225,13 @@ p {
text-align: left; text-align: left;
font-weight: 600; font-weight: 600;
color: var(--text-color); color: var(--text-color);
background-color: #f3f4f6; /* A bit darker than page background */ background-color: #f7f7f7;
} }
.table-striped > tbody > tr:nth-of-type(odd) > * { .table-striped > tbody > tr:nth-of-type(odd) > * {
--tw-bg-opacity: 1; background-color: #fafafa;
background-color: rgb(249 250 251 / var(--tw-bg-opacity));
} }
.table-hover > tbody > tr:hover > * { .table-hover > tbody > tr:hover > * {
background-color: rgb(243 244 246 / 0.8); background-color: #f1f1f1;
} }

View File

@ -5,11 +5,11 @@
*/ */
export const COSTS = { export const COSTS = {
BUILD: { BUILD: {
HOUSE: 50, HOUSE: 30,
FIELD: 15, FIELD: 15,
LUMBERJACK: 30, LUMBERJACK: 20,
QUARRY: 30, QUARRY: 20,
WELL: 20, WELL: 15,
} }
}; };
@ -19,7 +19,7 @@ export const COSTS = {
export const REWARDS = { export const REWARDS = {
// Village-related rewards // Village-related rewards
VILLAGE: { VILLAGE: {
CLEARING: { coins: 1, exp: 1 }, CLEARING: { coins: 5, exp: 1 },
FIELD_EXP: { FIELD_EXP: {
BASE: 1, BASE: 1,
WELL_MULTIPLIER: 2, WELL_MULTIPLIER: 2,
@ -33,7 +33,7 @@ export const REWARDS = {
}, },
// Habit-related rewards // Habit-related rewards
HABITS: { HABITS: {
COMPLETION: { coins: 3, exp: 1 }, COMPLETION: { coins: 2, exp: 1 },
ONBOARDING_COMPLETION: { coins: 75, exp: 0 }, ONBOARDING_COMPLETION: { coins: 50, exp: 0 },
} }
}; };