28 Commits
Author SHA1 Message Date
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
13 changed files with 1150 additions and 640 deletions
+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
+6 -2
View File
@@ -1,18 +1,21 @@
{ {
"name": "uvpn.shop", "name": "uvpn.shop",
"version": "0.4.1", "version": "0.7.2",
"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/frontend"
}, },
"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",
"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 +28,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. Сервис не требует от Пользователя предоставления паспортных данных, документов, фотографий или другой личной информации, кроме минимально необходимой для работы.
--- ---
+4 -1
View File
@@ -22,8 +22,11 @@
Попробовать бесплатно Попробовать бесплатно
</a> </a>
</header> </header>
<IpSecurity />
</template> </template>
<script setup> <script setup>
// Скрипты больше не нужны, всю магию сделает роутер! import IpSecurity from './IpSecurity.vue';
</script> </script>
+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>
+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, пользовательское соглашение, политика конфиденциальности, документы'
}
}
] ]
}) })
+48
View File
@@ -18,4 +18,52 @@ import PricingSection from '../components/PricingSection.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 { 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> </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
}
}) })