58 Commits
Author SHA1 Message Date
austnv 3dbacb4359 feat: minify component NodesStatus
Build and Deploy Frontend via SSH / deploy (push) Successful in 44s
2026-06-21 22:50:30 +03:00
austnv d8e20754c7 dix: release author 2026-06-21 22:44:14 +03:00
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
austnv d42aafaad7 UI patch 2026-06-07 01:47:51 +03:00
austnv fa8498ef4c v0.4.0
add:
- legal .md files
- routers
- home view
- rewrite App.vue
- update footer / header
- update features
2026-06-07 01:19:02 +03:00
austnv eb17a3cd67 fix grid-3 to grid-2 2026-06-07 00:21:32 +03:00
austnv fee07ce458 update pricing and faq 2026-06-07 00:03:16 +03:00
austnv 7cded67631 add ScreenshotButton to development needs 2026-05-27 22:18:30 +03:00
austnv 3fb0d198a2 update pricing section
fix: footer & header
2026-05-27 22:00:42 +03:00
austnv e4d73becc9 update #3 2026-05-24 13:17:32 +03:00
austnv eef1ba929f add FaqSection 2026-05-24 13:17:06 +03:00
austnv 055aaa20a1 add CompatibilitySection 2026-05-24 13:07:48 +03:00
austnv 3e68af7c58 update FeatureSection 2026-05-24 12:34:26 +03:00
26 changed files with 2392 additions and 617 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>
+820 -489
View File
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "uvpn.shop", "name": "frontend",
"version": "0.2.0", "version": "0.9.4",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -13,14 +13,21 @@
"@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",
"swiper": "^12.1.4", "swiper": "^12.1.4",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
"three": "^0.184.0", "three": "^0.184.0",
"vue": "^3.5.32" "vue": "^3.5.32",
"vue-router": "^4.6.4",
"vue3-simple-icons": "^16.10.0"
}, },
"devDependencies": { "devDependencies": {
"@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": {
+109
View File
@@ -0,0 +1,109 @@
# Пользовательское соглашение
### 1. Общие положения
1.1. Настоящее Пользовательское соглашение (далее — **«Соглашение»** ) регулирует порядок использования онлайн-сервиса (далее — **«Сервис»** ), предоставляемого Администрацией.
1.2. Используя Сервис, включая запуск бота, регистрацию, оплату услуг или получение доступа к материалам, Пользователь подтверждает, что полностью ознакомился с условиями настоящего Соглашения и принимает их в полном объёме.
1.3. В случае несогласия с условиями Соглашения Пользователь обязан прекратить использование Сервиса.
### 2. Характер услуг и цифровых товаров
2.1. Сервис предоставляет цифровые товары и услуги нематериального характера, включая, но не ограничиваясь: информационные материалы, обучающие программы, консультации, цифровые продукты и сервисные услуги.
2.2. Материалы, предоставляемые через Сервис, могут включать:
* информацию из открытых источников;
* авторские материалы Администрации и/или третьих лиц;
* аналитические обзоры, подборки, рекомендации, структурированные данные.
2.3. Пользователь осознаёт и соглашается, что ценность цифровых товаров и услуг Сервиса заключается **в систематизации, анализе, форме подачи, сопровождении, поддержке и обновлениях** , а не в эксклюзивности отдельных фрагментов информации.
2.4. Сервис не заявляет и не гарантирует уникальность, исключительность или недоступность отдельных элементов материалов вне Сервиса.
### 3. Отказ от гарантий и ответственности
3.1. Сервис предоставляется на условиях **«AS IS» («как есть»)** .
3.2. Администрация не гарантирует:
* соответствие Сервиса ожиданиям Пользователя;
* достижение каких-либо финансовых, коммерческих, профессиональных или иных результатов;
* бесперебойную и безошибочную работу Сервиса.
3.3. Администрация не несёт ответственности за:
* любые прямые или косвенные убытки, включая упущенную выгоду;
* последствия применения Пользователем полученных материалов;
* действия или бездействие третьих лиц;
* временные технические сбои и ограничения доступа.
3.4. Все решения о применении материалов, рекомендаций и услуг принимаются Пользователем **самостоятельно и на его риск** .
### 4. Законность использования
4.1. Сервис не предназначен для поощрения, организации или содействия противоправной деятельности.
4.2. Пользователь обязуется использовать Сервис исключительно в рамках применимого законодательства и правил третьих сторон.
4.3. Ответственность за законность использования материалов и услуг Сервиса полностью возлагается на Пользователя.
### 5. Интеллектуальная собственность
5.1. Все материалы, размещённые в Сервисе, охраняются законодательством об интеллектуальной собственности.
5.2. Пользователю запрещается копировать, распространять, перепродавать, передавать третьим лицам или иным образом использовать материалы Сервиса без разрешения правообладателя.
5.3. Нарушение прав интеллектуальной собственности может повлечь ограничение доступа к Сервису без компенсации.
### 6. Ограничение доступа
6.1. Администрация вправе приостановить или ограничить доступ Пользователя к Сервису в случае:
* нарушения условий настоящего Соглашения;
* выявления злоупотреблений;
* требований законодательства или платёжных провайдеров.
6.2. Ограничение доступа не освобождает Пользователя от обязательств, возникших ранее.
6.3. Администрация оставляет за собой право отказывать в обслуживании Пользователям, чьи действия могут создавать повышенные риски для Сервиса, платёжных провайдеров или третьих лиц.
### 7. Платежи и возвраты
7.1. Оплата услуг и цифровых товаров производится на условиях, указанных в Сервисе до момента оплаты.
7.2. В связи с нематериальным характером цифровых товаров и услуг, **возврат денежных средств после предоставления доступа не осуществляется** , за исключением случаев, указанных ниже.
7.3. Возврат средств возможен только если:
* услуга не была оказана по технической вине Сервиса;
* доступ к цифровому товару фактически не был предоставлен.
7.4. Для рассмотрения вопроса о возврате Пользователь обязан обратиться в службу поддержки в течение **24 часов** с момента оплаты.
7.5. Решение о возврате принимается Администрацией индивидуально.
7.6. Пользователь подтверждает, что обязуется **не инициировать возврат платежа (chargeback)** через платёжные системы без предварительного обращения в службу поддержки Сервиса.
### 8. Конфиденциальность
8.1. Администрация может собирать минимально необходимые технические данные для обеспечения работы Сервиса.
8.2. Администрация принимает разумные меры для защиты данных, однако не гарантирует абсолютную безопасность передаваемой информации.
### 9. Изменение условий
9.1. Администрация вправе вносить изменения в настоящее Соглашение.
9.2. Актуальная версия Соглашения публикуется в Сервисе.
9.3. Продолжение использования Сервиса означает согласие Пользователя с обновлёнными условиями.
### 10. Контактная информация
10.1. По всем вопросам Пользователь может обратиться в службу поддержки через форму в самом боте.
**Используя Сервис (в том числе запуская бота и/или вводя команду /start), Пользователь подтверждает, что ознакомлен с настоящим Соглашением и принимает его условия в полном объёме.**
ИНН: 973300277093
+65
View File
@@ -0,0 +1,65 @@
# Политика конфиденциальности
Политика конфиденциальности регулирует сбор, использование и защиту информации пользователей сервиса. Собираются идентификаторы аккаунта, техническая информация и история взаимодействий. Данные используются для обеспечения работы сервиса, связи с пользователем и анализа. Передача информации третьим лицам возможна только в законодательно установленных случаях или с согласия пользователя. Хранение данных осуществляется в течение необходимого срока, их защита — в разумных пределах. Пользователь самостоятельно несёт ответственность за риски, связанные с передачей данных. Администрация вправе вносить изменения в Политику без уведомления — согласие считается принятым при дальнейшем использовании сервиса.
**1. Общие положения**
1.1. Настоящая Политика конфиденциальности (далее — «Политика») регулирует порядок обработки и защиты информации, которую Пользователь передаёт при использовании сервиса (далее — «Сервис»).
1.2. Используя Сервис, Пользователь подтверждает своё согласие с условиями Политики. Если Пользователь не согласен с условиями — он обязан прекратить использование Сервиса.
---
**2. Сбор информации**
2.1. Сервис может собирать следующие типы данных:
* идентификаторы аккаунта (логин, ID, никнейм и т.п.);
* техническую информацию (IP-адрес, данные о браузере, устройстве и операционной системе);
* историю взаимодействий с Сервисом.
2.2. Сервис не требует от Пользователя предоставления паспортных данных, документов, фотографий или другой личной информации, кроме минимально необходимой для работы.
---
**3. Использование информации**
3.1. Сервис может использовать полученную информацию исключительно для:
* обеспечения работы функционала;
* связи с Пользователем (в том числе для уведомлений и поддержки);
* анализа и улучшения работы Сервиса.
---
**4. Передача информации третьим лицам**
4.1. Администрация не передаёт полученные данные третьим лицам, за исключением случаев:
* если это требуется по закону;
* если это необходимо для исполнения обязательств перед Пользователем (например, при работе с платёжными системами);
* если Пользователь сам дал на это согласие.
---
**5. Хранение и защита данных**
5.1. Данные хранятся в течение срока, необходимого для достижения целей обработки.
5.2. Администрация принимает разумные меры для защиты данных, но не гарантирует абсолютную безопасность информации при передаче через интернет.
---
**6. Отказ от ответственности**
6.1. Пользователь понимает и соглашается, что передача информации через интернет всегда сопряжена с рисками.
6.2. Администрация не несёт ответственности за утрату, кражу или раскрытие данных, если это произошло по вине третьих лиц или самого Пользователя.
---
**7. Изменения в Политике**
7.1. Администрация вправе изменять условия Политики без предварительного уведомления.
7.2. Продолжение использования Сервиса после внесения изменений означает согласие Пользователя с новой редакцией Политики.
+6 -11
View File
@@ -1,21 +1,16 @@
<template> <template>
<div class="bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-white font-sans antialiased min-h-screen"> <div class="bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-white font-sans antialiased min-h-screen flex flex-col">
<AppHeader /> <AppHeader />
<HeroSection />
<FeaturesSection /> <main class="grow">
<PricingSection /> <RouterView />
<StepsSection /> </main>
<TestimonialsSection />
<AppFooter /> <AppFooter />
</div> </div>
</template> </template>
<script setup> <script setup>
import AppHeader from './components/AppHeader.vue' import AppHeader from './components/AppHeader.vue'
import HeroSection from './components/HeroSection.vue'
import FeaturesSection from './components/FeaturesSection.vue'
import PricingSection from './components/PricingSection.vue'
import StepsSection from './components/StepsSection.vue'
import TestimonialsSection from './components/TestimonialsSection.vue'
import AppFooter from './components/AppFooter.vue' import AppFooter from './components/AppFooter.vue'
</script> </script>
+1
View File
@@ -1 +1,2 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "@tailwindcss/typography";
+3 -3
View File
@@ -6,9 +6,9 @@
<span class="text-lg font-bold">uVPN</span> <span class="text-lg font-bold">uVPN</span>
</div> </div>
<div class="flex gap-6 text-gray-500 dark:text-gray-400 text-sm"> <div class="flex gap-6 text-gray-500 dark:text-gray-400 text-sm">
<a href="#" class="hover:text-gray-900 dark:text-white transition">Политика конфиденциальности</a> <router-link to="/docs/policy" class="hover:text-gray-900 dark:hover:text-white text-gray-500 dark:text-gray-400 transition hover:cursor-pointer">Политика конфиденциальности</router-link>
<a href="#" class="hover:text-gray-900 dark:text-white transition">Условия использования</a> <router-link to="/docs/agreement" class="hover:text-gray-900 dark:hover:text-white text-gray-500 dark:text-gray-400 hover:cursor-pointer">Условия использования</router-link>
<a href="#" class="hover:text-gray-900 dark:text-white transition">Поддержка</a> <a href="mailto:support@uvpn.shop" target="_blank" class="hover:text-gray-900 dark:hover:text-white text-gray-500 dark:text-gray-400 transition">Поддержка</a>
</div> </div>
<p class="text-gray-500 dark:text-gray-400 text-sm"> <p class="text-gray-500 dark:text-gray-400 text-sm">
© 2026 uVPN. Все права защищены. © 2026 uVPN. Все права защищены.
+14 -17
View File
@@ -1,31 +1,28 @@
<template> <template>
<header class="flex items-center justify-between px-6 py-4 md:px-12 border-b border-gray-200 dark:border-gray-800 sticky top-0 bg-white/80 dark:bg-gray-900/80 backdrop-blur-xs z-50"> <header class="flex items-center justify-between px-6 py-4 md:px-12 border-b border-gray-200 dark:border-gray-800 sticky top-0 bg-white/80 dark:bg-gray-900/80 backdrop-blur-xs z-50">
<div class="flex items-center gap-3">
<!-- Логотип -->
<router-link to="/" class="flex items-center gap-3 hover:opacity-90 transition">
<img src="/images/logo.png" alt="uVPN logo" class="size-8" /> <img src="/images/logo.png" alt="uVPN logo" class="size-8" />
<span class="text-xl font-bold tracking-tight">uVPN</span> <span class="text-xl font-bold tracking-tight">uVPN</span>
</div> </router-link>
<!-- Якорные ссылки --> <!-- Навигация: теперь это router-link с хэшем секции -->
<nav class="hidden md:flex items-center gap-6 text-sm text-gray-500 dark:text-gray-400"> <nav class="hidden md:flex items-center gap-6 text-sm text-gray-500 dark:text-gray-400">
<a href="#hero" @click.prevent="scrollTo('hero')" class="hover:text-gray-900 dark:hover:text-gray-400 dark:text-white transition">Главная</a> <router-link :to="{ path: '/', hash: '#hero' }" class="hover:text-gray-900 dark:hover:text-white text-gray-500 dark:text-gray-400 transition">Главная</router-link>
<a href="#features" @click.prevent="scrollTo('features')" class="hover:text-gray-900 dark:hover:text-gray-400 dark:text-white transition">Преимущества</a> <router-link :to="{ path: '/', hash: '#features' }" class="hover:text-gray-900 dark:hover:text-white text-gray-500 dark:text-gray-400 transition">Преимущества</router-link>
<a href="#pricing" @click.prevent="scrollTo('pricing')" class="hover:text-gray-900 dark:hover:text-gray-400 dark:text-white transition">Тарифы</a> <router-link :to="{ path: '/', hash: '#pricing' }" class="hover:text-gray-900 dark:hover:text-white text-gray-500 dark:text-gray-400 transition">Тарифы</router-link>
<a href="#steps" @click.prevent="scrollTo('steps')" class="hover:text-gray-900 dark:hover:text-gray-400 dark:text-white transition">Как начать</a> <router-link :to="{ path: '/', hash: '#steps' }" class="hover:text-gray-900 dark:hover:text-white text-gray-500 dark:text-gray-400 transition">Как начать</router-link>
<a href="#testimonials" @click.prevent="scrollTo('testimonials')" class="hover:text-gray-900 dark:hover:text-gray-400 dark:text-white transition">Отзывы</a> <router-link :to="{ path: '/', hash: '#testimonials' }" class="hover:text-gray-900 dark:hover:text-white text-gray-500 dark:text-gray-400 transition">Отзывы</router-link>
<a href="#footer" @click.prevent="scrollTo('footer')" class="hover:text-gray-900 dark:hover:text-gray-400 dark:text-white transition">Контакты</a> <router-link :to="{ path: '/', hash: '#faq' }" class="hover:text-gray-900 dark:hover:text-white text-gray-500 dark:text-gray-400 transition">FAQ</router-link>
</nav> </nav>
<button class="bg-blue-600 dark:bg-blue-500 hover:bg-blue-700 transition px-5 py-2 rounded-lg font-medium text-sm text-white"> <!-- Кнопка тарифов -->
<a href="https://app.uvpn.shop" target="_blank" class="bg-blue-600 dark:bg-blue-500 hover:bg-blue-700 transition px-5 py-2 rounded-lg font-medium text-sm text-white">
Попробовать бесплатно Попробовать бесплатно
</button> </a>
</header> </header>
</template> </template>
<script setup> <script setup>
const scrollTo = (id) => {
const el = document.getElementById(id)
if (el) {
el.scrollIntoView({ behavior: 'smooth' })
}
}
</script> </script>
+50
View File
@@ -0,0 +1,50 @@
<template>
<section id="compatibility" 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-4">Совместимость</h2>
<p class="text-center text-gray-500 dark:text-gray-400 mb-14">Работает на всех устройствах</p>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-6">
<!-- iOS -->
<div class="bg-white dark:bg-gray-900 rounded-2xl p-6 text-center hover:bg-gray-100 dark:hover:bg-gray-800 transition border border-gray-200 dark:border-gray-800">
<AppleIcon class="w-10 h-10 text-blue-600 dark:text-blue-400 mx-auto mb-3" />
<p class="font-medium">iOS</p>
</div>
<!-- Android -->
<div class="bg-white dark:bg-gray-900 rounded-2xl p-6 text-center hover:bg-gray-100 dark:hover:bg-gray-800 transition border border-gray-200 dark:border-gray-800">
<AndroidIcon class="w-10 h-10 text-blue-600 dark:text-blue-400 mx-auto mb-3" />
<p class="font-medium">Android</p>
</div>
<!-- Windows -->
<div class="bg-white dark:bg-gray-900 rounded-2xl p-6 text-center hover:bg-gray-100 dark:hover:bg-gray-800 transition border border-gray-200 dark:border-gray-800">
<WindowsIcon class="w-10 h-10 text-blue-600 dark:text-blue-400 mx-auto mb-3" />
<p class="font-medium">Windows</p>
</div>
<!-- macOS -->
<div class="bg-white dark:bg-gray-900 rounded-2xl p-6 text-center hover:bg-gray-100 dark:hover:bg-gray-800 transition border border-gray-200 dark:border-gray-800">
<MacOsIcon class="w-10 h-10 text-blue-600 dark:text-blue-400 mx-auto mb-3" />
<p class="font-medium">macOS</p>
</div>
<!-- Linux -->
<div class="bg-white dark:bg-gray-900 rounded-2xl p-6 text-center hover:bg-gray-100 dark:hover:bg-gray-800 transition border border-gray-200 dark:border-gray-800">
<LinuxIcon class="w-10 h-10 text-blue-600 dark:text-blue-400 mx-auto mb-3" />
<p class="font-medium">Linux</p>
</div>
<!-- Smart TV -->
<div class="bg-white dark:bg-gray-900 rounded-2xl p-6 text-center hover:bg-gray-100 dark:hover:bg-gray-800 transition border border-gray-200 dark:border-gray-800">
<AppleTvIcon class="w-10 h-10 text-blue-600 dark:text-blue-400 mx-auto mb-3" />
<p class="font-medium">Smart TV</p>
</div>
</div>
</section>
</template>
<script setup>
// import { Apple, Smartphone, Monitor, Terminal, Server, Tv } from '@lucide/vue'
import { AppleIcon, AndroidIcon, LinuxIcon, AppleTvIcon, MacOsIcon } from 'vue3-simple-icons';
import WindowsIcon from './WindowsIcon.vue';
</script>
+85
View File
@@ -0,0 +1,85 @@
<template>
<div class="max-w-4xl mx-auto px-4 py-12">
<!-- Навигация: теперь используем router-link вместо обычных кнопок -->
<nav class="flex space-x-6 mb-8 pb-2">
<router-link
to="/docs/policy"
class="px-1 py-2 text-sm font-medium transition-colors border-b-2 -mb-2.5"
active-class="border-blue-600 text-blue-600 dark:text-blue-400 dark:border-blue-400"
inactive-class="border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400"
>
Политика конфиденциальности
</router-link>
<router-link
to="/docs/agreement"
class="px-1 py-2 text-sm font-medium transition-colors border-b-2 -mb-2.5"
active-class="border-blue-600 text-blue-600 dark:text-blue-400 dark:border-blue-400"
inactive-class="border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400"
>
Пользовательское соглашение
</router-link>
</nav>
<!-- Область отображения Markdown (Tailwind Typography) -->
<div
v-if="htmlContent"
v-html="htmlContent"
class="prose prose-slate max-w-none dark:prose-invert"
></div>
<div v-else-if="isLoading" class="text-center py-12 text-slate-500 dark:text-slate-400">
Загрузка документа...
</div>
<div v-else class="text-center py-12 text-red-500">
Документ не найден или произошла ошибка.
</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { marked } from 'marked'
// Принимаем параметр docName из роутера
const props = defineProps({
docName: {
type: String,
required: true
}
})
const htmlContent = ref('')
const isLoading = ref(true)
// Функция загрузки md-файла
const fetchMarkdown = async (name) => {
isLoading.value = true
htmlContent.value = ''
try {
// Загрузка из папки public/docs/
const response = await fetch(`/docs/${name}.md`)
if (!response.ok) throw new Error('Файл отсутствует')
const markdownText = await response.text()
htmlContent.value = marked(markdownText)
} catch (error) {
console.error('Ошибка отрисовки md:', error)
htmlContent.value = ''
} finally {
isLoading.value = false
}
}
// Загружаем файл при первой инициализации страницы
onMounted(() => {
fetchMarkdown(props.docName)
})
// Отслеживаем изменение ссылки (например, если пользователь перешел с policy на agreement)
watch(() => props.docName, (newName) => {
fetchMarkdown(newName)
})
</script>
+62
View File
@@ -0,0 +1,62 @@
<template>
<section id="faq" class="px-6 py-20 md:px-12 max-w-4xl mx-auto">
<h2 class="text-3xl md:text-4xl font-bold text-center mb-14">
Часто задаваемые вопросы
</h2>
<div class="space-y-4">
<details
v-for="(item, index) in faqItems"
:key="index"
class="group bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 overflow-hidden transition-colors hover:border-blue-300 dark:hover:border-blue-700"
>
<summary class="flex items-center justify-between px-6 py-5 cursor-pointer list-none">
<span class="font-medium text-left pr-4">{{ item.question }}</span>
<ChevronDown
class="w-5 h-5 shrink-0 text-gray-400 transition-transform duration-300 group-open:rotate-180"
/>
</summary>
<div class="px-6 pb-5 text-gray-500 dark:text-gray-400 leading-relaxed" v-html="item.answer"></div>
</details>
</div>
</section>
</template>
<script setup>
import { ChevronDown } from '@lucide/vue'
const faqItems = [
{
question: 'Как начать пользоваться uVPN?',
answer: 'Откройте бота <a href="https://t.me/uvpn_official_bot" target="_blank" rel="noopener" class="text-blue-600 dark:text-blue-400">@uVPN</a> в Telegram, нажмите «Старт», выберите тариф или активируйте 7 дней бесплатно. Конфигурация придёт автоматически — подключение займёт 30 секунд. При недоступности Telegram воспользуйтесь <a href="https://app.uvpn.shop" target="_blank" class="text-blue-600 dark:text-blue-400">нашим сайтом</a>.'
},
{
question: 'Нужно ли устанавливать отдельное приложение?',
answer: 'Нет. uVPN работает через стандартные протоколы Xray — настройка в один клик. Поддерживаются iOS, Android, Windows, macOS, Linux и Smart TV.'
},
{
question: 'Какая реальная скорость соединения?',
answer: 'Наши серверы поддерживают до 1 Гбит/с. Для стриминга 4K достаточно 25 Мбит/с — этого у нас с запасом. Реальная скорость зависит от вашего провайдера и выбранного сервера.'
},
{
question: 'Есть ли гарантия возврата денег?',
answer: 'Да. Если uVPN вам не подойдёт по любой причине, мы вернём деньги в течение 30 дней без вопросов — просто напишите в поддержку через Telegram.'
},
{
question: 'Сколько устройств можно подключить одновременно?',
answer: 'Standard — до 3 устройств, Premium — до 10 устройств одновременно. Для семьи или небольшого офиса идеально подойдёт Premium.'
},
{
question: 'Хранит ли uVPN логи моей активности?',
answer: 'Нет. Мы придерживаемся строгой политики no-log: не сохраняем историю посещений, IP-адреса, время подключений и другие персональные данные.'
},
{
question: 'Как оплатить подписку?',
answer: 'Оплата принимается через мини-приложение в Telegram или на <a href="https://app.uvpn.shop" target="_blank">сайте. Доступны банковские карты (Visa, Mastercard, МИР), СБП. Автосписания нет — продление только по вашему желанию.'
},
{
question: 'Что делать, если VPN перестал работать?',
answer: 'Обновите конфиги в вашем клиенте. Если не помогло — напишите в поддержку, мы отвечаем в течение 15 минут.'
}
]
</script>
+28 -7
View File
@@ -4,25 +4,46 @@
Почему выбирают uVPN Почему выбирают uVPN
</h2> </h2>
<div class="grid md:grid-cols-3 gap-8"> <div class="grid md:grid-cols-3 gap-8">
<!-- Преимущество 1 -->
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 text-center hover:bg-gray-100 dark:hover:bg-gray-800 transition border border-gray-200 dark:border-gray-800"> <div class="bg-white dark:bg-gray-900 rounded-2xl p-8 text-center hover:bg-gray-100 dark:hover:bg-gray-800 transition border border-gray-200 dark:border-gray-800">
<Zap class="w-12 h-12 text-blue-600 dark:text-blue-400 mx-auto mb-4" /> <Zap class="w-12 h-12 text-blue-600 dark:text-blue-400 mx-auto mb-4" />
<h3 class="text-xl font-semibold mb-3">Высокая скорость</h3> <h3 class="text-xl font-semibold mb-3">Молниеносная скорость</h3>
<p class="text-gray-500 dark:text-gray-400">Серверы в 90+ странах с пропускной способностью до 10 Гбит/с.</p> <p class="text-gray-500 dark:text-gray-400">Серверы до 1 Гбит/с стримите, играйте и работайте без задержек</p>
</div> </div>
<!-- Преимущество 2 -->
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 text-center hover:bg-gray-100 dark:hover:bg-gray-800 transition border border-gray-200 dark:border-gray-800"> <div class="bg-white dark:bg-gray-900 rounded-2xl p-8 text-center hover:bg-gray-100 dark:hover:bg-gray-800 transition border border-gray-200 dark:border-gray-800">
<Shield class="w-12 h-12 text-blue-600 dark:text-blue-400 mx-auto mb-4" /> <Shield class="w-12 h-12 text-blue-600 dark:text-blue-400 mx-auto mb-4" />
<h3 class="text-xl font-semibold mb-3">Полная безопасность</h3> <h3 class="text-xl font-semibold mb-3">Надёжное шифрование</h3>
<p class="text-gray-500 dark:text-gray-400">Шифрование AES-256 и политика отсутствия логов.</p> <p class="text-gray-500 dark:text-gray-400">Современные протоколы защищают ваши данные в любой сети, включая публичный WiFi</p>
</div> </div>
<!-- Преимущество 3 -->
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 text-center hover:bg-gray-100 dark:hover:bg-gray-800 transition border border-gray-200 dark:border-gray-800"> <div class="bg-white dark:bg-gray-900 rounded-2xl p-8 text-center hover:bg-gray-100 dark:hover:bg-gray-800 transition border border-gray-200 dark:border-gray-800">
<Globe class="w-12 h-12 text-blue-600 dark:text-blue-400 mx-auto mb-4" /> <Globe class="w-12 h-12 text-blue-600 dark:text-blue-400 mx-auto mb-4" />
<h3 class="text-xl font-semibold mb-3">Глобальный доступ</h3> <h3 class="text-xl font-semibold mb-3">30+ стран</h3>
<p class="text-gray-500 dark:text-gray-400">Обходите цензуру и получайте доступ к любому контенту из любой точки мира.</p> <p class="text-gray-500 dark:text-gray-400">Серверы в Европе, Азии и Америке. Выберите локацию под нужный сервис</p>
</div>
<!-- Преимущество 4 -->
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 text-center hover:bg-gray-100 dark:hover:bg-gray-800 transition border border-gray-200 dark:border-gray-800">
<FileX class="w-12 h-12 text-blue-600 dark:text-blue-400 mx-auto mb-4" />
<h3 class="text-xl font-semibold mb-3">Политика No-Log</h3>
<p class="text-gray-500 dark:text-gray-400">Не храним историю посещений, IP-адреса и никакие персональные данные</p>
</div>
<!-- Преимущество 5 -->
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 text-center hover:bg-gray-100 dark:hover:bg-gray-800 transition border border-gray-200 dark:border-gray-800">
<MessageCircle class="w-12 h-12 text-blue-600 dark:text-blue-400 mx-auto mb-4" />
<h3 class="text-xl font-semibold mb-3">Управление в Telegram</h3>
<p class="text-gray-500 dark:text-gray-400">Подключение, смена сервера, продление всё через удобного бота</p>
</div>
<!-- Преимущество 6 -->
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 text-center hover:bg-gray-100 dark:hover:bg-gray-800 transition border border-gray-200 dark:border-gray-800">
<RefreshCw class="w-12 h-12 text-blue-600 dark:text-blue-400 mx-auto mb-4" />
<h3 class="text-xl font-semibold mb-3">Стабильная работа</h3>
<p class="text-gray-500 dark:text-gray-400">Автоматические обновления конфигураций мы первые подстраиваемся под новые условия</p>
</div> </div>
</div> </div>
</section> </section>
</template> </template>
<script setup> <script setup>
import { Zap, Shield, Globe } from '@lucide/vue' import { Zap, Shield, Globe, FileX, MessageCircle, RefreshCw } from '@lucide/vue'
</script> </script>
+16 -5
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>
@@ -9,14 +12,21 @@
<!-- Левая колонка --> <!-- Левая колонка -->
<div class="text-white"> <div class="text-white">
<h1 class="text-4xl md:text-5xl font-extrabold leading-tight mb-6 drop-shadow-lg"> <h1 class="text-4xl md:text-5xl font-extrabold leading-tight mb-6 drop-shadow-lg">
Безопасный и быстрый VPN от <span class="text-blue-400">uVPN</span> Безопасный и быстрый интернет от <span class="text-blue-400">uVPN</span>
</h1> </h1>
<p class="text-gray-200 text-lg mb-8 max-w-md drop-shadow"> <p class="text-gray-200 text-lg mb-8 max-w-md drop-shadow">
Защитите свои данные, обходите блокировки и оставайтесь анонимными в сети с нашим современным VPN-сервисом на базе Xray. Защитите свои данные и оставайтесь анонимными в сети с нашим современным VPN-сервисом на базе Xray.
</p> </p>
<button class="bg-blue-600 hover:bg-blue-700 transition px-8 py-4 rounded-xl font-bold text-lg shadow-lg shadow-blue-600/30 flex items-center gap-2"> <a
<Zap class="w-5 h-5" /> Начать бесплатно href="https://app.uvpn.shop"
</button> target="_blank"
rel="noopener noreferrer"
class="bg-blue-600 hover:bg-blue-700 transition px-8 py-4 rounded-xl font-bold text-lg shadow-lg shadow-blue-600/30 inline-flex items-center gap-2 text-white no-underline"
>
<Zap class="w-5 h-5" />
Начать бесплатно
</a>
<p class="text-gray-300 text-sm mt-4 drop-shadow">30-дневная гарантия возврата денег</p> <p class="text-gray-300 text-sm mt-4 drop-shadow">30-дневная гарантия возврата денег</p>
</div> </div>
@@ -31,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>
+176
View File
@@ -0,0 +1,176 @@
<template>
<section class="px-6 py-20 md:px-12 max-w-7xl mx-auto">
<div class="flex items-center justify-between mb-10 flex-wrap gap-4">
<h2 class="text-3xl md:text-4xl font-bold">
Статус серверов
</h2>
<button
@click="refreshData"
:disabled="loading"
class="px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-xl 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-3">
<span
v-if="host.ping !== undefined && host.ping !== null"
class="font-mono font-semibold flex items-center gap-1.5"
: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 font-mono"></span>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { ref, computed, onMounted } 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)
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 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 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,
}
})
} catch {
error.value = 'Ошибка загрузки'
} finally {
loading.value = false
}
}
const refreshData = async () => {
loading.value = true
error.value = null
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,
}
})
// Пингуем все хосты параллельно
const pings = await Promise.all(hosts.value.map(h => pingHost(h)))
hosts.value.forEach((h, i) => {
if (h.isOnline) h.ping = pings[i]
})
} catch {
error.value = 'Ошибка обновления'
} finally {
loading.value = false
}
}
onMounted(() => {
fetchHosts()
})
</script>
+177 -41
View File
@@ -3,51 +3,187 @@
<h2 class="text-3xl md:text-4xl font-bold text-center mb-14"> <h2 class="text-3xl md:text-4xl font-bold text-center mb-14">
Прозрачные тарифы Прозрачные тарифы
</h2> </h2>
<div class="grid md:grid-cols-3 gap-8">
<!-- Тариф Месяц --> <!-- Состояние загрузки -->
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 flex flex-col border border-gray-200 dark:border-gray-800 hover:border-blue-600 transition"> <div v-if="loading" class="flex justify-center py-12">
<h3 class="text-xl font-semibold mb-2">Месяц</h3> <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
<p class="text-4xl font-extrabold mb-4">$11.99<span class="text-lg font-normal text-gray-500 dark:text-gray-400">/мес</span></p>
<ul class="text-gray-500 dark:text-gray-400 space-y-3 mb-8 flex-1">
<li class="flex items-center gap-2"><Check class="w-4 h-4 text-green-400" /> Все устройства</li>
<li class="flex items-center gap-2"><Check class="w-4 h-4 text-green-400" /> Безлимитный трафик</li>
<li class="flex items-center gap-2"><Check class="w-4 h-4 text-green-400" /> Поддержка 24/7</li>
</ul>
<button class="w-full bg-gray-800 hover:bg-gray-700 transition py-3 rounded-xl font-medium text-white">
Выбрать
</button>
</div>
<!-- Тариф Год (выделенный) -->
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 flex flex-col border-2 border-blue-600 relative transform scale-105 shadow-2xl shadow-blue-600/20">
<span 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">ПОПУЛЯРНЫЙ</span>
<h3 class="text-xl font-semibold mb-2">Год</h3>
<p class="text-4xl font-extrabold mb-4">$4.99<span class="text-lg font-normal text-gray-500 dark:text-gray-400">/мес</span></p>
<ul class="text-gray-500 dark:text-gray-400 space-y-3 mb-8 flex-1">
<li class="flex items-center gap-2"><Check class="w-4 h-4 text-green-400" /> Все устройства</li>
<li class="flex items-center gap-2"><Check class="w-4 h-4 text-green-400" /> Безлимитный трафик</li>
<li class="flex items-center gap-2"><Check class="w-4 h-4 text-green-400" /> Приоритетная поддержка</li>
</ul>
<button class="w-full bg-blue-600 dark:bg-blue-500 hover:bg-blue-700 transition py-3 rounded-xl font-bold text-white">
Выбрать тариф
</button>
</div>
<!-- Тариф 2 года -->
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 flex flex-col border border-gray-200 dark:border-gray-800 hover:border-blue-600 transition">
<h3 class="text-xl font-semibold mb-2">2 года</h3>
<p class="text-4xl font-extrabold mb-4">$2.99<span class="text-lg font-normal text-gray-500 dark:text-gray-400">/мес</span></p>
<ul class="text-gray-500 dark:text-gray-400 space-y-3 mb-8 flex-1">
<li class="flex items-center gap-2"><Check class="w-4 h-4 text-green-400" /> Все устройства</li>
<li class="flex items-center gap-2"><Check class="w-4 h-4 text-green-400" /> Безлимитный трафик</li>
<li class="flex items-center gap-2"><Check class="w-4 h-4 text-green-400" /> Максимальная экономия</li>
</ul>
<button class="w-full bg-gray-800 hover:bg-gray-700 transition py-3 rounded-xl font-medium text-white">
Выбрать
</button>
</div>
</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 flex-wrap">
<button
v-for="period in availablePeriods"
:key="period.value"
@click="selectedPeriod = period.value"
class="px-5 py-2 rounded-full text-sm font-medium transition hover:cursor-pointer"
:class="selectedPeriod === period.value
? 'bg-blue-600 text-white shadow-md'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'"
>
{{ period.label }}
</button>
</div>
<!-- Сетка тарифов -->
<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 relative"
:class="[
tariff.isPopular
? '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"
>
ПОПУЛЯРНЫЙ
</span>
<h3 class="text-xl font-semibold mb-2">{{ tariff.name }}</h3>
<p class="text-4xl font-extrabold mb-4">
{{ tariff.price }}<span class="text-lg font-normal text-gray-500 dark:text-gray-400">/мес</span>
</p>
<ul class="text-gray-500 dark:text-gray-400 space-y-3 mb-8 flex-1">
<li v-for="feature in tariff.features" :key="feature" class="flex items-center gap-2">
<Check class="w-4 h-4 text-green-400" /> {{ feature }}
</li>
</ul>
<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 ? 'Выбрать тариф' : 'Выбрать' }}
</a>
</div>
</div>
</template>
</section> </section>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue'
import { Check } from '@lucide/vue' import { Check } from '@lucide/vue'
// Состояния
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 activeTariffs = computed(() => {
return tariffsData.value.filter(tariff =>
tariff.enabled && tariff.enabled_periods?.includes(selectedPeriod.value)
)
})
// Карточки для отображения (с ценами, фичами и флагом популярности)
const tariffCards = computed(() => {
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)
}
// Формируем список особенностей (features)
const features = []
// 1. Описание из JSON (если есть)
const description = tariff.descriptions?.ru?.trim()
if (description) {
features.push(description)
}
// 2. Лимит устройств
if (tariff.hwid_device_limit) {
features.push(`До ${tariff.hwid_device_limit} устройств`)
}
// 3. Безлимитный трафик (всегда)
features.push('Безлимитный трафик')
// 4. Поддержка 24/7
features.push('Поддержка 24/7')
return {
key: tariff.key,
name: tariff.names?.ru || tariff.key,
price: `${monthlyPrice}`,
features,
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>
+411
View File
@@ -0,0 +1,411 @@
<template>
<div class="screenshot-controls">
<!-- Основная кнопка скриншота -->
<button
@click="takeScreenshot"
:disabled="isTakingScreenshot || !isModelReady"
class="screenshot-btn"
:class="{ 'screenshot-btn-loading': isTakingScreenshot }"
>
<Camera v-if="!isTakingScreenshot" class="w-5 h-5" />
<Loader2 v-else class="w-5 h-5 animate-spin" />
{{ buttonText }}
</button>
<!-- Уведомление -->
<Transition name="notification">
<div v-if="showNotification" class="screenshot-notification" :class="notificationType">
<CheckCircle v-if="notificationType === 'success'" class="w-4 h-4" />
<AlertCircle v-else-if="notificationType === 'error'" class="w-4 h-4" />
<Download v-else-if="notificationType === 'downloading'" class="w-4 h-4 animate-bounce" />
{{ notificationMessage }}
</div>
</Transition>
<!-- Меню настроек (опционально) -->
<div v-if="showSettings" class="screenshot-settings">
<button @click="toggleSettings" class="settings-btn">
<Settings class="w-4 h-4" />
</button>
<div v-if="settingsOpen" class="settings-dropdown">
<label>
<input type="checkbox" v-model="settings.keepAutoRotate" />
Сохранять авто-вращение
</label>
<label>
<select v-model="settings.format">
<option value="image/png">PNG</option>
<option value="image/jpeg">JPEG</option>
<option value="image/webp">WebP</option>
</select>
</label>
<label>
Качество:
<input type="range" v-model.number="settings.quality" min="0.1" max="1.0" step="0.1" />
{{ Math.round(settings.quality * 100) }}%
</label>
<label>
<input type="checkbox" v-model="settings.transparentBg" />
Прозрачный фон
</label>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, onUnmounted } from 'vue'
import { Camera, Loader2, CheckCircle, AlertCircle, Download, Settings } from '@lucide/vue'
// Пропсы для конфигурации
const props = defineProps({
// Ссылка на Three.js рендерер
renderer: {
type: Object,
required: true
},
// Ссылка на OrbitControls (для управления авто-вращением)
controls: {
type: Object,
default: null
},
// Флаг готовности модели
isModelReady: {
type: Boolean,
default: false
},
// Имя файла по умолчанию
filename: {
type: String,
default: 'screenshot'
},
// Показывать ли настройки
showSettings: {
type: Boolean,
default: false
},
// Текст на кнопке
buttonText: {
type: String,
default: 'Сделать скриншот'
},
// Автоматическое уведомление
autoNotify: {
type: Boolean,
default: true
}
})
// Состояния
const isTakingScreenshot = ref(false)
const showNotification = ref(false)
const notificationMessage = ref('')
const notificationType = ref('success')
const settingsOpen = ref(false)
// Настройки по умолчанию
const settings = ref({
keepAutoRotate: true,
format: 'image/png',
quality: 1.0,
transparentBg: false
})
// Таймер для уведомлений
let notificationTimeout = null
// Показать уведомление
const showNotificationMessage = (message, type = 'success', duration = 3000) => {
if (!props.autoNotify) return
if (notificationTimeout) clearTimeout(notificationTimeout)
notificationMessage.value = message
notificationType.value = type
showNotification.value = true
notificationTimeout = setTimeout(() => {
showNotification.value = false
}, duration)
}
// Переключение настроек
const toggleSettings = () => {
settingsOpen.value = !settingsOpen.value
}
// Основной метод скриншота
const takeScreenshot = async () => {
if (!props.renderer || !props.isModelReady) {
showNotificationMessage('Модель еще не загружена', 'error', 2000)
return
}
isTakingScreenshot.value = true
// Сохраняем состояние авто-вращения
let wasAutoRotating = false
if (props.controls && !settings.value.keepAutoRotate) {
wasAutoRotating = props.controls.autoRotate
if (wasAutoRotating) {
props.controls.autoRotate = false
props.controls.update()
}
}
try {
// Небольшая задержка для стабилизации кадра
await new Promise(resolve => setTimeout(resolve, 50))
const canvas = props.renderer.domElement
// Если нужен прозрачный фон
if (settings.value.transparentBg) {
const originalClearColor = props.renderer.getClearColor()
const originalAlpha = props.renderer.getClearAlpha()
props.renderer.setClearColor(0x000000, 0)
// Делаем скриншот после изменения прозрачности
await new Promise(resolve => setTimeout(resolve, 10))
const blob = await new Promise((resolve) => {
canvas.toBlob(resolve, settings.value.format, settings.value.quality)
})
// Восстанавливаем оригинальный цвет фона
props.renderer.setClearColor(originalClearColor, originalAlpha)
if (blob) {
downloadBlob(blob)
}
} else {
// Обычный скриншот
const blob = await new Promise((resolve) => {
canvas.toBlob(resolve, settings.value.format, settings.value.quality)
})
if (blob) {
downloadBlob(blob)
}
}
showNotificationMessage('Скриншот сохранен!', 'success', 2000)
} catch (error) {
console.error('Ошибка при создании скриншота:', error)
showNotificationMessage('Ошибка при сохранении скриншота', 'error', 3000)
} finally {
// Восстанавливаем авто-вращение
if (props.controls && !settings.value.keepAutoRotate && wasAutoRotating) {
props.controls.autoRotate = wasAutoRotating
props.controls.update()
}
isTakingScreenshot.value = false
}
}
// Скачивание blob
const downloadBlob = (blob) => {
// Показываем уведомление о начале скачивания
showNotificationMessage('Подготовка файла...', 'downloading', 1000)
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
const extension = settings.value.format.split('/')[1]
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-')
link.href = url
link.download = `${props.filename}-${timestamp}.${extension}`
document.body.appendChild(link)
link.click()
// Очищаем
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
// Очистка при размонтировании
onUnmounted(() => {
if (notificationTimeout) clearTimeout(notificationTimeout)
})
// Следим за изменениями прозрачности
watch(() => settings.value.transparentBg, (newVal) => {
if (props.renderer) {
if (newVal) {
props.renderer.setClearColor(0x000000, 0)
} else {
props.renderer.setClearColor(0x000000, 1)
}
}
})
</script>
<style scoped>
.screenshot-controls {
position: relative;
display: inline-block;
}
.screenshot-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.75rem;
color: white;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.screenshot-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.2);
transform: translateY(-1px);
}
.screenshot-btn:active:not(:disabled) {
transform: translateY(0);
}
.screenshot-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.screenshot-btn-loading {
cursor: wait;
}
.screenshot-notification {
position: fixed;
bottom: 2rem;
right: 2rem;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: white;
color: #1f2937;
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
z-index: 1000;
font-size: 0.875rem;
font-weight: 500;
}
.screenshot-notification.success {
background: #10b981;
color: white;
}
.screenshot-notification.error {
background: #ef4444;
color: white;
}
.screenshot-notification.downloading {
background: #3b82f6;
color: white;
}
.notification-enter-active,
.notification-leave-active {
transition: all 0.3s ease;
}
.notification-enter-from {
transform: translateX(100%);
opacity: 0;
}
.notification-leave-to {
transform: translateX(100%);
opacity: 0;
}
.screenshot-settings {
position: relative;
display: inline-block;
margin-left: 0.5rem;
}
.settings-btn {
padding: 0.75rem;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.75rem;
color: white;
cursor: pointer;
transition: all 0.2s ease;
}
.settings-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.settings-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 0.5rem;
padding: 1rem;
background: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.75rem;
color: white;
min-width: 200px;
z-index: 100;
}
.settings-dropdown label {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.settings-dropdown select,
.settings-dropdown input[type="range"] {
margin-left: auto;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.25rem;
color: white;
padding: 0.25rem;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
.animate-bounce {
animation: bounce 0.5s ease infinite;
}
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-25%);
}
}
</style>
+3
View File
@@ -0,0 +1,3 @@
<template>
<svg width="800px" height="800px" viewBox="-0.5 0 257 257" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"><path d="M0 36.357L104.62 22.11l.045 100.914-104.57.595L0 36.358zm104.57 98.293l.08 101.002L.081 221.275l-.006-87.302 104.494.677zm12.682-114.405L255.968 0v121.74l-138.716 1.1V20.246zM256 135.6l-.033 121.191-138.716-19.578-.194-101.84L256 135.6z" fill="#00ADEF"/></svg>
</template>
+9 -1
View File
@@ -1,5 +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 { createHead } from '@vueuse/head'
import './assets/styles.css' import './assets/styles.css'
createApp(App).mount('#app') const app = createApp(App)
const head = createHead()
app.use(router)
app.use(head)
app.mount('#app')
+53
View File
@@ -0,0 +1,53 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import DocViewer from '../components/DocViewer.vue'
const router = createRouter({
history: createWebHistory(),
scrollBehavior(to, from, savedPosition) {
// Если в ссылке есть хэш (например, #pricing)
if (to.hash) {
return new Promise((resolve) => {
// Даем 200 миллисекунд компонентам (Three.js/Swiper) на полную отрисовку
setTimeout(() => {
resolve({
el: to.hash,
behavior: 'smooth',
// Корректировка отступа сверху, чтобы липкая (sticky) шапка не перекрывала заголовок блока
offset: { x: 0, y: 80 }
})
}, 200)
})
}
// Если хэша нет, при переходе на новую страницу просто скроллим в самый верх
return { top: 0 }
},
routes: [
{
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, пользовательское соглашение, политика конфиденциальности, документы'
}
}
]
})
export default router
+26
View File
@@ -0,0 +1,26 @@
<template>
<HeroSection />
<FeaturesSection />
<CompatibilitySection />
<PricingSection />
<NodesStatus />
<StepsSection />
<TestimonialsSection />
<FaqSection />
</template>
<script setup>
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 { useRoute } from 'vue-router';
const route = useRoute();
</script>
+11 -3
View File
@@ -2,20 +2,28 @@ 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 vueDevTools from 'vite-plugin-vue-devtools'
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(),
vueDevTools(),
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
}
}) })