46 Commits
Author SHA1 Message Date
austnv 4e6e0d4d48 fix deploy
Build and Deploy Frontend via SSH / deploy (push) Successful in 47s
2026-06-21 22:39:42 +03:00
austnv a22662f11b v0.9.2
Build and Deploy Frontend via SSH / deploy (push) Failing after 4s
2026-06-21 21:56:47 +03:00
austnv 75531a9ec3 update workflow: add auto release 2026-06-21 21:56:19 +03:00
austnv e6ab2667c9 fix: micropatch NodesStatus 2026-06-21 21:50:00 +03:00
austnv 3a38b3f7fa v0.9.1
Build and Deploy Frontend via SSH / deploy (push) Successful in 42s
2026-06-21 21:43:21 +03:00
austnv b69cc4bc4b update NodesStatus.vue 2026-06-21 21:38:23 +03:00
austnv 4b7b2bc5c6 refactor NodesStatus.vue
Build and Deploy Frontend via SSH / deploy (push) Successful in 46s
2026-06-21 20:45:40 +03:00
austnv f6af141f56 update NodesStatus 2026-06-21 17:51:54 +03:00
austnv c5c0a6614a fix: update NodesStatus
Build and Deploy Frontend via SSH / deploy (push) Has been cancelled
2026-06-21 17:42:05 +03:00
austnv acb11b43c7 fix(workflow): finish deploy.yml 2026-06-21 17:22:48 +03:00
austnv b12c1a07c9 update workflow 2026-06-21 17:20:10 +03:00
austnv 03fbbed39c change ssh key managment system 2026-06-21 17:17:21 +03:00
austnv 0c825abad2 update workflow 2026-06-21 17:16:08 +03:00
austnv 89ffe3c50e update workflow 2026-06-21 17:07:58 +03:00
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 1400 additions and 652 deletions
+84
View File
@@ -0,0 +1,84 @@
name: Build and Deploy Frontend via SSH
on:
push:
tags:
- '*'
workflow_dispatch:
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: Create Gitea Release via API
run: |
# Получаем описание из тега (если есть)
TAG_MESSAGE=$(git tag -l --format='%(contents)' "${{ gitea.ref_name }}" 2>/dev/null || echo "Автоматический релиз через CI/CD")
# Экранируем кавычки и переносы для JSON
TAG_MESSAGE_ESCAPED=$(echo "$TAG_MESSAGE" | sed 's/"/\\"/g' | awk '{printf "%s\\n", $0}')
# Формируем JSON
JSON_PAYLOAD=$(cat <<EOF
{
"tag_name": "${{ gitea.ref_name }}",
"name": "Release ${{ gitea.ref_name }}",
"body": "## Релиз ${{ gitea.ref_name }}\n\n📝 ${TAG_MESSAGE_ESCAPED}\n\n---\n\n**🏷️ Тег:** \`${{ gitea.ref_name }}\`\n**👤 Автор:** ${{ gitea.actor }}\n\n✅ Автоматически собрано и развёрнуто",
"draft": false,
"prerelease": false
}
EOF
)
# Отправляем запрос
RESPONSE=$(curl -s -w "\n%{http_code}" \
-X POST \
-H "Authorization: token ${{ secrets.ACCESS_TOKEN }}" \
-H "Content-Type: application/json" \
-d "$JSON_PAYLOAD" \
"https://git.uvpn.shop/api/v1/repos/${{ gitea.repository }}/releases")
# Проверяем ответ
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$HTTP_CODE" -eq 201 ]; then
echo "✅ Релиз успешно создан!"
echo "$BODY" | jq '.'
elif [ "$HTTP_CODE" -eq 409 ]; then
echo "⚠️ Релиз с таким тегом уже существует, пропускаем"
else
echo "❌ Ошибка создания релиза (HTTP $HTTP_CODE)"
echo "$BODY"
exit 1
fi
- name: Install SSH key
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }}
- name: Deploy via SCP
run: |
tar -czf frontend.tar.gz -C dist .
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 ```bash
- 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
npm install npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build npm run build
``` ```
Ссылки:
* [Продакшн](https://uvpn.shop)
* [Тест](https://austnv-uvpn-frontend-7aca.twc1.net)
+9
View File
@@ -4,6 +4,15 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/images/logo.png"> <link rel="icon" href="/images/logo.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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> <title>uVPN</title>
</head> </head>
<body> <body>
+728 -490
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "uvpn.shop", "name": "frontend",
"version": "0.4.1", "version": "0.9.3",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -13,6 +13,8 @@
"@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",
"marked": "^18.0.5", "marked": "^18.0.5",
"swiper": "^12.1.4", "swiper": "^12.1.4",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
@@ -25,6 +27,7 @@
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"vite": "^8.0.8", "vite": "^8.0.8",
"vite-plugin-sitemap": "^0.8.2",
"vite-plugin-vue-devtools": "^8.1.1" "vite-plugin-vue-devtools": "^8.1.1"
}, },
"engines": { "engines": {
+2
View File
@@ -105,3 +105,5 @@
10.1. По всем вопросам Пользователь может обратиться в службу поддержки через форму в самом боте. 10.1. По всем вопросам Пользователь может обратиться в службу поддержки через форму в самом боте.
**Используя Сервис (в том числе запуская бота и/или вводя команду /start), Пользователь подтверждает, что ознакомлен с настоящим Соглашением и принимает его условия в полном объёме.** **Используя Сервис (в том числе запуская бота и/или вводя команду /start), Пользователь подтверждает, что ознакомлен с настоящим Соглашением и принимает его условия в полном объёме.**
ИНН: 973300277093
+2 -1
View File
@@ -17,7 +17,8 @@
* идентификаторы аккаунта (логин, ID, никнейм и т.п.); * идентификаторы аккаунта (логин, ID, никнейм и т.п.);
* техническую информацию (IP-адрес, данные о браузере, устройстве и операционной системе); * техническую информацию (IP-адрес, данные о браузере, устройстве и операционной системе);
* историю взаимодействий с Сервисом. * историю взаимодействий с Сервисом.
* 2.2. Сервис не требует от Пользователя предоставления паспортных данных, документов, фотографий или другой личной информации, кроме минимально необходимой для работы.
2.2. Сервис не требует от Пользователя предоставления паспортных данных, документов, фотографий или другой личной информации, кроме минимально необходимой для работы.
--- ---
-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>
+201
View File
@@ -0,0 +1,201 @@
<template>
<section class="px-6 py-20 md:px-12 max-w-7xl mx-auto">
<h2 class="text-3xl md:text-4xl font-bold text-center mb-14">
Статус серверов
</h2>
<div class="flex justify-end mb-4">
<button
@click="refreshData"
:disabled="loading"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<RefreshCw :size="16" :class="{ 'animate-spin': loading }" />
{{ loading ? 'Обновление...' : 'Обновить все' }}
</button>
</div>
<div v-if="loading && !hosts.length" class="flex justify-center py-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
<p v-else-if="error" class="text-center text-red-500 py-12">{{ error }}</p>
<div v-else class="space-y-2 max-w-3xl mx-auto">
<div
v-for="host in sortedHosts"
:key="host.address"
class="flex items-center justify-between bg-white dark:bg-gray-900 rounded-xl px-4 py-2.5 border transition hover:shadow-md"
:class="host.isOnline ? 'border-green-500/30' : 'border-red-500/30 opacity-60'"
>
<div class="flex items-center gap-3">
<img
v-if="host.countryCode"
:src="`https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/flags/4x3/${host.countryCode}.svg`"
class="h-5 w-auto rounded-sm"
/>
<span class="font-semibold text-gray-800 dark:text-white">{{ host.name }}</span>
<span
class="text-[10px] px-2 py-0.5 rounded-full font-medium"
:class="host.isOnline ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
>
{{ host.isOnline ? 'online' : 'offline' }}
</span>
</div>
<div class="flex items-center gap-4">
<span
v-if="host.ping !== undefined && host.ping !== null"
class="font-mono font-semibold flex items-center gap-1 min-w-[60px] justify-end"
:class="{
'text-green-600': host.ping < 200,
'text-yellow-600': host.ping >= 200 && host.ping < 500,
'text-red-600': host.ping >= 500,
}"
>
<Wifi :size="14" />
{{ host.ping }}ms
</span>
<span v-else class="text-gray-300 min-w-[60px] text-right"></span>
<button
@click="pingSingleHost(host)"
:disabled="host.pinging"
class="text-xs px-3 py-1 rounded-lg transition font-medium"
:class="[
host.isOnline && !host.pinging
? 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 cursor-not-allowed'
]"
>
{{ host.pinging ? '⏳' : 'Пинг' }}
</button>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { Wifi, RefreshCw } from '@lucide/vue'
import { getAllCountries } from '@tw-labs/countries'
const hosts = ref([])
const loading = ref(true)
const error = ref(null)
let interval = null
const countriesList = getAllCountries()
// Поиск страны по первому слову в названии
const findCountryByFirstWord = (name) => {
if (!name) return null
// Берём первое слово из названия
const firstWord = name.split(' ')[0]
if (!firstWord) return null
// Ищем точное совпадение
const exact = countriesList.find(c =>
c.label.toLowerCase() === firstWord.toLowerCase()
)
if (exact) return exact
// Ищем частичное совпадение
const partial = countriesList.find(c =>
firstWord.toLowerCase().includes(c.label.toLowerCase()) ||
c.label.toLowerCase().includes(firstWord.toLowerCase())
)
return partial || null
}
// Сортировка по пингу
const sortedHosts = computed(() => {
return [...hosts.value].sort((a, b) => {
if (a.ping === null && b.ping === null) return 0
if (a.ping === null) return 1
if (b.ping === null) return -1
return a.ping - b.ping
})
})
// Пинг одного хоста
const pingSingleHost = async (host) => {
if (host.pinging || !host.isOnline) return
host.pinging = true
host.ping = null
const result = await pingHost(host)
host.ping = result
host.pinging = false
}
// Пинг через fetch
const pingHost = async (host) => {
const url = `https://${host.address}:${host.port}`
const start = performance.now()
try {
await fetch(url, {
method: 'HEAD',
mode: 'no-cors',
cache: 'no-cache',
signal: AbortSignal.timeout(5000),
})
return Math.round(performance.now() - start)
} catch {
return Math.round(performance.now() - start)
}
}
// Пинг всех хостов
const pingAllHosts = async () => {
const pings = await Promise.all(
hosts.value.map(async (h) => {
if (!h.isOnline) return null
return await pingHost(h)
})
)
hosts.value.forEach((h, i) => {
if (h.isOnline) h.ping = pings[i]
})
}
const fetchHosts = async () => {
try {
const res = await fetch('/api/v1/hosts')
if (!res.ok) throw new Error()
const data = await res.json()
hosts.value = data.map((h) => {
const country = findCountryByFirstWord(h.name)
return {
...h,
countryCode: country?.code?.toLowerCase() || null,
isOnline: true,
ping: null,
pinging: false,
}
})
await pingAllHosts()
} catch {
error.value = 'Ошибка загрузки'
} finally {
loading.value = false
}
}
const refreshData = async () => {
loading.value = true
await fetchHosts()
loading.value = false
}
onMounted(() => {
fetchHosts()
interval = setInterval(refreshData, 30000)
})
onUnmounted(() => clearInterval(interval))
</script>
+112 -69
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>
+3
View File
@@ -1,10 +1,13 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import { createHead } from '@vueuse/head'
import './assets/styles.css' import './assets/styles.css'
const app = createApp(App) const app = createApp(App)
const head = createHead()
app.use(router) app.use(router)
app.use(head)
app.mount('#app') app.mount('#app')
+21 -2
View File
@@ -26,8 +26,27 @@ const router = createRouter({
}, },
routes: [ 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, пользовательское соглашение, политика конфиденциальности, документы'
}
}
] ]
}) })
+7 -2
View File
@@ -1,13 +1,12 @@
<template> <template>
<div>
<HeroSection /> <HeroSection />
<FeaturesSection /> <FeaturesSection />
<CompatibilitySection /> <CompatibilitySection />
<PricingSection /> <PricingSection />
<NodesStatus />
<StepsSection /> <StepsSection />
<TestimonialsSection /> <TestimonialsSection />
<FaqSection /> <FaqSection />
</div>
</template> </template>
<script setup> <script setup>
@@ -15,7 +14,13 @@ 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'
import { useRoute } from 'vue-router';
const route = useRoute();
</script> </script>
+11 -1
View File
@@ -3,17 +3,27 @@ import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import { default as Sitemap } from 'vite-plugin-sitemap';
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
vue(), vue(),
tailwindcss(), tailwindcss(),
Sitemap({
hostname: 'https://uvpn.shop',
dynamicRoutes: ['/docs/policy', '/docs/agreement'],
outDir: 'dist',
changefreq: 'weekly',
priority: 0.8
})
], ],
resolve: { resolve: {
alias: { alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)) '@': fileURLToPath(new URL('./src', import.meta.url))
}, },
}, },
build: {
chunkSizeWarningLimit: 1000
}
}) })