32 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
austnv 75f95ee2f8 edit policy 2026-06-12 17:35:50 +03:00
austnv 67b2fe7688 добавлен ИНН 2026-06-07 22:21:50 +00:00
austnv 76ba1948e7 minor 2026-06-07 23:14:15 +03:00
austnv 96ac249acf fix: @vueuse/head -> <meta> index.html 2026-06-07 23:13:51 +03:00
austnv f0165aaa2e update meta 2026-06-07 23:09:36 +03:00
austnv 05096522e0 update postscripts and rename title tag 2026-06-07 22:53:15 +03:00
Alexey Ustinov a52fa0f28e v0.5.0 - SEO friendly update
- sitemap.xml
- robots.txt
- meta-tags
2026-06-07 21:45:58 +03:00
austnv 4e24405d11 Обновить README.md 2026-06-07 09:30:01 +00:00
austnv d38ff3183a Delete directory '.gitea' 2026-06-07 09:27:33 +00:00
austnv 214f4e6029 update README
Gitea Actions Demo / Explore-Gitea-Actions (push) Has been cancelled
2026-06-07 11:42:47 +03:00
austnv 3dc78f34f3 update README
Gitea Actions Demo / Explore-Gitea-Actions (push) Has been cancelled
2026-06-07 11:17:02 +03:00
austnv 38b5bcd153 update README
Gitea Actions Demo / Explore-Gitea-Actions (push) Has been cancelled
2026-06-07 11:04:49 +03:00
austnv a20d4aeb92 update runner
Gitea Actions Demo / Explore-Gitea-Actions (push) Has been cancelled
2026-06-07 10:44:51 +03:00
austnv 17a29311ea gitea runner
Gitea Actions Demo / Explore-Gitea-Actions (push) Has been cancelled
2026-06-07 10:29:51 +03:00
17 changed files with 1523 additions and 642 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"]
}
+13 -31
View File
@@ -1,38 +1,20 @@
# uvpn.shop
# Фронтенд лендинга uVPN
This template should help get you started developing with Vue 3 in Vite.
Стек:
## Recommended IDE Setup
* Vue
* Vue Router
* JS
* Tailwind CSS
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
Запуск:
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
```bash
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```
Ссылки:
* [Продакшн](https://uvpn.shop)
* [Тест](https://austnv-uvpn-frontend-7aca.twc1.net)
+9
View File
@@ -4,6 +4,15 @@
<meta charset="UTF-8">
<link rel="icon" href="/images/logo.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Безопасный, быстрый и анонимный VPN-сервис для вашего ПК и смартфона. Защитите свои данные от посторонних глаз уже сегодня.">
<meta name="keywords" content="vpn, сервис, анонимность, безопасность, шифрование, купить vpn">
<meta property="og:title" content="uVPN">
<meta property="og:description" content="Безопасный, быстрый и анонимный VPN-сервис для вашего ПК и смартфона. Защитите свои данные от посторонних глаз уже сегодня.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://uvpn.shop/">
<meta property="og:site_name" content="uVPN">
<meta property="telegram:channel" content="@uvpn_shop">
<meta property="tg:site_verification" content="g7j8/rPFXfhyrq5q0QQV7EsYWv4=">
<title>uVPN</title>
</head>
<body>
+728 -490
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "uvpn.shop",
"version": "0.4.1",
"version": "0.8.0",
"private": true,
"type": "module",
"scripts": {
@@ -13,6 +13,8 @@
"@tailwindcss/vite": "^4.3.0",
"@tresjs/cientos": "^5.7.1",
"@tresjs/core": "^5.8.1",
"@tw-labs/countries": "^0.0.2",
"@vueuse/head": "^0.9.8",
"marked": "^18.0.5",
"swiper": "^12.1.4",
"tailwindcss": "^4.3.0",
@@ -25,6 +27,7 @@
"@tailwindcss/typography": "^0.5.19",
"@vitejs/plugin-vue": "^6.0.6",
"vite": "^8.0.8",
"vite-plugin-sitemap": "^0.8.2",
"vite-plugin-vue-devtools": "^8.1.1"
},
"engines": {
+2
View File
@@ -105,3 +105,5 @@
10.1. По всем вопросам Пользователь может обратиться в службу поддержки через форму в самом боте.
**Используя Сервис (в том числе запуская бота и/или вводя команду /start), Пользователь подтверждает, что ознакомлен с настоящим Соглашением и принимает его условия в полном объёме.**
ИНН: 973300277093
+2 -1
View File
@@ -17,7 +17,8 @@
* идентификаторы аккаунта (логин, ID, никнейм и т.п.);
* техническую информацию (IP-адрес, данные о браузере, устройстве и операционной системе);
* историю взаимодействий с Сервисом.
* 2.2. Сервис не требует от Пользователя предоставления паспортных данных, документов, фотографий или другой личной информации, кроме минимально необходимой для работы.
2.2. Сервис не требует от Пользователя предоставления паспортных данных, документов, фотографий или другой личной информации, кроме минимально необходимой для работы.
---
-1
View File
@@ -25,5 +25,4 @@
</template>
<script setup>
// Скрипты больше не нужны, всю магию сделает роутер!
</script>
+4
View File
@@ -1,4 +1,7 @@
<template>
<IpSecurity />
<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>
@@ -38,6 +41,7 @@
<script setup>
import { Zap } from '@lucide/vue'
import Hero3D from './Hero3D.vue'
import IpSecurity from './IpSecurity.vue';
</script>
<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>
+112 -69
View File
@@ -4,8 +4,20 @@
Прозрачные тарифы
</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
v-for="period in availablePeriods"
:key="period.value"
@@ -20,17 +32,20 @@
</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
v-for="tariff in tariffCards"
: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="[
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'
]"
>
<!-- Бейдж "ПОПУЛЯРНЫЙ" -->
<span
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"
@@ -49,98 +64,126 @@
</li>
</ul>
<button
class="w-full transition py-3 rounded-xl font-medium text-white"
<a
: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
? 'bg-blue-600 dark:bg-blue-500 hover:bg-blue-700 font-bold'
: 'bg-gray-800 hover:bg-gray-700'"
>
{{ tariff.isPopular ? 'Выбрать тариф' : 'Выбрать' }}
</button>
</a>
</div>
</div>
</template>
</section>
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { Check } from '@lucide/vue'
// Исходный конфиг
const pricingConfig = {
default_tariff: "premium",
tariffs: [
{
key: "standard",
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 },
hwid_device_limit: 2,
monthly_gb: 0.0
},
{
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 loading = ref(true)
const error = ref(null)
const tariffsData = ref([]) // исходные тарифы (из API)
const defaultTariffKey = ref('') // ключ популярного тарифа
const selectedPeriod = ref(1) // выбранный период (месяцев)
// Доступные периоды (вычисляются динамически)
const availablePeriods = computed(() => {
const periodsSet = new Set()
for (const tariff of tariffsData.value) {
if (tariff.enabled && tariff.enabled_periods) {
tariff.enabled_periods.forEach(p => periodsSet.add(p))
}
]
}
const sorted = Array.from(periodsSet).sort((a, b) => a - b)
return sorted.map(value => ({
value,
label: value === 1 ? '1 мес' : `${value} мес`
}))
})
// Доступные периоды
const availablePeriods = [
{ value: 1, label: '1 мес' },
{ value: 3, label: '3 мес' },
{ value: 6, label: '6 мес' },
{ value: 12, label: '12 мес' }
]
// Активные (включённые) тарифы, отфильтрованные по выбранному периоду
const activeTariffs = computed(() => {
return tariffsData.value.filter(tariff =>
tariff.enabled && tariff.enabled_periods?.includes(selectedPeriod.value)
)
})
// Выбранный период (по умолчанию 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 tariffs = pricingConfig.tariffs
const defaultKey = pricingConfig.default_tariff
return activeTariffs.value.map(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)
}
// Разделяем тарифы: популярный (премиум) и остальные
const premiumTariff = tariffs.find(t => t.key === defaultKey)
const otherTariffs = tariffs.filter(t => t.key !== defaultKey)
// Формируем список особенностей (features)
const features = []
// Порядок: первый обычный, премиум, второй обычный
const ordered = [
{ ...otherTariffs[0], isPopular: false },
{ ...premiumTariff, isPopular: true },
]
// 1. Описание из JSON (если есть)
const description = tariff.descriptions?.ru?.trim()
if (description) {
features.push(description)
}
return ordered.map(tariff => {
const price = getMonthlyPrice(tariff, selectedPeriod.value)
// 2. Лимит устройств
if (tariff.hwid_device_limit) {
features.push(`До ${tariff.hwid_device_limit} устройств`)
}
// Фичи: разбиваем описание по запятой и добавляем общие пункты
const descriptionFeatures = tariff.descriptions.ru
.split(',')
.map(s => s.trim())
.filter(Boolean)
const staticFeatures = ['Безлимитный трафик', 'Поддержка 24/7']
const features = [...new Set([...descriptionFeatures, ...staticFeatures])]
// 3. Безлимитный трафик (всегда)
features.push('Безлимитный трафик')
// 4. Поддержка 24/7
features.push('Поддержка 24/7')
return {
key: tariff.key,
name: tariff.names.ru,
price: `${price}`,
name: tariff.names?.ru || tariff.key,
price: `${monthlyPrice}`,
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>
+3
View File
@@ -1,10 +1,13 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { createHead } from '@vueuse/head'
import './assets/styles.css'
const app = createApp(App)
const head = createHead()
app.use(router)
app.use(head)
app.mount('#app')
+21 -2
View File
@@ -26,8 +26,27 @@ const router = createRouter({
},
routes: [
{ path: '/', name: 'home', component: HomeView },
{ path: '/docs/:docName', name: 'documents', component: DocViewer, props: true }
{
path: '/',
name: 'home',
component: HomeView,
meta: {
title: 'uVPN',
description: 'Безопасный, быстрый и анонимный VPN-сервис для вашего ПК и смартфона. Защитите свои данные от посторонних глаз уже сегодня.',
keywords: 'vpn, сервис, анонимность, безопасность, шифрование, купить vpn'
}
},
{
path: '/docs/:docName',
name: 'documents',
component: DocViewer,
props: true,
meta: {
title: 'Юридические сведения',
description: 'Пользовательское соглашение и политика конфиденциальности сервиса uVPN.',
keywords: 'vpn, пользовательское соглашение, политика конфиденциальности, документы'
}
}
]
})
+50
View File
@@ -4,6 +4,7 @@
<FeaturesSection />
<CompatibilitySection />
<PricingSection />
<NodesStatus />
<StepsSection />
<TestimonialsSection />
<FaqSection />
@@ -15,7 +16,56 @@ import HeroSection from '../components/HeroSection.vue'
import FeaturesSection from '../components/FeaturesSection.vue'
import CompatibilitySection from '../components/CompatibilitySection.vue'
import PricingSection from '../components/PricingSection.vue'
import NodesStatus from '@/components/NodesStatus.vue'
import StepsSection from '../components/StepsSection.vue'
import TestimonialsSection from '../components/TestimonialsSection.vue'
import FaqSection from '../components/FaqSection.vue'
// import { useHead } from '@vueuse/head';
import { useRoute } from 'vue-router';
const route = useRoute();
// useHead({
// title: route.meta.title,
// meta: [
// {
// name: 'description',
// content: route.meta.description
// },
// {
// name: 'keywords',
// content: route.meta.keywords
// },
// // Open Graph теги для соцсетей
// {
// property: 'og:title',
// content: route.meta.title
// },
// {
// property: 'og:description',
// content: route.meta.description
// },
// {
// property: 'og:type',
// content: 'website'
// },
// {
// property: 'og:url',
// content: window.location.href // Здесь можно также задать каноничный URL
// },
// {
// property: 'og:site_name',
// content: route.meta.title
// },
// {
// property: 'telegram:channel',
// content: '@uvpn_shop'
// },
// {
// property: 'tg:site_verification',
// content: 'g7j8/rPFXfhyrq5q0QQV7EsYWv4='
// },
// ]
// });
</script>
+11 -1
View File
@@ -3,17 +3,27 @@ import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import { default as Sitemap } from 'vite-plugin-sitemap';
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
tailwindcss(),
Sitemap({
hostname: 'https://uvpn.shop',
dynamicRoutes: ['/docs/policy', '/docs/agreement'],
outDir: 'dist',
changefreq: 'weekly',
priority: 0.8
})
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
build: {
chunkSizeWarningLimit: 1000
}
})