Небольшие правки по 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>
<div class="habit-card">
<div class="habit-header">
<div class="habit-details">
<div class="habit-details" style="flex-grow: 1;">
<h3>{{ habit.name }}</h3>
<p class="habit-schedule">{{ getScheduleText(habit) }}</p>
</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>
<!-- Calendar / History Grid (only for full user dashboard, not onboarding) -->
@ -22,6 +14,15 @@
</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-for="i in 15" :key="i" class="confetti-particle"></div>
</div>
@ -172,13 +173,18 @@ const emitComplete = () => {
.habit-header {
display: flex;
justify-content: space-between;
justify-content: flex-start; /* Adjust as needed */
align-items: center;
margin-bottom: 20px;
}
.habit-details {
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 {
@ -248,6 +254,7 @@ const emitComplete = () => {
.day-label {
font-size: 0.85em;
line-height: 0.8;
}
/* 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(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; }
/* Responsive Styles for the action button */
@media (max-width: 768px) {
.habit-action button {
width: 100%;
}
}
</style>

View File

@ -1,99 +1,106 @@
<template>
<div class="onboarding-funnel">
<!-- Progress Bar -->
<div class="progress-bar">
<div v-for="n in 5" :key="n" class="step" :class="{ 'active': currentStep === n, 'completed': currentStep > n }">
<div class="step-circle">{{ n }}</div>
<div class="step-label">{{ getStepLabel(n) }}</div>
<!-- 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="step-content">
<div class="step-container">
<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>Шаг 1: Создайте свою первую привычку</h2>
<p>Привычки - это основа продуктивности. С чего начнем?</p>
<form @submit.prevent="handleCreateHabit">
<div class="form-group">
<label for="habit-name">Название привычки</label>
<input id="habit-name" type="text" v-model="form.habitName" placeholder="Например, Читать 15 минут" required />
</div>
<div class="form-group">
<label>Дни выполнения</label>
<div class="days-selector">
<button v-for="day in daysOfWeek" :key="day.value" type="button" :class="{ 'selected': form.selectedDays.includes(day.value) }" @click="toggleDay(day.value)">{{ day.label }}</button>
<!-- 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>
<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>
<button type="submit" :disabled="isLoading">{{ isLoading ? 'Создание...' : 'Создать и перейти далее' }}</button>
</form>
</div>
<!-- Step 2: Complete Habit -->
<div v-if="currentStep === 2">
<h2>Шаг 2: Завершите привычку</h2>
<p>Отлично! Теперь отметьте привычку <strong>"{{ createdHabit?.name }}"</strong> как выполненную, чтобы получить награду.</p>
<div v-if="error" class="error-message">{{ error }}</div>
<HabitCard
v-if="createdHabit"
:habit="createdHabit"
:is-submitting-habit="isLoading"
:show-history-grid="false"
:force-show-action="true"
@complete="handleCompleteOnboardingHabit"
/>
</div>
<!-- NEW Step 3: Reward Screen -->
<div v-if="currentStep === 3" class="reward-step">
<div class="reward-icon">💰</div>
<h2>Вы получили {{ onboardingRewardAmount }} монет!</h2>
<p class="reward-caption">Деньги можно тратить на строительство вашей деревни.</p>
<button @click="nextStep">Продолжить</button>
</div>
<!-- Step 4: Build a House (was Step 3) -->
<div v-if="currentStep === 4">
<h2>Шаг 4: Постройте дом</h2>
<p>Вы заработали <strong>{{ onboardingRewardAmount }}</strong> золота! Нажмите на пустой участок в деревне, чтобы построить Дом (Стоимость: 50 монет) и увеличить лимит рабочих.</p>
<VillageGrid
v-if="villageData"
:village-data="villageData"
@tile-click="handleTileClickToBuild"
/>
<div v-else-if="villagePending" class="loading-placeholder">Загрузка деревни...</div>
<div v-if="error" class="error-message">{{ error }}</div>
<button @click="nextStep" :disabled="isLoading || !isHouseBuilt" class="next-button">
{{ isLoading ? 'Загрузка...' : 'Продолжить' }}
</button>
</div>
<!-- Step 5: Register (was Step 4) -->
<div v-if="currentStep === 5">
<h2>Шаг 5: Сохраните свой прогресс!</h2>
<p>Ваша деревня растет! Чтобы не потерять свой прогресс и соревноваться с другими, зарегистрируйтесь.</p>
<form @submit.prevent="handleRegister">
<div class="form-group">
<label for="nickname">Ваше имя</label>
<input id="nickname" type="text" v-model="form.nickname" placeholder="Смурфик" required />
</div>
<div class="form-group">
<label for="email">Email</label>
<input id="email" type="email" v-model="form.email" placeholder="smurf@example.com" required />
</div>
<div class="form-group">
<label for="password">Пароль (мин. 8 символов)</label>
<input id="password" type="password" v-model="form.password" required />
<div 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 type="submit" :disabled="isLoading">{{ isLoading ? 'Регистрация...' : 'Завершить и сохранить' }}</button>
</form>
<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>
@ -151,15 +158,24 @@ watch(currentStep, async (newStep) => {
// --- Methods ---
const getStepLabel = (step: number): string => {
switch (step) {
case 1: return 'Создать привычку';
case 2: return 'Завершить';
case 1: return 'Привычка';
case 2: return 'Выполнение';
case 3: return 'Награда';
case 4: 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;
@ -271,49 +287,241 @@ const handleRegister = async () => {
</script>
<style scoped>
/* --- Main Funnel & Progress Bar --- */
.onboarding-funnel { width: 100%; max-width: 800px; margin: 2rem auto; padding: 2rem; font-family: sans-serif; background-color: #f9f9f9; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1); }
.progress-bar { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 3rem; position: relative; }
.progress-bar::before { content: ''; position: absolute; top: 18px; left: 10%; right: 10%; height: 4px; background-color: #e0e0e0; z-index: 1; }
.step { display: flex; flex-direction: column; align-items: center; text-align: center; flex: 1; position: relative; z-index: 2; }
.step-circle { width: 36px; height: 36px; border-radius: 50%; background-color: #e0e0e0; color: #888; display: flex; justify-content: center; align-items: center; font-weight: bold; font-size: 1.2rem; border: 4px solid #e0e0e0; transition: all 0.3s ease; }
.step-label { margin-top: 0.5rem; font-size: 0.9rem; color: #888; }
.step.active .step-circle { background-color: #fff; border-color: #3b82f6; color: #3b82f6; }
.step.active .step-label { color: #3b82f6; font-weight: bold; }
.step.completed .step-circle { background-color: #16a34a; border-color: #16a34a; color: #fff; }
.step.completed .step-label { color: #333; }
.step.completed::after { content: ''; position: absolute; top: 18px; left: -50%; width: 100%; height: 4px; background-color: #16a34a; z-index: -1; }
.step:first-child.completed::after { width: 50%; left: 0; }
/* --- Content & Form Styling --- */
.step-content { margin-top: 2rem; }
.step-container { animation: fadeIn 0.5s ease; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.step-container h2 { font-size: 1.8rem; margin-bottom: 1rem; color: #333; }
.step-container p { font-size: 1.1rem; color: #666; margin-bottom: 2rem; }
.loading-placeholder { background-color: #f0f0f0; border: 2px dashed #ccc; padding: 3rem; text-align: center; color: #999; border-radius: 8px; margin-bottom: 2rem; }
button { background-color: #3b82f6; color: white; padding: 1rem 2rem; border: none; border-radius: 8px; font-size: 1.1rem; font-weight: bold; cursor: pointer; transition: background-color 0.2s; width: 100%; }
button:hover:not(:disabled) { background-color: #2563eb; }
button:disabled { background-color: #9ca3af; cursor: not-allowed; }
.form-group { margin-bottom: 1.5rem; text-align: left; }
.form-group label { display: block; margin-bottom: 0.5rem; font-weight: bold; color: #555; }
.form-group input { width: 100%; padding: 0.75rem 1rem; font-size: 1rem; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }
.days-selector { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.days-selector button { background-color: #e0e0e0; color: #333; font-weight: normal; font-size: 0.9rem; padding: 0.5rem 1rem; border-radius: 20px; width: auto; }
.days-selector button.selected { background-color: #3b82f6; color: white; }
.error-message { color: #ef4444; margin-bottom: 1rem; text-align: center; }
/* Step 3 (Reward) specific styles */
.reward-step { text-align: center; }
.reward-icon { font-size: 5rem; line-height: 1; margin-bottom: 1rem; }
.reward-caption { color: #888; font-size: 1rem; }
/* Step 4 (Build) specific styles */
.next-button {
margin-top: 1rem;
background-color: #16a34a; /* Green for next step */
/* --- Main Funnel Layout --- */
.onboarding-funnel {
width: 100%;
max-width: 720px;
margin: 40px auto;
padding: 0 24px;
}
.next-button:hover:not(:disabled) {
background-color: #15803d;
/* --- 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>

View File

@ -1,6 +1,6 @@
<template>
<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 -->
<div class="empty-corner"></div>
@ -43,8 +43,17 @@ const props = defineProps({
type: Object,
required: true,
},
isOnboarding: {
type: Boolean,
default: false,
},
});
const villageGridWrapperClass = computed(() => ({
'village-grid-wrapper': true,
'is-onboarding': props.isOnboarding,
}));
defineEmits(['tile-click']);
// --- Grid Dimensions ---
@ -105,6 +114,7 @@ const tileClasses = (tile) => {
'tile-blocked': tile.terrainType === 'BLOCKED_TREE' || tile.terrainType === 'BLOCKED_STONE',
'tile-object': !!tile.object,
'tile-empty': tile.terrainType === 'EMPTY' && !tile.object,
'tile-clearing': tile.terrainState === 'CLEARING',
};
};
</script>
@ -121,48 +131,60 @@ const tileClasses = (tile) => {
.village-grid-wrapper {
display: grid;
gap: 4px;
padding: 4px;
gap: 8px; /* Increased gap */
border-radius: 16px; /* Rounded wrapper */
background-color: var(--container-bg-color);
padding: 12px; /* Increased padding */
width: fit-content;
margin: 0 auto;
box-shadow: 0 8px 30px rgba(0,0,0,0.06);
overflow-x: auto;
overflow-y: hidden;
}
.empty-corner {
grid-column: 1;
grid-row: 8;
.empty-corner, .col-labels, .row-labels {
opacity: 0.6;
font-size: 0.8rem;
}
.col-labels {
grid-column: 2 / -1;
grid-column: 2 / -1; /* Dynamic spanning */
grid-row: 8;
display: flex;
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 {
grid-column: 1;
grid-row: 1 / 8;
grid-row: 1 / -1; /* Dynamic spanning */
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
color: #999;
width: 20px;
}
.col-label, .row-label {
.row-label {
height: var(--tile-size);
display: flex;
justify-content: center;
align-items: center;
font-size: 0.8rem;
justify-content: center;
line-height: 20px;
}
.village-grid {
grid-column: 2 / -1;
grid-row: 1 / 8;
grid-column: 2 / -1; /* Dynamic spanning */
grid-row: 1 / -1; /* Dynamic spanning */
display: grid;
gap: 4px;
border: 1px solid #e0e0e0;
gap: 8px; /* Increased gap */
}
.tile {
@ -171,27 +193,105 @@ const tileClasses = (tile) => {
display: flex;
justify-content: center;
align-items: center;
border: 1px solid #e0e0e0;
border-radius: 4px;
background-color: #f9f9f9;
border-radius: 12px;
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 {
background-color: #f3f4f6;
background-color: #EBE5D7; /* A soft, earthy tone */
border: 1px solid #DCD5C6;
}
.tile.tile-blocked:hover {
transform: translateY(-2px);
}
.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 {
background-color: #fefce8;
border-color: #facc15;
.tile.tile-clearing {
background-color: #ffe0b2; /* A soft orange/yellow for "in progress" */
border: 1px solid #ffcc80;
}
.tile.selected {
box-shadow: 0 0 0 3px var(--primary-color);
transform: scale(1.05);
}
.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>

View File

@ -2,10 +2,9 @@
<div class="app-container">
<header v-if="isAuthenticated" class="top-bar">
<div class="user-info-top">
<span>{{ user.nickname }}</span>
<span>💰 {{ displayedCoins }}</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>
</header>

View File

@ -2,7 +2,47 @@
<div class="page-container">
<h1>Мои Привычки</h1>
<!-- Create Habit Form -->
<!-- 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 -->
<div class="form-container">
<h2>Создать новую привычку</h2>
<form @submit.prevent="createHabit">
@ -29,46 +69,6 @@
</form>
</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 -->
<ConfirmDialog
@ -288,8 +288,8 @@ onMounted(fetchHabits);
display: flex;
justify-content: center;
align-items: center;
width: 40px;
height: 40px;
width: 33px;
height: 33px;
border: 1px solid var(--border-color);
border-radius: 50%;
font-size: 0.9em;
@ -312,6 +312,7 @@ onMounted(fetchHabits);
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 45px;
}
.habit-card {
@ -325,15 +326,17 @@ onMounted(fetchHabits);
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.habit-card > div {
.habit-view-content {
display: flex;
justify-content: space-between;
align-items: center;
flex-direction: column;
gap: 15px; /* Spacing between name, days, and actions */
width: 100%;
justify-content: flex-start;
align-items: flex-start;
}
.habit-info h3 {
margin: 0 0 10px 0;
margin: 0;
font-size: 1.15rem;
}
@ -341,6 +344,8 @@ onMounted(fetchHabits);
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-start;
padding: 15px 0 5px;
}
.day-chip {
@ -364,6 +369,8 @@ onMounted(fetchHabits);
.habit-actions {
display: flex;
gap: 10px;
justify-content: flex-start;
width: 100%;
}
/* Edit Form Specific Styles */

View File

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

View File

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

View File

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

View File

@ -63,10 +63,7 @@
<h5>{{ getBuildingName(action.buildingType) }}</h5>
<p class="building-description">{{ getBuildingDescription(action.buildingType) }}</p>
<div class="building-footer">
<div class="cost">
{{ action.cost }} монет
</div>
<button :disabled="!action.isEnabled || isSubmitting" @click="handleActionClick(action)" class="btn btn-primary btn-sm">
<button :disabled="!action.isEnabled || isSubmitting" @click="handleActionClick(action)" class="btn btn-primary btn-sm btn-full-width">
{{ getActionLabel(action) }}
</button>
</div>
@ -95,25 +92,17 @@
<!-- Event Log -->
<div v-if="villageEvents?.length" class="event-log-container">
<h2>Журнал событий</h2>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Дата</th>
<th>Событие</th>
<th>Монеты</th>
<th>EXP</th>
</tr>
</thead>
<tbody>
<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 class="event-list">
<div v-for="event in villageEvents" :key="event.id" class="event-card">
<div class="event-card-header">
<span class="event-date">{{ new Date(event.createdAt).toLocaleString() }}</span>
<div class="event-rewards">
<span v-if="event.coins" class="event-reward-tag coins">{{ event.coins }} 💰</span>
<span v-if="event.exp" class="event-reward-tag exp">{{ event.exp }} </span>
</div>
</div>
<p class="event-message">{{ formatMessageCoordinates(event.message) }}</p>
</div>
</div>
</div>
</div>
@ -164,6 +153,7 @@ const tileClasses = (tile) => {
'tile-blocked': tile.terrainType === 'BLOCKED_TREE' || tile.terrainType === 'BLOCKED_STONE',
'tile-object': !!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) => {
if (action.type === 'BUILD') {
return `Построить`;
return `${action.cost} монет`; // Return cost instead of "Построить"
}
return action.type;
};
@ -330,7 +320,7 @@ const handleAddCoins = () => handleAdminAction('/api/admin/user/add-coins');
<style scoped>
.village-page-layout {
--tile-size: clamp(50px, 12vw, 65px);
--tile-size: clamp(55px, 12vw, 70px);
display: flex;
flex-direction: column;
align-items: center;
@ -344,105 +334,126 @@ const handleAddCoins = () => handleAdminAction('/api/admin/user/add-coins');
}
.village-container {
display: flex;
justify-content: center;
width: 100%;
padding: 0 10px; /* Add some padding on mobile */
max-width: fit-content; /* Changed from width: 100%; display: flex; justify-content: center; */
margin: 0 auto; /* Centered horizontally */
margin-top: 20px;
}
/* --- New Demo-Inspired Tile Styles --- */
.tile {
width: var(--tile-size);
height: var(--tile-size);
display: flex;
justify-content: center;
align-items: center;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--container-bg-color);
border-radius: 12px;
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 {
background-color: #f3f4f6;
background-color: #EBE5D7; /* A soft, earthy tone */
border: 1px solid #DCD5C6;
}
.tile.tile-blocked:hover {
transform: translateY(-2px);
}
.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 {
background-color: #fefce8;
border-color: #facc15;
.tile.tile-clearing {
background-color: #ffe0b2; /* A soft orange/yellow for "in progress" */
border: 1px solid #ffcc80;
}
.tile.selected {
border: 2px solid var(--primary-color);
box-shadow: 0 0 10px rgb(59 130 246 / 50%);
box-shadow: 0 0 0 3px var(--primary-color);
transform: scale(1.05);
}
.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 */
.village-grid-wrapper {
display: grid;
grid-template-columns: 20px repeat(5, var(--tile-size));
grid-template-rows: repeat(7, var(--tile-size)) 20px; /* Grid first, then labels */
gap: 4px;
border: 2px solid var(--border-color);
border-radius: 8px;
background-color: var(--background-color);
padding: 4px;
grid-template-rows: repeat(7, var(--tile-size)) 20px;
gap: 8px; /* Increased gap */
border-radius: 16px; /* Rounded wrapper */
background-color: var(--container-bg-color);
padding: 12px; /* Increased padding */
width: fit-content;
margin: 0 auto;
overflow-x: auto; /* Allow horizontal scroll if needed */
overflow-y: hidden; /* Prevent vertical scroll */
box-shadow: 0 8px 30px rgba(0,0,0,0.06);
/* Removed overflow-x: auto; and overflow-y: hidden; */
}
.empty-corner, .col-labels, .row-labels {
opacity: 0.6;
font-size: 0.8rem;
}
.empty-corner {
grid-column: 1;
grid-row: 8; /* Position at the bottom-left */
grid-row: 8;
width: 20px;
height: 20px;
}
.col-labels {
grid-column: 2 / span 5;
grid-row: 8; /* Position labels at the bottom (8th row) */
grid-row: 8;
display: flex;
justify-content: space-around;
align-items: center;
height: 20px;
color: var(--text-color-light);
font-weight: normal; /* Muted */
opacity: 0.7; /* Muted */
}
.col-label {
width: var(--tile-size); /* Match new tile width */
width: var(--tile-size);
text-align: center;
line-height: 20px;
}
.row-labels {
grid-column: 1;
grid-row: 1 / span 7; /* Position labels on the left of the grid */
grid-row: 1 / span 7;
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
width: 20px;
color: var(--text-color-light);
font-weight: normal; /* Muted */
opacity: 0.7; /* Muted */
}
.row-label {
height: var(--tile-size); /* Match new tile height */
height: var(--tile-size);
display: flex;
align-items: center;
justify-content: center;
@ -451,12 +462,11 @@ const handleAddCoins = () => handleAdminAction('/api/admin/user/add-coins');
.village-grid {
grid-column: 2 / span 5;
grid-row: 1 / span 7; /* Position grid at the top */
grid-row: 1 / span 7;
display: grid;
grid-template-columns: repeat(5, var(--tile-size));
grid-template-rows: repeat(7, var(--tile-size));
gap: 4px;
background-color: var(--background-color);
gap: 8px; /* Increased gap */
}
@ -473,7 +483,6 @@ const handleAddCoins = () => handleAdminAction('/api/admin/user/add-coins');
justify-content: center;
align-items: flex-end;
z-index: 1000;
padding: 10px;
}
.tile-overlay-panel {
@ -506,7 +515,7 @@ const handleAddCoins = () => handleAdminAction('/api/admin/user/add-coins');
.tile-description {
text-align: center;
margin-top: -10px;
margin: -10px;
color: var(--text-color-light);
font-style: italic;
}
@ -578,6 +587,7 @@ const handleAddCoins = () => handleAdminAction('/api/admin/user/add-coins');
cursor: not-allowed;
}
.event-log-container {
width: 100%;
max-width: 800px;
@ -585,27 +595,65 @@ const handleAddCoins = () => handleAdminAction('/api/admin/user/add-coins');
.event-log-container h2 {
text-align: center;
margin-bottom: 16px;
}
.table-responsive {
overflow-x: auto;
width: 100%;
.event-list {
max-height: 400px;
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;
padding: 12px 16px;
border: 1px solid var(--border-color);
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
}
.table td, .table th {
white-space: nowrap;
padding: 12px 15px;
vertical-align: middle;
.event-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
flex-wrap: wrap;
gap: 8px;
}
.table .event-message {
white-space: normal;
min-width: 300px;
max-width: 500px;
.event-date {
font-size: 0.8rem;
color: var(--text-color-light);
}
.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 {
@ -659,12 +707,16 @@ const handleAddCoins = () => handleAdminAction('/api/admin/user/add-coins');
.building-footer {
display: flex;
justify-content: space-between;
justify-content: center;
align-items: center;
width: 100%;
margin-top: auto;
}
.building-footer .btn-full-width {
width: 100%;
}
.building-footer .cost {
font-weight: 600;
font-size: 0.9em;
@ -691,4 +743,49 @@ const handleAddCoins = () => handleAdminAction('/api/admin/user/add-coins');
.disabled-overlay span {
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>

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 */
:root {
--primary-color: #3b82f6; /* A friendly blue */
--primary-color-hover: #2563eb;
--secondary-color: #6b7280; /* A neutral gray */
--secondary-color-hover: #4b5563;
--danger-color: #ef4444; /* A soft red */
/* New playful color palette */
--primary-color: #FF6B6B; /* Coral Red for high-contrast CTA */
--primary-color-hover: #FF4F4F;
--secondary-color: #4ECDC4; /* Teal for accents */
--secondary-color-hover: #3DB8AE;
--danger-color: #ef4444;
--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;
--text-color: #1f2937;
--text-color-light: #6b7280;
--border-color: #e5e7eb;
--text-color: #333333; /* Darker grey for better contrast on cream */
--text-color-light: #757575;
--border-color: #797979;
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
}
@ -37,26 +41,26 @@ body {
h1, h2, h3, h4, h5, h6 {
color: var(--text-color);
font-weight: 600;
font-weight: 700; /* Bolder headings */
margin-top: 0;
}
h1 {
font-size: 2rem; /* 32px */
margin-bottom: 1.5rem; /* 24px */
font-size: 2.5rem; /* 40px */
margin-bottom: 1rem;
text-align: center;
}
h2 {
font-size: 1.5rem; /* 24px */
margin-bottom: 1rem; /* 16px */
font-size: 1.75rem; /* 28px */
margin-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.5rem;
}
h3 {
font-size: 1.25rem; /* 20px */
margin-bottom: 0.75rem; /* 12px */
font-size: 1.5rem; /* 24px */
margin-bottom: 0.75rem;
}
p {
@ -64,7 +68,7 @@ p {
margin-bottom: 1rem;
}
/* --- Buttons --- */
/* --- Buttons (New Style) --- */
.btn {
display: inline-block;
font-weight: 600;
@ -72,18 +76,32 @@ p {
white-space: nowrap;
vertical-align: middle;
user-select: none;
border: 1px solid transparent;
padding: 0.75rem 1.5rem;
border: 2px solid transparent;
padding: 0.875rem 1.75rem; /* Increased padding */
font-size: 1rem;
line-height: 1.5;
border-radius: 0.375rem;
border-radius: 12px; /* Rounded corners */
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 {
opacity: 0.65;
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.btn-primary {
@ -97,15 +115,17 @@ p {
border-color: var(--primary-color-hover);
}
/* Secondary button is now an outline button */
.btn-secondary {
color: #fff;
background-color: var(--secondary-color);
border-color: var(--secondary-color);
color: var(--primary-color);
background-color: transparent;
border-color: var(--primary-color);
}
.btn-secondary:hover {
background-color: var(--secondary-color-hover);
border-color: var(--secondary-color-hover);
background-color: var(--primary-color);
color: #fff;
border-color: var(--primary-color);
}
.btn-danger {
@ -120,9 +140,9 @@ p {
}
.btn-sm {
padding: 0.25rem 0.5rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
border-radius: 0.2rem;
border-radius: 8px;
}
@ -147,14 +167,15 @@ p {
background-color: #fff;
background-clip: padding-box;
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;
box-sizing: border-box; /* Add this line */
}
.form-control:focus {
outline: 0;
border-color: var(--primary-color);
box-shadow: 0 0 0 0.25rem rgb(59 130 246 / 25%);
border-color: var(--secondary-color); /* Use accent color for focus */
box-shadow: 0 0 0 0.25rem rgb(78 205 196 / 25%);
}
.form-select {
@ -165,14 +186,14 @@ p {
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #212529;
color: var(--text-color);
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-position: right 1rem center;
background-size: 16px 12px;
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;
-webkit-appearance: none;
-moz-appearance: none;
@ -204,14 +225,13 @@ p {
text-align: left;
font-weight: 600;
color: var(--text-color);
background-color: #f3f4f6; /* A bit darker than page background */
background-color: #f7f7f7;
}
.table-striped > tbody > tr:nth-of-type(odd) > * {
--tw-bg-opacity: 1;
background-color: rgb(249 250 251 / var(--tw-bg-opacity));
background-color: #fafafa;
}
.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 = {
BUILD: {
HOUSE: 50,
HOUSE: 30,
FIELD: 15,
LUMBERJACK: 30,
QUARRY: 30,
WELL: 20,
LUMBERJACK: 20,
QUARRY: 20,
WELL: 15,
}
};
@ -19,7 +19,7 @@ export const COSTS = {
export const REWARDS = {
// Village-related rewards
VILLAGE: {
CLEARING: { coins: 1, exp: 1 },
CLEARING: { coins: 5, exp: 1 },
FIELD_EXP: {
BASE: 1,
WELL_MULTIPLIER: 2,
@ -33,7 +33,7 @@ export const REWARDS = {
},
// Habit-related rewards
HABITS: {
COMPLETION: { coins: 3, exp: 1 },
ONBOARDING_COMPLETION: { coins: 75, exp: 0 },
COMPLETION: { coins: 2, exp: 1 },
ONBOARDING_COMPLETION: { coins: 50, exp: 0 },
}
};