Initial commit: Vue 3 Gold Land frontend
Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -0,0 +1,36 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Dependencies & build output
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# Editor
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
.eslintcache
|
||||
|
||||
# Vite
|
||||
*.timestamp-*-*.mjs
|
||||
.vite
|
||||
|
||||
# Env secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# Child Gold Land — Frontend
|
||||
|
||||
رابط کاربری Vue 3 + Vite + Pinia.
|
||||
|
||||
## راهاندازی
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## اسکریپتها
|
||||
|
||||
| دستور | توضیح |
|
||||
|--------|--------|
|
||||
| `npm run dev` | سرور توسعه |
|
||||
| `npm run build` | بیلد production |
|
||||
| `npm run preview` | پیشنمایش بیلد |
|
||||
| `npm run type-check` | بررسی TypeScript |
|
||||
@@ -0,0 +1,19 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
declare module '*.svg' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
|
||||
declare module '*.png' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fa" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#050b1d">
|
||||
<title>هوش مالی</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "child-gold-land",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/vazirmatn": "^5.2.5",
|
||||
"chart.js": "^4.4.9",
|
||||
"lucide-vue-next": "^0.511.0",
|
||||
"pinia": "^3.0.2",
|
||||
"vue": "^3.5.40",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/node": "^24.13.3",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"npm-run-all2": "^9.0.2",
|
||||
"typescript": "~6.0.0",
|
||||
"vite": "^8.1.5",
|
||||
"vite-plugin-vue-devtools": "^8.1.5",
|
||||
"vue-tsc": "^3.3.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.12.0"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,36 @@
|
||||
import sharp from 'sharp'
|
||||
import { writeFileSync } from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname, join } from 'path'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const input = join(__dirname, '../src/assets/profile/avatar.png')
|
||||
|
||||
const { data, info } = await sharp(input)
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true })
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i]
|
||||
const g = data[i + 1]
|
||||
const b = data[i + 2]
|
||||
const sum = r + g + b
|
||||
|
||||
const isBlack = sum < 55
|
||||
const isNavy = b > r + 8 && b > g + 2 && sum < 140
|
||||
const isDarkBlueBg = b > 15 && r < 35 && g < 45 && b < 90 && sum < 100
|
||||
|
||||
if (isBlack || isNavy || isDarkBlueBg) {
|
||||
data[i + 3] = 0
|
||||
}
|
||||
}
|
||||
|
||||
const output = await sharp(data, {
|
||||
raw: { width: info.width, height: info.height, channels: 4 },
|
||||
})
|
||||
.png()
|
||||
.toBuffer()
|
||||
|
||||
writeFileSync(input, output)
|
||||
console.log('Done:', info.width, 'x', info.height)
|
||||
@@ -0,0 +1,113 @@
|
||||
import sharp from "sharp";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const target = join(__dirname, "..", "src", "assets", "icons", "castle-gold-land.png");
|
||||
|
||||
/** Near-black threshold: R+G+B <= this counts as background candidate */
|
||||
const NEAR_BLACK = 40;
|
||||
/** Soften: fade alpha within this distance (px) of flood-filled region */
|
||||
const SOFT_RADIUS = 2;
|
||||
|
||||
const { data, info } = await sharp(target)
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
|
||||
const { width: w, height: h } = info;
|
||||
const n = w * h;
|
||||
const out = Buffer.from(data);
|
||||
const bg = new Uint8Array(n); // 1 = background to remove
|
||||
|
||||
const isNearBlack = (i) => {
|
||||
const o = i * 4;
|
||||
return out[o] + out[o + 1] + out[o + 2] <= NEAR_BLACK;
|
||||
};
|
||||
|
||||
const queue = [];
|
||||
const push = (x, y) => {
|
||||
const i = y * w + x;
|
||||
if (bg[i]) return;
|
||||
if (!isNearBlack(i)) return;
|
||||
bg[i] = 1;
|
||||
queue.push(i);
|
||||
};
|
||||
|
||||
// Seed BFS from all border pixels that are near-black
|
||||
for (let x = 0; x < w; x++) {
|
||||
push(x, 0);
|
||||
push(x, h - 1);
|
||||
}
|
||||
for (let y = 0; y < h; y++) {
|
||||
push(0, y);
|
||||
push(w - 1, y);
|
||||
}
|
||||
|
||||
// Flood-fill only connected near-black from edges (internal dark windows stay)
|
||||
const dirs = [
|
||||
[1, 0],
|
||||
[-1, 0],
|
||||
[0, 1],
|
||||
[0, -1],
|
||||
];
|
||||
let qh = 0;
|
||||
while (qh < queue.length) {
|
||||
const i = queue[qh++];
|
||||
const x = i % w;
|
||||
const y = (i / w) | 0;
|
||||
for (const [dx, dy] of dirs) {
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
|
||||
push(nx, ny);
|
||||
}
|
||||
}
|
||||
|
||||
// Soften edges: for non-bg pixels near bg, reduce alpha by proximity
|
||||
const soft = new Float32Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (bg[i]) {
|
||||
soft[i] = 0;
|
||||
continue;
|
||||
}
|
||||
const x = i % w;
|
||||
const y = (i / w) | 0;
|
||||
let minD = SOFT_RADIUS + 1;
|
||||
for (let dy = -SOFT_RADIUS; dy <= SOFT_RADIUS; dy++) {
|
||||
for (let dx = -SOFT_RADIUS; dx <= SOFT_RADIUS; dx++) {
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
|
||||
const j = ny * w + nx;
|
||||
if (!bg[j]) continue;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d < minD) minD = d;
|
||||
}
|
||||
}
|
||||
if (minD <= SOFT_RADIUS) {
|
||||
soft[i] = minD / (SOFT_RADIUS + 0.001);
|
||||
} else {
|
||||
soft[i] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
let removed = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = i * 4 + 3;
|
||||
if (bg[i]) {
|
||||
out[a] = 0;
|
||||
removed++;
|
||||
} else if (soft[i] < 1) {
|
||||
out[a] = Math.round(out[a] * soft[i]);
|
||||
}
|
||||
}
|
||||
|
||||
await sharp(out, {
|
||||
raw: { width: w, height: h, channels: 4 },
|
||||
})
|
||||
.png()
|
||||
.toFile(target);
|
||||
|
||||
console.log(`Wrote ${target}`);
|
||||
console.log(`Removed ${removed} background pixels (edge flood-fill); soft radius=${SOFT_RADIUS}`);
|
||||
@@ -0,0 +1,131 @@
|
||||
import sharp from "sharp";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { dirname, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const target = join(__dirname, "..", "src", "assets", "illustrations", "treasure-chest.png");
|
||||
const tempOut = join(tmpdir(), "treasure-chest-out.png");
|
||||
|
||||
/** Near-black threshold: R+G+B <= this counts as background candidate */
|
||||
const NEAR_BLACK = 40;
|
||||
/** Soften: fade alpha within this distance (px) of flood-filled region */
|
||||
const SOFT_RADIUS = 2;
|
||||
|
||||
const { data, info } = await sharp(target)
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
|
||||
const { width: w, height: h } = info;
|
||||
const n = w * h;
|
||||
const out = Buffer.from(data);
|
||||
const bg = new Uint8Array(n); // 1 = background to remove
|
||||
|
||||
const isNearBlack = (i) => {
|
||||
const o = i * 4;
|
||||
return out[o] + out[o + 1] + out[o + 2] <= NEAR_BLACK;
|
||||
};
|
||||
|
||||
const queue = [];
|
||||
const push = (x, y) => {
|
||||
const i = y * w + x;
|
||||
if (bg[i]) return;
|
||||
if (!isNearBlack(i)) return;
|
||||
bg[i] = 1;
|
||||
queue.push(i);
|
||||
};
|
||||
|
||||
// Seed BFS from all border pixels that are near-black
|
||||
for (let x = 0; x < w; x++) {
|
||||
push(x, 0);
|
||||
push(x, h - 1);
|
||||
}
|
||||
for (let y = 0; y < h; y++) {
|
||||
push(0, y);
|
||||
push(w - 1, y);
|
||||
}
|
||||
|
||||
// Flood-fill only connected near-black from edges (internal dark chest parts stay)
|
||||
const dirs = [
|
||||
[1, 0],
|
||||
[-1, 0],
|
||||
[0, 1],
|
||||
[0, -1],
|
||||
];
|
||||
let qh = 0;
|
||||
while (qh < queue.length) {
|
||||
const i = queue[qh++];
|
||||
const x = i % w;
|
||||
const y = (i / w) | 0;
|
||||
for (const [dx, dy] of dirs) {
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
|
||||
push(nx, ny);
|
||||
}
|
||||
}
|
||||
|
||||
// Soften edges: for non-bg pixels near bg, reduce alpha by proximity
|
||||
const soft = new Float32Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (bg[i]) {
|
||||
soft[i] = 0;
|
||||
continue;
|
||||
}
|
||||
const x = i % w;
|
||||
const y = (i / w) | 0;
|
||||
let minD = SOFT_RADIUS + 1;
|
||||
for (let dy = -SOFT_RADIUS; dy <= SOFT_RADIUS; dy++) {
|
||||
for (let dx = -SOFT_RADIUS; dx <= SOFT_RADIUS; dx++) {
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
|
||||
const j = ny * w + nx;
|
||||
if (!bg[j]) continue;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d < minD) minD = d;
|
||||
}
|
||||
}
|
||||
if (minD <= SOFT_RADIUS) {
|
||||
soft[i] = minD / (SOFT_RADIUS + 0.001);
|
||||
} else {
|
||||
soft[i] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
let removed = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = i * 4 + 3;
|
||||
if (bg[i]) {
|
||||
out[a] = 0;
|
||||
removed++;
|
||||
} else if (soft[i] < 1) {
|
||||
out[a] = Math.round(out[a] * soft[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Write via ASCII temp path — Node/libvips can fail writing Unicode apostrophe paths on Windows
|
||||
await sharp(out, {
|
||||
raw: { width: w, height: h, channels: 4 },
|
||||
})
|
||||
.png()
|
||||
.toFile(tempOut);
|
||||
|
||||
const ps = spawnSync(
|
||||
"powershell.exe",
|
||||
[
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
`Copy-Item -LiteralPath '${tempOut.replace(/'/g, "''")}' -Destination '${target.replace(/'/g, "''")}' -Force`,
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
if (ps.status !== 0) {
|
||||
console.error(ps.stderr || ps.stdout);
|
||||
process.exit(ps.status ?? 1);
|
||||
}
|
||||
|
||||
console.log(`Wrote ${target}`);
|
||||
console.log(`Removed ${removed} background pixels (edge flood-fill); soft radius=${SOFT_RADIUS}`);
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import ToastContainer from '@/components/common/ToastContainer.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<Transition name="page" mode="out-in">
|
||||
<component :is="Component" :key="route.path" />
|
||||
</Transition>
|
||||
</RouterView>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
let bootstrapped = false
|
||||
|
||||
export function isAppBootstrapped() {
|
||||
return bootstrapped
|
||||
}
|
||||
|
||||
export function markAppBootstrapped() {
|
||||
bootstrapped = true
|
||||
}
|
||||
|
||||
export function resetAppBootstrap() {
|
||||
bootstrapped = false
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
const TOKEN_KEY = 'gold-land-auth-token'
|
||||
|
||||
let authToken: string | null = null
|
||||
|
||||
export function getAuthToken(): string | null {
|
||||
if (authToken) return authToken
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function setAuthToken(token: string | null) {
|
||||
authToken = token
|
||||
try {
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token)
|
||||
else localStorage.removeItem(TOKEN_KEY)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
token: string
|
||||
user: {
|
||||
id: string
|
||||
name: string
|
||||
username: string
|
||||
}
|
||||
me: import('./client').MeResponse
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
user: {
|
||||
id: string
|
||||
name: string
|
||||
level: number
|
||||
xp: number
|
||||
maxXp: number
|
||||
coins: number
|
||||
missionProgress: number
|
||||
missionTarget: number
|
||||
}
|
||||
wallet: {
|
||||
cash: number
|
||||
goldGrams: number
|
||||
}
|
||||
progress: {
|
||||
completedChallenges: string[]
|
||||
completedLessons: string[]
|
||||
completedGames: string[]
|
||||
claimedRewards: string[]
|
||||
ownedItems: string[]
|
||||
mapLevels: Array<{
|
||||
id: number
|
||||
title: string
|
||||
stars: number
|
||||
maxStars: number
|
||||
status: 'completed' | 'current' | 'unlocked' | 'locked'
|
||||
lessonRoute?: string
|
||||
x: number
|
||||
y: number
|
||||
}>
|
||||
totalStars: number
|
||||
maxStars: number
|
||||
}
|
||||
achievements: Array<{
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
unlocked: boolean
|
||||
}>
|
||||
transactions: Array<{
|
||||
id: string
|
||||
type: 'income' | 'expense'
|
||||
title: string
|
||||
amount: number
|
||||
unit: 'تومان' | 'سکه' | 'گرم'
|
||||
date: string
|
||||
}>
|
||||
market: {
|
||||
selectedRange: '1d' | '7d' | '30d' | '90d' | '1y'
|
||||
}
|
||||
}
|
||||
|
||||
export interface MarketGoldResponse {
|
||||
currentPrice: number
|
||||
dailyChangePercent: number
|
||||
range: string
|
||||
history: number[]
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface DailyChallengeResponse {
|
||||
id: string
|
||||
question: string
|
||||
coinReward: number
|
||||
xpReward: number
|
||||
completed: boolean
|
||||
options: Array<{ id: string; label: string; icon: string; color: string }>
|
||||
}
|
||||
|
||||
export interface DailyChallengeCompleteResponse extends MeResponse {
|
||||
feedback: string
|
||||
alreadyCompleted: boolean
|
||||
}
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? '/api'
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(init?.headers as Record<string, string> | undefined),
|
||||
}
|
||||
|
||||
const token = getAuthToken()
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
} catch {
|
||||
throw new Error('اتصال به سرور برقرار نشد. لطفاً backend را اجرا کنید.')
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({ error: 'خطای سرور' }))
|
||||
throw new Error(body.error ?? 'خطای سرور')
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
export const api = {
|
||||
login: (username: string, password: string) =>
|
||||
request<AuthResponse>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
}),
|
||||
register: (username: string, password: string, name?: string) =>
|
||||
request<AuthResponse>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password, name }),
|
||||
}),
|
||||
getMe: () => request<MeResponse>('/me'),
|
||||
updateProfile: (name: string) =>
|
||||
request<MeResponse>('/me/profile', { method: 'PATCH', body: JSON.stringify({ name }) }),
|
||||
updatePassword: (currentPassword: string, newPassword: string) =>
|
||||
request<{ ok: true }>('/me/password', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
}),
|
||||
buyGold: (grams: number) =>
|
||||
request<MeResponse>('/wallet/gold/buy', { method: 'POST', body: JSON.stringify({ grams }) }),
|
||||
sellGold: (grams: number) =>
|
||||
request<MeResponse>('/wallet/gold/sell', { method: 'POST', body: JSON.stringify({ grams }) }),
|
||||
getMarketGold: (range: string) => request<MarketGoldResponse>(`/market/gold?range=${range}`),
|
||||
setMarketRange: (range: MeResponse['market']['selectedRange']) =>
|
||||
request<MeResponse>('/market/preferences', { method: 'PATCH', body: JSON.stringify({ range }) }),
|
||||
getDailyChallenge: () => request<DailyChallengeResponse>('/challenges/daily'),
|
||||
completeDailyChallenge: (optionId: string) =>
|
||||
request<DailyChallengeCompleteResponse>('/challenges/daily/complete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ optionId }),
|
||||
}),
|
||||
completeGame: (gameId: string) =>
|
||||
request<MeResponse>(`/games/${gameId}/complete`, { method: 'POST', body: '{}' }),
|
||||
purchaseProduct: (productId: string) =>
|
||||
request<MeResponse>('/store/purchases', { method: 'POST', body: JSON.stringify({ productId }) }),
|
||||
completeLesson: (lessonId: string) =>
|
||||
request<MeResponse>(`/lessons/${lessonId}/complete`, { method: 'POST', body: '{}' }),
|
||||
completeMapLevel: (levelId: number, stars: number) =>
|
||||
request<MeResponse>(`/map/levels/${levelId}/complete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ stars }),
|
||||
}),
|
||||
getGoldLandProgress: () => request<import('@/modules/gold-land/types/goldLand.types').GoldLandProgressResponse>('/gold-land/progress'),
|
||||
getGoldLandLevel: (levelId: number) =>
|
||||
request<import('@/modules/gold-land/types/goldLand.types').LevelDetailResponse>(
|
||||
`/gold-land/levels/${levelId}`,
|
||||
),
|
||||
startGoldLandLevel: (levelId: number) =>
|
||||
request<{ levelId: number; status: string }>(`/gold-land/levels/${levelId}/start`, {
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
}),
|
||||
completeGoldLandLevel: (
|
||||
levelId: number,
|
||||
answers: import('@/modules/gold-land/types/goldLand.types').LessonSubmissionAnswer[],
|
||||
durationMs?: number,
|
||||
) =>
|
||||
request<import('@/modules/gold-land/types/goldLand.types').CompleteLevelResponse>(
|
||||
`/gold-land/levels/${levelId}/complete`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ answers, durationMs }),
|
||||
},
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { api, type MeResponse } from '@/api/client'
|
||||
import { useGamesStore } from '@/stores/games'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useWalletStore } from '@/stores/wallet'
|
||||
|
||||
export function applyMeResponse(data: MeResponse) {
|
||||
const userStore = useUserStore()
|
||||
const walletStore = useWalletStore()
|
||||
const gamesStore = useGamesStore()
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
userStore.$patch({
|
||||
name: data.user.name,
|
||||
level: data.user.level,
|
||||
xp: data.user.xp,
|
||||
maxXp: data.user.maxXp,
|
||||
coins: data.user.coins,
|
||||
missionProgress: data.user.missionProgress,
|
||||
missionTarget: data.user.missionTarget,
|
||||
completedChallenges: [...data.progress.completedChallenges],
|
||||
completedLessons: [...data.progress.completedLessons],
|
||||
achievements: data.achievements.map((a) => ({ ...a })),
|
||||
})
|
||||
|
||||
walletStore.$patch({
|
||||
cash: data.wallet.cash,
|
||||
goldGrams: data.wallet.goldGrams,
|
||||
transactions: data.transactions.map((t) => ({ ...t })),
|
||||
})
|
||||
|
||||
gamesStore.$patch({
|
||||
completedGames: [...data.progress.completedGames],
|
||||
claimedRewards: [...data.progress.claimedRewards],
|
||||
ownedItems: [...data.progress.ownedItems],
|
||||
mapLevels: data.progress.mapLevels.map((l) => ({ ...l })),
|
||||
totalStars: data.progress.totalStars,
|
||||
maxStars: data.progress.maxStars,
|
||||
})
|
||||
|
||||
marketStore.$patch({
|
||||
selectedRange: data.market.selectedRange,
|
||||
})
|
||||
}
|
||||
|
||||
export async function bootstrapFromServer() {
|
||||
const data = await api.getMe()
|
||||
applyMeResponse(data)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function syncMarketQuote(range: string) {
|
||||
const marketStore = useMarketStore()
|
||||
const quote = await api.getMarketGold(range)
|
||||
marketStore.$patch({
|
||||
currentPrice: quote.currentPrice,
|
||||
dailyChange: quote.dailyChangePercent,
|
||||
priceHistoryCache: {
|
||||
...marketStore.priceHistoryCache,
|
||||
[quote.range]: quote.history,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
After Width: | Height: | Size: 880 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect x="6" y="10" width="20" height="18" rx="2" fill="#7637c9"/>
|
||||
<rect x="4" y="6" width="8" height="10" rx="1" fill="#9c50e9"/>
|
||||
<rect x="20" y="6" width="8" height="10" rx="1" fill="#9c50e9"/>
|
||||
<polygon points="4,6 8,2 12,6" fill="#f4ae21"/>
|
||||
<polygon points="20,6 24,2 28,6" fill="#f4ae21"/>
|
||||
<rect x="12" y="16" width="8" height="12" fill="#251b4d"/>
|
||||
<rect x="10" y="14" width="12" height="4" rx="1" fill="#ffd55e" opacity="0.6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 526 B |
@@ -0,0 +1,11 @@
|
||||
import menuTrophy from './menu-trophy.svg'
|
||||
import menuFriends from './menu-friends.svg'
|
||||
import menuSettings from './menu-settings.svg'
|
||||
import menuLogout from './menu-logout.svg'
|
||||
|
||||
export const profileMenuIcons = {
|
||||
achievements: menuTrophy,
|
||||
friends: menuFriends,
|
||||
settings: menuSettings,
|
||||
logout: menuLogout,
|
||||
} as const
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<circle cx="11" cy="12" r="5" fill="#1682cb"/>
|
||||
<circle cx="21" cy="12" r="5" fill="#9c50e9"/>
|
||||
<path d="M6 26c0-4 3.5-7 8-7h4c4.5 0 8 3 8 7" fill="#1682cb"/>
|
||||
<path d="M16 26c0-4 2.5-7 5-7s5 3 5 7" fill="#9c50e9" opacity="0.85"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 316 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect x="6" y="8" width="20" height="18" rx="3" fill="#f18c16"/>
|
||||
<rect x="9" y="11" width="14" height="12" rx="2" fill="#c46a0a"/>
|
||||
<circle cx="16" cy="17" r="2" fill="#ffd55e"/>
|
||||
<path d="M16 14v-4M16 24v-2" stroke="#ffd55e" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M22 8v-2a2 2 0 0 0-4 0v2" fill="#f4ae21"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 410 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<path d="M16 10.5a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11z" fill="#aeb9d2"/>
|
||||
<path d="M16 4.5l1.4 2.4 2.8-.6.8 2.7 2.6 1.2-1.2 2.6 1.2 2.6-2.6 1.2-.8 2.7-2.8-.6L16 21.5l-1.4-2.4-2.8.6-.8-2.7-2.6-1.2 1.2-2.6-1.2-2.6 2.6-1.2.8-2.7 2.8.6L16 4.5z" fill="#8a96ad"/>
|
||||
<circle cx="16" cy="16" r="2.5" fill="#6b7588"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 394 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<path d="M8 28h16v-3H8v3z" fill="#c88a0a"/>
|
||||
<path d="M10 11h12l-1.5 14H11.5L10 11z" fill="#f4ae21"/>
|
||||
<path d="M11 11V9a5 5 0 0 1 10 0v2" stroke="#ffd55e" stroke-width="2.5" fill="none"/>
|
||||
<rect x="9" y="10" width="14" height="3" rx="1.5" fill="#ffd55e"/>
|
||||
<ellipse cx="16" cy="8" rx="6" ry="2" fill="#ffd55e" opacity="0.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 413 B |
|
After Width: | Height: | Size: 967 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<path d="M8 24 L32 10 L56 24 V28 H8 V24Z" fill="#F4F7FB" stroke="#C8D2E0" stroke-width="1.2"/>
|
||||
<rect x="10" y="28" width="44" height="26" rx="2" fill="#EEF3F9" stroke="#C8D2E0" stroke-width="1.2"/>
|
||||
<rect x="8" y="52" width="48" height="5" rx="1.5" fill="#DCE5F0"/>
|
||||
<rect x="16" y="32" width="7" height="20" rx="1" fill="#FFFFFF" stroke="#B8C4D4"/>
|
||||
<rect x="28.5" y="32" width="7" height="20" rx="1" fill="#FFFFFF" stroke="#B8C4D4"/>
|
||||
<rect x="41" y="32" width="7" height="20" rx="1" fill="#FFFFFF" stroke="#B8C4D4"/>
|
||||
<circle cx="32" cy="20" r="3.2" fill="#F4AE21"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 659 B |
|
After Width: | Height: | Size: 731 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 56" fill="none">
|
||||
<rect x="4" y="8" width="52" height="32" rx="4" fill="#22b54b" stroke="#138d36" stroke-width="1.5"/>
|
||||
<rect x="8" y="12" width="44" height="6" rx="2" fill="rgba(255,255,255,0.2)"/>
|
||||
<rect x="8" y="22" width="30" height="4" rx="1" fill="rgba(0,0,0,0.1)"/>
|
||||
<rect x="40" y="16" width="36" height="28" rx="4" fill="#1a9a3f" stroke="#138d36" stroke-width="1.5" transform="rotate(8 58 30)"/>
|
||||
<rect x="44" y="20" width="28" height="5" rx="1" fill="rgba(255,255,255,0.15)" transform="rotate(8 58 30)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 583 B |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 497 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="c" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#ffd55e"/>
|
||||
<stop offset="100%" stop-color="#e8940a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="36" cy="36" r="30" fill="url(#c)" stroke="#c88a0a" stroke-width="2"/>
|
||||
<circle cx="36" cy="36" r="22" fill="none" stroke="rgba(255,255,255,0.25)" stroke-width="2"/>
|
||||
<text x="36" y="42" text-anchor="middle" fill="#5a3800" font-size="22" font-weight="bold" font-family="sans-serif">$</text>
|
||||
<ellipse cx="36" cy="62" rx="24" ry="5" fill="rgba(244,174,33,0.25)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 652 B |
|
After Width: | Height: | Size: 497 KiB |
|
After Width: | Height: | Size: 323 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" fill="none">
|
||||
<rect width="72" height="72" rx="14" fill="#19284b"/>
|
||||
<polyline points="12,50 24,38 36,42 48,28 60,22" stroke="#22b54b" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<circle cx="60" cy="22" r="4" fill="#f4ae21"/>
|
||||
<rect x="14" y="14" width="20" height="12" rx="3" fill="#7637c9" opacity="0.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 390 B |
|
After Width: | Height: | Size: 988 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" fill="none">
|
||||
<rect width="72" height="72" rx="14" fill="#19284b"/>
|
||||
<ellipse cx="36" cy="40" rx="18" ry="16" fill="#22b54b"/>
|
||||
<circle cx="28" cy="34" r="4" fill="#fff"/>
|
||||
<circle cx="44" cy="34" r="4" fill="#fff"/>
|
||||
<circle cx="28" cy="34" r="2" fill="#050b1d"/>
|
||||
<circle cx="44" cy="34" r="2" fill="#050b1d"/>
|
||||
<path d="M28 44 Q36 52 44 44" stroke="#138d36" stroke-width="2" fill="none"/>
|
||||
<rect x="30" y="52" width="4" height="8" rx="1" fill="#138d36"/>
|
||||
<rect x="38" y="52" width="4" height="8" rx="1" fill="#138d36"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 600 B |
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" fill="none">
|
||||
<rect width="72" height="72" rx="14" fill="#19284b"/>
|
||||
<circle cx="36" cy="30" r="14" fill="#f5c89a"/>
|
||||
<rect x="24" y="44" width="24" height="20" rx="4" fill="#1682cb"/>
|
||||
<rect x="28" y="48" width="16" height="4" rx="1" fill="rgba(255,255,255,0.2)"/>
|
||||
<circle cx="30" cy="28" r="2" fill="#050b1d"/>
|
||||
<circle cx="42" cy="28" r="2" fill="#050b1d"/>
|
||||
<rect x="30" y="16" width="12" height="6" rx="2" fill="#7637c9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 503 B |
|
After Width: | Height: | Size: 951 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" fill="none">
|
||||
<rect width="72" height="72" rx="14" fill="#19284b"/>
|
||||
<ellipse cx="36" cy="44" rx="20" ry="16" fill="#f4ae21"/>
|
||||
<circle cx="28" cy="36" r="3" fill="#050b1d"/>
|
||||
<circle cx="44" cy="36" r="3" fill="#050b1d"/>
|
||||
<rect x="30" y="18" width="12" height="6" rx="3" fill="#e8940a"/>
|
||||
<rect x="32" y="12" width="8" height="8" rx="2" fill="#ffd55e"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 429 B |
|
After Width: | Height: | Size: 1.0 MiB |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" fill="none">
|
||||
<rect width="72" height="72" rx="14" fill="#19284b"/>
|
||||
<circle cx="36" cy="36" r="22" fill="none" stroke="#7637c9" stroke-width="6"/>
|
||||
<path d="M36 14 L36 36 L50 44" fill="#f4ae21"/>
|
||||
<path d="M36 36 L22 28" fill="#22b54b"/>
|
||||
<path d="M36 36 L48 24" fill="#1682cb"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 353 B |
|
After Width: | Height: | Size: 604 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" fill="none">
|
||||
<rect width="72" height="72" rx="14" fill="#19284b"/>
|
||||
<circle cx="30" cy="32" r="12" fill="#f5c89a"/>
|
||||
<rect x="20" y="44" width="20" height="16" rx="3" fill="#7637c9"/>
|
||||
<rect x="38" y="38" width="24" height="18" rx="3" fill="#1682cb"/>
|
||||
<circle cx="46" cy="52" r="4" fill="#050b1d"/>
|
||||
<circle cx="54" cy="52" r="4" fill="#050b1d"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 422 B |
|
After Width: | Height: | Size: 270 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffd55e"/>
|
||||
<stop offset="100%" stop-color="#f4ae21"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="8" y="20" width="48" height="28" rx="4" fill="url(#g)" stroke="#c88a0a" stroke-width="1.5"/>
|
||||
<rect x="12" y="24" width="40" height="6" rx="2" fill="rgba(255,255,255,0.25)"/>
|
||||
<rect x="12" y="34" width="40" height="6" rx="2" fill="rgba(0,0,0,0.08)"/>
|
||||
<ellipse cx="32" cy="52" rx="22" ry="4" fill="rgba(244,174,33,0.2)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 609 B |
|
After Width: | Height: | Size: 766 KiB |
@@ -0,0 +1,41 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 200" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#1a2850"/>
|
||||
<stop offset="100%" stop-color="#071126"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="mountain" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#27204b"/>
|
||||
<stop offset="100%" stop-color="#111d3c"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="360" height="200" fill="url(#sky)"/>
|
||||
<!-- clouds -->
|
||||
<ellipse cx="60" cy="40" rx="30" ry="12" fill="rgba(255,255,255,0.08)"/>
|
||||
<ellipse cx="280" cy="55" rx="40" ry="14" fill="rgba(255,255,255,0.06)"/>
|
||||
<!-- mountains -->
|
||||
<polygon points="0,140 80,60 160,140" fill="url(#mountain)" opacity="0.8"/>
|
||||
<polygon points="100,140 200,40 300,140" fill="#1a2850" opacity="0.9"/>
|
||||
<polygon points="200,140 290,70 360,140" fill="url(#mountain)" opacity="0.7"/>
|
||||
<!-- castle -->
|
||||
<rect x="155" y="55" width="50" height="45" fill="#7438c9" rx="2"/>
|
||||
<rect x="148" y="45" width="12" height="20" fill="#9c55e8"/>
|
||||
<rect x="200" y="45" width="12" height="20" fill="#9c55e8"/>
|
||||
<polygon points="148,45 154,35 160,45" fill="#f5b62d"/>
|
||||
<polygon points="200,45 206,35 212,45" fill="#f5b62d"/>
|
||||
<rect x="172" y="70" width="16" height="30" fill="#27204b"/>
|
||||
<!-- ground -->
|
||||
<ellipse cx="180" cy="175" rx="120" ry="20" fill="rgba(32,185,75,0.15)"/>
|
||||
<!-- coins -->
|
||||
<circle cx="90" cy="150" r="8" fill="#f5b62d" opacity="0.7"/>
|
||||
<circle cx="270" cy="145" r="6" fill="#ffd86a" opacity="0.6"/>
|
||||
<circle cx="310" cy="155" r="10" fill="#f5b62d" opacity="0.5"/>
|
||||
<!-- explorer character -->
|
||||
<circle cx="120" cy="130" r="18" fill="#f5c89a"/>
|
||||
<rect x="108" y="148" width="24" height="30" rx="4" fill="#7438c9"/>
|
||||
<rect x="100" y="155" width="10" height="22" rx="3" fill="#f5c89a"/>
|
||||
<rect x="130" y="155" width="10" height="22" rx="3" fill="#f5c89a"/>
|
||||
<ellipse cx="120" cy="118" rx="20" ry="8" fill="#5a3825"/>
|
||||
<!-- gold nugget -->
|
||||
<polygon points="145,140 155,125 165,140 160,150 150,150" fill="#f5b62d" stroke="#ffd86a" stroke-width="1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 172 KiB |
@@ -0,0 +1,85 @@
|
||||
import heroExplorer from './hero-explorer.svg'
|
||||
import treasureChest from './treasure-chest.png'
|
||||
import professor from './professor.png'
|
||||
import bankBuilding from './bank-building.png'
|
||||
import phoneIcon from './phone-icon.png'
|
||||
import shoppingCart from './shopping-cart.svg'
|
||||
import mapScene from './map-scene.png'
|
||||
import mapNodeOrb from './map-node-orb.png'
|
||||
import goldBar from './gold-bar.svg'
|
||||
import goldBarsStack from './gold-bars-stack.png'
|
||||
import coinLarge from './coin-large.png'
|
||||
import coinsStack from './coins-stack.png'
|
||||
import cashBundle from './cash-bundle.png'
|
||||
import homeHeroBg from './home-hero-bg.png'
|
||||
import gamepadIcon from './gamepad-icon.png'
|
||||
import targetIcon from './target-icon.png'
|
||||
import cartIcon from './cart-icon.png'
|
||||
import friendsIcon from './friends-icon.png'
|
||||
import challengeBanner from './challenge-banner.png'
|
||||
import gameGoldChart from './game-gold-chart.png'
|
||||
import gameInflation from './game-inflation.png'
|
||||
import gamePortfolio from './game-portfolio.png'
|
||||
import gameShopping from './game-shopping.png'
|
||||
import gamePiggy from './game-piggy.png'
|
||||
import gameInvestor from './game-investor.png'
|
||||
import productCastle from './product-castle.png'
|
||||
import productCrown from './product-crown.png'
|
||||
import productSuit from './product-suit.png'
|
||||
import productPet from './product-pet.png'
|
||||
import productSea from './product-sea.png'
|
||||
import schoolBackpack from './school-backpack.svg'
|
||||
|
||||
export const illustrations = {
|
||||
heroExplorer,
|
||||
treasureChest,
|
||||
professor,
|
||||
bankBuilding,
|
||||
phoneIcon,
|
||||
shoppingCart,
|
||||
mapScene,
|
||||
mapNodeOrb,
|
||||
goldBar,
|
||||
goldBarsStack,
|
||||
coinLarge,
|
||||
coinsStack,
|
||||
cashBundle,
|
||||
homeHeroBg,
|
||||
gamepadIcon,
|
||||
targetIcon,
|
||||
cartIcon,
|
||||
friendsIcon,
|
||||
challengeBanner,
|
||||
gameGoldChart,
|
||||
gameInflation,
|
||||
gamePortfolio,
|
||||
gameShopping,
|
||||
gamePiggy,
|
||||
gameInvestor,
|
||||
productCastle,
|
||||
productCrown,
|
||||
productSuit,
|
||||
productPet,
|
||||
productSea,
|
||||
schoolBackpack,
|
||||
} as const
|
||||
|
||||
export type IllustrationKey = keyof typeof illustrations
|
||||
|
||||
export const gameIllustrations: Record<string, string> = {
|
||||
'gold-guess': gameGoldChart,
|
||||
inflation: gameInflation,
|
||||
portfolio: gamePortfolio,
|
||||
'smart-shop': gameShopping,
|
||||
savings: gamePiggy,
|
||||
investor: gameInvestor,
|
||||
}
|
||||
|
||||
export const productIllustrations: Record<string, string> = {
|
||||
castle: productCastle,
|
||||
suit: productSuit,
|
||||
pet: productPet,
|
||||
'sea-bg': productSea,
|
||||
crown: productCrown,
|
||||
'boost-xp': coinLarge,
|
||||
}
|
||||
|
After Width: | Height: | Size: 532 KiB |
|
After Width: | Height: | Size: 178 KiB |
@@ -0,0 +1,56 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 680" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#1a3a7a"/>
|
||||
<stop offset="40%" stop-color="#0d2248"/>
|
||||
<stop offset="100%" stop-color="#050b1d"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="grass" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#1a6b35"/>
|
||||
<stop offset="100%" stop-color="#0a3d1c"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="pathG" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#c8940a"/>
|
||||
<stop offset="100%" stop-color="#ffd55e"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Sky -->
|
||||
<rect width="360" height="680" fill="url(#sky)"/>
|
||||
<!-- Clouds -->
|
||||
<ellipse cx="80" cy="60" rx="50" ry="16" fill="rgba(255,255,255,0.07)"/>
|
||||
<ellipse cx="280" cy="90" rx="60" ry="18" fill="rgba(255,255,255,0.05)"/>
|
||||
<ellipse cx="180" cy="45" rx="40" ry="12" fill="rgba(255,255,255,0.06)"/>
|
||||
<!-- Far mountains -->
|
||||
<polygon points="0,200 100,80 200,200" fill="#152040" opacity="0.7"/>
|
||||
<polygon points="120,200 260,60 360,200" fill="#1a2850" opacity="0.8"/>
|
||||
<!-- Castle -->
|
||||
<rect x="148" y="30" width="64" height="52" rx="3" fill="#7637c9"/>
|
||||
<rect x="138" y="18" width="16" height="24" rx="2" fill="#9c50e9"/>
|
||||
<rect x="206" y="18" width="16" height="24" rx="2" fill="#9c50e9"/>
|
||||
<polygon points="138,18 146,6 154,18" fill="#f4ae21"/>
|
||||
<polygon points="206,18 214,6 222,18" fill="#f4ae21"/>
|
||||
<rect x="168" y="50" width="24" height="32" fill="#251b4d"/>
|
||||
<rect x="140" y="38" width="80" height="6" rx="2" fill="#ffd55e" opacity="0.4"/>
|
||||
<!-- Hills -->
|
||||
<ellipse cx="90" cy="580" rx="100" ry="50" fill="url(#grass)" opacity="0.6"/>
|
||||
<ellipse cx="270" cy="480" rx="110" ry="55" fill="url(#grass)" opacity="0.55"/>
|
||||
<ellipse cx="120" cy="380" rx="90" ry="45" fill="url(#grass)" opacity="0.5"/>
|
||||
<!-- River -->
|
||||
<path d="M180,660 Q120,560 210,480 Q290,400 170,320 Q90,240 180,160 Q220,120 180,90" stroke="#1682cb" stroke-width="16" fill="none" opacity="0.35" stroke-linecap="round"/>
|
||||
<!-- Winding golden path -->
|
||||
<path d="M180,650 Q90,590 130,520 Q170,450 80,400 Q40,350 120,290 Q180,230 130,170 Q100,120 180,85" stroke="url(#pathG)" stroke-width="16" fill="none" stroke-linecap="round" opacity="0.9"/>
|
||||
<path d="M180,650 Q90,590 130,520 Q170,450 80,400 Q40,350 120,290 Q180,230 130,170 Q100,120 180,85" stroke="rgba(255,255,255,0.15)" stroke-width="3" fill="none" stroke-dasharray="8 12" stroke-linecap="round"/>
|
||||
<!-- Trees -->
|
||||
<polygon points="40,560 52,520 64,560" fill="#22b54b"/>
|
||||
<rect x="50" y="560" width="4" height="14" fill="#5a3825"/>
|
||||
<polygon points="310,420 324,375 338,420" fill="#22b54b"/>
|
||||
<rect x="322" y="420" width="4" height="14" fill="#5a3825"/>
|
||||
<polygon points="60,340 72,300 84,340" fill="#1a8a3a"/>
|
||||
<rect x="70" y="340" width="4" height="12" fill="#5a3825"/>
|
||||
<!-- Rocks -->
|
||||
<ellipse cx="250" cy="600" rx="16" ry="10" fill="#2a3550" opacity="0.7"/>
|
||||
<ellipse cx="100" cy="460" rx="12" ry="8" fill="#2a3550" opacity="0.6"/>
|
||||
<!-- Character at start -->
|
||||
<circle cx="175" cy="635" r="12" fill="#f5c89a"/>
|
||||
<rect x="167" y="647" width="16" height="18" rx="3" fill="#7637c9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<rect x="18" y="6" width="28" height="52" rx="6" fill="#2A2F3A" stroke="#1A1E28" stroke-width="1.5"/>
|
||||
<rect x="21" y="12" width="22" height="38" rx="2" fill="url(#screen)"/>
|
||||
<circle cx="32" cy="54" r="2.2" fill="#6B7280"/>
|
||||
<rect x="28" y="8.5" width="8" height="2" rx="1" fill="#4B5563"/>
|
||||
<defs>
|
||||
<linearGradient id="screen" x1="21" y1="12" x2="43" y2="50" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#5B8CFF"/>
|
||||
<stop offset="0.45" stop-color="#A78BFA"/>
|
||||
<stop offset="1" stop-color="#F472B6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 644 B |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 100" fill="none">
|
||||
<rect x="30" y="40" width="60" height="50" rx="4" fill="#7637c9"/>
|
||||
<rect x="22" y="28" width="14" height="22" rx="2" fill="#9c50e9"/>
|
||||
<rect x="84" y="28" width="14" height="22" rx="2" fill="#9c50e9"/>
|
||||
<polygon points="22,28 29,16 36,28" fill="#f4ae21"/>
|
||||
<polygon points="84,28 91,16 98,28" fill="#f4ae21"/>
|
||||
<rect x="48" y="58" width="24" height="32" fill="#251b4d"/>
|
||||
<rect x="20" y="50" width="80" height="8" rx="2" fill="#ffd55e" opacity="0.5"/>
|
||||
<circle cx="60" cy="22" r="8" fill="#f4ae21"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 592 B |
|
After Width: | Height: | Size: 724 KiB |
|
After Width: | Height: | Size: 853 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
|
||||
<ellipse cx="50" cy="65" rx="28" ry="22" fill="#c87832"/>
|
||||
<circle cx="50" cy="38" r="18" fill="#c87832"/>
|
||||
<circle cx="44" cy="34" r="3" fill="#050b1d"/>
|
||||
<circle cx="56" cy="34" r="3" fill="#050b1d"/>
|
||||
<ellipse cx="50" cy="40" rx="8" ry="5" fill="#050b1d"/>
|
||||
<path d="M30 55 Q20 45 28 35" stroke="#c87832" stroke-width="6" fill="none" stroke-linecap="round"/>
|
||||
<path d="M70 55 Q80 45 72 35" stroke="#c87832" stroke-width="6" fill="none" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 554 B |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
|
||||
<rect width="100" height="100" rx="12" fill="#0a2e4a"/>
|
||||
<ellipse cx="50" cy="75" rx="45" ry="12" fill="#1682cb" opacity="0.3"/>
|
||||
<path d="M0 60 Q25 45 50 55 Q75 65 100 50 L100 100 L0 100Z" fill="#1682cb" opacity="0.5"/>
|
||||
<circle cx="20" cy="30" r="6" fill="#fff" opacity="0.15"/>
|
||||
<circle cx="75" cy="22" r="8" fill="#fff" opacity="0.1"/>
|
||||
<path d="M30 40 Q50 20 70 35" stroke="#ffd55e" stroke-width="2" fill="none" opacity="0.4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 522 B |
|
After Width: | Height: | Size: 568 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
|
||||
<rect x="20" y="30" width="60" height="55" rx="6" fill="#7637c9"/>
|
||||
<circle cx="50" cy="28" r="16" fill="#f5c89a"/>
|
||||
<rect x="30" y="50" width="40" height="35" rx="4" fill="#1682cb"/>
|
||||
<rect x="38" y="58" width="24" height="6" rx="2" fill="rgba(255,255,255,0.2)"/>
|
||||
<rect x="35" y="20" width="30" height="8" rx="2" fill="#251b4d"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 420 B |
|
After Width: | Height: | Size: 114 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 120" fill="none">
|
||||
<circle cx="50" cy="35" r="22" fill="#f5c89a"/>
|
||||
<ellipse cx="50" cy="22" rx="24" ry="10" fill="#ccc"/>
|
||||
<circle cx="42" cy="33" r="3" fill="#333"/>
|
||||
<circle cx="58" cy="33" r="3" fill="#333"/>
|
||||
<path d="M42 42 Q50 50 58 42" stroke="#333" stroke-width="2" fill="none"/>
|
||||
<rect x="25" y="55" width="50" height="55" rx="8" fill="#1987d4"/>
|
||||
<rect x="30" y="60" width="40" height="8" rx="2" fill="#fff" opacity="0.3"/>
|
||||
<circle cx="50" cy="85" r="4" fill="#f5b62d"/>
|
||||
<!-- glasses -->
|
||||
<circle cx="42" cy="33" r="7" fill="none" stroke="#333" stroke-width="1.5"/>
|
||||
<circle cx="58" cy="33" r="7" fill="none" stroke="#333" stroke-width="1.5"/>
|
||||
<line x1="49" y1="33" x2="51" y2="33" stroke="#333" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 807 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" fill="none">
|
||||
<rect x="16" y="28" width="48" height="40" rx="8" fill="#7637c9"/>
|
||||
<rect x="22" y="18" width="12" height="16" rx="3" fill="#9c50e9"/>
|
||||
<rect x="46" y="18" width="12" height="16" rx="3" fill="#9c50e9"/>
|
||||
<rect x="28" y="36" width="24" height="8" rx="2" fill="rgba(255,255,255,0.2)"/>
|
||||
<rect x="24" y="48" width="32" height="6" rx="2" fill="rgba(255,255,255,0.15)"/>
|
||||
<circle cx="40" cy="14" r="6" fill="#f4ae21"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 501 B |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 160" fill="none">
|
||||
<circle cx="80" cy="50" r="20" fill="#f5c89a"/>
|
||||
<rect x="65" y="70" width="30" height="40" rx="4" fill="#20b94b"/>
|
||||
<!-- cart -->
|
||||
<rect x="100" y="85" width="60" height="35" rx="4" fill="#7438c9"/>
|
||||
<circle cx="115" cy="125" r="8" fill="#333"/>
|
||||
<circle cx="145" cy="125" r="8" fill="#333"/>
|
||||
<rect x="95" y="75" width="4" height="50" fill="#666"/>
|
||||
<!-- price bubbles -->
|
||||
<rect x="130" y="20" width="60" height="30" rx="8" fill="#111d3c" stroke="#e73343" stroke-width="2"/>
|
||||
<text x="160" y="40" text-anchor="middle" fill="#e73343" font-size="10" font-family="sans-serif">112,000</text>
|
||||
<rect x="20" y="30" width="55" height="28" rx="8" fill="#111d3c" stroke="#20b94b" stroke-width="2"/>
|
||||
<text x="47" y="48" text-anchor="middle" fill="#20b94b" font-size="9" font-family="sans-serif">100,000</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 899 B |
|
After Width: | Height: | Size: 729 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" fill="none">
|
||||
<rect x="10" y="35" width="60" height="40" rx="6" fill="#7438c9"/>
|
||||
<rect x="10" y="30" width="60" height="10" rx="4" fill="#9c55e8"/>
|
||||
<rect x="30" y="45" width="20" height="15" rx="3" fill="#27204b"/>
|
||||
<rect x="5" y="28" width="8" height="12" rx="2" fill="#f5b62d"/>
|
||||
<rect x="67" y="28" width="8" height="12" rx="2" fill="#f5b62d"/>
|
||||
<circle cx="40" cy="22" r="6" fill="#f5b62d"/>
|
||||
<ellipse cx="40" cy="75" rx="30" ry="4" fill="rgba(245,182,45,0.2)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 543 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
||||
|
After Width: | Height: | Size: 276 B |
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,9 @@
|
||||
import avatar from './avatar.png'
|
||||
import shieldBadge from './shield-badge.png'
|
||||
import schoolBanner from './school-banner.png'
|
||||
|
||||
export const profileAssets = {
|
||||
avatar,
|
||||
shieldBadge,
|
||||
schoolBanner,
|
||||
} as const
|
||||
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 788 KiB |
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import PageBackButton from './PageBackButton.vue'
|
||||
import CoinBadge from './CoinBadge.vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title?: string
|
||||
showBack?: boolean
|
||||
coins?: number
|
||||
}>(),
|
||||
{
|
||||
showBack: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ back: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<div class="app-header__side">
|
||||
<PageBackButton v-if="showBack" @click="emit('back')" />
|
||||
</div>
|
||||
|
||||
<h1 v-if="title" class="app-header__title">{{ title }}</h1>
|
||||
<slot name="center" />
|
||||
|
||||
<div class="app-header__side app-header__side--end">
|
||||
<CoinBadge v-if="coins !== undefined" :count="coins" variant="store" />
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-header {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr minmax(40px, auto);
|
||||
align-items: center;
|
||||
direction: rtl;
|
||||
min-height: 52px;
|
||||
padding: 4px 0 10px;
|
||||
margin-bottom: 4px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-header__side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
grid-column: 1;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.app-header__side--end {
|
||||
grid-column: 3;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.app-header__title {
|
||||
grid-column: 2;
|
||||
text-align: center;
|
||||
font-size: 1.1875rem;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import { X } from 'lucide-vue-next'
|
||||
|
||||
defineProps<{
|
||||
title?: string
|
||||
open: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="open" class="modal-overlay" @click.self="emit('close')">
|
||||
<div class="modal game-card-base" role="dialog" aria-modal="true">
|
||||
<header v-if="title" class="modal__header">
|
||||
<h2 class="modal__title">{{ title }}</h2>
|
||||
<button class="modal__close" aria-label="بستن" @click="emit('close')">
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</header>
|
||||
<div class="modal__body">
|
||||
<slot />
|
||||
</div>
|
||||
<footer v-if="$slots.footer" class="modal__footer">
|
||||
<slot name="footer" />
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(4, 8, 23, 0.75);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: min(100%, 388px);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-card), var(--shadow-purple);
|
||||
}
|
||||
|
||||
.modal__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.modal__title {
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.modal__close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
color: var(--text-muted);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.modal__close:active {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.modal__body {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.modal__footer {
|
||||
padding: 0 18px 18px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.modal-enter-active .modal,
|
||||
.modal-leave-active .modal {
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-from .modal,
|
||||
.modal-leave-to .modal {
|
||||
transform: scale(0.94) translateY(10px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Home, TrendingUp, Wallet, User } from 'lucide-vue-next'
|
||||
import castleGoldLand from '@/assets/icons/castle-gold-land.png'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: 'خانه', icon: Home },
|
||||
{ path: '/market', label: 'بازار', icon: TrendingUp },
|
||||
{ path: '/map', label: 'سرزمین طلا', icon: null, center: true },
|
||||
{ path: '/wallet', label: 'کیف پول', icon: Wallet },
|
||||
{ path: '/profile', label: 'پروفایل', icon: User },
|
||||
]
|
||||
|
||||
const activePath = computed(() => route.path)
|
||||
|
||||
function isActive(path: string) {
|
||||
if (path === '/') return activePath.value === '/'
|
||||
return activePath.value.startsWith(path)
|
||||
}
|
||||
|
||||
function navigate(path: string) {
|
||||
if (route.path !== path) router.push(path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="bottom-nav" aria-label="ناوبری اصلی">
|
||||
<div class="bottom-nav__inner">
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
:key="item.path"
|
||||
class="bottom-nav__item"
|
||||
:class="{
|
||||
'bottom-nav__item--active': isActive(item.path),
|
||||
'bottom-nav__item--center': item.center,
|
||||
}"
|
||||
:aria-current="isActive(item.path) ? 'page' : undefined"
|
||||
@click="navigate(item.path)"
|
||||
>
|
||||
<span
|
||||
class="bottom-nav__icon-wrap"
|
||||
:class="{ 'bottom-nav__icon-wrap--center': item.center }"
|
||||
>
|
||||
<template v-if="item.center">
|
||||
<img
|
||||
:src="castleGoldLand"
|
||||
alt=""
|
||||
class="bottom-nav__castle"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="bottom-nav__center-label">{{ item.label }}</span>
|
||||
</template>
|
||||
<component :is="item.icon" v-else :size="20" />
|
||||
</span>
|
||||
<span v-if="!item.center" class="bottom-nav__label">{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
bottom: var(--nav-bottom-offset);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
max-width: var(--app-max-width);
|
||||
z-index: 200;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
background: #131933;
|
||||
border-bottom-left-radius: 24px;
|
||||
border-bottom-right-radius: 24px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.bottom-nav__inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-around;
|
||||
height: var(--nav-height);
|
||||
padding: 0 6px 4px;
|
||||
background: #131933;
|
||||
border-top: 1px solid rgba(85, 53, 142, 0.22);
|
||||
border-bottom-left-radius: 24px;
|
||||
border-bottom-right-radius: 24px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.bottom-nav__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 2px;
|
||||
flex: 1 1 0;
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
min-height: 38px;
|
||||
color: var(--text-muted);
|
||||
transition: color var(--transition-fast);
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
|
||||
.bottom-nav__item--active {
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.bottom-nav__item--center {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.bottom-nav__icon-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.bottom-nav__item--active .bottom-nav__icon-wrap:not(.bottom-nav__icon-wrap--center) {
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.bottom-nav__icon-wrap--center {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: -18px;
|
||||
transform: translateX(-50%);
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 50% 35%,
|
||||
#c084fc 0%,
|
||||
#9d4edd 38%,
|
||||
#6b21a8 72%,
|
||||
#3b0764 100%
|
||||
);
|
||||
border: 2px solid rgba(30, 10, 50, 0.85);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18);
|
||||
color: #fff;
|
||||
padding: 8px 6px 10px;
|
||||
clip-path: inset(0 0 14px 0);
|
||||
}
|
||||
|
||||
.bottom-nav__castle {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.bottom-nav__center-label {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 800;
|
||||
color: var(--gold);
|
||||
white-space: nowrap;
|
||||
line-height: 1.15;
|
||||
-webkit-text-stroke: 0;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.bottom-nav__label {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { formatNumber } from '@/utils/storage'
|
||||
import { illustrations } from '@/assets/illustrations'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
count: number
|
||||
animate?: boolean
|
||||
variant?: 'default' | 'store'
|
||||
}>(),
|
||||
{
|
||||
variant: 'default',
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="coin-badge"
|
||||
:class="{
|
||||
'coin-animate': animate,
|
||||
'coin-badge--store': variant === 'store',
|
||||
}"
|
||||
>
|
||||
<img
|
||||
v-if="variant === 'store'"
|
||||
:src="illustrations.coinLarge"
|
||||
alt=""
|
||||
class="coin-badge__img"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span v-else class="coin-badge__icon" aria-hidden="true">🪙</span>
|
||||
<span class="coin-badge__count">{{ formatNumber(count) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.coin-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: linear-gradient(135deg, rgba(244, 174, 33, 0.15), rgba(244, 174, 33, 0.08));
|
||||
border: 1px solid rgba(244, 174, 33, 0.35);
|
||||
border-radius: 999px;
|
||||
padding: 5px 12px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 800;
|
||||
color: var(--gold-light);
|
||||
box-shadow: 0 0 12px rgba(244, 174, 33, 0.12);
|
||||
}
|
||||
|
||||
.coin-badge--store {
|
||||
gap: 6px;
|
||||
padding: 6px 12px 6px 10px;
|
||||
background: #0d1730;
|
||||
border: 1px solid rgba(118, 75, 190, 0.28);
|
||||
color: #fff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.coin-badge__icon {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.coin-badge__img {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.coin-badge__count {
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label: string
|
||||
value: string
|
||||
unit?: string
|
||||
illustration?: string
|
||||
variant?: 'gold' | 'purple' | 'green' | 'blue' | 'stacked'
|
||||
large?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fin-stat" :class="[`fin-stat--${variant ?? 'purple'}`, { 'fin-stat--large': large }]">
|
||||
<div v-if="illustration" class="fin-stat__illus">
|
||||
<img :src="illustration" alt="" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="fin-stat__body">
|
||||
<span class="fin-stat__label">{{ label }}</span>
|
||||
<span class="fin-stat__value">{{ value }}</span>
|
||||
<span v-if="unit" class="fin-stat__unit">{{ unit }}</span>
|
||||
</div>
|
||||
<div v-if="$slots.decor" class="fin-stat__decor">
|
||||
<slot name="decor" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fin-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid rgba(118, 75, 190, 0.45);
|
||||
background: var(--card-bg);
|
||||
box-shadow: var(--shadow-card);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fin-stat--large {
|
||||
padding: 18px;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.fin-stat--gold {
|
||||
background: linear-gradient(135deg, rgba(60, 42, 10, 0.95), rgba(30, 22, 8, 0.98));
|
||||
border-color: rgba(244, 174, 33, 0.4);
|
||||
}
|
||||
|
||||
.fin-stat--gold::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -20%;
|
||||
left: -10%;
|
||||
width: 50%;
|
||||
height: 80%;
|
||||
background: radial-gradient(circle, rgba(244, 174, 33, 0.1) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fin-stat--purple {
|
||||
background: linear-gradient(135deg, rgba(45, 28, 90, 0.95), rgba(20, 15, 45, 0.98));
|
||||
border-color: rgba(134, 84, 210, 0.45);
|
||||
}
|
||||
|
||||
.fin-stat--green {
|
||||
background: linear-gradient(135deg, rgba(15, 50, 30, 0.95), rgba(8, 28, 18, 0.98));
|
||||
border-color: rgba(34, 181, 75, 0.35);
|
||||
}
|
||||
|
||||
.fin-stat--blue {
|
||||
background: linear-gradient(135deg, rgba(15, 40, 70, 0.95), rgba(8, 22, 45, 0.98));
|
||||
border-color: rgba(22, 130, 203, 0.35);
|
||||
}
|
||||
|
||||
.fin-stat--stacked {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.fin-stat__illus {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fin-stat--large .fin-stat__illus {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.fin-stat__illus img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.fin-stat__body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fin-stat__label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fin-stat__value {
|
||||
font-size: clamp(1.125rem, 4.5vw, 1.5rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.fin-stat--large .fin-stat__value {
|
||||
font-size: clamp(1.5rem, 6vw, 2rem);
|
||||
color: var(--gold-light);
|
||||
}
|
||||
|
||||
.fin-stat--gold .fin-stat__value {
|
||||
color: var(--gold-light);
|
||||
}
|
||||
|
||||
.fin-stat__unit {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fin-stat__decor {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 12px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import PrimaryButton from './PrimaryButton.vue'
|
||||
import { gameIllustrations } from '@/assets/illustrations'
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
color: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ start: [] }>()
|
||||
|
||||
const illustration = gameIllustrations[props.id]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="game-card card-enter">
|
||||
<div class="game-card__illus">
|
||||
<img v-if="illustration" :src="illustration" :alt="title" />
|
||||
<span v-else aria-hidden="true">{{ icon }}</span>
|
||||
</div>
|
||||
<div class="game-card__info">
|
||||
<h3 class="game-card__title">{{ title }}</h3>
|
||||
<p class="game-card__desc">{{ description }}</p>
|
||||
</div>
|
||||
<PrimaryButton size="sm" variant="gold" @click="emit('start')">شروع</PrimaryButton>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.game-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
background: var(--card-bg);
|
||||
border: var(--card-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.game-card:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.game-card__illus {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.game-card__illus img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: transparent;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.game-card__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.game-card__title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 800;
|
||||
color: var(--gold-light);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.game-card__desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { formatDecimal } from '@/utils/storage'
|
||||
|
||||
defineProps<{
|
||||
grams: number
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gold-badge">
|
||||
<span class="gold-badge__icon" aria-hidden="true">🥇</span>
|
||||
<span class="gold-badge__value">{{ formatDecimal(grams) }} گرم</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.gold-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: rgba(245, 182, 45, 0.12);
|
||||
border: 1px solid rgba(245, 182, 45, 0.3);
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: var(--gold-light);
|
||||
}
|
||||
|
||||
.gold-badge__icon {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label: string
|
||||
icon: string
|
||||
color: string
|
||||
selected?: boolean
|
||||
disabled?: boolean
|
||||
variant?: 'gold' | 'purple' | 'blue' | 'orange'
|
||||
}>()
|
||||
|
||||
defineEmits<{ select: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="answer-option"
|
||||
:class="[
|
||||
variant ? `answer-option--${variant}` : '',
|
||||
{ 'answer-option--selected': selected, 'answer-option--disabled': disabled },
|
||||
]"
|
||||
:disabled="disabled"
|
||||
@click="$emit('select')"
|
||||
>
|
||||
<span class="answer-option__icon" aria-hidden="true">{{ icon }}</span>
|
||||
<span class="answer-option__label">{{ label }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.answer-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
padding: 16px 18px;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-main);
|
||||
min-height: 68px;
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast), border-color var(--transition-fast);
|
||||
border: 2px solid rgba(255, 255, 255, 0.08);
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
.answer-option:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.answer-option--gold {
|
||||
background: linear-gradient(135deg, rgba(40, 60, 20, 0.9), rgba(20, 35, 12, 0.95));
|
||||
border-color: rgba(34, 181, 75, 0.35);
|
||||
}
|
||||
|
||||
.answer-option--purple {
|
||||
background: linear-gradient(135deg, rgba(50, 25, 80, 0.9), rgba(25, 12, 50, 0.95));
|
||||
border-color: rgba(118, 55, 201, 0.4);
|
||||
}
|
||||
|
||||
.answer-option--blue {
|
||||
background: linear-gradient(135deg, rgba(15, 45, 75, 0.9), rgba(8, 25, 50, 0.95));
|
||||
border-color: rgba(22, 130, 203, 0.4);
|
||||
}
|
||||
|
||||
.answer-option--orange {
|
||||
background: linear-gradient(135deg, rgba(70, 40, 10, 0.9), rgba(40, 22, 6, 0.95));
|
||||
border-color: rgba(241, 140, 22, 0.4);
|
||||
}
|
||||
|
||||
.answer-option--selected {
|
||||
border-color: var(--gold) !important;
|
||||
box-shadow: 0 0 20px rgba(244, 174, 33, 0.25), inset 0 0 20px rgba(244, 174, 33, 0.05);
|
||||
}
|
||||
|
||||
.answer-option--disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.answer-option__icon {
|
||||
font-size: 1.875rem;
|
||||
width: 44px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.answer-option__label {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowRight } from 'lucide-vue-next'
|
||||
|
||||
defineEmits<{ click: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="page-back-btn"
|
||||
aria-label="بازگشت"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<ArrowRight :size="22" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: var(--text-main);
|
||||
border-radius: 50%;
|
||||
transition: background var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.page-back-btn:active {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string
|
||||
subtitle?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-title">
|
||||
<h1 class="page-title__main">{{ title }}</h1>
|
||||
<p v-if="subtitle" class="page-title__sub">{{ subtitle }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-title {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-title__main {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.page-title__sub {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
variant?: 'gold' | 'green' | 'purple' | 'red' | 'blue'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
fullWidth?: boolean
|
||||
disabled?: boolean
|
||||
type?: 'button' | 'submit'
|
||||
}>(),
|
||||
{
|
||||
variant: 'gold',
|
||||
size: 'md',
|
||||
fullWidth: false,
|
||||
disabled: false,
|
||||
type: 'button',
|
||||
},
|
||||
)
|
||||
|
||||
defineEmits<{ click: [event: MouseEvent] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
class="game-btn"
|
||||
:class="[
|
||||
`game-btn--${variant}`,
|
||||
`game-btn--${size}`,
|
||||
{ 'game-btn--full': fullWidth, 'game-btn--disabled': disabled },
|
||||
]"
|
||||
:disabled="disabled"
|
||||
@click="$emit('click', $event)"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.game-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 800;
|
||||
min-height: 46px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-fast), opacity var(--transition-fast), filter var(--transition-fast);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.game-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.18) 0%, transparent 45%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.game-btn:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.game-btn--sm {
|
||||
padding: 8px 18px;
|
||||
font-size: 0.8125rem;
|
||||
min-height: 38px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.game-btn--md {
|
||||
padding: 12px 24px;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.game-btn--lg {
|
||||
padding: 15px 28px;
|
||||
font-size: 1.0625rem;
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.game-btn--full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.game-btn--gold {
|
||||
background: linear-gradient(180deg, #ffd55e 0%, var(--gold) 55%, #e09810 100%);
|
||||
color: #3a2200;
|
||||
}
|
||||
|
||||
.game-btn--green {
|
||||
background: linear-gradient(180deg, #34d058 0%, var(--green) 52%, var(--green-dark) 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.game-btn--purple {
|
||||
background: linear-gradient(180deg, var(--purple-light) 0%, var(--purple) 52%, #5520a0 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.game-btn--red {
|
||||
background: linear-gradient(180deg, #ff5060 0%, var(--red) 52%, var(--red-dark) 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.game-btn--blue {
|
||||
background: linear-gradient(180deg, #3aa8e8 0%, var(--blue) 52%, #0d5a8a 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.game-btn--disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
value: number
|
||||
max: number
|
||||
color?: string
|
||||
height?: number
|
||||
animated?: boolean
|
||||
variant?: 'gold' | 'purple'
|
||||
}>(),
|
||||
{
|
||||
color: 'var(--gold)',
|
||||
height: 10,
|
||||
animated: true,
|
||||
variant: 'gold',
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="progress-bar" :class="`progress-bar--${variant}`" :style="{ height: `${height}px` }">
|
||||
<div
|
||||
class="progress-bar__fill"
|
||||
:class="{ 'progress-bar__fill--animated': animated }"
|
||||
:style="{ width: `${Math.min(100, (value / max) * 100)}%`, background: color }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.progress-bar__fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, var(--gold), var(--gold-light));
|
||||
box-shadow: 0 0 10px rgba(244, 174, 33, 0.45);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-bar__fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.25), transparent);
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.progress-bar--purple .progress-bar__fill {
|
||||
background: linear-gradient(90deg, var(--purple), var(--purple-light));
|
||||
box-shadow: 0 0 10px rgba(118, 55, 201, 0.4);
|
||||
}
|
||||
|
||||
.progress-bar__fill--animated {
|
||||
transition: width 0.7s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title?: string
|
||||
glow?: boolean
|
||||
variant?: 'default' | 'gold' | 'purple' | 'challenge' | 'mission'
|
||||
padding?: 'sm' | 'md' | 'lg'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="section-card"
|
||||
:class="[
|
||||
variant ? `section-card--${variant}` : 'section-card--default',
|
||||
{ 'section-card--glow': glow },
|
||||
padding ? `section-card--${padding}` : 'section-card--md',
|
||||
]"
|
||||
>
|
||||
<header v-if="title || $slots.header" class="section-card__header">
|
||||
<slot name="header">
|
||||
<h2 v-if="title" class="section-card__title">{{ title }}</h2>
|
||||
</slot>
|
||||
</header>
|
||||
<div class="section-card__body">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section-card {
|
||||
background: var(--card-bg);
|
||||
border: var(--card-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-card--glow {
|
||||
box-shadow: var(--shadow-card), var(--shadow-purple);
|
||||
}
|
||||
|
||||
.section-card--gold {
|
||||
background: linear-gradient(145deg, rgba(80, 55, 20, 0.95), rgba(45, 30, 10, 0.98));
|
||||
border-color: rgba(244, 174, 33, 0.45);
|
||||
}
|
||||
|
||||
.section-card--purple {
|
||||
background: linear-gradient(145deg, rgba(45, 30, 90, 0.95), rgba(20, 15, 45, 0.98));
|
||||
border-color: rgba(134, 84, 210, 0.5);
|
||||
}
|
||||
|
||||
.section-card--challenge {
|
||||
background: linear-gradient(145deg, rgba(70, 45, 15, 0.98), rgba(35, 22, 8, 0.98));
|
||||
border: 1.5px solid rgba(244, 174, 33, 0.4);
|
||||
box-shadow: var(--shadow-card), var(--shadow-gold);
|
||||
}
|
||||
|
||||
.section-card--challenge::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -30%;
|
||||
right: -20%;
|
||||
width: 60%;
|
||||
height: 60%;
|
||||
background: radial-gradient(circle, rgba(244, 174, 33, 0.12) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.section-card--mission {
|
||||
background: linear-gradient(145deg, rgba(25, 35, 80, 0.98), rgba(15, 20, 50, 0.98));
|
||||
border-color: rgba(118, 55, 201, 0.45);
|
||||
}
|
||||
|
||||
.section-card--sm { padding: 12px; }
|
||||
.section-card--md { padding: 16px; }
|
||||
.section-card--lg { padding: 18px; }
|
||||
|
||||
.section-card__header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-card__title {
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-main);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label: string
|
||||
value: string
|
||||
icon?: string
|
||||
accent?: 'gold' | 'purple' | 'green' | 'blue'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stat-card" :class="accent ? `stat-card--${accent}` : ''">
|
||||
<div class="stat-card__icon" aria-hidden="true">{{ icon }}</div>
|
||||
<div class="stat-card__content">
|
||||
<span class="stat-card__label">{{ label }}</span>
|
||||
<span class="stat-card__value">{{ value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
background: linear-gradient(145deg, var(--surface-light), var(--surface));
|
||||
border: 1px solid var(--border-purple);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.stat-card--gold {
|
||||
border-color: rgba(245, 182, 45, 0.35);
|
||||
}
|
||||
|
||||
.stat-card--purple {
|
||||
border-color: var(--border-purple);
|
||||
}
|
||||
|
||||
.stat-card--green {
|
||||
border-color: rgba(32, 185, 75, 0.35);
|
||||
}
|
||||
|
||||
.stat-card--blue {
|
||||
border-color: rgba(25, 135, 212, 0.35);
|
||||
}
|
||||
|
||||
.stat-card__icon {
|
||||
font-size: 1.5rem;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.stat-card__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.stat-card__label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-card__value {
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const { toasts } = useToast()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="toast-container" aria-live="polite">
|
||||
<TransitionGroup name="toast">
|
||||
<div
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
class="toast"
|
||||
:class="`toast--${toast.type}`"
|
||||
>
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
width: min(calc(100% - 32px), 388px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 12px 18px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-card);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.toast--success {
|
||||
background: linear-gradient(135deg, #1a6b35, var(--green));
|
||||
border: 1px solid rgba(34, 181, 75, 0.5);
|
||||
}
|
||||
|
||||
.toast--error {
|
||||
background: linear-gradient(135deg, #8b1a26, var(--red));
|
||||
border: 1px solid rgba(230, 52, 67, 0.5);
|
||||
}
|
||||
|
||||
.toast--info {
|
||||
background: linear-gradient(135deg, var(--background-purple), var(--purple));
|
||||
border: 1px solid rgba(134, 84, 210, 0.5);
|
||||
}
|
||||
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.toast-enter-from,
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-14px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Settings, Trophy } from 'lucide-vue-next'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { formatNumber } from '@/utils/storage'
|
||||
import { profileAssets } from '@/assets/profile'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
|
||||
const xpPercent = computed(() => (userStore.xp / userStore.maxXp) * 100)
|
||||
const xpLabel = computed(
|
||||
() => `${formatNumber(userStore.xp)} / ${formatNumber(userStore.maxXp)} XP`,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="home-header">
|
||||
<div class="home-header__user">
|
||||
<img
|
||||
:src="profileAssets.avatar"
|
||||
alt=""
|
||||
class="home-header__avatar"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="home-header__info">
|
||||
<span class="home-header__name">{{ userStore.name }}</span>
|
||||
<span class="home-header__level">سطح {{ formatNumber(userStore.level) }}</span>
|
||||
<div class="home-header__xp">
|
||||
<div class="home-header__xp-bar">
|
||||
<div class="home-header__xp-fill" :style="{ width: `${xpPercent}%` }" />
|
||||
</div>
|
||||
<span class="home-header__xp-label">{{ xpLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="home-header__actions">
|
||||
<button class="home-header__icon-btn" aria-label="دستاوردها">
|
||||
<Trophy :size="18" />
|
||||
</button>
|
||||
<button class="home-header__icon-btn home-header__icon-btn--plain" aria-label="تنظیمات" @click="router.push('/settings')">
|
||||
<Settings :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
direction: ltr;
|
||||
padding: 10px 14px 0;
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.home-header__user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.home-header__avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid rgba(255, 255, 255, 0.85);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
|
||||
background: rgba(8, 20, 43, 0.4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.home-header__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
direction: rtl;
|
||||
text-align: left;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.home-header__name {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
line-height: 1.2;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.home-header__level {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.home-header__xp {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 3px;
|
||||
direction: ltr;
|
||||
width: min(150px, 42vw);
|
||||
}
|
||||
|
||||
.home-header__xp-bar {
|
||||
flex: 1;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.home-header__xp-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #f4ae21, #ffd55e);
|
||||
}
|
||||
|
||||
.home-header__xp-label {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
white-space: nowrap;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.home-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.home-header__icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 12px;
|
||||
background: rgba(16, 24, 48, 0.55);
|
||||
border: 1px solid rgba(244, 174, 33, 0.45);
|
||||
color: var(--gold);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.home-header__icon-btn--plain {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.home-header__icon-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Star } from 'lucide-vue-next'
|
||||
import { illustrations } from '@/assets/illustrations'
|
||||
import { getMapElementStyle } from '@/utils/mapDepth'
|
||||
import type { LevelItem } from '@/modules/gold-land/types/goldLand.types'
|
||||
|
||||
export type MapNodeLevel = LevelItem & { x: number; y: number }
|
||||
|
||||
const props = defineProps<{
|
||||
level: MapNodeLevel
|
||||
}>()
|
||||
|
||||
defineEmits<{ click: [level: MapNodeLevel] }>()
|
||||
|
||||
const MAX_STARS = 3
|
||||
const ORB_SIZE = 40
|
||||
const SIDE_STAR_SIZE = Math.round(ORB_SIZE * 0.38)
|
||||
const CENTER_STAR_SIZE = Math.round(ORB_SIZE * 0.46)
|
||||
|
||||
const starSlots = computed(() =>
|
||||
Array.from({ length: MAX_STARS }, (_, index) => index < props.level.stars),
|
||||
)
|
||||
|
||||
const middleStarIndex = computed(() => Math.floor(MAX_STARS / 2))
|
||||
|
||||
function starSize(index: number) {
|
||||
return index === middleStarIndex.value ? CENTER_STAR_SIZE : SIDE_STAR_SIZE
|
||||
}
|
||||
|
||||
const nodeStyle = computed(() =>
|
||||
getMapElementStyle(props.level.x, props.level.y, {
|
||||
scaleBoost: props.level.status === 'in_progress' ? 1.06 : 1,
|
||||
zBoost: props.level.id * 4,
|
||||
}),
|
||||
)
|
||||
|
||||
const isDisabled = computed(() => props.level.status === 'locked')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="map-node"
|
||||
:class="[`map-node--${level.status}`]"
|
||||
:style="nodeStyle"
|
||||
:disabled="isDisabled"
|
||||
:aria-label="`مرحله ${level.id}: ${level.title}`"
|
||||
@click="$emit('click', level)"
|
||||
>
|
||||
<img
|
||||
:src="illustrations.mapNodeOrb"
|
||||
alt=""
|
||||
class="map-node__orb"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<span class="map-node__number">{{ level.id }}</span>
|
||||
|
||||
<span class="map-node__stars">
|
||||
<Star
|
||||
v-for="(filled, index) in starSlots"
|
||||
:key="index"
|
||||
:size="starSize(index)"
|
||||
:fill="filled ? 'currentColor' : 'none'"
|
||||
:stroke-width="2"
|
||||
:class="[
|
||||
filled ? 'map-node__star--filled' : 'map-node__star--empty',
|
||||
{ 'map-node__star--center': index === middleStarIndex },
|
||||
]"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.map-node {
|
||||
position: absolute;
|
||||
--orb-size: 40px;
|
||||
width: var(--orb-size);
|
||||
height: var(--orb-size);
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
overflow: visible;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.map-node__orb {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.map-node__stars {
|
||||
position: absolute;
|
||||
top: calc(var(--orb-size) * -0.34);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
width: max-content;
|
||||
max-width: none;
|
||||
gap: calc(var(--orb-size) * 0.02);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.map-node__star--center {
|
||||
margin-bottom: calc(var(--orb-size) * 0.03);
|
||||
}
|
||||
|
||||
.map-node__stars :deep(svg) {
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.map-node__star--filled {
|
||||
color: #ffd55e;
|
||||
}
|
||||
|
||||
.map-node__star--empty {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.map-node__number {
|
||||
position: absolute;
|
||||
top: 42%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.25em;
|
||||
font-weight: 800;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.map-node--in_progress {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.map-node--in_progress .map-node__number {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.map-node--locked,
|
||||
.map-node--coming_soon {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.map-node--locked {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.map-node--locked .map-node__orb,
|
||||
.map-node--coming_soon .map-node__orb {
|
||||
filter: grayscale(0.85) brightness(0.75);
|
||||
}
|
||||
|
||||
.map-node--locked .map-node__number {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.map-node--available:active,
|
||||
.map-node--in_progress:active,
|
||||
.map-node--completed:active {
|
||||
transform: translate(-50%, -50%) scale(calc(var(--depth-scale) * 0.94));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,247 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Line } from 'vue-chartjs'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Filler,
|
||||
Tooltip,
|
||||
type Chart,
|
||||
type ScriptableContext,
|
||||
type TooltipItem,
|
||||
} from 'chart.js'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { formatNumber } from '@/utils/storage'
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Filler, Tooltip)
|
||||
|
||||
const gridLinesPlugin = {
|
||||
id: 'marketGridLines',
|
||||
beforeDatasetsDraw(chart: Chart) {
|
||||
const { ctx, chartArea, scales } = chart
|
||||
if (!chartArea || !scales.y) return
|
||||
|
||||
const { left, right, top, bottom } = chartArea
|
||||
const yScale = scales.y
|
||||
|
||||
ctx.save()
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.07)'
|
||||
ctx.lineWidth = 1
|
||||
|
||||
yScale.ticks.forEach((tick) => {
|
||||
const y = yScale.getPixelForValue(tick.value)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(left, y)
|
||||
ctx.lineTo(right, y)
|
||||
ctx.stroke()
|
||||
})
|
||||
|
||||
const verticalCount = 5
|
||||
for (let i = 0; i <= verticalCount; i += 1) {
|
||||
const x = left + ((right - left) / verticalCount) * i
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, top)
|
||||
ctx.lineTo(x, bottom)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
ctx.restore()
|
||||
},
|
||||
}
|
||||
|
||||
ChartJS.register(gridLinesPlugin)
|
||||
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
const AXIS_SCALE = 6100
|
||||
|
||||
function createLineGradient(chart: Chart) {
|
||||
const { ctx, chartArea } = chart
|
||||
if (!chartArea) return '#32d74b'
|
||||
|
||||
const gradient = ctx.createLinearGradient(chartArea.left, 0, chartArea.right, 0)
|
||||
gradient.addColorStop(0, '#22e865')
|
||||
gradient.addColorStop(0.45, '#6fdc3a')
|
||||
gradient.addColorStop(0.78, '#c8e630')
|
||||
gradient.addColorStop(1, '#ffd55e')
|
||||
return gradient
|
||||
}
|
||||
|
||||
function createFillGradient(chart: Chart) {
|
||||
const { ctx, chartArea } = chart
|
||||
if (!chartArea) return 'rgba(50, 215, 75, 0.15)'
|
||||
|
||||
const { left, top, right, bottom } = chartArea
|
||||
const width = right - left
|
||||
const height = bottom - top
|
||||
|
||||
// Horizontal: green (left) → purple (right)
|
||||
const horizontal = ctx.createLinearGradient(left, 0, right, 0)
|
||||
horizontal.addColorStop(0, 'rgba(34, 220, 100, 0.72)')
|
||||
horizontal.addColorStop(0.35, 'rgba(34, 200, 90, 0.55)')
|
||||
horizontal.addColorStop(0.62, 'rgba(70, 150, 120, 0.38)')
|
||||
horizontal.addColorStop(0.82, 'rgba(118, 55, 201, 0.52)')
|
||||
horizontal.addColorStop(1, 'rgba(156, 80, 233, 0.62)')
|
||||
|
||||
// Vertical fade: opaque under line → transparent at bottom
|
||||
const vertical = ctx.createLinearGradient(0, top, 0, bottom)
|
||||
vertical.addColorStop(0, 'rgba(255, 255, 255, 0.92)')
|
||||
vertical.addColorStop(0.35, 'rgba(255, 255, 255, 0.45)')
|
||||
vertical.addColorStop(0.7, 'rgba(255, 255, 255, 0.12)')
|
||||
vertical.addColorStop(1, 'rgba(255, 255, 255, 0)')
|
||||
|
||||
// Composite horizontal + vertical fade on offscreen canvas
|
||||
const offscreen = document.createElement('canvas')
|
||||
offscreen.width = Math.max(1, Math.round(width))
|
||||
offscreen.height = Math.max(1, Math.round(height))
|
||||
const offCtx = offscreen.getContext('2d')
|
||||
if (!offCtx) return horizontal
|
||||
|
||||
offCtx.fillStyle = horizontal
|
||||
offCtx.fillRect(0, 0, offscreen.width, offscreen.height)
|
||||
offCtx.globalCompositeOperation = 'destination-in'
|
||||
offCtx.fillStyle = vertical
|
||||
offCtx.fillRect(0, 0, offscreen.width, offscreen.height)
|
||||
|
||||
const pattern = ctx.createPattern(offscreen, 'no-repeat')
|
||||
if (!pattern) return horizontal
|
||||
|
||||
// Align pattern with chart area
|
||||
const matrix = new DOMMatrix().translate(left, top)
|
||||
pattern.setTransform(matrix)
|
||||
return pattern
|
||||
}
|
||||
|
||||
function isLastPoint(context: ScriptableContext<'line'>) {
|
||||
const dataset = context.dataset.data as number[]
|
||||
return context.dataIndex === dataset.length - 1
|
||||
}
|
||||
|
||||
function buildYAxisConfig(values: number[]) {
|
||||
if (!values.length) {
|
||||
return { min: 0, max: 1, ticks: [0, 0.33, 0.66, 1] }
|
||||
}
|
||||
|
||||
const min = Math.min(...values)
|
||||
const max = Math.max(...values)
|
||||
const range = max - min || max * 0.02
|
||||
const padding = range * 0.025
|
||||
const yMin = min - padding
|
||||
const yMax = max + padding
|
||||
const step = (yMax - yMin) / 3
|
||||
|
||||
return {
|
||||
min: yMin,
|
||||
max: yMax,
|
||||
ticks: [yMin, yMin + step, yMin + step * 2, yMax],
|
||||
}
|
||||
}
|
||||
|
||||
const yAxisConfig = computed(() => buildYAxisConfig(marketStore.priceHistory))
|
||||
|
||||
const chartData = computed(() => ({
|
||||
labels: marketStore.labels,
|
||||
datasets: [
|
||||
{
|
||||
data: marketStore.priceHistory,
|
||||
borderColor: (context: ScriptableContext<'line'>) =>
|
||||
createLineGradient(context.chart),
|
||||
backgroundColor: (context: ScriptableContext<'line'>) =>
|
||||
createFillGradient(context.chart),
|
||||
fill: true,
|
||||
tension: 0.42,
|
||||
pointRadius: (context: ScriptableContext<'line'>) =>
|
||||
isLastPoint(context) ? 3 : 0,
|
||||
pointHoverRadius: (context: ScriptableContext<'line'>) =>
|
||||
isLastPoint(context) ? 4 : 0,
|
||||
pointBackgroundColor: '#ffd55e',
|
||||
pointBorderColor: '#ffd55e',
|
||||
pointBorderWidth: 0,
|
||||
pointHoverBackgroundColor: '#ffd55e',
|
||||
pointHoverBorderColor: '#fff8dc',
|
||||
pointHoverBorderWidth: 2,
|
||||
borderWidth: 2,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
const chartOptions = computed(() => {
|
||||
const yAxis = yAxisConfig.value
|
||||
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
layout: {
|
||||
padding: { left: 0, right: 6, top: 2, bottom: 2 },
|
||||
},
|
||||
animation: {
|
||||
duration: 700,
|
||||
easing: 'easeOutQuart' as const,
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
rtl: true,
|
||||
backgroundColor: 'rgba(19, 25, 51, 0.96)',
|
||||
borderColor: 'rgba(118, 75, 190, 0.35)',
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
titleFont: { family: 'Vazirmatn', size: 11 },
|
||||
bodyFont: { family: 'Vazirmatn', weight: 'bold' as const, size: 12 },
|
||||
callbacks: {
|
||||
label: (ctx: TooltipItem<'line'>) => {
|
||||
const value = ctx.parsed.y ?? 0
|
||||
return `${formatNumber(value)} تومان`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: false,
|
||||
grid: { display: false },
|
||||
ticks: { display: false },
|
||||
border: { display: false },
|
||||
},
|
||||
y: {
|
||||
position: 'left' as const,
|
||||
display: true,
|
||||
min: yAxis.min,
|
||||
max: yAxis.max,
|
||||
afterBuildTicks: (axis: { ticks: { value: number }[] }) => {
|
||||
axis.ticks = yAxis.ticks.map((value) => ({ value }))
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgba(174, 185, 210, 0.42)',
|
||||
font: { family: 'Vazirmatn', size: 10, weight: 600 },
|
||||
padding: 2,
|
||||
autoSkip: false,
|
||||
maxTicksLimit: 4,
|
||||
callback: (value: string | number) =>
|
||||
formatNumber(Math.round(Number(value) / AXIS_SCALE)),
|
||||
},
|
||||
grid: { display: false },
|
||||
border: { display: false },
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="market-chart">
|
||||
<Line :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.market-chart {
|
||||
height: 138px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import { formatNumber } from '@/utils/storage'
|
||||
import { productIllustrations, illustrations } from '@/assets/illustrations'
|
||||
|
||||
defineProps<{
|
||||
id: string
|
||||
title: string
|
||||
price: number
|
||||
emoji: string
|
||||
owned?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{ select: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="product-card"
|
||||
:class="{ 'product-card--owned': owned }"
|
||||
@click="$emit('select')"
|
||||
>
|
||||
<h3 class="product-card__title">{{ title }}</h3>
|
||||
|
||||
<div class="product-card__visual">
|
||||
<img
|
||||
v-if="productIllustrations[id]"
|
||||
:src="productIllustrations[id]"
|
||||
:alt="title"
|
||||
class="product-card__image"
|
||||
/>
|
||||
<span v-else class="product-card__emoji" aria-hidden="true">{{ emoji }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="owned" class="product-card__price product-card__price--owned">مال توئه ✓</div>
|
||||
<div v-else class="product-card__price">
|
||||
<span class="product-card__amount">{{ formatNumber(price) }}</span>
|
||||
<img :src="illustrations.coinLarge" alt="" class="product-card__coin" aria-hidden="true" />
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 14px 12px 12px;
|
||||
background:
|
||||
radial-gradient(circle at 50% 35%, rgba(96, 66, 184, 0.1) 0%, transparent 58%),
|
||||
#1a1535;
|
||||
border: 1px solid #6042b8;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 0 10px rgba(96, 66, 184, 0.18);
|
||||
transition: transform var(--transition-fast), border-color var(--transition-fast);
|
||||
min-height: 252px;
|
||||
}
|
||||
|
||||
.product-card:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.product-card--owned {
|
||||
border-color: rgba(34, 181, 75, 0.35);
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.product-card__title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
line-height: 1.35;
|
||||
color: #fff;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.product-card__visual {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 150px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.product-card__image {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.product-card__emoji {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 2.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.product-card__price {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 40px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.42);
|
||||
border: 1px solid rgba(96, 66, 184, 0.22);
|
||||
}
|
||||
|
||||
.product-card__price--owned {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 800;
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.product-card__amount {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.product-card__coin {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,207 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { formatNumber } from '@/utils/storage'
|
||||
import { illustrations } from '@/assets/illustrations'
|
||||
import type { Transaction } from '@/stores/wallet'
|
||||
|
||||
const props = defineProps<{
|
||||
transaction: Transaction
|
||||
}>()
|
||||
|
||||
const iconConfig = computed(() => {
|
||||
const { title } = props.transaction
|
||||
|
||||
if (title === 'خرید طلا' || title === 'فروش طلا') {
|
||||
return {
|
||||
src: illustrations.goldBarsStack,
|
||||
variant: title === 'خرید طلا' ? 'red' : 'gold',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
src: illustrations.coinLarge,
|
||||
variant: 'plain',
|
||||
}
|
||||
})
|
||||
|
||||
const isCoinIncome = computed(
|
||||
() => props.transaction.type === 'income' && props.transaction.unit === 'سکه',
|
||||
)
|
||||
|
||||
const isTomanExpense = computed(
|
||||
() => props.transaction.type === 'expense' && props.transaction.unit === 'تومان',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="tx-card">
|
||||
<div
|
||||
class="tx-card__icon"
|
||||
:class="`tx-card__icon--${iconConfig.variant}`"
|
||||
>
|
||||
<img :src="iconConfig.src" alt="" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<div class="tx-card__info">
|
||||
<span class="tx-card__title">{{ transaction.title }}</span>
|
||||
<span class="tx-card__date">{{ transaction.date }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="tx-card__amount"
|
||||
:class="{
|
||||
'tx-card__amount--coin': isCoinIncome,
|
||||
'tx-card__amount--expense': isTomanExpense,
|
||||
'tx-card__amount--income': transaction.type === 'income' && !isCoinIncome,
|
||||
}"
|
||||
>
|
||||
<template v-if="isCoinIncome">
|
||||
<span class="tx-card__amount-value">
|
||||
+{{ formatNumber(transaction.amount) }} {{ transaction.unit }}
|
||||
</span>
|
||||
<img
|
||||
:src="illustrations.coinLarge"
|
||||
alt=""
|
||||
class="tx-card__amount-coin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="isTomanExpense">
|
||||
<span class="tx-card__amount-value">
|
||||
−{{ formatNumber(transaction.amount) }}
|
||||
</span>
|
||||
<span class="tx-card__amount-badge" aria-hidden="true" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="tx-card__amount-value">
|
||||
{{ transaction.type === 'income' ? '+' : '−' }}{{ formatNumber(transaction.amount) }}
|
||||
{{ transaction.unit }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tx-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
direction: ltr;
|
||||
min-height: 72px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 18px;
|
||||
background: #151830;
|
||||
}
|
||||
|
||||
.tx-card__icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tx-card__icon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tx-card__icon--red {
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, #7a2430 0%, #5c1824 100%);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.tx-card__icon--red img {
|
||||
width: 88%;
|
||||
height: 88%;
|
||||
}
|
||||
|
||||
.tx-card__icon--gold {
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, rgba(244, 174, 33, 0.18) 0%, rgba(244, 174, 33, 0.08) 100%);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.tx-card__icon--gold img {
|
||||
width: 88%;
|
||||
height: 88%;
|
||||
}
|
||||
|
||||
.tx-card__icon--plain img {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
.tx-card__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.tx-card__title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.tx-card__date {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #8b95ab;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.tx-card__amount {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.tx-card__amount-value {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tx-card__amount--coin .tx-card__amount-value {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tx-card__amount--expense .tx-card__amount-value {
|
||||
color: #ff6b7a;
|
||||
}
|
||||
|
||||
.tx-card__amount--income .tx-card__amount-value {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.tx-card__amount-coin {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tx-card__amount-badge {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(180deg, #ff5060 0%, #e63443 100%);
|
||||
box-shadow: 0 0 8px rgba(230, 52, 67, 0.35);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface ToastMessage {
|
||||
id: number
|
||||
message: string
|
||||
type: 'success' | 'error' | 'info'
|
||||
}
|
||||
|
||||
const toasts = ref<ToastMessage[]>([])
|
||||
let nextId = 0
|
||||
|
||||
export function useToast() {
|
||||
function show(message: string, type: ToastMessage['type'] = 'info', duration = 3000) {
|
||||
const id = nextId++
|
||||
toasts.value.push({ id, message, type })
|
||||
setTimeout(() => {
|
||||
toasts.value = toasts.value.filter((t) => t.id !== id)
|
||||
}, duration)
|
||||
}
|
||||
|
||||
function success(message: string) {
|
||||
show(message, 'success')
|
||||
}
|
||||
|
||||
function error(message: string) {
|
||||
show(message, 'error')
|
||||
}
|
||||
|
||||
return { toasts, show, success, error }
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
export interface GameItem {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
route?: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export interface StoreProduct {
|
||||
id: string
|
||||
title: string
|
||||
price: number
|
||||
category: 'all' | 'special' | 'visual' | 'boost'
|
||||
emoji: string
|
||||
}
|
||||
|
||||
export interface MapLevel {
|
||||
id: number
|
||||
title: string
|
||||
stars: number
|
||||
maxStars: number
|
||||
status: 'completed' | 'current' | 'unlocked' | 'locked'
|
||||
lessonRoute?: string
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface Achievement {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
unlocked: boolean
|
||||
}
|
||||
|
||||
export interface ChallengeOption {
|
||||
id: string
|
||||
label: string
|
||||
icon: string
|
||||
color: string
|
||||
isCorrect: boolean
|
||||
feedback: string
|
||||
}
|
||||
|
||||
export const INITIAL_TRANSACTIONS = [
|
||||
{ id: '1', type: 'expense' as const, title: 'خرید طلا', amount: 1600000, unit: 'تومان' as const, date: '۱۴۰۴/۰۴/۱۲' },
|
||||
{ id: '2', type: 'income' as const, title: 'ماموریت روزانه', amount: 50, unit: 'سکه' as const, date: '۱۴۰۴/۰۴/۱۱' },
|
||||
{ id: '3', type: 'income' as const, title: 'پاداش بازی', amount: 20, unit: 'سکه' as const, date: '۱۴۰۴/۰۴/۱۰' },
|
||||
]
|
||||
|
||||
export const GAMES: GameItem[] = [
|
||||
{ id: 'gold-guess', title: 'حدس قیمت طلا', description: 'قیمت فردا رو حدس بزن', icon: '📈', route: '/games/gold-guess', color: '#7438c9' },
|
||||
{ id: 'inflation', title: 'تورم رو شکست بده', description: 'ارزش پولت رو حفظ کن', icon: '👾', route: '/lessons/inflation', color: '#e73343' },
|
||||
{ id: 'portfolio', title: 'ساخت سبد سرمایهگذاری', description: 'بهترین ترکیب رو انتخاب کن', icon: '📊', color: '#1987d4' },
|
||||
{ id: 'smart-shop', title: 'خرید هوشمند', description: 'بهترین خرید رو انجام بده', icon: '🛒', color: '#20b94b' },
|
||||
{ id: 'savings', title: 'چالش پسانداز', description: 'پسانداز کن و پیشرفت کن', icon: '🐷', color: '#f5b62d' },
|
||||
{ id: 'investor', title: 'شاگرد سرمایهگذار', description: 'تصمیم بگیر و سود ببر', icon: '🎓', color: '#f07a2a' },
|
||||
]
|
||||
|
||||
export const STORE_PRODUCTS: StoreProduct[] = [
|
||||
{ id: 'castle', title: 'قلعه طلایی', price: 5000, category: 'special', emoji: '🏰' },
|
||||
{ id: 'suit', title: 'لباس ماجراجو', price: 2500, category: 'visual', emoji: '🧥' },
|
||||
{ id: 'pet', title: 'حیوان همراه', price: 3000, category: 'special', emoji: '🦊' },
|
||||
{ id: 'sea-bg', title: 'پسزمینه دریایی', price: 1500, category: 'visual', emoji: '🌊' },
|
||||
{ id: 'crown', title: 'تاج طلایی', price: 4000, category: 'special', emoji: '👑' },
|
||||
{ id: 'boost-xp', title: 'تقویت XP', price: 800, category: 'boost', emoji: '⚡' },
|
||||
]
|
||||
|
||||
export const CHALLENGE_OPTIONS: ChallengeOption[] = [
|
||||
{ id: 'gold', label: 'طلا', icon: '🥇', color: '#f5b62d', isCorrect: true, feedback: 'عالی! طلا در بلندمدت میتونه ارزش پولت رو حفظ کنه.' },
|
||||
{ id: 'dollar', label: 'دلار', icon: '💵', color: '#20b94b', isCorrect: false, feedback: 'دلار گزینهایه، ولی طلا برای حفظ ارزش در تورم بهتره.' },
|
||||
{ id: 'bank', label: 'بانک', icon: '🏦', color: '#1987d4', isCorrect: false, feedback: 'پسانداز بانکی امنه، ولی سودش معمولاً از تورم کمتره.' },
|
||||
{ id: 'phone', label: 'خرید گوشی', icon: '📱', color: '#7438c9', isCorrect: false, feedback: 'گوشی ارزش مالی نگه نمیداره. بهتره سرمایهگذاری کنی!' },
|
||||
]
|
||||
|
||||
export const ACHIEVEMENTS: Achievement[] = [
|
||||
{ id: 'first-gold', title: 'اولین خرید طلا', description: 'اولین خرید طلای خودت رو انجام دادی', icon: '🥇', unlocked: true },
|
||||
{ id: 'saver', title: 'پساندازگر کوچک', description: '۱۰۰ هزار تومان پسانداز کردی', icon: '💰', unlocked: true },
|
||||
{ id: 'market', title: 'شاگرد بازار', description: '۵ بار در بازار معامله کردی', icon: '📊', unlocked: false },
|
||||
{ id: 'inflation', title: 'شکستدهنده تورم', description: 'درس تورم رو کامل کردی', icon: '👾', unlocked: false },
|
||||
]
|
||||
|
||||
function generatePriceHistory(basePrice: number, points: number): number[] {
|
||||
const history: number[] = []
|
||||
let price = basePrice * 0.92
|
||||
for (let i = 0; i < points; i++) {
|
||||
const change = (Math.sin(i * 0.4) * 0.015 + Math.random() * 0.008) * price
|
||||
price += change
|
||||
history.push(Math.round(price))
|
||||
}
|
||||
history[history.length - 1] = basePrice
|
||||
return history
|
||||
}
|
||||
|
||||
export const GOLD_PRICE = 1830000
|
||||
export const PRICE_HISTORY = {
|
||||
'1d': generatePriceHistory(GOLD_PRICE, 24),
|
||||
'7d': generatePriceHistory(GOLD_PRICE, 28),
|
||||
'30d': generatePriceHistory(GOLD_PRICE, 30),
|
||||
'90d': generatePriceHistory(GOLD_PRICE, 30),
|
||||
'1y': generatePriceHistory(GOLD_PRICE, 52),
|
||||
}
|
||||
|
||||
export const MAP_LEVELS: MapLevel[] = [
|
||||
{ id: 1, title: 'پول چیه؟', stars: 0, maxStars: 3, status: 'current', x: 24, y: 79 },
|
||||
{ id: 2, title: 'پسانداز', stars: 0, maxStars: 3, status: 'locked', x: 59, y: 63 },
|
||||
{ id: 3, title: 'نیاز و خواسته', stars: 0, maxStars: 3, status: 'locked', x: 32, y: 47 },
|
||||
{ id: 4, title: 'طلا چیه؟', stars: 0, maxStars: 3, status: 'locked', x: 22, y: 36 },
|
||||
{ id: 5, title: 'بازار طلا', stars: 0, maxStars: 3, status: 'locked', x: 42, y: 34 },
|
||||
{ id: 6, title: 'تورم', stars: 0, maxStars: 3, status: 'locked', lessonRoute: '/lessons/inflation', x: 54, y: 66 },
|
||||
{ id: 7, title: 'سرمایهگذاری', stars: 0, maxStars: 3, status: 'locked', x: 40, y: 61 },
|
||||
{ id: 8, title: 'سبد دارایی', stars: 0, maxStars: 3, status: 'locked', x: 28, y: 77 },
|
||||
{ id: 9, title: 'ریسک و پاداش', stars: 0, maxStars: 3, status: 'locked', x: 47, y: 63 },
|
||||
{ id: 10, title: 'پسانداز هوشمند', stars: 0, maxStars: 3, status: 'locked', x: 33, y: 52 },
|
||||
{ id: 11, title: 'اقتصاد خانواده', stars: 0, maxStars: 3, status: 'locked', x: 47, y: 41 },
|
||||
{ id: 12, title: 'کارآفرینی', stars: 0, maxStars: 3, status: 'locked', x: 48, y: 30 },
|
||||
{ id: 13, title: 'سرزمین طلا', stars: 0, maxStars: 3, status: 'locked', x: 54, y: 18 },
|
||||
]
|
||||
|
||||
export const ILLUSTRATIONS = {
|
||||
heroExplorer: '/src/assets/illustrations/hero-explorer.svg',
|
||||
treasureChest: '/src/assets/illustrations/treasure-chest.svg',
|
||||
professor: '/src/assets/illustrations/professor.svg',
|
||||
shoppingCart: '/src/assets/illustrations/shopping-cart.svg',
|
||||
goldBar: '/src/assets/illustrations/gold-bar.svg',
|
||||
coin: '/src/assets/illustrations/coin.svg',
|
||||
castle: '/src/assets/illustrations/castle.svg',
|
||||
mapBackground: '/src/assets/illustrations/map-scene.svg',
|
||||
} as const
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import BottomNavigation from '@/components/common/BottomNavigation.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mobile-layout">
|
||||
<RouterView />
|
||||
<BottomNavigation />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mobile-layout {
|
||||
min-height: 100dvh;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
</style>
|
||||