Skip to content

Commit d5e6a26

Browse files
committed
feat маскотик
1 parent b76967e commit d5e6a26

10 files changed

Lines changed: 1449 additions & 7 deletions

File tree

‎src/app/providers/RouterProvider.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { OnboardingPage } from '@/pages/onboarding';
55
import { DashboardPage } from '@/pages/dashboard';
66
import { ChatPage } from '@/pages/chat';
77
import { ProfilePage } from '@/pages/profile';
8-
import { GoalsPage } from '@/pages/goals';
8+
import { ArenaPage } from '@/pages/arena';
99
import { AnalyticsPage } from '@/pages/analytics';
1010
import { FinancePage } from '@/pages/finance';
1111

@@ -44,7 +44,7 @@ const router = createBrowserRouter([
4444
{ path: 'finance', element: <FinancePage /> },
4545
{ path: 'chat', element: <ChatPage /> },
4646
{ path: 'profile', element: <ProfilePage /> },
47-
{ path: 'goals', element: <GoalsPage /> },
47+
{ path: 'arena', element: <ArenaPage /> },
4848
{ path: 'analytics', element: <AnalyticsPage /> },
4949
],
5050
},
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { create } from 'zustand';
2+
import { persist } from 'zustand/middleware';
3+
import { REWARDS, findShopItem, type ShopItem } from './copyCat';
4+
5+
const todayStr = () => new Date().toISOString().slice(0, 10);
6+
7+
function daysBetween(isoA: string, isoB: string): number {
8+
const a = new Date(isoA + 'T00:00:00');
9+
const b = new Date(isoB + 'T00:00:00');
10+
return Math.round((b.getTime() - a.getTime()) / 86_400_000);
11+
}
12+
13+
export type BuyResult = { ok: true; item: ShopItem } | { ok: false; reason: 'owned' | 'no_coins' | 'not_found' };
14+
15+
interface ArenaState {
16+
coins: number;
17+
owned: string[]; // купленные товары
18+
equipped: string[]; // надетые аксессуары кота
19+
dailyQuizDate: string | null;
20+
starterPackDate: string | null;
21+
lastVisit: string | null;
22+
weeklyScore: number;
23+
rank: number;
24+
daysAway: number; // вычисляется при заходе (эффект Тамагочи)
25+
26+
checkIn: () => void;
27+
addCoins: (amount: number, bumpScore?: boolean) => void;
28+
buyItem: (id: string) => BuyResult;
29+
toggleEquip: (id: string) => void;
30+
markDailyQuizDone: () => void;
31+
claimStarterPack: () => boolean;
32+
}
33+
34+
export const useArenaStore = create<ArenaState>()(
35+
persist(
36+
(set, get) => ({
37+
coins: 120,
38+
owned: [],
39+
equipped: [],
40+
dailyQuizDate: null,
41+
starterPackDate: null,
42+
lastVisit: null,
43+
weeklyScore: 320,
44+
rank: 7,
45+
daysAway: 0,
46+
47+
// Вызывается при открытии Арены: бонус за вход и расчёт дней отсутствия.
48+
checkIn: () => {
49+
const { lastVisit, coins } = get();
50+
const today = todayStr();
51+
if (lastVisit === today) return;
52+
const away = lastVisit ? daysBetween(lastVisit, today) : 0;
53+
set({
54+
coins: coins + REWARDS.dailyLogin,
55+
daysAway: away,
56+
lastVisit: today,
57+
});
58+
},
59+
60+
addCoins: (amount, bumpScore = true) =>
61+
set((s) => ({
62+
coins: Math.max(0, s.coins + amount),
63+
weeklyScore: bumpScore && amount > 0 ? s.weeklyScore + amount : s.weeklyScore,
64+
rank: bumpScore && amount >= 50 ? Math.max(1, s.rank - 1) : s.rank,
65+
})),
66+
67+
buyItem: (id) => {
68+
const item = findShopItem(id);
69+
if (!item) return { ok: false, reason: 'not_found' };
70+
const s = get();
71+
if (s.owned.includes(id)) return { ok: false, reason: 'owned' };
72+
if (s.coins < item.price) return { ok: false, reason: 'no_coins' };
73+
set({
74+
coins: s.coins - item.price,
75+
owned: [...s.owned, id],
76+
// аксессуар кота сразу надеваем (один слот — один предмет)
77+
equipped: item.slot
78+
? [...s.equipped.filter((eid) => findShopItem(eid)?.slot !== item.slot), id]
79+
: s.equipped,
80+
});
81+
return { ok: true, item };
82+
},
83+
84+
toggleEquip: (id) => {
85+
const item = findShopItem(id);
86+
if (!item || !item.slot) return;
87+
set((s) => {
88+
if (s.equipped.includes(id)) {
89+
return { equipped: s.equipped.filter((e) => e !== id) };
90+
}
91+
const cleaned = s.equipped.filter((eid) => findShopItem(eid)?.slot !== item.slot);
92+
return { equipped: [...cleaned, id] };
93+
});
94+
},
95+
96+
markDailyQuizDone: () => set({ dailyQuizDate: todayStr() }),
97+
98+
claimStarterPack: () => {
99+
const s = get();
100+
if (s.starterPackDate === todayStr()) return false;
101+
set({
102+
coins: s.coins + REWARDS.starterPack,
103+
weeklyScore: s.weeklyScore + REWARDS.starterPack,
104+
starterPackDate: todayStr(),
105+
});
106+
return true;
107+
},
108+
}),
109+
{ name: 'ekvator-arena' },
110+
),
111+
);
112+
113+
// Удобные селекторы-хелперы (используются в UI).
114+
export const isDailyQuizDone = (s: ArenaState) => s.dailyQuizDate === todayStr();
115+
export const isStarterClaimed = (s: ArenaState) => s.starterPackDate === todayStr();
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
// КопиКот — данные персонажа Арены: состояния, фразы, экономика, магазин, вопросы.
2+
3+
export type CatStateKey = 'rich' | 'normal' | 'hungry' | 'sad' | 'sleeping';
4+
5+
export interface CatStateMeta {
6+
key: CatStateKey;
7+
label: string;
8+
aura: string;
9+
caption: string;
10+
}
11+
12+
// Состояние зависит от баланса монет и того, как давно заходил пользователь.
13+
export const CAT_STATES: Record<CatStateKey, CatStateMeta> = {
14+
rich: { key: 'rich', label: 'Богатый', aura: '#FFD54A', caption: 'На горе золота' },
15+
normal: { key: 'normal', label: 'Любопытный', aura: '#A29BFE', caption: 'Наблюдает за тобой' },
16+
hungry: { key: 'hungry', label: 'Голодный', aura: '#FF8A65', caption: 'Пора экономить' },
17+
sad: { key: 'sad', label: 'Грустный', aura: '#90A4AE', caption: 'Скучал по тебе' },
18+
sleeping: { key: 'sleeping', label: 'Спящий', aura: '#7986CB', caption: 'Заснул без тебя' },
19+
};
20+
21+
export const RICH_THRESHOLD = 500;
22+
export const HUNGRY_THRESHOLD = 100;
23+
const SAD_AFTER_DAYS = 2;
24+
const SLEEP_AFTER_DAYS = 5;
25+
26+
export function getCatState(coins: number, daysAway = 0): CatStateMeta {
27+
if (daysAway >= SLEEP_AFTER_DAYS) return CAT_STATES.sleeping;
28+
if (daysAway >= SAD_AFTER_DAYS) return CAT_STATES.sad;
29+
if (coins >= RICH_THRESHOLD) return CAT_STATES.rich;
30+
if (coins < HUNGRY_THRESHOLD) return CAT_STATES.hungry;
31+
return CAT_STATES.normal;
32+
}
33+
34+
// ─── Фразы кота (тап → случайная фраза). Три типа. ──────────────────────────
35+
export type PhraseType = 'wisdom' | 'reminder' | 'meow';
36+
export interface CatPhrase { type: PhraseType; text: string }
37+
38+
export const CAT_PHRASES: Record<PhraseType, string[]> = {
39+
wisdom: [
40+
'Сложный процент — восьмое чудо света. Кто понимает — зарабатывает.',
41+
'Сначала заплати себе: отложи 10% до того, как потратишь остальное.',
42+
'Подушка безопасности — это 3–6 твоих месячных расходов. У меня — корм на год.',
43+
'Не храни все яйца в одной корзине. И все рыбки — в одной миске.',
44+
'Кредитка — инструмент, а не доход. Я проверял когтями, так и есть.',
45+
],
46+
reminder: [
47+
'Ежедневный квиз не пройден. Я наблюдаю. Внимательно.',
48+
'Монеты сами себя не заработают. Сыграй дуэль!',
49+
'Загляни в Витрину — там новая корона. Мне идёт, я мерил.',
50+
'Ты не открывал обучающие карточки. А ведь там монеты лежат.',
51+
'Соперник уже разминается. Не дай ему забрать твой рейтинг.',
52+
],
53+
meow: [
54+
'Мяу. Это было глубоко, согласись.',
55+
'Погладь меня и иди копить дальше.',
56+
'Я не толстый, я инвестировал в шерсть.',
57+
'Знаешь, почему я в плаще? Финансы — это супергеройство.',
58+
'Тшш. Считаю твои монеты. Их... достаточно. Пока.',
59+
],
60+
};
61+
62+
const PHRASE_POOL: CatPhrase[] = (Object.keys(CAT_PHRASES) as PhraseType[]).flatMap((type) =>
63+
CAT_PHRASES[type].map((text) => ({ type, text })),
64+
);
65+
66+
export function getRandomPhrase(): CatPhrase {
67+
return PHRASE_POOL[Math.floor(Math.random() * PHRASE_POOL.length)];
68+
}
69+
70+
// ─── Экономика монет ────────────────────────────────────────────────────────
71+
export const REWARDS = {
72+
dailyLogin: 20,
73+
starterPack: 50,
74+
soloPerCorrect: 12,
75+
dailyQuestion: 25,
76+
duelWin: 80,
77+
duelLose: 15,
78+
} as const;
79+
80+
export const DUEL_STAKE = 40;
81+
82+
// ─── Витрина наград (магазин) ───────────────────────────────────────────────
83+
export type ShopTab = 'cat' | 'app' | 'partners';
84+
export const SHOP_TABS: { key: ShopTab; label: string }[] = [
85+
{ key: 'cat', label: 'КопиКот' },
86+
{ key: 'app', label: 'Приложение' },
87+
{ key: 'partners', label: 'Партнёры' },
88+
];
89+
90+
export type AccessorySlot = 'head' | 'face' | 'neck' | 'paw' | 'feet';
91+
92+
export interface ShopItem {
93+
id: string;
94+
tab: ShopTab;
95+
name: string;
96+
price: number;
97+
emoji: string;
98+
desc: string;
99+
slot?: AccessorySlot; // только для аксессуаров кота
100+
partner?: boolean;
101+
}
102+
103+
export const SHOP_ITEMS: ShopItem[] = [
104+
// Для КопиКота (60–200) — аксессуары, можно примерить
105+
{ id: 'c_crown', tab: 'cat', name: 'Корона', price: 200, emoji: '👑', slot: 'head', desc: 'Монарх виртуальной казны' },
106+
{ id: 'c_hat', tab: 'cat', name: 'Цилиндр', price: 140, emoji: '🎩', slot: 'head', desc: 'Джентльмен от мира финансов' },
107+
{ id: 'c_glasses', tab: 'cat', name: 'Очки', price: 120, emoji: '🕶️', slot: 'face', desc: 'Будущее ослепительно' },
108+
{ id: 'c_bowtie', tab: 'cat', name: 'Бабочка', price: 80, emoji: '🎀', slot: 'neck', desc: 'Для важных переговоров' },
109+
{ id: 'c_skate', tab: 'cat', name: 'Скейтборд', price: 160, emoji: '🛹', slot: 'feet', desc: 'Катится к финсвободе' },
110+
{ id: 'c_wand', tab: 'cat', name: 'Жезл', price: 60, emoji: '🪄', slot: 'paw', desc: 'Немного финансовой магии' },
111+
// Для приложения (50–500) — визуальные апгрейды
112+
{ id: 'a_gold', tab: 'app', name: 'Золотая тема', price: 500, emoji: '🌟', desc: 'Премиальный золотой интерфейс' },
113+
{ id: 'a_neon', tab: 'app', name: 'Неоновая тема', price: 400, emoji: '💜', desc: 'Тёмный неон для ночных трат' },
114+
{ id: 'a_frame', tab: 'app', name: 'Рамка аватара', price: 250, emoji: '🖼️', desc: 'Анимированная рамка профиля' },
115+
{ id: 'a_icons', tab: 'app', name: 'Иконки категорий', price: 150, emoji: '🎨', desc: 'Кастомные иконки расходов' },
116+
{ id: 'a_emoji', tab: 'app', name: 'Пак стикеров', price: 50, emoji: '🐾', desc: 'Стикеры КопиКота в заметках' },
117+
// От партнёров (3000–10000)
118+
{ id: 'p_coffee', tab: 'partners', name: 'Кофе в подарок', price: 3000, emoji: '☕', desc: 'Промокод в кофейне', partner: true },
119+
{ id: 'p_shop', tab: 'partners', name: 'Скидка 15%', price: 4000, emoji: '🛍️', desc: 'Промокод в магазине', partner: true },
120+
{ id: 'p_music', tab: 'partners', name: 'Музыка · 1 мес.', price: 5000, emoji: '🎧', desc: 'Подписка на стриминг', partner: true },
121+
{ id: 'p_premium', tab: 'partners', name: 'ФинПилот Premium', price: 10000, emoji: '🚀', desc: 'Месяц Premium-версии', partner: true },
122+
];
123+
124+
export function findShopItem(id: string): ShopItem | undefined {
125+
return SHOP_ITEMS.find((i) => i.id === id);
126+
}
127+
128+
// ─── Вопросы Арены (дуэли и квизы) ──────────────────────────────────────────
129+
export type QuestionType = 'term' | 'case' | 'spend';
130+
export interface ArenaQuestion {
131+
id: string;
132+
type: QuestionType;
133+
question: string;
134+
options: string[];
135+
correctAnswer: number;
136+
explanation: string;
137+
personal?: boolean; // «угадай свои расходы» — личный вопрос
138+
}
139+
140+
export const ARENA_QUESTIONS: ArenaQuestion[] = [
141+
{
142+
id: 'a1', type: 'term',
143+
question: 'Что такое диверсификация простыми словами?',
144+
options: ['Хранить деньги в одном банке', 'Распределить вложения между разными активами', 'Тратить всё сразу', 'Брать кредит на инвестиции'],
145+
correctAnswer: 1,
146+
explanation: 'Диверсификация снижает риск: падение одного актива компенсируют другие.',
147+
},
148+
{
149+
id: 'a2', type: 'term',
150+
question: 'Что значит «ликвидность» актива?',
151+
options: ['Как быстро его можно продать без потери цены', 'Сколько он весит', 'Его доходность за год', 'Размер комиссии брокера'],
152+
correctAnswer: 0,
153+
explanation: 'Ликвидность — скорость, с которой актив превращается в деньги по справедливой цене.',
154+
},
155+
{
156+
id: 'a3', type: 'case',
157+
question: 'Кейс: есть свободные 30 000 ₽ и нет подушки безопасности. Куда логичнее сначала?',
158+
options: ['Купить акции одной компании', 'Сформировать резерв на накопительном счёте', 'Вложить в крипту', 'Потратить на технику'],
159+
correctAnswer: 1,
160+
explanation: 'Без подушки сначала создаём резерв — он защищает от долгов при форс-мажоре.',
161+
},
162+
{
163+
id: 'a4', type: 'case',
164+
question: 'Кейс: банк предлагает рассрочку 0% на 12 месяцев. В чём чаще подвох?',
165+
options: ['Подвоха не бывает', 'Цена завышена или есть скрытая страховка', 'Нельзя закрыть досрочно', 'Это всегда выгоднее вклада'],
166+
correctAnswer: 1,
167+
explanation: 'Рассрочку «0%» часто компенсируют наценкой или навязанной страховкой — читай условия.',
168+
},
169+
{
170+
id: 'a5', type: 'spend', personal: true,
171+
question: 'Угадай свои расходы: сколько в среднем уходит на такси за месяц?',
172+
options: ['до 1 000 ₽', '1 000–3 000 ₽', '3 000–6 000 ₽', 'больше 6 000 ₽'],
173+
correctAnswer: 1,
174+
explanation: 'Это персональный вопрос — он помогает заметить, куда реально утекают деньги.',
175+
},
176+
{
177+
id: 'a6', type: 'spend', personal: true,
178+
question: 'Угадай свои расходы: какая категория съедает больше всего за месяц?',
179+
options: ['Еда и кафе', 'Транспорт', 'Развлечения', 'Подписки'],
180+
correctAnswer: 0,
181+
explanation: 'У большинства людей еда и кафе — крупнейшая управляемая статья расходов.',
182+
},
183+
{
184+
id: 'a7', type: 'term',
185+
question: 'Что такое «сложный процент»?',
186+
options: ['Процент только на первоначальную сумму', 'Процент, начисляемый в том числе на ранее начисленные проценты', 'Комиссия за перевод', 'Налог на доход'],
187+
correctAnswer: 1,
188+
explanation: 'Сложный процент — это проценты на проценты, главный двигатель долгих накоплений.',
189+
},
190+
{
191+
id: 'a8', type: 'case',
192+
question: 'Кейс: пришла зарплата. Какой порядок здоровее?',
193+
options: ['Сначала все траты, остаток — отложить', 'Сначала отложить часть, потом тратить', 'Потратить всё сразу', 'Перевести всё в наличные'],
194+
correctAnswer: 1,
195+
explanation: 'Принцип «сначала заплати себе»: откладывай в начале месяца, а не по остатку.',
196+
},
197+
];
198+
199+
export function shuffle<T>(arr: T[]): T[] {
200+
const a = [...arr];
201+
for (let i = a.length - 1; i > 0; i--) {
202+
const j = Math.floor(Math.random() * (i + 1));
203+
[a[i], a[j]] = [a[j], a[i]];
204+
}
205+
return a;
206+
}
207+
208+
export function getDuelQuestions(count = 5): ArenaQuestion[] {
209+
return shuffle(ARENA_QUESTIONS).slice(0, Math.min(count, ARENA_QUESTIONS.length));
210+
}
211+
212+
// Вопрос дня — детерминирован по дате, чтобы у всех был «один и тот же».
213+
export function getDailyQuestion(): ArenaQuestion {
214+
const today = new Date();
215+
const seed = today.getFullYear() * 1000 + (today.getMonth() + 1) * 50 + today.getDate();
216+
return ARENA_QUESTIONS[seed % ARENA_QUESTIONS.length];
217+
}
218+
219+
// ─── Боты-соперники для дуэли ───────────────────────────────────────────────
220+
export interface Bot { name: string; emoji: string }
221+
export const BOT_OPPONENTS: Bot[] = [
222+
{ name: 'Костя Капитал', emoji: '🦊' },
223+
{ name: 'Инна Инвест', emoji: '🦉' },
224+
{ name: 'Дима Депозит', emoji: '🐻' },
225+
{ name: 'Лера Ликвид', emoji: '🐰' },
226+
];
227+
228+
export function getRandomBot(): Bot {
229+
return BOT_OPPONENTS[Math.floor(Math.random() * BOT_OPPONENTS.length)];
230+
}

‎src/pages/arena/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { ArenaPage } from './ui/ArenaPage';

0 commit comments

Comments
 (0)