feat: redesign home, market, wallet, and settings screens
Frontend CI / unit (test, type-check, build) (push) Failing after 1m25s
Frontend CI / browser E2E (frontend + pinned backend) (push) Skipped

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-26 14:54:44 +03:30
co-authored by Cursor
parent 83cc9a61f9
commit 8a311c1fd5
16 changed files with 937 additions and 672 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

+7 -1
View File
@@ -11,12 +11,15 @@ 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 homeHeroBg from './home-hero-bg.jpg'
import marketHero from './market-hero.jpg'
import walletHero from './wallet-hero.jpg'
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 nextMissionBanner from './next-mission-banner.webp'
import gameGoldChart from './game-gold-chart.png'
import gameInflation from './game-inflation.png'
import gamePortfolio from './game-portfolio.png'
@@ -45,11 +48,14 @@ export const illustrations = {
coinsStack,
cashBundle,
homeHeroBg,
marketHero,
walletHero,
gamepadIcon,
targetIcon,
cartIcon,
friendsIcon,
challengeBanner,
nextMissionBanner,
gameGoldChart,
gameInflation,
gamePortfolio,
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

+14 -6
View File
@@ -25,9 +25,9 @@ function createFillGradient(chart: Chart) {
if (!chartArea) return 'rgba(232, 196, 92, 0.2)'
const gradient = ctx.createLinearGradient(0, chartArea.top, 0, chartArea.bottom)
gradient.addColorStop(0, 'rgba(244, 214, 120, 0.55)')
gradient.addColorStop(0.55, 'rgba(118, 75, 190, 0.28)')
gradient.addColorStop(1, 'rgba(118, 75, 190, 0)')
gradient.addColorStop(0, 'rgba(232, 184, 74, 0.45)')
gradient.addColorStop(0.45, 'rgba(232, 184, 74, 0.12)')
gradient.addColorStop(1, 'rgba(232, 184, 74, 0)')
return gradient
}
@@ -47,7 +47,7 @@ const chartData = computed(() => ({
datasets: [
{
data: marketStore.priceHistory,
borderColor: '#f4d678',
borderColor: '#e8c15a',
backgroundColor: (context: ScriptableContext<'line'>) => createFillGradient(context.chart),
fill: true,
tension: 0.35,
@@ -100,11 +100,19 @@ const chartOptions = computed(() => {
border: { display: false },
},
y: {
display: false,
display: true,
position: 'right',
min: domain.min,
max: domain.max,
grid: { display: false },
border: { display: false },
ticks: {
color: '#8e98b8',
maxTicksLimit: 4,
padding: 6,
font: { size: 10, family: 'Vazirmatn' },
callback: (value: string | number) => formatNumber(Number(value)),
},
},
},
}
@@ -119,7 +127,7 @@ const chartOptions = computed(() => {
<style scoped>
.market-chart {
height: 196px;
height: 148px;
width: 100%;
position: relative;
margin-top: 4px;
+22 -48
View File
@@ -24,13 +24,6 @@ const iconConfig = computed(() => {
}
})
const isCoinIncome = computed(
() => props.transaction.type === 'income' && props.transaction.unit === 'سکه',
)
const isTomanExpense = computed(
() => props.transaction.type === 'expense' && props.transaction.unit === 'تومان',
)
</script>
<template>
@@ -49,35 +42,12 @@ const isTomanExpense = computed(
<div
class="tx-card__amount"
:class="{
'tx-card__amount--coin': isCoinIncome,
'tx-card__amount--expense': isTomanExpense,
'tx-card__amount--income': transaction.type === 'income' && !isCoinIncome,
}"
:class="transaction.type === 'income' ? 'tx-card__amount--income' : 'tx-card__amount--expense'"
>
<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>
<span class="tx-card__amount-value">
{{ transaction.type === 'income' ? '+' : '−' }}{{ formatNumber(transaction.amount) }}
</span>
<span class="tx-card__amount-unit">سکه آموزشی</span>
</div>
</article>
</template>
@@ -87,11 +57,12 @@ const isTomanExpense = computed(
display: flex;
align-items: center;
gap: 12px;
direction: ltr;
direction: rtl;
min-height: 72px;
padding: 12px 14px;
border-radius: 18px;
background: #151830;
border-radius: 16px;
background: #171c36;
border: 1px solid #3a446e;
}
.tx-card__icon {
@@ -162,11 +133,12 @@ const isTomanExpense = computed(
}
.tx-card__amount {
display: inline-flex;
align-items: center;
gap: 6px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
flex-shrink: 0;
direction: ltr;
margin-right: auto;
}
.tx-card__amount-value {
@@ -176,16 +148,18 @@ const isTomanExpense = computed(
line-height: 1;
}
.tx-card__amount--coin .tx-card__amount-value {
color: #fff;
}
.tx-card__amount--expense .tx-card__amount-value {
color: #ff6b7a;
color: #ff5d6c;
}
.tx-card__amount--income .tx-card__amount-value {
color: var(--green);
color: #2fce86;
}
.tx-card__amount-unit {
color: #8b95ab;
font-size: 0.68rem;
font-weight: 700;
}
.tx-card__amount-coin {
+1
View File
@@ -15,5 +15,6 @@ import BottomNavigation from '@/components/common/BottomNavigation.vue'
min-height: 100dvh;
position: relative;
overflow-x: hidden;
overflow-y: visible;
}
</style>
+202 -47
View File
@@ -1,14 +1,79 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import HomeHeader from '@/components/home/HomeHeader.vue'
import { useUserStore } from '@/stores/user'
import { useWalletStore } from '@/stores/wallet'
import { formatNumber, formatDecimal } from '@/utils/storage'
import { illustrations } from '@/assets/illustrations'
import { goldLandApi } from '@/modules/gold-land/services/goldLand.api'
import type { LevelItem } from '@/modules/gold-land/types/goldLand.types'
const router = useRouter()
const userStore = useUserStore()
const walletStore = useWalletStore()
const levels = ref<LevelItem[]>([])
const nextLevel = computed(() => {
return (
levels.value.find((level) => level.status === 'in_progress') ??
levels.value.find((level) => level.status === 'available') ??
null
)
})
const nextMissionTitle = computed(() => {
const level = nextLevel.value
if (!level) return 'همهٔ مأموریت‌های قلعه را تمام کردی.'
return level.description || level.title
})
const nextMissionLines = computed(() => {
const words = nextMissionTitle.value.trim().split(/\s+/)
if (words.length < 2) return [nextMissionTitle.value, '']
const total = nextMissionTitle.value.length
let best = 1
let bestDiff = Number.POSITIVE_INFINITY
let used = 0
for (let index = 0; index < words.length - 1; index += 1) {
used += words[index].length + (index > 0 ? 1 : 0)
const diff = Math.abs(used - total / 2)
if (diff < bestDiff) {
bestDiff = diff
best = index + 1
}
}
return [words.slice(0, best).join(' '), words.slice(best).join(' ')]
})
const nextMissionNumber = computed(() => {
const level = nextLevel.value
if (!level) return ''
return String(level.id).replace(/\d/g, (digit) => '۰۱۲۳۴۵۶۷۸۹'[Number(digit)] ?? digit)
})
onMounted(async () => {
try {
const progress = await goldLandApi.getProgress()
levels.value = progress.levels
} catch (error) {
console.error(error)
}
})
async function continueMission() {
const level = nextLevel.value
if (!level) {
router.push('/map')
return
}
try {
await goldLandApi.startLevel(level.id)
router.push(`/gold-land/levels/${level.id}`)
} catch (error) {
console.error(error)
}
}
const quickActions = [
{ label: 'چالش آموزشی', icon: null, image: illustrations.targetIcon, route: '/challenge', color: '#f4ae21' },
@@ -20,16 +85,13 @@ const quickActions = [
<template>
<div class="home-page">
<!-- Hero -->
<section class="hero card-enter">
<img
:src="illustrations.homeHeroBg"
alt=""
class="hero__bg"
aria-hidden="true"
/>
<div class="hero__fade" />
<img
:src="illustrations.homeHeroBg"
alt=""
class="home-page__bg"
aria-hidden="true"
/>
<div class="home-page__content">
<HomeHeader />
<div class="hero__stats">
@@ -48,6 +110,20 @@ const quickActions = [
</div>
</div>
</div>
<div class="home-page__stack">
<section class="mission-banner card-enter" style="animation-delay:0.04s">
<img
:src="illustrations.nextMissionBanner"
alt=""
class="mission-banner__art"
/>
<p class="mission-banner__title">
<span>{{ nextMissionLines[0] }}</span>
<span v-if="nextMissionLines[1]">{{ nextMissionLines[1] }}</span>
</p>
<span class="mission-banner__kind">{{ nextMissionNumber }}</span>
<button type="button" class="mission-banner__go" :aria-label="`ادامه ${nextMissionTitle}`" @click="continueMission" />
</section>
<!-- Quick actions -->
@@ -90,25 +166,19 @@ const quickActions = [
</button>
</div>
</section>
</div>
</div>
</div>
</template>
<style scoped>
.home-page {
min-height: 100dvh;
padding: 0 0 calc(var(--nav-height) + var(--nav-bottom-offset) + env(safe-area-inset-bottom, 0px) + 16px);
background: #040817;
}
/* ── Hero ── */
.hero {
position: relative;
height: 320px;
overflow: hidden;
margin-bottom: 14px;
min-height: 100dvh;
background: transparent;
}
.hero__bg {
.home-page__bg {
position: absolute;
inset: 0;
width: 100%;
@@ -118,18 +188,23 @@ const quickActions = [
z-index: 0;
}
.hero__fade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 72px;
background: linear-gradient(180deg, transparent 0%, #040817 100%);
.home-page__content {
position: relative;
z-index: 1;
pointer-events: none;
display: flex;
flex-direction: column;
min-height: 100dvh;
padding: 0 0 calc(var(--nav-height) + var(--nav-bottom-offset) + env(safe-area-inset-bottom, 0px) + 12px);
}
.hero :deep(.home-header) {
.home-page__stack {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: auto;
}
.home-page__content :deep(.home-header) {
position: relative;
z-index: 3;
}
@@ -194,13 +269,81 @@ const quickActions = [
line-height: 1.15;
}
.mission-banner {
position: relative;
z-index: 2;
margin: 0 14px;
border-radius: 18px;
overflow: hidden;
direction: rtl;
container-type: inline-size;
}
.mission-banner__art {
display: block;
width: 100%;
height: auto;
}
.mission-banner__title {
position: absolute;
top: 34%;
right: 5%;
width: 52%;
z-index: 1;
margin: 0;
color: #fff;
display: flex;
flex-direction: column;
gap: 7px;
font-size: 0.82rem;
font-weight: 800;
line-height: 1.35;
text-align: right;
text-shadow: 0 2px 0 rgba(30, 0, 60, 0.55);
}
.mission-banner__kind {
position: absolute;
left: 6.4%;
top: 52.7%;
width: 5.6%;
height: 7%;
z-index: 2;
display: grid;
place-items: center;
margin: 0;
padding: 0;
background: none;
color: #f7e7c8;
font-size: 3.2cqi;
font-weight: 800;
line-height: 1;
pointer-events: none;
text-shadow: 0 1px 0 rgba(40, 16, 0, 0.5);
}
.mission-banner__go {
position: absolute;
left: 3%;
right: 3%;
bottom: 4%;
z-index: 1;
height: 23%;
border: none;
border-radius: 14px;
background: transparent;
cursor: pointer;
}
/* ── Quick actions ── */
.quick-actions {
position: relative;
z-index: 2;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
padding: 0 14px;
margin-bottom: 14px;
gap: 10px;
padding: 4px 14px 0;
direction: ltr;
}
@@ -212,17 +355,15 @@ const quickActions = [
gap: 8px;
min-height: 86px;
padding: 12px 4px 10px;
border-radius: 16px;
background: #1a1540;
border: 1px solid rgba(118, 75, 190, 0.35);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.05),
0 4px 12px rgba(0, 0, 0, 0.2);
transition: transform var(--transition-fast);
border: none;
border-radius: 18px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0) 38%),
linear-gradient(180deg, #4a348c 0%, #2a1760 52%, #1a0d40 100%);
}
.quick-action:active {
transform: scale(0.96);
transform: scale(0.98);
}
.quick-action__icon {
@@ -237,6 +378,9 @@ const quickActions = [
object-fit: contain;
background: transparent;
display: block;
filter:
drop-shadow(0 3px 0 rgba(8, 4, 20, 0.55))
drop-shadow(0 8px 6px rgba(0, 0, 0, 0.35));
}
.quick-action__label {
@@ -245,11 +389,13 @@ const quickActions = [
color: #fff;
text-align: center;
line-height: 1.25;
text-shadow: 0 2px 0 rgba(8, 4, 20, 0.65);
}
/* ── Challenge banner ── */
.challenge {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: flex-start;
@@ -257,10 +403,14 @@ const quickActions = [
margin: 0 14px;
min-height: 128px;
padding: 16px;
border-radius: 20px;
border-radius: 22px;
border: none;
overflow: hidden;
background: transparent;
box-shadow:
0 8px 0 #14081f,
0 16px 22px rgba(0, 0, 0, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.22);
}
.challenge__bg {
@@ -287,6 +437,8 @@ const quickActions = [
text-align: right;
margin-right: 0;
margin-left: auto;
padding: 10px 12px 12px;
border-radius: 16px;
}
.challenge__title {
@@ -296,15 +448,19 @@ const quickActions = [
line-height: 1.2;
width: 100%;
text-align: right;
text-shadow:
0 2px 0 rgba(70, 20, 10, 0.45),
0 6px 10px rgba(0, 0, 0, 0.35);
}
.challenge__text {
font-size: 0.75rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.88);
font-weight: 700;
color: #fff;
line-height: 1.55;
width: 100%;
text-align: right;
text-shadow: 0 2px 0 rgba(20, 6, 40, 0.7);
}
.challenge__btn {
@@ -318,12 +474,11 @@ const quickActions = [
font-size: 0.875rem;
font-weight: 800;
color: #3a2200;
background: linear-gradient(180deg, #ffd55e 0%, #f4ae21 55%, #e09810 100%);
background: linear-gradient(180deg, #ffe38a 0%, #f6c043 48%, #d8920c 100%);
cursor: pointer;
transition: transform var(--transition-fast);
}
.challenge__btn:active {
transform: scale(0.96);
transform: scale(0.98);
}
</style>
+8 -6
View File
@@ -167,18 +167,20 @@ onActivated(loadProgress)
.map-page {
position: relative;
padding: 0;
height: calc(
min-height: calc(
100dvh - var(--nav-height) - var(--nav-bottom-offset) - env(safe-area-inset-bottom, 0px)
);
min-height: 0;
overflow: hidden;
height: auto;
overflow-x: hidden;
overflow-y: auto;
box-sizing: border-box;
}
.map-scroll {
position: absolute;
inset: 0;
overflow: hidden;
position: relative;
min-height: inherit;
overflow-x: hidden;
overflow-y: auto;
}
.map-header {
+7 -5
View File
@@ -149,9 +149,10 @@ function forgotPassword() {
.login-screen__frame {
position: relative;
width: min(100%, 430px);
height: 100dvh;
max-height: 100dvh;
overflow: hidden;
min-height: 100dvh;
height: auto;
overflow-x: hidden;
overflow-y: visible;
background: #071427;
display: flex;
flex-direction: column;
@@ -160,8 +161,9 @@ function forgotPassword() {
.login-screen__hero {
width: 100%;
flex: 1 1 auto;
height: 0;
min-height: 0;
height: auto;
min-height: 180px;
max-height: 52vh;
object-fit: cover;
object-position: top center;
display: block;
+321 -428
View File
@@ -1,529 +1,422 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { Minus, Plus, TrendingUp } from 'lucide-vue-next'
import PageBackButton from '@/components/common/PageBackButton.vue'
import { TrendingUp, Heart } from 'lucide-vue-next'
import AppModal from '@/components/common/AppModal.vue'
import MarketChart from '@/components/market/MarketChart.vue'
import { useUserStore } from '@/stores/user'
import { useWalletStore } from '@/stores/wallet'
import { useMarketStore, type TimeRange } from '@/stores/market'
import { formatNumber, formatDecimal, formatCurrency, formatPercent } from '@/utils/storage'
import { formatNumber, formatDecimal, formatPercent } from '@/utils/storage'
import { tomansForGoldPurchase, tomansForGoldSale } from '@/utils/goldTrade'
import { useToast } from '@/composables/useToast'
import { illustrations } from '@/assets/illustrations'
const router = useRouter()
const userStore = useUserStore()
const walletStore = useWalletStore()
const marketStore = useMarketStore()
const toast = useToast()
const timeRanges: { key: TimeRange; label: string }[] = [
{ key: '1d', label: '۱ روز' },
{ key: '7d', label: '۷ روز' },
{ key: '30d', label: '۳۰ روز' },
{ key: '90d', label: '۹۰ روز' },
{ key: '7d', label: '۱ هفته' },
{ key: '30d', label: '۱ ماه' },
{ key: '90d', label: '۳ ماه' },
{ key: '1y', label: '۱ سال' },
]
const showBuyModal = ref(false)
const showSellModal = ref(false)
const tradeGrams = ref(1)
const tradeBuyEstimate = computed(() => {
const g = Number(tradeGrams.value)
if (!Number.isFinite(g) || g <= 0) return null
return tomansForGoldPurchase(g)
})
const tradeSellEstimate = computed(() => {
const g = Number(tradeGrams.value)
if (!Number.isFinite(g) || g <= 0) return null
return tomansForGoldSale(g)
})
const assetValue = computed(() => walletStore.goldGrams * marketStore.currentPrice)
const tradeMode = ref<'buy' | 'sell'>('buy')
const tradeGrams = ref(0.1)
const submitting = ref(false)
function openBuy() { tradeGrams.value = 1; showBuyModal.value = true }
function openSell() { tradeGrams.value = 1; showSellModal.value = true }
const tradeEstimate = computed(() => {
const grams = Number(tradeGrams.value)
if (!Number.isFinite(grams) || grams <= 0) return null
return tradeMode.value === 'buy' ? tomansForGoldPurchase(grams) : tomansForGoldSale(grams)
})
async function confirmBuy() {
const result = await walletStore.buyGold(tradeGrams.value, marketStore.currentPrice)
result.success ? (toast.success(result.message), showBuyModal.value = false) : toast.error(result.message)
const gramLabel = computed(() => formatDecimal(tradeGrams.value, 1).replace('٫', '/').replace('.', '/'))
function stepGrams(direction: -1 | 1) {
const next = Math.round((tradeGrams.value + direction * 0.1) * 10) / 10
tradeGrams.value = Math.min(100, Math.max(0.1, next))
}
async function confirmSell() {
const result = await walletStore.sellGold(tradeGrams.value, marketStore.currentPrice)
result.success ? (toast.success(result.message), showSellModal.value = false) : toast.error(result.message)
async function submitTrade() {
if (submitting.value) return
submitting.value = true
try {
const result =
tradeMode.value === 'buy'
? await walletStore.buyGold(tradeGrams.value, marketStore.currentPrice)
: await walletStore.sellGold(tradeGrams.value, marketStore.currentPrice)
result.success ? toast.success(result.message) : toast.error(result.message)
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="market-page">
<!-- Header -->
<header class="market-header card-enter">
<PageBackButton @click="router.push('/')" />
<div class="market-header__title">
<img :src="illustrations.goldBar" alt="" class="market-header__title-icon" aria-hidden="true" />
<h1>بازار طلا</h1>
</div>
<div class="market-header__coins">
<img :src="illustrations.coinLarge" alt="" class="market-header__coin-icon" aria-hidden="true" />
<span>{{ formatNumber(userStore.coins) }}</span>
</div>
</header>
<!-- Price + Chart -->
<section class="price-section card-enter" style="animation-delay:0.04s">
<p class="price-section__label">قیمت هر گرم طلا</p>
<p class="price-section__amount">{{ formatNumber(marketStore.currentPrice) }}</p>
<div class="price-section__meta">
<span class="price-section__unit">تومان</span>
<span class="price-section__change">
<TrendingUp :size="14" stroke-width="2.5" />
{{ formatPercent(marketStore.dailyChange) }} (امروز)
</span>
</div>
<MarketChart />
<div class="time-filters">
<button
v-for="range in timeRanges"
:key="range.key"
class="time-filter"
:class="{ 'time-filter--active': marketStore.selectedRange === range.key }"
@click="marketStore.setRange(range.key)"
>
{{ range.label }}
</button>
</div>
<section class="market-hero">
<img :src="illustrations.marketHero" alt="" class="market-hero__img" />
<header class="market-header">
<PageBackButton @click="router.push('/')" />
<div class="market-header__copy" />
<span class="market-header__spacer" aria-hidden="true" />
</header>
</section>
<!-- Buy / Sell -->
<div class="trade-buttons card-enter" style="animation-delay:0.08s">
<button type="button" class="trade-btn trade-btn--buy" @click="openBuy">خرید</button>
<button type="button" class="trade-btn trade-btn--sell" @click="openSell">فروش</button>
</div>
<!-- Assets -->
<section class="asset-card card-enter" style="animation-delay:0.12s">
<div class="asset-card__info">
<div class="asset-card__title">
<Heart :size="14" fill="#f4ae21" color="#f4ae21" aria-hidden="true" />
<span>دارایی من</span>
<div class="market-body">
<section class="price-card">
<div class="price-card__top">
<img :src="illustrations.goldBarsStack" alt="" class="price-card__gold" />
<div class="price-card__main">
<p class="price-card__label">قیمت هر گرم طلای آموزشی</p>
<p class="price-card__amount">{{ formatNumber(marketStore.currentPrice) }}</p>
<p class="price-card__unit">سکه آموزشی</p>
</div>
<div class="price-card__change" :class="{ 'price-card__change--down': marketStore.dailyChange < 0 }">
<span>
<TrendingUp :size="14" />
{{ formatPercent(marketStore.dailyChange) }}
</span>
<small>در ۲۴ ساعت گذشته</small>
</div>
</div>
<p class="asset-card__grams">
{{ formatDecimal(walletStore.goldGrams) }}
<span class="asset-card__unit">گرم</span>
</p>
<p class="asset-card__value">
<img :src="illustrations.coinLarge" alt="" class="asset-card__coin" aria-hidden="true" />
<span>{{ formatNumber(assetValue) }}</span>
<span class="asset-card__value-unit">تومان</span>
</p>
</div>
<img
:src="illustrations.treasureChest"
alt=""
class="asset-card__chest"
aria-hidden="true"
/>
</section>
<AppModal :open="showBuyModal" title="خرید طلا" @close="showBuyModal = false">
<div class="trade-form">
<label class="trade-form__label">مقدار (گرم)</label>
<input
v-model.number="tradeGrams"
type="number"
min="0.000001"
step="0.000001"
class="trade-form__input"
dir="ltr"
/>
<p class="trade-form__cost">
<template v-if="tradeBuyEstimate != null">
برآورد هزینه (خرید به بالا): {{ formatCurrency(tradeBuyEstimate) }}
</template>
<template v-else>مقدار نامعتبر</template>
</p>
<p class="trade-form__hint">مبلغ نهایی پس از تأیید از سرور ثبت می‌شود.</p>
<p class="trade-form__balance">موجودی: {{ formatCurrency(walletStore.cash) }}</p>
</div>
<template #footer>
<button type="button" class="trade-btn trade-btn--buy trade-btn--modal" @click="confirmBuy">
تأیید خرید
</button>
</template>
</AppModal>
<MarketChart />
<AppModal :open="showSellModal" title="فروش طلا" @close="showSellModal = false">
<div class="trade-form">
<label class="trade-form__label">مقدار (گرم)</label>
<input
v-model.number="tradeGrams"
type="number"
min="0.000001"
step="0.000001"
class="trade-form__input"
dir="ltr"
/>
<p class="trade-form__cost">
<template v-if="tradeSellEstimate != null">
برآورد دریافتی (فروش به پایین): {{ formatCurrency(tradeSellEstimate) }}
</template>
<template v-else>مقدار نامعتبر</template>
</p>
<p class="trade-form__hint">مبلغ نهایی پس از تأیید از سرور ثبت می‌شود.</p>
<p class="trade-form__balance">طلای موجود: {{ formatDecimal(walletStore.goldGrams) }} گرم</p>
</div>
<template #footer>
<button type="button" class="trade-btn trade-btn--sell trade-btn--modal" @click="confirmSell">
تأیید فروش
<div class="time-filters">
<button
v-for="range in timeRanges"
:key="range.key"
type="button"
class="time-filter"
:class="{ 'time-filter--active': marketStore.selectedRange === range.key }"
@click="marketStore.setRange(range.key)"
>
{{ range.label }}
</button>
</div>
</section>
<div class="trade-switch">
<button
type="button"
class="trade-switch__btn"
:class="{ 'trade-switch__btn--active': tradeMode === 'buy' }"
@click="tradeMode = 'buy'"
>
خرید طلا
</button>
</template>
</AppModal>
<button
type="button"
class="trade-switch__btn"
:class="{ 'trade-switch__btn--active': tradeMode === 'sell' }"
@click="tradeMode = 'sell'"
>
فروش طلا
</button>
</div>
<div class="amount-card">
<span>مقدار (گرم)</span>
<div class="amount-card__stepper">
<button type="button" aria-label="کم کردن" @click="stepGrams(-1)"><Minus :size="18" /></button>
<strong>{{ gramLabel }}</strong>
<button type="button" aria-label="زیاد کردن" @click="stepGrams(1)"><Plus :size="18" /></button>
</div>
</div>
<p class="estimate">
<span>مبلغ تقریبی</span>
<strong>{{ tradeEstimate == null ? '—' : formatNumber(tradeEstimate) }}</strong>
</p>
<button type="button" class="trade-submit" :disabled="submitting" @click="submitTrade">
{{ tradeMode === 'buy' ? 'خرید طلا' : 'فروش طلا' }}
</button>
</div>
</div>
</template>
<style scoped>
.market-page {
position: relative;
min-height: 100dvh;
padding: 0 var(--page-padding) calc(var(--nav-height) + var(--nav-bottom-offset) + env(safe-area-inset-bottom, 0px) + 16px);
}
/* ── Header ── */
.market-header {
display: grid;
grid-template-columns: 40px 1fr auto;
align-items: center;
direction: rtl;
gap: 8px;
height: 52px;
margin-bottom: 18px;
padding-top: 4px;
}
.market-header :deep(.page-back-btn) {
grid-column: 1;
justify-self: start;
}
.market-header__title {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
direction: rtl;
}
.market-header__title-icon {
width: 28px;
height: 28px;
object-fit: contain;
flex-shrink: 0;
}
.market-header__title h1 {
font-size: 1.125rem;
font-weight: 800;
color: var(--text-main);
white-space: nowrap;
}
.market-header__coins {
grid-column: 3;
justify-self: end;
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 34px;
padding: 5px 12px 5px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
font-size: 0.8125rem;
font-weight: 800;
color: var(--text-main);
}
.market-header__coin-icon {
width: 20px;
height: 20px;
object-fit: contain;
}
/* ── Price section ── */
.price-section {
margin-bottom: 16px;
padding: 16px 14px 14px;
background: transparent;
border: 1px solid rgba(174, 185, 210, 0.28);
border-radius: 22px;
padding-bottom: calc(var(--nav-height) + var(--nav-bottom-offset) + env(safe-area-inset-bottom, 0px) + 16px);
}
.price-section__label {
text-align: center;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 8px;
.market-hero {
position: relative;
height: 230px;
}
.price-section__amount {
.market-hero__img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100dvh;
object-fit: cover;
object-position: center top;
z-index: 0;
}
.market-header {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 1;
display: grid;
grid-template-columns: 40px 1fr 40px;
align-items: start;
padding: 8px 10px 0;
direction: rtl;
}
.market-header__copy {
text-align: center;
font-size: clamp(1.75rem, 7vw, 2rem);
}
.market-header__copy h1 {
margin: 0;
color: #fff;
font-size: 1.2rem;
font-weight: 800;
color: var(--gold);
line-height: 1.1;
margin-bottom: 8px;
letter-spacing: -0.02em;
}
.price-section__meta {
.market-header__copy p {
margin: 4px 0 0;
color: rgba(255, 255, 255, 0.88);
font-size: 0.75rem;
font-weight: 700;
}
.market-header__spacer {
width: 40px;
}
.market-body {
position: relative;
z-index: 1;
margin-top: -28px;
display: flex;
flex-direction: column;
gap: 12px;
padding: 0 14px;
}
.price-card {
position: relative;
padding: 14px 14px 10px;
border-radius: 18px;
background: #171c36;
border: 1px solid #3a446e;
}
.price-card::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1.5px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(240, 208, 120, 0.2) 14%,
#f6d98a 50%,
rgba(240, 208, 120, 0.2) 86%,
transparent 100%
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite: exclude;
clip-path: inset(0 0 80% 0);
pointer-events: none;
}
.price-card__top {
display: grid;
grid-template-columns: 54px 1fr auto;
gap: 10px;
align-items: center;
justify-content: center;
gap: 24px;
direction: rtl;
margin-bottom: 8px;
}
.price-card__gold {
width: 54px;
height: 42px;
object-fit: contain;
}
.price-card__label {
margin: 0;
color: rgba(220, 228, 245, 0.82);
font-size: 0.75rem;
font-weight: 700;
}
.price-card__amount {
margin: 2px 0 0;
color: #fff;
font-size: 1.45rem;
font-weight: 800;
line-height: 1.2;
}
.price-card__unit {
margin: 0;
color: rgba(220, 228, 245, 0.7);
font-size: 0.75rem;
font-weight: 700;
}
.price-card__change {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
direction: ltr;
margin-bottom: 6px;
color: #2fce86;
font-size: 0.82rem;
font-weight: 800;
}
.price-section__unit {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-muted);
}
.price-section__change {
.price-card__change span {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.8125rem;
font-weight: 700;
color: #32d74b;
direction: rtl;
}
.price-card__change small {
color: rgba(220, 228, 245, 0.62);
font-size: 0.68rem;
font-weight: 600;
}
.price-card__change--down {
color: #ff6b6b;
}
.time-filters {
display: flex;
gap: 6px;
margin-top: 12px;
direction: rtl;
justify-content: space-between;
align-items: center;
margin-top: 4px;
}
.time-filter {
flex: 1;
min-height: 34px;
padding: 7px 4px;
border-radius: 12px;
font-size: 0.6875rem;
font-weight: 700;
background: rgba(255, 255, 255, 0.04);
color: var(--text-muted);
border: 1px solid rgba(255, 255, 255, 0.07);
transition: all var(--transition-fast);
flex: 0 0 auto;
height: 28px;
padding: 0 8px;
border: none;
border-radius: 8px;
background: transparent;
color: #9aa3c2;
font-family: inherit;
font-size: 0.75rem;
font-weight: 800;
}
.time-filter--active {
background: rgba(255, 255, 255, 0.08);
color: var(--text-main);
border-color: rgba(174, 185, 210, 0.35);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
background: linear-gradient(180deg, #f8d56a 0%, #e2aa2a 100%);
color: #2a2416;
padding: 0 12px;
}
/* ── Trade buttons ── */
.trade-buttons {
position: relative;
.trade-switch {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
direction: ltr;
margin-bottom: 22px;
direction: rtl;
}
.trade-buttons::after {
content: '';
position: absolute;
left: 50%;
bottom: -8px;
transform: translateX(-50%);
width: 110px;
height: 2px;
border-radius: 999px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(241, 140, 22, 0.08) 12%,
rgba(241, 140, 22, 0.22) 28%,
rgba(241, 140, 22, 0.9) 50%,
rgba(241, 140, 22, 0.22) 72%,
rgba(241, 140, 22, 0.08) 88%,
transparent 100%
);
box-shadow: 0 0 8px rgba(241, 140, 22, 0.55);
pointer-events: none;
}
.trade-btn {
min-height: 52px;
border: none;
border-radius: 16px;
.trade-switch__btn {
height: 46px;
border-radius: 14px;
border: 1.5px solid #7c5cbf;
background: transparent;
color: #d9ccff;
font-family: inherit;
font-size: 1.0625rem;
font-size: 0.95rem;
font-weight: 800;
}
.trade-switch__btn--active {
border-color: transparent;
background: #7b46ef;
color: #fff;
cursor: pointer;
transition: transform var(--transition-fast), filter var(--transition-fast);
position: relative;
overflow: hidden;
}
.trade-btn::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.16) 0%, transparent 48%);
pointer-events: none;
}
.trade-btn:active {
transform: scale(0.98);
}
.trade-btn--buy {
background: linear-gradient(180deg, #34d058 0%, #22b54b 52%, #138d36 100%);
}
.trade-btn--sell {
background: linear-gradient(180deg, #ff5060 0%, #e63443 52%, #b8172d 100%);
}
.trade-btn--modal {
width: 100%;
}
/* ── Asset card ── */
.asset-card {
.amount-card,
.estimate {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 118px;
padding: 16px 18px;
background: #1a223a;
border: 1px solid rgba(174, 185, 210, 0.28);
border-radius: 20px;
overflow: hidden;
}
.asset-card__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
direction: rtl;
text-align: right;
}
.asset-card__title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-muted);
}
.asset-card__grams {
font-size: 1.625rem;
min-height: 58px;
padding: 0 14px;
border-radius: 16px;
background: #171c36;
border: 1px solid #3a446e;
color: #fff;
font-weight: 800;
color: var(--text-main);
line-height: 1.15;
}
.asset-card__unit {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-muted);
margin-right: 4px;
}
.asset-card__value {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.875rem;
font-weight: 700;
color: var(--text-main);
}
.asset-card__coin {
width: 18px;
height: 18px;
object-fit: contain;
flex-shrink: 0;
}
.asset-card__value-unit {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-muted);
}
.asset-card__chest {
width: 88px;
height: 88px;
object-fit: contain;
flex-shrink: 0;
margin-left: -4px;
}
/* ── Trade modal form ── */
.trade-form {
.amount-card__stepper {
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
gap: 12px;
}
.trade-form__label {
font-size: 0.875rem;
font-weight: 700;
.amount-card__stepper button {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
background: #2a2454;
color: #fff;
}
.trade-form__input {
padding: 12px 14px;
border-radius: var(--radius-sm);
border: 1px solid rgba(118, 75, 190, 0.45);
background: #040817;
color: var(--text-main);
.amount-card__stepper strong {
min-width: 36px;
text-align: center;
font-size: 1.05rem;
}
.estimate strong {
font-size: 1.25rem;
}
.trade-submit {
height: 52px;
border: none;
border-radius: 16px;
background: linear-gradient(180deg, #f8d56e 0%, #e7b02e 100%);
color: #1b2433;
font-family: inherit;
font-size: 1rem;
font-weight: 700;
appearance: textfield;
-moz-appearance: textfield;
}
.trade-form__input::-webkit-outer-spin-button,
.trade-form__input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.trade-form__cost {
font-size: 1.0625rem;
font-size: 1.05rem;
font-weight: 800;
color: var(--gold-light);
}
.trade-form__hint {
font-size: 0.75rem;
color: var(--text-muted);
line-height: 1.5;
margin: 0;
.trade-submit:active {
transform: translateY(1px);
}
.trade-form__balance {
font-size: 0.8125rem;
color: var(--text-muted);
.trade-submit:disabled {
opacity: 0.7;
}
</style>
+227 -81
View File
@@ -1,29 +1,41 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { Camera, ChevronDown, Eye, EyeOff } from 'lucide-vue-next'
import PageBackButton from '@/components/common/PageBackButton.vue'
import PrimaryButton from '@/components/common/PrimaryButton.vue'
import { useUserStore } from '@/stores/user'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast'
import { api } from '@/api/client'
import { runMeMutation } from '@/api/sync'
import { profileAssets } from '@/assets/profile'
import { loadFromStorage, saveToStorage } from '@/utils/storage'
const router = useRouter()
const userStore = useUserStore()
const authStore = useAuthStore()
const toast = useToast()
const ages = [8, 9, 10, 11, 12, 13, 14, 15, 16]
const displayName = ref(userStore.name)
const email = ref(loadFromStorage('settings-email', ''))
const age = ref(loadFromStorage('settings-age', 11))
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const showCurrent = ref(false)
const showNew = ref(false)
const showConfirm = ref(false)
const saving = ref(false)
function fa(value: number) {
return String(value).replace(/\d/g, (digit) => '۰۱۲۳۴۵۶۷۸۹'[Number(digit)] ?? digit)
}
async function saveProfile() {
const name = displayName.value.trim()
if (!name) {
toast.error('نام نمایشی را وارد کنید')
toast.error('نام کاربری را وارد کنید')
return
}
@@ -53,6 +65,8 @@ async function saveProfile() {
try {
await runMeMutation(() => api.updateProfile(name))
authStore.displayName = name
saveToStorage('settings-email', email.value.trim())
saveToStorage('settings-age', age.value)
if (wantsPasswordChange) {
await api.updatePassword(currentPassword.value, newPassword.value)
@@ -61,7 +75,7 @@ async function saveProfile() {
confirmPassword.value = ''
}
toast.success(wantsPasswordChange ? 'تنظیمات و رمز عبور ذخیره شد' : 'تنظیمات ذخیره شد')
toast.success(wantsPasswordChange ? 'تنظیمات و رمز عبور ذخیره شد' : 'تغییرات ذخیره شد')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'خطا در ذخیره')
} finally {
@@ -78,94 +92,125 @@ async function saveProfile() {
<span class="settings-header__spacer" aria-hidden="true" />
</header>
<section class="settings-section card-enter">
<h2 class="settings-section__title">حساب کاربری</h2>
<section class="settings-card">
<h2 class="settings-card__title">اطلاعات حساب</h2>
<label class="settings-field">
<span class="settings-field__label">نام نمایشی</span>
<input v-model="displayName" type="text" class="settings-field__input" />
</label>
<div class="settings-avatar">
<img :src="profileAssets.avatar" alt="" class="settings-avatar__img" />
<span class="settings-avatar__camera" aria-hidden="true">
<Camera :size="14" />
</span>
</div>
<label class="settings-field">
<span class="settings-field__label">نام کاربری</span>
<input v-model="displayName" type="text" class="settings-field__input" placeholder="نام کاربری" />
</label>
<label class="settings-field">
<span class="settings-field__label">ایمیل</span>
<input
:value="authStore.username ?? '—'"
type="text"
class="settings-field__input settings-field__input--readonly"
readonly
v-model="email"
type="email"
class="settings-field__input settings-field__input--ltr"
placeholder="email@example.com"
dir="ltr"
autocomplete="email"
/>
</label>
<label class="settings-field">
<span class="settings-field__label">سن</span>
<span class="settings-field__select-wrap">
<select v-model.number="age" class="settings-field__input settings-field__input--select">
<option v-for="item in ages" :key="item" :value="item">{{ fa(item) }} سال</option>
</select>
<ChevronDown class="settings-field__chevron" :size="18" aria-hidden="true" />
</span>
</label>
</section>
<section class="settings-card">
<h2 class="settings-card__title">تغییر رمز عبور</h2>
<label class="settings-field">
<span class="settings-field__label">رمز عبور فعلی</span>
<input
v-model="currentPassword"
type="password"
class="settings-field__input"
autocomplete="current-password"
placeholder="••••••"
dir="ltr"
/>
<span class="settings-field__secret">
<input
v-model="currentPassword"
:type="showCurrent ? 'text' : 'password'"
class="settings-field__input"
autocomplete="current-password"
placeholder="رمز عبور فعلی"
/>
<button type="button" class="settings-field__eye" @click="showCurrent = !showCurrent">
<EyeOff v-if="showCurrent" :size="18" />
<Eye v-else :size="18" />
</button>
</span>
</label>
<label class="settings-field">
<span class="settings-field__label">رمز عبور جدید</span>
<input
v-model="newPassword"
type="password"
class="settings-field__input"
autocomplete="new-password"
placeholder="حداقل ۶ کاراکتر"
dir="ltr"
/>
<span class="settings-field__secret">
<input
v-model="newPassword"
:type="showNew ? 'text' : 'password'"
class="settings-field__input"
autocomplete="new-password"
placeholder="رمز عبور جدید"
/>
<button type="button" class="settings-field__eye" @click="showNew = !showNew">
<EyeOff v-if="showNew" :size="18" />
<Eye v-else :size="18" />
</button>
</span>
</label>
<label class="settings-field">
<span class="settings-field__label">تکرار رمز عبور جدید</span>
<input
v-model="confirmPassword"
type="password"
class="settings-field__input"
autocomplete="new-password"
placeholder="••••••"
dir="ltr"
/>
<span class="settings-field__secret">
<input
v-model="confirmPassword"
:type="showConfirm ? 'text' : 'password'"
class="settings-field__input"
autocomplete="new-password"
placeholder="تکرار رمز عبور جدید"
/>
<button type="button" class="settings-field__eye" @click="showConfirm = !showConfirm">
<EyeOff v-if="showConfirm" :size="18" />
<Eye v-else :size="18" />
</button>
</span>
</label>
<PrimaryButton variant="gold" full-width :disabled="saving" @click="saveProfile">
{{ saving ? 'در حال ذخیره...' : 'ذخیره تغییرات' }}
</PrimaryButton>
</section>
<section class="settings-section card-enter" style="animation-delay:0.06s">
<h2 class="settings-section__title">اپلیکیشن</h2>
<p class="settings-note">سایر تنظیمات به‌زودی اضافه می‌شوند.</p>
</section>
<button type="button" class="settings-save" :disabled="saving" @click="saveProfile">
{{ saving ? 'در حال ذخیره...' : 'ذخیره تغییرات' }}
</button>
</div>
</template>
<style scoped>
.settings-page {
min-height: 100dvh;
padding: 0 var(--page-padding) calc(env(safe-area-inset-bottom, 0px) + 24px);
padding: 8px 16px calc(var(--nav-height) + var(--nav-bottom-offset) + env(safe-area-inset-bottom, 0px) + 20px);
background: #040817;
direction: rtl;
}
.settings-header {
display: grid;
grid-template-columns: 40px 1fr 40px;
align-items: center;
direction: rtl;
min-height: 52px;
margin-bottom: 18px;
padding-top: 4px;
min-height: 48px;
margin-bottom: 8px;
}
.settings-header__title {
grid-column: 2;
margin: 0;
text-align: center;
font-size: 1.1875rem;
font-size: 1.15rem;
font-weight: 800;
color: #fff;
}
@@ -175,62 +220,163 @@ async function saveProfile() {
height: 40px;
}
.settings-section {
padding: 18px 16px;
margin-bottom: 12px;
border-radius: 18px;
background: #0d1730;
border: 1px solid rgba(85, 53, 142, 0.25);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.04),
0 4px 12px rgba(0, 0, 0, 0.2);
.settings-card {
margin-top: 14px;
padding: 18px 16px 8px;
border-radius: 22px;
background: #151830;
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.settings-section__title {
font-size: 0.9375rem;
.settings-card__title {
margin: 0 0 14px;
text-align: center;
font-size: 1rem;
font-weight: 800;
color: var(--gold-light);
margin-bottom: 14px;
color: #fff;
}
.settings-avatar {
position: relative;
width: 84px;
height: 84px;
margin: 0 auto 16px;
}
.settings-avatar__img {
width: 84px;
height: 84px;
border-radius: 50%;
object-fit: cover;
border: 3px solid rgba(255, 255, 255, 0.85);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.28);
}
.settings-avatar__camera {
position: absolute;
left: 0;
bottom: 2px;
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 50%;
background: #151830;
color: #d7e4ff;
border: 2px solid rgba(255, 255, 255, 0.7);
}
.settings-field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
margin-bottom: 14px;
}
.settings-field__label {
font-size: 0.8125rem;
font-size: 0.82rem;
font-weight: 700;
color: var(--text-muted);
color: rgba(210, 224, 248, 0.82);
}
.settings-field__input {
width: 100%;
min-height: 46px;
padding: 11px 14px;
height: 48px;
padding: 0 14px;
border-radius: 14px;
border: 1px solid rgba(118, 75, 190, 0.45);
border: 1px solid rgba(150, 180, 230, 0.16);
background: #040817;
color: var(--text-main);
color: #fff;
font-family: inherit;
font-size: 0.9375rem;
font-weight: 600;
font-size: 0.95rem;
font-weight: 700;
text-align: right;
outline: none;
}
.settings-field__input::placeholder {
color: rgba(180, 198, 226, 0.45);
font-weight: 600;
}
.settings-field__input:focus {
border-color: rgba(244, 174, 33, 0.65);
border-color: rgba(246, 196, 83, 0.7);
}
.settings-field__input--readonly {
opacity: 0.75;
.settings-field__input:-webkit-autofill,
.settings-field__input:-webkit-autofill:hover,
.settings-field__input:-webkit-autofill:focus {
-webkit-text-fill-color: #fff;
caret-color: #fff;
box-shadow: 0 0 0 1000px #040817 inset;
transition: background-color 9999s ease-out;
}
.settings-note {
font-size: 0.8125rem;
color: var(--text-muted);
line-height: 1.6;
.settings-field__input--ltr {
text-align: left;
}
.settings-field__select-wrap,
.settings-field__secret {
position: relative;
display: block;
}
.settings-field__input--select {
appearance: none;
padding-left: 36px;
}
.settings-field__chevron {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: rgba(210, 224, 248, 0.8);
pointer-events: none;
}
.settings-field__secret .settings-field__input {
padding-left: 42px;
}
.settings-field__eye {
position: absolute;
left: 8px;
top: 50%;
transform: translateY(-50%);
display: grid;
place-items: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: rgba(210, 224, 248, 0.85);
cursor: pointer;
}
.settings-save {
display: block;
width: 100%;
height: 52px;
margin-top: 18px;
border: none;
border-radius: 16px;
background: linear-gradient(180deg, #ffe38a 0%, #f6c043 46%, #e3a31a 100%);
color: #1b2433;
font-family: inherit;
font-size: 1.05rem;
font-weight: 800;
cursor: pointer;
}
.settings-save:active {
transform: translateY(1px);
}
.settings-save:disabled {
opacity: 0.7;
cursor: default;
}
</style>
+124 -42
View File
@@ -1,52 +1,47 @@
<script setup lang="ts">
import PageTitle from '@/components/common/PageTitle.vue'
import FinancialStatCard from '@/components/common/FinancialStatCard.vue'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { SlidersHorizontal } from 'lucide-vue-next'
import TransactionItem from '@/components/wallet/TransactionItem.vue'
import { useUserStore } from '@/stores/user'
import { useWalletStore } from '@/stores/wallet'
import { formatNumber, formatDecimal, formatCurrency } from '@/utils/storage'
import { formatNumber, formatDecimal } from '@/utils/storage'
import { illustrations } from '@/assets/illustrations'
const router = useRouter()
const userStore = useUserStore()
const walletStore = useWalletStore()
const goldLabel = computed(() => formatDecimal(walletStore.goldGrams, 1).replace('٫', '/').replace('.', '/'))
</script>
<template>
<div class="page-content wallet-page">
<PageTitle title="کیف پول" />
<img :src="illustrations.walletHero" alt="" class="wallet-page__bg" />
<div class="balance-stack">
<FinancialStatCard
label="تومان مجازی"
:value="formatCurrency(walletStore.cash)"
:illustration="illustrations.cashBundle"
variant="green"
large
class="card-enter"
/>
<FinancialStatCard
label="طلای من"
:value="formatDecimal(walletStore.goldGrams)"
unit="گرم"
:illustration="illustrations.goldBar"
variant="gold"
large
class="card-enter"
style="animation-delay:0.05s"
/>
<FinancialStatCard
label="سکه‌ها"
:value="formatNumber(userStore.coins)"
:illustration="illustrations.coinLarge"
variant="purple"
large
class="card-enter"
style="animation-delay:0.1s"
/>
<div class="wallet-balances">
<article class="balance-card">
<img :src="illustrations.goldBarsStack" alt="" class="balance-card__icon" />
<p class="balance-card__label">موجودی طلای آموزشی</p>
<p class="balance-card__value">{{ goldLabel }}</p>
</article>
<article class="balance-card">
<img :src="illustrations.coinLarge" alt="" class="balance-card__icon" />
<p class="balance-card__label">موجودی سکه آموزشی</p>
<p class="balance-card__value">{{ formatNumber(userStore.coins) }}</p>
</article>
</div>
<section class="wallet-history card-enter" style="animation-delay:0.14s">
<h2 class="wallet-history__title">تاریخچه اخیر</h2>
<div class="wallet-actions">
<button type="button" class="wallet-actions__sell" @click="router.push('/market')">فروش طلا</button>
<button type="button" class="wallet-actions__buy" @click="router.push('/market')">خرید طلا</button>
</div>
<section class="wallet-history">
<header class="wallet-history__head">
<h2>تاریخچه تراکنش‌ها</h2>
<SlidersHorizontal :size="18" aria-hidden="true" />
</header>
<div class="wallet-history__list">
<TransactionItem
v-for="tx in walletStore.transactions"
@@ -60,24 +55,111 @@ const walletStore = useWalletStore()
</template>
<style scoped>
.balance-stack {
display: flex;
flex-direction: column;
.wallet-page {
position: relative;
background: transparent;
padding-top: 380px;
}
.wallet-page__bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center top;
z-index: 0;
pointer-events: none;
}
.wallet-page > :not(.wallet-page__bg) {
position: relative;
z-index: 1;
}
.wallet-balances {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 16px;
direction: rtl;
}
.wallet-history {
.balance-card {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
gap: 6px;
min-height: 132px;
padding: 14px 8px 12px;
border-radius: 18px;
background: #171c36;
border: 1px solid #3a446e;
text-align: center;
}
.wallet-history__title {
.balance-card__icon {
width: 42px;
height: 42px;
object-fit: contain;
}
.balance-card__label {
margin: 0;
color: rgba(220, 228, 245, 0.78);
font-size: 0.75rem;
font-weight: 700;
}
.balance-card__value {
margin: 0;
color: #fff;
font-size: 1.35rem;
font-weight: 800;
}
.wallet-actions {
display: grid;
grid-template-columns: 0.85fr 1.25fr;
gap: 10px;
direction: rtl;
margin: 12px 0 18px;
}
.wallet-actions__buy,
.wallet-actions__sell {
height: 48px;
border: none;
border-radius: 14px;
font-family: inherit;
font-size: 1rem;
font-weight: 800;
cursor: pointer;
}
.wallet-actions__buy {
background: linear-gradient(180deg, #f8d56e 0%, #e7b02e 100%);
color: #1b2433;
}
.wallet-actions__sell {
background: #7b46ef;
color: #fff;
text-align: right;
}
.wallet-history__head {
display: flex;
align-items: center;
justify-content: space-between;
direction: rtl;
margin-bottom: 12px;
color: #fff;
}
.wallet-history__head h2 {
margin: 0;
font-size: 1rem;
font-weight: 800;
}
.wallet-history__list {
+1 -6
View File
@@ -30,6 +30,7 @@ const router = createRouter({
{ path: 'map', name: 'map', component: () => import('@/pages/LearningMapPage.vue'), meta: { title: 'سرزمین طلا', nav: true } },
{ path: 'wallet', name: 'wallet', component: () => import('@/pages/WalletPage.vue'), meta: { title: 'کیف پول', nav: true } },
{ path: 'profile', name: 'profile', component: () => import('@/pages/ProfilePage.vue'), meta: { title: 'پروفایل', nav: true } },
{ path: 'settings', name: 'settings', component: () => import('@/pages/SettingsPage.vue'), meta: { title: 'تنظیمات', nav: true } },
],
},
{
@@ -62,12 +63,6 @@ const router = createRouter({
component: () => import('@/pages/GoldPredictionGamePage.vue'),
meta: { title: 'حدس قیمت طلا', nav: false, requiresAuth: true },
},
{
path: '/settings',
name: 'settings',
component: () => import('@/pages/SettingsPage.vue'),
meta: { title: 'تنظیمات', nav: false, requiresAuth: true },
},
{
path: '/gold-land/levels/:levelId',
name: 'gold-land-level',
+3 -2
View File
@@ -15,7 +15,7 @@ html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
scrollbar-gutter: stable;
overflow-y: scroll;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(174, 185, 210, 0.28) #040817;
}
@@ -28,7 +28,7 @@ body {
background: #040817;
color: var(--text-main);
overflow-x: hidden;
overscroll-behavior-y: none;
overflow-y: auto;
}
::-webkit-scrollbar {
@@ -65,6 +65,7 @@ body {
background: #040817;
position: relative;
overflow-x: hidden;
overflow-y: visible;
box-shadow: none;
}