15 Commits
Author SHA1 Message Date
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
11 changed files with 452 additions and 8 deletions
+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>
+153 -2
View File
@@ -1,17 +1,19 @@
{
"name": "uvpn.shop",
"version": "0.3.1",
"version": "0.5.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "uvpn.shop",
"version": "0.3.1",
"version": "0.5.4",
"dependencies": {
"@lucide/vue": "^1.16.0",
"@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",
@@ -24,6 +26,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": {
@@ -1200,6 +1203,20 @@
"vue": ">=3.4"
}
},
"node_modules/@tw-labs/countries": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@tw-labs/countries/-/countries-0.0.2.tgz",
"integrity": "sha512-mnCpRLhpMPTCMTnV18nKjLrdZ+4bUZSxdOgFWqdsZSM62RwPk1ddQCTN2XtRAR2zcLzlX91iTg2kSJ006qmXlA==",
"license": "MIT",
"peerDependencies": {
"react": ">=17"
},
"peerDependenciesMeta": {
"react": {
"optional": true
}
}
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
@@ -1520,6 +1537,58 @@
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/head": {
"version": "0.9.8",
"resolved": "https://registry.npmjs.org/@vueuse/head/-/head-0.9.8.tgz",
"integrity": "sha512-zt8+JksoVFKxRvmABlaUHA62w+8nOcD8cJnaJ0+SHcr6xaIP3GXgh7/n2TzUoWw4l3d9UxRNs+tapgHdsQ7RbA==",
"license": "MIT",
"dependencies": {
"@vueuse/shared": "^9.3.0",
"@zhead/schema": "^0.8.5",
"@zhead/schema-vue": "^0.8.5"
},
"peerDependencies": {
"vue": ">=2.7 || >=3"
}
},
"node_modules/@vueuse/head/node_modules/@vueuse/shared": {
"version": "9.13.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-9.13.0.tgz",
"integrity": "sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==",
"license": "MIT",
"dependencies": {
"vue-demi": "*"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/head/node_modules/@vueuse/shared/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@vueuse/metadata": {
"version": "13.9.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.9.0.tgz",
@@ -1541,6 +1610,82 @@
"vue": "^3.5.0"
}
},
"node_modules/@zhead/schema": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/@zhead/schema/-/schema-0.8.5.tgz",
"integrity": "sha512-1S3Otr2zpl1zwP72dNseVXQNG9tnTQ6hHUEUYwINvBjRj6bHcUwdE+Itc9OLxnGAJT/7p8P7GHGo5sshXJNJsA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/harlan-zw"
}
},
"node_modules/@zhead/schema-raw": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/@zhead/schema-raw/-/schema-raw-0.8.5.tgz",
"integrity": "sha512-Aq+9mksf5zbtj7HYluT6PVyfpQ6z7mja9MzjFxg76Vt+Q9i0oL1XN6ZYaCXImWRafwbyAxjFQ5aUCVyFn79OpA==",
"license": "MIT",
"dependencies": {
"@zhead/schema": "0.8.5"
},
"funding": {
"url": "https://github.com/sponsors/harlan-zw"
}
},
"node_modules/@zhead/schema-vue": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/@zhead/schema-vue/-/schema-vue-0.8.5.tgz",
"integrity": "sha512-6aXjYy3fZVeYBLrHcJQqzqwzC/2tafRO5UxZEgBHnryRnzeLNZV6nTptDvIPWiJObMoJTK21vbg3gkfLNQg84g==",
"license": "MIT",
"dependencies": {
"@vueuse/shared": "^9.2.0",
"@zhead/schema": "0.8.5",
"@zhead/schema-raw": "0.8.5"
},
"funding": {
"url": "https://github.com/sponsors/harlan-zw"
},
"peerDependencies": {
"vue": ">=2.7 || >=3"
}
},
"node_modules/@zhead/schema-vue/node_modules/@vueuse/shared": {
"version": "9.13.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-9.13.0.tgz",
"integrity": "sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==",
"license": "MIT",
"dependencies": {
"vue-demi": "*"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@zhead/schema-vue/node_modules/@vueuse/shared/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/ansis": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.0.tgz",
@@ -2951,6 +3096,12 @@
"vite": "^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 || ^8.0.0"
}
},
"node_modules/vite-plugin-sitemap": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/vite-plugin-sitemap/-/vite-plugin-sitemap-0.8.2.tgz",
"integrity": "sha512-bqIw6NVOXg6je81lzX8Lm0vjf8/QSAp8di8fYQzZ3ZdVicOm8+6idBGALJiy1R1FiXNIK8rgORO6HBqXyHW+iQ==",
"dev": true
},
"node_modules/vite-plugin-vue-devtools": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/vite-plugin-vue-devtools/-/vite-plugin-vue-devtools-8.1.2.tgz",
+6 -2
View File
@@ -1,18 +1,21 @@
{
"name": "uvpn.shop",
"version": "0.4.1",
"version": "0.6.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"postbuild": "scp -r dist root@uvpn.shop:/opt/uvpn.shop"
},
"dependencies": {
"@lucide/vue": "^1.16.0",
"@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 +28,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. Сервис не требует от Пользователя предоставления паспортных данных, документов, фотографий или другой личной информации, кроме минимально необходимой для работы.
---
+4 -1
View File
@@ -22,8 +22,11 @@
Попробовать бесплатно
</a>
</header>
<IpSecurity />
</template>
<script setup>
// Скрипты больше не нужны, всю магию сделает роутер!
import IpSecurity from './IpSecurity.vue';
</script>
+192
View File
@@ -0,0 +1,192 @@
<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">
<div class="flex h-4 items-center">
<img
v-if="flagUrl"
:src="flagUrl"
:alt="countryCode"
class="w-4 h-3 object-cover"
@error="handleFlagError"
/>
</div>
<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, withFlags } from '@tw-labs/countries'
const ipData = ref(null)
const loading = ref(true)
const error = ref(false)
const flagError = 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
flagError.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
// Добавляем флаговые данные через withFlags
let countryWithFlags = null
if (foundCountry) {
countryWithFlags = withFlags(foundCountry)
}
ipData.value = {
...data,
location: {
...data.location,
computed_country_code: countryCode
},
_flags: countryWithFlags
}
} 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(() => {
return ipData.value?.location?.computed_country_code || null
})
// URL флага
const flagUrl = computed(() => {
// Сначала пробуем взять SVG из withFlags
if (ipData.value?._flags?.flagSvg && !flagError.value) {
return ipData.value._flags.flagSvg
}
// Если нет — формируем сами через flagcdn
const code = countryCode.value
if (!code || flagError.value) return null
return `https://flagcdn.com/${code.toLowerCase()}.svg`
})
function handleFlagError() {
flagError.value = true
}
function truncateText(text, maxLength) {
if (!text) return ''
return text.length > maxLength ? text.slice(0, maxLength) + '...' : text
}
onMounted(() => {
fetchIp()
})
</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, пользовательское соглашение, политика конфиденциальности, документы'
}
}
]
})
+48
View File
@@ -18,4 +18,52 @@ import PricingSection from '../components/PricingSection.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>
+12
View File
@@ -3,6 +3,7 @@ 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/
@@ -10,6 +11,17 @@ export default defineConfig({
plugins: [
vue(),
tailwindcss(),
Sitemap({
hostname: 'https://uvpn.shop',
// Плагин сам прочитает маршруты из вашего router, но если нужно указать вручную:
// routes: ['/', '/docs'], // можно опустить — он определит автоматически
// Если у вас динамические маршруты (например, /docs/:docName), их нужно добавить отдельно:
dynamicRoutes: ['/docs/policy', '/docs/agreement'], // сюда перечислите все возможные динамические URL
// Настройки changefreq, priority и т.д.:
outDir: 'dist', // папка сборки
changefreq: 'weekly',
priority: 0.8
})
],
resolve: {
alias: {