Newer
Older
gnexus-tasks / frontend / src / views / GardenView.vue
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
  api,
  type GardenExpansion,
  type GardenSpecies,
  type GardenState,
  type Task,
  type XpEvent,
  type XpSummary,
} from '../api'
import { formatDate } from '../taskui'
import { useRealtimeRefresh } from '../realtime'
import GardenScene from '../components/GardenScene.vue'

defineOptions({ name: 'GardenView' })

// Сад (ТЗ 3.13): живая сцена — домик, растения (закрытые задачи), декорации.
// Принцип «контент открывается за опыт, покупается за монеты»: XP — уровень,
// монеты — валюта сада (рост растений, декорации, расширения карты).

const { t, tm } = useI18n()

const tasks = ref<Task[]>([])
const events = ref<XpEvent[]>([])
const xp = ref<XpSummary | null>(null)
const garden = ref<GardenState | null>(null)
const error = ref('')
const loading = ref(false)

// Справка «как это работает»: модалка с абзацами из локали
const aboutOpen = ref(false)
const aboutParagraphs = computed(() => {
  const messages = tm('garden.about')
  return Array.isArray(messages)
    ? (messages as unknown[]).filter((p): p is string => typeof p === 'string')
    : []
})

const doneTasks = computed(() =>
  tasks.value
    .filter((task) => task.status === 'done' && task.done_at)
    .sort((a, b) => (a.done_at! < b.done_at! ? 1 : -1)),
)

// Название уровня: 13 ботанических званий (garden.levels), дальше — последнее
function levelName(level: number): string {
  const idx = Math.min(level, 13) - 1
  return t(`garden.levels.${idx}`)
}

// --- Дерево уровней: что открывается на каждом уровне ---

const levelsOpen = ref(false)

interface LevelNode {
  level: number
  name: string
  threshold: number // порог XP: 100·L·(L−1)/2
  species: GardenSpecies[]
  expansion: GardenExpansion | null
  expansionNo: number // порядковый номер расширения (I, II, …) — не совпадает с уровнем
}

const EXPANSION_ROMANS = ['I', 'II', 'III', 'IV', 'V']

const levelsTree = computed<LevelNode[]>(() => {
  const expansions = garden.value?.expansions ?? []
  const nodes: LevelNode[] = []
  for (let level = 1; level <= 13; level++) {
    const expIdx = expansions.findIndex((e) => e.level === level)
    nodes.push({
      level,
      name: levelName(level),
      threshold: (100 * level * (level - 1)) / 2,
      species: (garden.value?.species ?? []).filter((s) => s.level === level),
      expansion: expIdx >= 0 ? expansions[expIdx] : null,
      expansionNo: expIdx + 1,
    })
  }
  return nodes
})

// Последняя награда: любое событие XP (закрытие, создание задачи/проекта)
const lastReward = computed(() => events.value[0] ?? null)

const rewardTitle = computed(() => {
  const e = lastReward.value
  if (!e) return ''
  if (e.kind === 'create_task') return t('garden.rewardCreateTask')
  if (e.kind === 'create_project') return t('garden.rewardCreateProject')
  const task = tasks.value.find((task) => task.id === e.task_id)
  return task?.title ?? doneTasks.value[0]?.title ?? ''
})

// --- Ачивки с тирами: ступени I/II/III внутри каждой, без «провала» ---

interface Achievement {
  key: string
  icon: string
  tiers: number[]
  progress: number
}

const ROMANS = ['I', 'II', 'III', 'IV']

// Текущая ступень: первая незакрытая; -1 — все пройдены
function currentTier(a: Achievement): number {
  return a.tiers.findIndex((goal) => a.progress < goal)
}

function tierLabel(a: Achievement): string {
  const idx = currentTier(a)
  const inHours = a.key === 'marathon'
  const fmt = (n: number) => (inHours ? `${Math.round(n / 60)} ч` : `${n}`)
  // Пройдено: галочка + высшая ступень («✓ III»); в процессе: ступень и прогресс
  if (idx === -1) return `✓ ${ROMANS[a.tiers.length - 1]}`
  return `${ROMANS[idx]} · ${fmt(a.progress)}/${fmt(a.tiers[idx])}`
}

const achievements = computed<Achievement[]>(() => {
  const done = doneTasks.value
  const recurringDone = done.some((task) => task.task_type === 'recurring')
  // Спринтер: максимум закрытых за один день
  const perDay = new Map<string, number>()
  done.forEach((task) => {
    const day = task.done_at!.slice(0, 10)
    perDay.set(day, (perDay.get(day) ?? 0) + 1)
  })
  const bestDay = Math.max(0, ...perDay.values())
  // Разносторонний: проекты, в которых есть закрытые задачи
  const projectsWithDone = new Set(done.map((task) => task.project?.id).filter((id) => id != null))
  // Марафонец: суммарные минуты (факт, иначе оценка)
  const totalMinutes = done.reduce(
    (sum, task) => sum + (task.actual_minutes ?? task.estimated_minutes ?? 0),
    0,
  )
  // Дедлайн-босс: закрытые со строгим дедлайном-датой
  const strictDone = done.filter((task) => task.deadline_date).length
  // «Выбрал и сделал»: закрыто через режим «3 вариантов»
  const viaOptions = events.value.filter((e) => e.via_options).length
  // Преодоление: закрытые «ментально сложные» (метка «не хочется делать»)
  const willpowerDone = done.filter((task) => task.mentally_hard).length
  // проект закрыт: есть проект (от 3 задач), все задачи которого завершены
  const projects = new Map<number, { total: number; done: number }>()
  tasks.value.forEach((task) => {
    if (!task.project) return
    const bucket = projects.get(task.project.id) ?? { total: 0, done: 0 }
    bucket.total += 1
    if (task.status === 'done') bucket.done += 1
    projects.set(task.project.id, bucket)
  })
  const projectDone = [...projects.values()].some((p) => p.total >= 3 && p.done === p.total)
  return [
    { key: 'firstDone', icon: 'ph-flower-lotus', tiers: [1], progress: Math.min(done.length, 1) },
    { key: 'done10', icon: 'ph-leaf', tiers: [10, 50, 100], progress: done.length },
    { key: 'level5', icon: 'ph-crown-simple', tiers: [5, 10, 15], progress: xp.value?.level ?? 1 },
    { key: 'sprinter', icon: 'ph-lightning', tiers: [3, 10, 25], progress: bestDay },
    { key: 'explorer', icon: 'ph-stack', tiers: [3, 5, 8], progress: projectsWithDone.size },
    { key: 'marathon', icon: 'ph-mountains', tiers: [600, 3000, 6000], progress: totalMinutes },
    { key: 'deadlineBoss', icon: 'ph-medal', tiers: [5, 15, 30], progress: strictDone },
    { key: 'chooser', icon: 'ph-shuffle', tiers: [5, 25, 50], progress: viaOptions },
    { key: 'willpower', icon: 'ph-brain', tiers: [5, 25, 50], progress: willpowerDone },
    { key: 'recurring', icon: 'ph-repeat', tiers: [1], progress: recurringDone ? 1 : 0 },
    { key: 'projectDone', icon: 'ph-flag-checkered', tiers: [1], progress: projectDone ? 1 : 0 },
  ]
})

// --- Маркет: расширения, декорации, каталог видов ---

const shopOpen = ref(false)
const shopBusy = ref<string | null>(null) // item_key текущего запроса
// Что только что куплено — подсказка «перетащите из инвентаря на карту»
const boughtKey = ref<string | null>(null)

// Состояние карточки магазина: замок / не хватает монет / можно купить
type ShopState = 'locked' | 'poor' | 'ok' | 'max'

function decorShopState(decor: { key: string; cost: number; unique: boolean }): ShopState {
  if (decor.unique && (garden.value?.items ?? []).some((i) => i.item_key === decor.key)) {
    return 'max'
  }
  return (garden.value?.balance ?? 0) >= decor.cost ? 'ok' : 'poor'
}

function expansionShopState(): ShopState {
  const next = garden.value?.next_expansion
  if (!next) return 'max'
  if ((garden.value?.level ?? 1) < next.level) return 'locked'
  return (garden.value?.balance ?? 0) >= next.cost ? 'ok' : 'poor'
}

function seedShopState(species: GardenSpecies): ShopState {
  if (!species.unlocked) return 'locked'
  return (garden.value?.balance ?? 0) >= species.seed_cost ? 'ok' : 'poor'
}

async function buy(itemKey: string): Promise<void> {
  shopBusy.value = itemKey
  error.value = ''
  try {
    const res = await api.buyGardenItem(itemKey)
    if (garden.value) garden.value.balance = res.balance
    boughtKey.value = itemKey
    await load()
  } catch (e) {
    error.value = String(e)
  } finally {
    shopBusy.value = null
  }
}

async function load() {
  loading.value = true
  error.value = ''
  try {
    const [taskList, xpSummary, eventList, gardenState] = await Promise.all([
      api.listTasks(),
      api.getXp(),
      api.getXpEvents(),
      api.getGarden(),
    ])
    tasks.value = taskList
    xp.value = xpSummary
    events.value = eventList
    garden.value = gardenState
  } catch (e) {
    error.value = String(e)
  } finally {
    loading.value = false
  }
}

async function onMove(id: number, x: number | null, y: number | null): Promise<void> {
  // пауза на весь свой move-запрос + refetch: 300 мс после жеста может не
  // хватить, и чужое событие посреди load() пересоберёт сцену под рукой
  dragHold.value = true
  try {
    await api.moveGardenItem(id, x, y)
    await load() // инвентарь и сцена — из одного состояния бэка
  } catch {
    await load() // позиция не принята — вернуть из состояния бэка
  } finally {
    if (dragHoldTimer) clearTimeout(dragHoldTimer)
    dragHoldTimer = setTimeout(() => (dragHold.value = false), 50)
  }
}

async function onUpgrade(id: number): Promise<void> {
  try {
    await api.upgradePlant(id)
    await load()
  } catch (e) {
    error.value = String(e)
  }
}

onMounted(load)

// --- Реактивность (ТЗ 3.14) ---

// Подавление refetch'а во время drag: событие с соседней вкладки посреди жеста
// сбросило бы перетаскиваемый элемент (syncState пересобирает сцену)
const dragHold = ref(false)
let dragHoldTimer: ReturnType<typeof setTimeout> | undefined

function onDragging(dragging: boolean): void {
  if (dragging) {
    dragHold.value = true
    if (dragHoldTimer) clearTimeout(dragHoldTimer)
    return
  }
  // конец жеста: летит move-запрос и его SSE-эхо — держим паузу ещё 300 мс
  if (dragHoldTimer) clearTimeout(dragHoldTimer)
  dragHoldTimer = setTimeout(() => (dragHold.value = false), 300)
}

useRealtimeRefresh(() => {
  if (!dragHold.value) void load()
}, ['garden.changed', 'xp.changed', 'task.changed'])
</script>

<template>
  <section>
    <GnSkeleton v-if="loading && !garden" type="block" stack :count="2" class="page-skeleton" />

    <template v-else>
      <GnPageHeader kicker="GNexus Tasks" :title="t('garden.title')">
        <template #meta>
          <GnBadge variant="primary"><i class="ph ph-trophy" aria-hidden="true" />
            {{ t('garden.level', { n: xp?.level ?? 1 }) }} · {{ levelName(xp?.level ?? 1) }}
          </GnBadge>
          <GnBadge variant="neutral"><i class="ph ph-lightning" aria-hidden="true" />
            {{ t('garden.xpTotal', { n: xp?.total_xp ?? 0 }) }}
          </GnBadge>
          <GnBadge variant="warning" :title="t('garden.coinsHint')">
            <i class="ph ph-coins" aria-hidden="true" />
            {{ t('garden.coins', { n: garden?.balance ?? 0 }) }}
          </GnBadge>
        </template>
        <template #actions>
          <GnButton variant="secondary" icon="ph-list-numbers" @click="levelsOpen = true">
            {{ t('garden.levelsTitle') }}
          </GnButton>
          <GnButton variant="secondary" icon="ph-storefront" @click="shopOpen = true">
            {{ t('garden.shop') }}
          </GnButton>
          <GnIconButton icon="ph-question" :label="t('garden.aboutOpen')" :title="t('garden.aboutOpen')" @click="aboutOpen = true" />
        </template>
      </GnPageHeader>

      <!-- Справка: что происходит в саду и как это работает -->
      <GnModal :open="aboutOpen" :title="t('garden.aboutTitle')" @update:open="aboutOpen = $event">
        <div class="about-body">
          <p v-for="(p, i) in aboutParagraphs" :key="i">{{ p }}</p>
        </div>
      </GnModal>

      <!-- Дерево уровней: звания, пороги XP и что открывается на каждом уровне -->
      <GnModal :open="levelsOpen" :title="t('garden.levelsTitle')" @update:open="levelsOpen = $event">
        <p class="levels-hint">{{ t('garden.levelsHint') }}</p>
        <div class="levels-tree">
          <div
            v-for="node in levelsTree"
            :key="node.level"
            class="level-node"
            :class="{ current: node.level === xp?.level, past: node.level < (xp?.level ?? 1) }"
          >
            <div class="level-node-head">
              <i
                :class="node.level < (xp?.level ?? 1) ? 'ph ph-check-circle' : 'ph ph-lock-simple'"
                aria-hidden="true"
              />
              <span class="level-node-title">
                {{ t('garden.level', { n: node.level }) }} · {{ node.name }}
              </span>
              <GnBadge variant="neutral" class="level-threshold">
                <i class="ph ph-lightning" aria-hidden="true" /> {{ node.threshold }}
              </GnBadge>
              <GnBadge v-if="node.level === xp?.level" variant="success">
                {{ t('garden.levelCurrent') }}
              </GnBadge>
            </div>
            <div v-if="node.species.length || node.expansion" class="level-node-unlocks">
              <i class="ph ph-lock-simple-open unlock-lock" aria-hidden="true" />
              <span v-for="s in node.species" :key="s.key" class="level-unlock">
                <i :class="`ph ${s.key}`" aria-hidden="true" />
                {{ t(`garden.speciesNames.${s.key}`) }}
                <GnBadge variant="neutral" class="unlock-price">
                  <i class="ph ph-coins" aria-hidden="true" /> {{ s.seed_cost }}
                </GnBadge>
                <GnBadge v-if="s.premium" variant="warning">{{ t('garden.rareOnly') }}</GnBadge>
              </span>
              <span v-if="node.expansion" class="level-unlock">
                <i class="ph ph-map-trifold" aria-hidden="true" />
                {{ t('garden.expansion') }} {{ EXPANSION_ROMANS[node.expansionNo - 1] ?? node.expansionNo }}
                <GnBadge variant="neutral" class="unlock-price">
                  <i class="ph ph-coins" aria-hidden="true" /> {{ node.expansion.cost }}
                </GnBadge>
              </span>
            </div>
          </div>
        </div>
      </GnModal>

      <!-- Маркет: расширения (первым блоком), декорации, каталог видов -->
      <GnModal :open="shopOpen" :title="t('garden.shopTitle')" @update:open="shopOpen = $event">
        <div class="shop-body">
          <div class="shop-hint">{{ t('garden.speciesHint') }}</div>
          <!-- Текущий баланс: обновляется сразу после покупки (buy пишет res.balance в state) -->
          <GnBadge variant="warning" class="shop-coins" :title="t('garden.coinsHint')">
            <i class="ph ph-coins" aria-hidden="true" />
            {{ t('garden.coins', { n: garden?.balance ?? 0 }) }}
          </GnBadge>
          <GnAlert v-if="boughtKey" variant="success" class="shop-bought">
            <i :class="boughtKey.startsWith('seed:') ? 'ph ph-plant' : 'ph ph-hand-grabbing'" aria-hidden="true" />
            {{
              boughtKey.startsWith('seed:')
                ? t('garden.seedBought')
                : t('garden.boughtHint', { name: t(`garden.decorNames.${boughtKey}`) })
            }}
          </GnAlert>

          <!-- 1. Расширение сада -->
          <div class="shop-block">
            <div class="shop-block-title">
              <i class="ph ph-map-trifold" aria-hidden="true" />
              <span>{{ t('garden.expansion') }}</span>
            </div>
            <p class="shop-block-hint">
              {{ t('garden.expansionCurrent', { cols: garden?.grid.cols, rows: garden?.grid.rows }) }}
            </p>
            <div v-if="garden?.next_expansion" class="shop-card" :class="expansionShopState()">
              <i class="ph ph-tree-palm" aria-hidden="true" />
              <span class="shop-name">{{ t(`garden.expansion`) }} {{ garden.expansions_bought + 1 }}</span>
              <span class="shop-meta">
                <template v-if="expansionShopState() === 'locked'">
                  {{ t('garden.lockedUntil', { n: garden.next_expansion.level }) }}
                </template>
                <template v-else>{{ t('garden.expansionHint', { n: 8 }) }}</template>
              </span>
              <GnBadge variant="neutral">
                <i class="ph ph-coins" aria-hidden="true" /> {{ garden.next_expansion.cost }}
              </GnBadge>
              <GnButton
                size="sm"
                variant="primary"
                icon="ph-shopping-cart-simple"
                :disabled="expansionShopState() !== 'ok' || shopBusy !== null"
                :loading="shopBusy === garden.next_expansion.key"
                @click="buy(garden.next_expansion.key)"
              >
                {{ t('garden.buy') }}
              </GnButton>
            </div>
            <p v-else class="shop-max">{{ t('garden.expansionMax') }}</p>
          </div>

          <!-- 2. Декорации -->
          <div class="shop-block">
            <div class="shop-block-title">
              <i class="ph ph-flowers" aria-hidden="true" />
              <span>{{ t('garden.decorations') }}</span>
            </div>
            <p class="shop-block-hint">{{ t('garden.decorationsHint') }}</p>
            <div
              v-for="decor in garden?.decorations ?? []"
              :key="decor.key"
              class="shop-item"
              :class="decorShopState(decor)"
            >
              <i class="ph ph-flower" aria-hidden="true" />
              <span class="shop-name">
                {{ t(`garden.decorNames.${decor.key}`) }}
                <GnBadge v-if="decor.unique" variant="neutral">{{ t('garden.unique') }}</GnBadge>
              </span>
              <GnBadge variant="neutral" class="shop-price">
                <i class="ph ph-coins" aria-hidden="true" /> {{ decor.cost }}
              </GnBadge>
              <span v-if="decorShopState(decor) === 'poor'" class="shop-poor">
                {{ t('garden.notEnoughCoins', { n: decor.cost - (garden?.balance ?? 0) }) }}
              </span>
              <span v-else-if="decorShopState(decor) === 'max'" class="shop-owned">✓</span>
              <GnButton
                v-else
                size="sm"
                variant="secondary"
                icon="ph-shopping-cart-simple"
                :disabled="decorShopState(decor) !== 'ok'"
                :loading="shopBusy === decor.key"
                @click="buy(decor.key)"
              >
                {{ t('garden.buy') }}
              </GnButton>
            </div>
          </div>

          <!-- 3. Семена растений: открытые уровнем виды покупаются за монеты -->
          <div class="shop-block">
            <div class="shop-block-title">
              <i class="ph ph-plant" aria-hidden="true" />
              <span>{{ t('garden.seeds') }}</span>
            </div>
            <p class="shop-block-hint">{{ t('garden.seedsHint') }}</p>
            <div
              v-for="species in garden?.species ?? []"
              :key="species.key"
              class="shop-item"
              :class="seedShopState(species)"
            >
              <i :class="`ph ${species.key}`" aria-hidden="true" />
              <span class="shop-name">
                {{ t(`garden.speciesNames.${species.key}`) }}
                <GnBadge v-if="species.premium" variant="warning">{{ t('garden.rareOnly') }}</GnBadge>
              </span>
              <GnBadge variant="neutral" class="shop-price">
                <i class="ph ph-coins" aria-hidden="true" /> {{ species.seed_cost }}
              </GnBadge>
              <span v-if="seedShopState(species) === 'locked'" class="shop-meta">
                {{ t('garden.lockedUntil', { n: species.level }) }}
              </span>
              <span v-else-if="seedShopState(species) === 'poor'" class="shop-poor">
                {{ t('garden.notEnoughCoins', { n: species.seed_cost - (garden?.balance ?? 0) }) }}
              </span>
              <GnButton
                v-else
                size="sm"
                variant="secondary"
                icon="ph-shopping-cart-simple"
                :loading="shopBusy === `seed:${species.key}`"
                @click="buy(`seed:${species.key}`)"
              >
                {{ t('garden.buy') }}
              </GnButton>
            </div>
          </div>
        </div>
      </GnModal>

      <GnAlert v-if="error" variant="error">{{ error }}</GnAlert>

      <!-- Прогресс уровня: XP только растёт, уровень не падает -->
      <GnProgress
        v-if="xp"
        class="level-progress"
        :value="xp.level_xp"
        :max="xp.level_span"
        :label="t('garden.levelProgress', { xp: xp.level_xp, span: xp.level_span })"
      />

      <!-- Последняя награда: закрытие, создание задачи/проекта — что и когда начислено последним -->
      <div v-if="lastReward" class="last-plant">
        <i class="ph ph-gift" aria-hidden="true" />
        <span>{{
          t('garden.lastReward', {
            title: rewardTitle,
            date: formatDate(lastReward.created_at),
          })
        }}</span>
      </div>

      <!-- Сцена сада: домик, растения (drag&drop), декорации -->
      <GardenScene
        v-if="garden && garden.items.length"
        :state="garden"
        :balance="garden.balance"
        @move="onMove"
        @upgrade="onUpgrade"
        @dragging="onDragging"
      />
      <GnEmptyState
        v-else
        icon="ph-flower-lotus"
        :title="t('garden.emptyTitle')"
        :text="t('garden.emptyText')"
      >
        <GnButton variant="secondary" icon="ph-question" @click="aboutOpen = true">
          {{ t('garden.aboutOpen') }}
        </GnButton>
      </GnEmptyState>

      <!-- Ачивки с тирами: прогресс к следующей ступени виден всегда -->
      <div class="achievements-title">
        <i class="ph ph-trophy" aria-hidden="true" />
        <span>{{ t('garden.achievements') }}</span>
      </div>
      <div class="achievements-grid">
        <div
          v-for="a in achievements"
          :key="a.key"
          class="achievement"
          :class="{ unlocked: currentTier(a) === -1 }"
        >
          <i :class="`ph ${a.icon}`" aria-hidden="true" />
          <span class="achievement-name">{{ t(`garden.ach.${a.key}`) }}</span>
          <GnBadge
            :variant="currentTier(a) === -1 ? 'success' : 'neutral'"
            :title="
              currentTier(a) === -1
                ? t('garden.achDone', { r: ROMANS[a.tiers.length - 1] })
                : t('garden.achStep')
            "
          >
            {{ tierLabel(a) }}
          </GnBadge>
        </div>
      </div>
    </template>
  </section>
</template>

<style scoped>
.page-skeleton {
  margin-bottom: 1rem;
}
.level-progress {
  margin-bottom: 1rem;
  /* на всю ширину контейнера (в ките .progress упёрт в max-width: 640px) */
  max-width: 100%;
}
/* справка о саде */
.about-body {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  font-size: 0.95em;
  line-height: 1.5;
}
/* дерево уровней */
.levels-hint {
  margin: 0 0 0.75rem;
  font-size: 0.88em;
  color: var(--text-muted, #888);
  line-height: 1.45;
}
.levels-tree {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}
.level-node {
  padding: 0.6rem 0.75rem;
  border: 1px solid var(--border, #2a2f45);
  opacity: 0.6;
}
.level-node.past {
  opacity: 0.85;
  /* фон как у пройденных достижений: зелёная подложка */
  background: color-mix(in srgb, var(--success, #9ece6a) 8%, transparent);
}
.level-node.current {
  opacity: 1;
  background: color-mix(in srgb, var(--accent, #7aa2f7) 8%, transparent);
}
.level-node-head {
  display: flex;
  gap: 0.5rem;
  align-items: center;
  font-weight: 600;
  font-size: 0.95em;
}
.level-node-head > i {
  font-size: 1.1rem;
  color: var(--text-muted, #888);
}
.level-node.past > .level-node-head > i,
.level-node.current > .level-node-head > i {
  color: var(--success, #9ece6a);
}
.level-node-title {
  flex: 1 1 auto;
}
.level-threshold {
  white-space: nowrap;
}
.level-node-unlocks {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem 1rem;
  margin-top: 0.4rem;
}
.unlock-lock {
  color: var(--success, #9ece6a);
  align-self: center;
}
.level-unlock {
  display: flex;
  gap: 0.35rem;
  align-items: center;
  font-size: 0.85em;
}
.level-unlock > i:first-child {
  font-size: 1rem;
  color: var(--success, #9ece6a);
}
.unlock-price {
  white-space: nowrap;
}
/* маркет */
.shop-body {
  display: flex;
  flex-direction: column;
  gap: 1.25rem;
  font-size: 0.95em;
}
.shop-hint {
  color: var(--text-muted, #888);
  line-height: 1.45;
}
.shop-coins {
  align-self: flex-start;
}
.shop-block {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}
.shop-block-title {
  display: flex;
  gap: 0.5rem;
  align-items: center;
  font-weight: 600;
  font-size: 1rem;
}
.shop-block-title > i {
  color: var(--accent, #7aa2f7);
}
.shop-block-hint {
  margin: 0;
  font-size: 0.88em;
  color: var(--text-muted, #888);
}
.shop-item,
.shop-card {
  display: flex;
  gap: 0.6rem;
  align-items: center;
  padding: 0.6rem 0.75rem;
  border: 1px solid var(--border, #2a2f45);
}
.shop-card > i:first-child {
  font-size: 1.3rem;
  color: var(--accent, #7aa2f7);
}
.shop-item > i:first-child {
  font-size: 1.3rem;
  color: var(--success, #9ece6a);
}
.shop-item.locked > i:first-child {
  color: var(--text-muted, #888);
  opacity: 0.5;
}
.shop-item.locked {
  opacity: 0.6;
}
.shop-name {
  flex: 1 1 auto;
  display: flex;
  gap: 0.4rem;
  align-items: center;
  font-weight: 600;
  font-size: 0.92em;
}
.shop-meta {
  font-size: 0.82em;
  color: var(--text-muted, #888);
}
.shop-price {
  white-space: nowrap;
}
.shop-poor {
  font-size: 0.82em;
  color: #f7768e;
  white-space: nowrap;
}
.shop-owned {
  color: var(--success, #9ece6a);
  font-weight: 700;
}
.shop-max {
  margin: 0;
  font-size: 0.9em;
  color: var(--text-muted, #888);
}
/* последний росток */
.last-plant {
  display: flex;
  gap: 0.5rem;
  align-items: center;
  margin-bottom: 1.5rem;
  font-size: 0.92em;
  color: var(--text-muted, #888);
}
.last-plant > i {
  color: var(--accent, #7aa2f7);
}
/* ачивки */
.achievements-title {
  display: flex;
  gap: 0.6rem;
  align-items: center;
  margin: 0 0 0.75rem;
  font-weight: 600;
  font-size: 1.05rem;
}
.achievements-title > i {
  color: var(--accent, #7aa2f7);
}
.achievements-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 0.75rem;
}
/* на десктопе — ровно 4 карточки в ряд */
@media (min-width: 1024px) {
  .achievements-grid {
    grid-template-columns: repeat(4, 1fr);
  }
}
.achievement {
  display: flex;
  gap: 0.6rem;
  align-items: center;
  padding: 0.75rem;
  border: 1px solid var(--border, #2a2f45);
  opacity: 0.55;
}
.achievement.unlocked {
  opacity: 1;
  background: color-mix(in srgb, var(--success, #9ece6a) 8%, transparent);
}
.achievement > i {
  font-size: 1.3rem;
  color: var(--success, #9ece6a);
}
.achievement-name {
  flex: 1 1 auto;
  font-size: 0.92em;
  font-weight: 600;
}
</style>