18 Commits
Author SHA1 Message Date
austnv 07229efac0 add NodesStatus.vue & v0.8.0
Build and Deploy Frontend via SSH / deploy (push) Failing after 35s
2026-06-21 17:02:02 +03:00
austnv 39023a66c2 add workflow 2026-06-21 16:33:44 +03:00
austnv 20cd884718 Delete directory '.vscode' 2026-06-21 15:57:35 +03:00
austnv 1e4882379a fix: move IpSecurity component from AppHeader to HeroSection
This change was need to remove IpSecurity component from legal docs viewer
2026-06-16 16:32:36 +03:00
austnv bb5b8cf012 Merge pull request 'feat: implement dynamic pricing from remnawave-minishop API' (#4) from dev into main
Reviewed-on: #4
2026-06-14 22:10:11 +00:00
austnv 3c0891fe94 update postbuild script 2026-06-15 00:57:56 +03:00
austnv bf9b095b27 update flags flagcdn -> jsdelivr (unavaluable from Russia) 2026-06-15 00:55:19 +03:00
austnv c8486ca001 update pricing section to issue #2 2026-06-15 00:14:20 +03:00
austnv 7b7d58f61f update pricing to new api 2026-06-14 22:38:12 +03:00
austnv fa88b2ff84 Merge pull request 'Merge dev into main: версия 0.6.0 с компонентом IpSecurity, флагами и улучшениями' (#3) from dev into main
Reviewed-on: #3
2026-06-14 18:31:46 +00:00
austnv 82b4bf7b37 fix + update version 2026-06-14 21:08:23 +03:00
austnv e56f0f54d3 asn.name -> company.name
+ hosting to detect vpn
2026-06-14 20:55:46 +03:00
austnv 10b05ffd45 fix 2026-06-14 20:46:41 +03:00
austnv 5dd97a03a4 1 rem div img 2026-06-14 20:42:54 +03:00
austnv 8f8eeb958c update component 2026-06-14 20:24:42 +03:00
austnv b3fd3219d7 add flag support 2026-06-14 19:57:46 +03:00
austnv fadaed985e update component 2026-06-14 14:37:12 +03:00
austnv 4a812f60b3 add IpSecurity component 2026-06-14 13:22:27 +03:00
11 changed files with 1276 additions and 1138 deletions
+40
View File
@@ -0,0 +1,40 @@
name: Build and Deploy Frontend via SSH
on:
push:
tags:
- '*'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm install
- name: Build project
run: npm run build
- name: Deploy via SCP
run: |
# Создаём архив собранных файлов
tar -czf frontend.tar.gz -C dist .
# Копируем архив на хост через SCP
scp -o StrictHostKeyChecking=no frontend.tar.gz ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/tmp/
# Распаковываем и перемещаем файлы на хосте
ssh -o StrictHostKeyChecking=no ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} << 'EOF'
mkdir -p /opt/uvpn.shop/frontend
rm -rf /opt/uvpn.shop/frontend/*
tar -xzf /tmp/frontend.tar.gz -C /opt/uvpn.shop/frontend
rm /tmp/frontend.tar.gz
EOF
-3
View File
@@ -1,3 +0,0 @@
{
"recommendations": ["Vue.volar"]
}
+586 -1012
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,19 +1,19 @@
{ {
"name": "uvpn.shop", "name": "uvpn.shop",
"version": "0.5.3", "version": "0.8.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview"
"postbuild": "scp -r dist root@uvpn.shop:/opt/uvpn.shop"
}, },
"dependencies": { "dependencies": {
"@lucide/vue": "^1.16.0", "@lucide/vue": "^1.16.0",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"@tresjs/cientos": "^5.7.1", "@tresjs/cientos": "^5.7.1",
"@tresjs/core": "^5.8.1", "@tresjs/core": "^5.8.1",
"@tw-labs/countries": "^0.0.2",
"@vueuse/head": "^0.9.8", "@vueuse/head": "^0.9.8",
"marked": "^18.0.5", "marked": "^18.0.5",
"swiper": "^12.1.4", "swiper": "^12.1.4",
-1
View File
@@ -25,5 +25,4 @@
</template> </template>
<script setup> <script setup>
// Скрипты больше не нужны, всю магию сделает роутер!
</script> </script>
+4
View File
@@ -1,4 +1,7 @@
<template> <template>
<IpSecurity />
<section id="hero" class="relative overflow-hidden px-6 py-16 md:py-24 md:px-12"> <section id="hero" class="relative overflow-hidden px-6 py-16 md:py-24 md:px-12">
<!-- Контейнер для параллакс-фона --> <!-- Контейнер для параллакс-фона -->
<div class="parallax-bg absolute inset-0 z-0"></div> <div class="parallax-bg absolute inset-0 z-0"></div>
@@ -38,6 +41,7 @@
<script setup> <script setup>
import { Zap } from '@lucide/vue' import { Zap } from '@lucide/vue'
import Hero3D from './Hero3D.vue' import Hero3D from './Hero3D.vue'
import IpSecurity from './IpSecurity.vue';
</script> </script>
<style scoped> <style scoped>
+148
View File
@@ -0,0 +1,148 @@
<template>
<div v-if="ipData" class="bg-gray-50/80 dark:bg-gray-800/30 border-b border-gray-200 dark:border-gray-700 text-sm backdrop-blur-sm">
<div class="container mx-auto px-6 py-2 md:px-12">
<div class="flex items-center justify-between gap-3 flex-wrap">
<!-- Блок с IP и защитой -->
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
<span class="text-gray-500 dark:text-gray-400 text-xs">🌐 Ваш IP:</span>
<code class="font-mono text-xs md:text-sm font-medium bg-white dark:bg-gray-900 px-2 py-0.5 rounded border border-gray-200 dark:border-gray-700">
{{ ipData.ip }}
</code>
</div>
<span class="inline-flex items-center gap-2 text-xs px-2 py-0.5 rounded-full font-medium" :class="isProtected ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'">
<span class="relative flex h-1.5 w-1.5">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75" :class="isProtected ? 'bg-green-500' : 'bg-amber-500'"></span>
<span class="relative inline-flex rounded-full h-1.5 w-1.5" :class="isProtected ? 'bg-green-600' : 'bg-amber-600'"></span>
</span>
{{ isProtected ? 'Защищено' : 'Не защищено' }}
</span>
</div>
<!-- Локация с флагом -->
<div class="flex items-center justify-center gap-1 text-xs text-gray-500 dark:text-gray-400">
<!-- Локальный SVG-флаг -->
<img
v-if="countryCode"
:src="`https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/flags/4x3/${countryCode}.svg`"
:alt="countryCode"
class="h-3 w-auto"
/>
<template v-if="ipData.location?.city">
<span class="hidden sm:inline">{{ ipData.location.city }}</span>
<span class="text-gray-400 hidden sm:inline"></span>
</template>
<span v-if="ipData.location?.country" class="text-gray-600 dark:text-gray-300 font-medium hidden sm:inline">
{{ ipData.location.country }}
</span>
<template v-if="ipData.company?.name">
<span class="text-gray-400 hidden md:inline"></span>
<span class="hidden md:inline text-gray-400">
{{ truncateText(ipData.company.name, 30) }}
</span>
</template>
</div>
</div>
</div>
</div>
<!-- Лоадер -->
<div v-else-if="loading" class="bg-gray-50/80 dark:bg-gray-800/30 border-b border-gray-200 dark:border-gray-700">
<div class="container mx-auto px-6 py-2 md:px-12">
<div class="flex items-center gap-3">
<div class="h-5 w-28 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
<div class="h-5 w-16 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
</div>
</div>
</div>
<!-- Ошибка -->
<div v-else-if="error" class="bg-amber-50 dark:bg-amber-900/10 border-b border-amber-200 dark:border-amber-800/50">
<div class="container mx-auto px-6 py-1.5 md:px-12">
<div class="flex items-center gap-2 text-xs text-amber-700 dark:text-amber-400">
<span></span>
<span>Не удалось определить IP</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { getAllCountries } from '@tw-labs/countries'
const ipData = ref(null)
const loading = ref(true)
const error = ref(false)
// Список стран для маппинга названия -> код
const countriesList = getAllCountries()
function findCountryByName(countryName) {
if (!countryName) return null
const found = countriesList.find(c => c.label.toLowerCase() === countryName.toLowerCase())
if (found) return found
// частичное совпадение (например "Russian Federation" vs "Russia")
const partial = countriesList.find(c =>
countryName.toLowerCase().includes(c.label.toLowerCase()) ||
c.label.toLowerCase().includes(countryName.toLowerCase())
)
return partial || null
}
async function fetchIp() {
loading.value = true
error.value = false
try {
const response = await fetch('/api/v1/ip')
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json()
const countryName = data.location?.country
const foundCountry = findCountryByName(countryName)
const countryCode = foundCountry?.code || null
ipData.value = {
...data,
location: {
...data.location,
computed_country_code: countryCode
}
}
} catch (err) {
console.error('IP fetch error:', err)
error.value = true
ipData.value = null
} finally {
loading.value = false
}
}
const isProtected = computed(() => {
if (!ipData.value) return false
return ipData.value.security?.vpn ||
ipData.value.security?.proxy ||
ipData.value.security?.tor ||
ipData.value.security?.hosting
})
const countryCode = computed(() => {
const code = ipData.value?.location?.computed_country_code
return code ? code.toLowerCase() : null
})
function truncateText(text, maxLength) {
if (!text) return ''
return text.length > maxLength ? text.slice(0, maxLength) + '...' : text
}
onMounted(() => {
fetchIp()
})
</script>
+333
View File
@@ -0,0 +1,333 @@
<template>
<section id="nodes" class="px-6 py-20 md:px-12 max-w-7xl mx-auto">
<div class="flex justify-between items-center mb-10 flex-wrap gap-4">
<div>
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white">
🌍 Статус серверов
</h2>
<p class="text-gray-500 dark:text-gray-400 mt-2">
{{ onlineCount }} из {{ nodes.length }} серверов онлайн
<span v-if="averagePing !== null" class="ml-4">
📶 Средний пинг: <span class="font-semibold text-blue-600 dark:text-blue-400">{{ averagePing }}ms</span>
</span>
</p>
</div>
<button
@click="refreshData"
:disabled="loading"
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<span :class="{ 'animate-spin': loading }">🔄</span>
{{ loading ? 'Обновление...' : 'Обновить' }}
</button>
</div>
<!-- Состояние загрузки -->
<div v-if="loading && !nodes.length" class="flex justify-center py-20">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
<!-- Ошибка -->
<div v-else-if="error" class="text-center text-red-500 py-12">
<p class="text-lg">{{ error }}</p>
<button @click="refreshData" class="mt-4 text-blue-600 dark:text-blue-400 hover:underline">
Попробовать снова
</button>
</div>
<!-- Сетка серверов -->
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div
v-for="node in sortedNodes"
:key="node.name"
class="bg-white dark:bg-gray-900 rounded-xl p-5 border transition hover:shadow-lg"
:class="[
node.isOnline
? 'border-green-500/30 dark:border-green-500/20 hover:border-green-500'
: 'border-red-500/30 dark:border-red-500/20 opacity-60'
]"
>
<!-- Шапка -->
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<!-- Флаг из CDN -->
<img
v-if="node.country_code"
:src="`https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/flags/4x3/${node.country_code.toLowerCase()}.svg`"
:alt="node.country_code.toLowerCase()"
class="h-5 w-auto rounded-sm"
/>
<div>
<h3 class="font-semibold text-gray-900 dark:text-white">
{{ node.name }}
</h3>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ node.country_code }}
</span>
</div>
</div>
<span
class="text-xs px-2 py-1 rounded-full font-medium"
:class="node.isOnline
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400'"
>
{{ node.isOnline ? '🟢 Онлайн' : '🔴 Офлайн' }}
</span>
</div>
<!-- IP и порт -->
<div class="text-sm text-gray-500 dark:text-gray-400 mb-3 font-mono">
{{ node.ip }}:{{ node.port }}
</div>
<!-- Статистика -->
<div class="grid grid-cols-2 gap-2 text-sm">
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-2 text-center">
<div class="text-gray-500 dark:text-gray-400 text-xs">Пользователи</div>
<div class="font-semibold text-gray-900 dark:text-white">
{{ node.users_online }}
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-2 text-center">
<div class="text-gray-500 dark:text-gray-400 text-xs">Uptime</div>
<div class="font-semibold text-gray-900 dark:text-white">
{{ formatUptime(node.uptime) }}
</div>
</div>
</div>
<!-- Пинг (если измерен) -->
<div v-if="node.ping !== undefined && node.ping !== null" class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<div class="flex justify-between items-center text-sm">
<span class="text-gray-500 dark:text-gray-400">📶 Пинг</span>
<span
class="font-semibold"
:class="{
'text-green-600 dark:text-green-400': node.ping < 50,
'text-yellow-600 dark:text-yellow-400': node.ping >= 50 && node.ping < 150,
'text-orange-600 dark:text-orange-400': node.ping >= 150 && node.ping < 300,
'text-red-600 dark:text-red-400': node.ping >= 300
}"
>
{{ node.ping }}ms
</span>
</div>
<!-- Прогресс-бар пинга -->
<div class="w-full h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full mt-1 overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
:class="{
'bg-green-500': node.ping < 50,
'bg-yellow-500': node.ping >= 50 && node.ping < 150,
'bg-orange-500': node.ping >= 150 && node.ping < 300,
'bg-red-500': node.ping >= 300
}"
:style="{ width: Math.min((node.ping / 300) * 100, 100) + '%' }"
></div>
</div>
</div>
<!-- Кнопка проверки пинга -->
<button
@click="pingNode(node)"
:disabled="node.pinging || !node.isOnline"
class="mt-3 w-full text-xs py-1.5 rounded-lg transition font-medium"
:class="[
node.isOnline
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900/30 disabled:opacity-50'
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 cursor-not-allowed'
]"
>
{{ node.pinging ? '⏳ Измерение...' : 'Проверить пинг' }}
</button>
</div>
</div>
<!-- Футер с статистикой -->
<div
v-if="!loading && nodes.length"
class="mt-10 grid grid-cols-2 md:grid-cols-4 gap-4 text-center"
>
<div class="bg-gray-50 dark:bg-gray-800/50 rounded-xl p-4">
<div class="text-2xl font-bold text-gray-900 dark:text-white">
{{ nodes.length }}
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Всего серверов</div>
</div>
<div class="bg-gray-50 dark:bg-gray-800/50 rounded-xl p-4">
<div class="text-2xl font-bold text-green-600 dark:text-green-400">
{{ onlineCount }}
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Онлайн</div>
</div>
<div class="bg-gray-50 dark:bg-gray-800/50 rounded-xl p-4">
<div class="text-2xl font-bold text-red-600 dark:text-red-400">
{{ offlineCount }}
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Офлайн</div>
</div>
<div class="bg-gray-50 dark:bg-gray-800/50 rounded-xl p-4">
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400">
{{ totalUsers }}
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Всего пользователей</div>
</div>
</div>
</section>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
// Состояние
const nodes = ref([])
const loading = ref(false)
const error = ref(null)
let updateInterval = null
// Вычисляемые свойства
const sortedNodes = computed(() => {
return [...nodes.value].sort((a, b) => {
// Сначала онлайн
if (a.isOnline !== b.isOnline) {
return a.isOnline ? -1 : 1
}
// Потом по имени
return a.name.localeCompare(b.name)
})
})
const onlineCount = computed(() => {
return nodes.value.filter(n => n.isOnline).length
})
const offlineCount = computed(() => {
return nodes.value.filter(n => !n.isOnline).length
})
const totalUsers = computed(() => {
return nodes.value.reduce((sum, n) => sum + (n.users_online || 0), 0)
})
const averagePing = computed(() => {
const pings = nodes.value
.filter(n => n.ping !== undefined && n.ping !== null)
.map(n => n.ping)
if (pings.length === 0) return null
return Math.round(pings.reduce((a, b) => a + b, 0) / pings.length)
})
// Методы
const formatUptime = (seconds) => {
if (!seconds) return '0м'
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (days > 0) return `${days}д ${hours}ч`
if (hours > 0) return `${hours}ч ${minutes}м`
return `${minutes}м`
}
// Загрузка данных
const fetchNodes = async () => {
loading.value = true
error.value = null
try {
const response = await fetch('/api/v1/nodes')
if (!response.ok) {
throw new Error(`Ошибка: ${response.status}`)
}
const data = await response.json()
// Добавляем поле isOnline и инициализируем пинг
nodes.value = data.map(node => ({
...node,
isOnline: true, // Все ноды из бэкенда считаем онлайн
ping: undefined,
pinging: false
}))
} catch (err) {
console.error('Failed to load nodes:', err)
error.value = 'Не удалось загрузить статус серверов'
} finally {
loading.value = false
}
}
// Измерение пинга через WebSocket
const pingNode = async (node) => {
if (!node.isOnline || node.pinging) return
node.pinging = true
node.ping = undefined
const startTime = performance.now()
const wsUrl = `wss://${node.ip}:${node.port}`
try {
const ws = new WebSocket(wsUrl)
const timeout = setTimeout(() => {
ws.close()
node.ping = null
node.pinging = false
}, 5000)
ws.onopen = () => {
const endTime = performance.now()
clearTimeout(timeout)
node.ping = Math.round(endTime - startTime)
node.pinging = false
ws.close()
}
ws.onerror = () => {
clearTimeout(timeout)
node.ping = null
node.pinging = false
}
} catch {
node.ping = null
node.pinging = false
}
}
// Измерение пинга для всех нод
const pingAllNodes = async () => {
const pingPromises = nodes.value.map(node => pingNode(node))
await Promise.allSettled(pingPromises)
}
// Обновление данных
const refreshData = async () => {
await fetchNodes()
// После загрузки данных измеряем пинг для онлайн нод
await pingAllNodes()
}
// Жизненный цикл
onMounted(() => {
refreshData()
// Автообновление каждые 30 секунд
updateInterval = setInterval(() => {
if (!loading.value) {
refreshData()
}
}, 30000)
})
onUnmounted(() => {
if (updateInterval) {
clearInterval(updateInterval)
}
})
</script>
+113 -70
View File
@@ -4,8 +4,20 @@
Прозрачные тарифы Прозрачные тарифы
</h2> </h2>
<!-- Состояние загрузки -->
<div v-if="loading" class="flex justify-center py-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
<!-- Состояние ошибки -->
<div v-else-if="error" class="text-center text-red-500 py-12">
{{ error }}
</div>
<!-- Основной контент -->
<template v-else>
<!-- Переключатель периода --> <!-- Переключатель периода -->
<div class="flex justify-center gap-2 mb-10"> <div class="flex justify-center gap-2 mb-10 flex-wrap">
<button <button
v-for="period in availablePeriods" v-for="period in availablePeriods"
:key="period.value" :key="period.value"
@@ -20,17 +32,20 @@
</div> </div>
<!-- Сетка тарифов --> <!-- Сетка тарифов -->
<div class="grid md:grid-cols-2 gap-12"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8"
style="grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));"
>
<div <div
v-for="tariff in tariffCards" v-for="tariff in tariffCards"
:key="tariff.key" :key="tariff.key"
class="bg-white dark:bg-gray-900 rounded-2xl p-8 flex flex-col border transition" class="bg-white dark:bg-gray-900 rounded-2xl p-8 flex flex-col border transition relative"
:class="[ :class="[
tariff.isPopular tariff.isPopular
? 'border-2 border-blue-600 relative transform scale-105 shadow-2xl shadow-blue-600/20' ? 'border-2 border-blue-600 shadow-xl shadow-blue-600/20'
: 'border border-gray-200 dark:border-gray-800 hover:border-blue-600' : 'border border-gray-200 dark:border-gray-800 hover:border-blue-600'
]" ]"
> >
<!-- Бейдж "ПОПУЛЯРНЫЙ" -->
<span <span
v-if="tariff.isPopular" v-if="tariff.isPopular"
class="absolute top-0 right-0 bg-blue-600 dark:bg-blue-500 text-gray-900 dark:text-white text-xs font-bold px-4 py-1 rounded-bl-xl rounded-tr-xl" class="absolute top-0 right-0 bg-blue-600 dark:bg-blue-500 text-gray-900 dark:text-white text-xs font-bold px-4 py-1 rounded-bl-xl rounded-tr-xl"
@@ -49,98 +64,126 @@
</li> </li>
</ul> </ul>
<button <a
class="w-full transition py-3 rounded-xl font-medium text-white" :href="'https://app.uvpn.shop'"
target="_blank"
rel="noopener noreferrer"
class="w-full transition py-3 rounded-xl font-medium text-white block text-center"
:class="tariff.isPopular :class="tariff.isPopular
? 'bg-blue-600 dark:bg-blue-500 hover:bg-blue-700 font-bold' ? 'bg-blue-600 dark:bg-blue-500 hover:bg-blue-700 font-bold'
: 'bg-gray-800 hover:bg-gray-700'" : 'bg-gray-800 hover:bg-gray-700'"
> >
{{ tariff.isPopular ? 'Выбрать тариф' : 'Выбрать' }} {{ tariff.isPopular ? 'Выбрать тариф' : 'Выбрать' }}
</button> </a>
</div> </div>
</div> </div>
</template>
</section> </section>
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed, onMounted } from 'vue'
import { Check } from '@lucide/vue' import { Check } from '@lucide/vue'
// Исходный конфиг // Состояния
const pricingConfig = { const loading = ref(true)
default_tariff: "premium", const error = ref(null)
tariffs: [ const tariffsData = ref([]) // исходные тарифы (из API)
{ const defaultTariffKey = ref('') // ключ популярного тарифа
key: "standard", const selectedPeriod = ref(1) // выбранный период (месяцев)
names: { ru: "Standard", en: "Standard" },
descriptions: { ru: "До 3 устройств", en: "up to 3 devices" }, // Доступные периоды (вычисляются динамически)
prices_rub: { "1": 179.0, "3": 387.0, "6": 594.0, "12": 996.0 }, const availablePeriods = computed(() => {
hwid_device_limit: 2, const periodsSet = new Set()
monthly_gb: 0.0 for (const tariff of tariffsData.value) {
}, if (tariff.enabled && tariff.enabled_periods) {
{ tariff.enabled_periods.forEach(p => periodsSet.add(p))
key: "premium",
names: { ru: "Premium", en: "Premium"},
descriptions: { ru: "До 10 устройств, сервера РФ и США", en: "Up to 10 devices, RU, USA servers" },
prices_rub: { "1": 349.0, "3": 987.0, "6": 1734.0, "12": 1992.0 },
hwid_device_limit: 10,
monthly_gb: 0.0
} }
] }
} const sorted = Array.from(periodsSet).sort((a, b) => a - b)
return sorted.map(value => ({
value,
label: value === 1 ? '1 мес' : `${value} мес`
}))
})
// Доступные периоды // Активные (включённые) тарифы, отфильтрованные по выбранному периоду
const availablePeriods = [ const activeTariffs = computed(() => {
{ value: 1, label: '1 мес' }, return tariffsData.value.filter(tariff =>
{ value: 3, label: '3 мес' }, tariff.enabled && tariff.enabled_periods?.includes(selectedPeriod.value)
{ value: 6, label: '6 мес' }, )
{ value: 12, label: '12 мес' } })
]
// Выбранный период (по умолчанию 1 месяц) // Карточки для отображения (с ценами, фичами и флагом популярности)
const selectedPeriod = ref(1)
// Функция для получения цены за месяц
const getMonthlyPrice = (tariff, period) => {
const total = tariff.prices_rub[String(period)]
if (!total) return '—'
const monthly = total / period
return Number.isInteger(monthly) ? monthly : monthly.toFixed(2)
}
// Подготовка данных для карточек
const tariffCards = computed(() => { const tariffCards = computed(() => {
const tariffs = pricingConfig.tariffs return activeTariffs.value.map(tariff => {
const defaultKey = pricingConfig.default_tariff const totalPrice = tariff.prices_rub?.[String(selectedPeriod.value)]
let monthlyPrice = '—'
if (totalPrice && totalPrice > 0) {
const monthly = totalPrice / selectedPeriod.value
monthlyPrice = Number.isInteger(monthly) ? monthly : monthly.toFixed(2)
}
// Разделяем тарифы: популярный (премиум) и остальные // Формируем список особенностей (features)
const premiumTariff = tariffs.find(t => t.key === defaultKey) const features = []
const otherTariffs = tariffs.filter(t => t.key !== defaultKey)
// Порядок: первый обычный, премиум, второй обычный // 1. Описание из JSON (если есть)
const ordered = [ const description = tariff.descriptions?.ru?.trim()
{ ...otherTariffs[0], isPopular: false }, if (description) {
{ ...premiumTariff, isPopular: true }, features.push(description)
] }
return ordered.map(tariff => { // 2. Лимит устройств
const price = getMonthlyPrice(tariff, selectedPeriod.value) if (tariff.hwid_device_limit) {
features.push(`До ${tariff.hwid_device_limit} устройств`)
}
// Фичи: разбиваем описание по запятой и добавляем общие пункты // 3. Безлимитный трафик (всегда)
const descriptionFeatures = tariff.descriptions.ru features.push('Безлимитный трафик')
.split(',')
.map(s => s.trim()) // 4. Поддержка 24/7
.filter(Boolean) features.push('Поддержка 24/7')
const staticFeatures = ['Безлимитный трафик', 'Поддержка 24/7']
const features = [...new Set([...descriptionFeatures, ...staticFeatures])]
return { return {
key: tariff.key, key: tariff.key,
name: tariff.names.ru, name: tariff.names?.ru || tariff.key,
price: `${price}`, price: `${monthlyPrice}`,
features, features,
isPopular: tariff.isPopular isPopular: tariff.key === defaultTariffKey.value
} }
}) })
}) })
// Загрузка данных с API
const fetchTariffs = async () => {
loading.value = true
error.value = null
try {
const response = await fetch('/api/v1/tariffs')
if (!response.ok) {
throw new Error(`Ошибка загрузки: ${response.status}`)
}
const data = await response.json()
tariffsData.value = data.tariffs || []
defaultTariffKey.value = data.default_tariff || ''
// Установка начального периода (первый доступный)
if (availablePeriods.value.length > 0) {
const exists = availablePeriods.value.some(p => p.value === selectedPeriod.value)
if (!exists) {
selectedPeriod.value = availablePeriods.value[0].value
}
}
} catch (err) {
console.error('Failed to load tariffs:', err)
error.value = 'Не удалось загрузить тарифы. Попробуйте позже.'
} finally {
loading.value = false
}
}
onMounted(() => {
fetchTariffs()
})
</script> </script>
+2
View File
@@ -4,6 +4,7 @@
<FeaturesSection /> <FeaturesSection />
<CompatibilitySection /> <CompatibilitySection />
<PricingSection /> <PricingSection />
<NodesStatus />
<StepsSection /> <StepsSection />
<TestimonialsSection /> <TestimonialsSection />
<FaqSection /> <FaqSection />
@@ -15,6 +16,7 @@ import HeroSection from '../components/HeroSection.vue'
import FeaturesSection from '../components/FeaturesSection.vue' import FeaturesSection from '../components/FeaturesSection.vue'
import CompatibilitySection from '../components/CompatibilitySection.vue' import CompatibilitySection from '../components/CompatibilitySection.vue'
import PricingSection from '../components/PricingSection.vue' import PricingSection from '../components/PricingSection.vue'
import NodesStatus from '@/components/NodesStatus.vue'
import StepsSection from '../components/StepsSection.vue' import StepsSection from '../components/StepsSection.vue'
import TestimonialsSection from '../components/TestimonialsSection.vue' import TestimonialsSection from '../components/TestimonialsSection.vue'
import FaqSection from '../components/FaqSection.vue' import FaqSection from '../components/FaqSection.vue'
+5 -7
View File
@@ -5,7 +5,6 @@ import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import { default as Sitemap } from 'vite-plugin-sitemap'; import { default as Sitemap } from 'vite-plugin-sitemap';
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
@@ -13,12 +12,8 @@ export default defineConfig({
tailwindcss(), tailwindcss(),
Sitemap({ Sitemap({
hostname: 'https://uvpn.shop', hostname: 'https://uvpn.shop',
// Плагин сам прочитает маршруты из вашего router, но если нужно указать вручную: dynamicRoutes: ['/docs/policy', '/docs/agreement'],
// routes: ['/', '/docs'], // можно опустить — он определит автоматически outDir: 'dist',
// Если у вас динамические маршруты (например, /docs/:docName), их нужно добавить отдельно:
dynamicRoutes: ['/docs/policy', '/docs/agreement'], // сюда перечислите все возможные динамические URL
// Настройки changefreq, priority и т.д.:
outDir: 'dist', // папка сборки
changefreq: 'weekly', changefreq: 'weekly',
priority: 0.8 priority: 0.8
}) })
@@ -28,4 +23,7 @@ export default defineConfig({
'@': fileURLToPath(new URL('./src', import.meta.url)) '@': fileURLToPath(new URL('./src', import.meta.url))
}, },
}, },
build: {
chunkSizeWarningLimit: 1000
}
}) })