- PRICING-LOCATION-ANALYSIS.md (45KB): технический анализ Marzban API - IMPLEMENTATION-SUMMARY.md (12KB): краткая выжимка для разработки - MINI_APP_ARCHITECTURE.md: обновленная архитектура Ключевые решения: ✅ Скидки за период: -10% (3м), -15% (6м), -20% (год) ✅ Выбор локаций из реальных нод Marzban ✅ Мультишаговая воронка: тариф → период → локации → оплата ❌ Динамическое ценообразование отклонено ⏸️ Семейные планы отложены на V2.0 (требуют Device Monitor) ✅ Реферальная система уже работает MVP план: 2 недели (20 часов) - Week 1: Backend API (8h) - /api/nodes + /api/create-user updates - Week 2: Frontend UI (12h) - мультишаговый /app/plans/page.tsx
1223 lines
45 KiB
Markdown
1223 lines
45 KiB
Markdown
# 🎯 Анализ: Система тарифов с выбором локаций + скидки за период
|
||
|
||
## 📊 Текущее состояние
|
||
|
||
### Что есть сейчас:
|
||
1. ✅ **Тарифы** определены в `/app/plans/page.tsx`:
|
||
- Trial (7 дней бесплатно)
|
||
- Start (100₽/мес, 1 локация)
|
||
- Plus (299₽/мес, 3 локации)
|
||
- Max (350₽/мес, 15+ локаций)
|
||
|
||
2. ✅ **SetupWizard** (`/components/SetupWizard.tsx`):
|
||
- Есть UI для выбора локаций
|
||
- Но используется ПОСЛЕ покупки (в wizard'е настройки)
|
||
- НЕ влияет на создание пользователя
|
||
|
||
3. ✅ **API создания пользователя** (`/app/api/create-user/route.ts`):
|
||
- Создает пользователя с фиксированными inbounds
|
||
- НЕТ привязки к нодам/локациям
|
||
- Используется hardcoded список inbounds:
|
||
```typescript
|
||
inbounds: {
|
||
vless: ['VLESS TCP', 'VLESS Reality'],
|
||
vmess: ['VMess WS'],
|
||
trojan: ['Trojan TCP']
|
||
}
|
||
```
|
||
|
||
4. ❌ **Что НЕ работает**:
|
||
- Выбор локаций НЕ сохраняется в Marzban
|
||
- Нет связи между тарифом и доступными нодами
|
||
- Нет скидок за длительные периоды (3 мес, 6 мес, год)
|
||
|
||
---
|
||
|
||
## 🔍 Анализ Marzban API
|
||
|
||
### Доступные endpoints (из https://panel.umbrix.net/docs):
|
||
|
||
#### 1. **Управление нодами** (`/api/nodes`):
|
||
```bash
|
||
GET /api/nodes # Список всех нод с их параметрами
|
||
GET /api/node/{node_id} # Детали конкретной ноды
|
||
POST /api/node # Создать ноду
|
||
PUT /api/node/{node_id} # Изменить ноду
|
||
DELETE /api/node/{node_id} # Удалить ноду
|
||
GET /api/nodes/usage # Статистика по нодам
|
||
```
|
||
|
||
**Структура ноды** (`NodeResponse`):
|
||
```json
|
||
{
|
||
"id": 1,
|
||
"name": "🇺🇸 USA Node",
|
||
"address": "194.113.210.187",
|
||
"port": 62050,
|
||
"api_port": 62051,
|
||
"status": "connected",
|
||
"message": null,
|
||
"xray_version": "1.8.21",
|
||
"usage": {
|
||
"uplink": 123456789,
|
||
"downlink": 987654321
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 2. **Inbounds** (`/api/inbounds`):
|
||
```bash
|
||
GET /api/inbounds # Список всех inbounds (протоколов)
|
||
```
|
||
|
||
**Структура inbound**:
|
||
```json
|
||
{
|
||
"tag": "VLESS TCP",
|
||
"protocol": "vless",
|
||
"network": "tcp",
|
||
"tls": "reality",
|
||
"port": 443
|
||
}
|
||
```
|
||
|
||
#### 3. **Создание пользователя** (`POST /api/user`):
|
||
```json
|
||
{
|
||
"username": "test_user",
|
||
"status": "active",
|
||
"expire": 1735689600, // Unix timestamp
|
||
"data_limit": 107374182400, // Bytes
|
||
"data_limit_reset_strategy": "no_reset",
|
||
"proxies": {
|
||
"vmess": {"id": "uuid-here"},
|
||
"vless": {},
|
||
"trojan": {}
|
||
},
|
||
"inbounds": {
|
||
"vless": ["VLESS TCP", "VLESS Reality"],
|
||
"vmess": ["VMess WS"],
|
||
"trojan": ["Trojan TCP"]
|
||
},
|
||
"note": "Тариф Plus, TG: 12345"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🎨 Новая воронка с выбором локаций
|
||
|
||
### Идеальный User Flow:
|
||
|
||
```
|
||
1. Пользователь открывает /plans
|
||
↓
|
||
2. Выбирает тариф (Start/Plus/Max)
|
||
↓
|
||
3. Видит варианты периода оплаты:
|
||
┌─────────────────────────────────────┐
|
||
│ ⭕ 1 месяц - 299₽ (без скидки) │
|
||
│ ⭕ 3 месяца - 807₽ (-10% = 897₽) │
|
||
│ ⭕ 6 месяцев - 1523₽ (-15% = 1794₽)│
|
||
│ ⭕ 1 год - 2870₽ (-20% = 3588₽)│
|
||
└─────────────────────────────────────┘
|
||
↓
|
||
4. Выбирает локации (в зависимости от тарифа):
|
||
┌─────────────────────────────────────┐
|
||
│ Start: 1 локация из списка │
|
||
│ Plus: 3 локации из списка │
|
||
│ Max: Все локации │
|
||
└─────────────────────────────────────┘
|
||
|
||
Список локаций (из Marzban API):
|
||
☐ 🇳🇱 Нидерланды (ping: 15ms)
|
||
☐ 🇩🇪 Германия (ping: 20ms)
|
||
☐ 🇺🇸 США (ping: 120ms)
|
||
☐ 🇸🇬 Сингапур (ping: 180ms)
|
||
☑ 🇯🇵 Япония (ping: 160ms)
|
||
☐ 🇬🇧 Великобритания (ping: 35ms)
|
||
↓
|
||
5. Нажимает "Оплатить" → Payment gateway
|
||
↓
|
||
6. После оплаты → API создает пользователя с выбранными локациями
|
||
```
|
||
|
||
---
|
||
|
||
## 🚀 Техническая реализация
|
||
|
||
### **Этап 1: Получить список нод из Marzban**
|
||
|
||
Создать новый API endpoint `/api/nodes`:
|
||
|
||
```typescript
|
||
// app/api/nodes/route.ts
|
||
import { NextResponse } from 'next/server';
|
||
|
||
const MARZBAN_API = process.env.MARZBAN_PANEL_URL;
|
||
const ADMIN_USERNAME = process.env.MARZBAN_ADMIN_USERNAME;
|
||
const ADMIN_PASSWORD = process.env.MARZBAN_ADMIN_PASSWORD;
|
||
|
||
export async function GET() {
|
||
try {
|
||
// 1. Получить токен админа
|
||
const tokenResponse = await fetch(`${MARZBAN_API}/api/admin/token`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: `grant_type=password&username=${ADMIN_USERNAME}&password=${ADMIN_PASSWORD}`,
|
||
});
|
||
|
||
const { access_token } = await tokenResponse.json();
|
||
|
||
// 2. Получить список нод
|
||
const nodesResponse = await fetch(`${MARZBAN_API}/api/nodes`, {
|
||
headers: { 'Authorization': `Bearer ${access_token}` },
|
||
});
|
||
|
||
const nodes = await nodesResponse.json();
|
||
|
||
// 3. Форматировать для фронтенда
|
||
const locations = nodes
|
||
.filter(node => node.status === 'connected')
|
||
.map(node => ({
|
||
id: node.id,
|
||
name: node.name, // "🇺🇸 USA Node"
|
||
address: node.address,
|
||
ping: calculatePing(node.address), // TODO: реальный ping
|
||
country: extractCountry(node.name), // "US"
|
||
}));
|
||
|
||
return NextResponse.json({ success: true, locations });
|
||
} catch (error) {
|
||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||
}
|
||
}
|
||
|
||
function extractCountry(nodeName: string): string {
|
||
// Извлечь код страны из emoji флага
|
||
const match = nodeName.match(/🇦-🇿{2}/);
|
||
return match ? getFlagCode(match[0]) : 'Unknown';
|
||
}
|
||
|
||
function calculatePing(address: string): string {
|
||
// TODO: Реальный ping check (или хардкод по регионам)
|
||
const mockPings = {
|
||
'194.113.210.187': '120ms', // USA
|
||
'193.168.175.128': '15ms', // NL
|
||
// ...
|
||
};
|
||
return mockPings[address] || '50ms';
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### **Этап 2: Страница выбора тарифа с периодом**
|
||
|
||
Обновить `/app/plans/page.tsx`:
|
||
|
||
```tsx
|
||
'use client';
|
||
|
||
import { useState, useEffect } from 'react';
|
||
import { Check, ChevronRight } from 'lucide-react';
|
||
|
||
type Period = '1month' | '3months' | '6months' | '1year';
|
||
type PlanType = 'start' | 'plus' | 'max';
|
||
|
||
interface Location {
|
||
id: number;
|
||
name: string;
|
||
country: string;
|
||
ping: string;
|
||
}
|
||
|
||
export default function Plans() {
|
||
const [selectedPlan, setSelectedPlan] = useState<PlanType | null>(null);
|
||
const [selectedPeriod, setSelectedPeriod] = useState<Period>('1month');
|
||
const [selectedLocations, setSelectedLocations] = useState<number[]>([]);
|
||
const [availableLocations, setAvailableLocations] = useState<Location[]>([]);
|
||
|
||
// Загрузить локации из API
|
||
useEffect(() => {
|
||
async function fetchLocations() {
|
||
const response = await fetch('/api/nodes');
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
setAvailableLocations(data.locations);
|
||
}
|
||
}
|
||
fetchLocations();
|
||
}, []);
|
||
|
||
// Тарифы с ценами за разные периоды
|
||
const plans = {
|
||
start: {
|
||
name: 'Start',
|
||
locations: 1,
|
||
prices: {
|
||
'1month': 100,
|
||
'3months': 270, // -10%
|
||
'6months': 510, // -15%
|
||
'1year': 960, // -20%
|
||
}
|
||
},
|
||
plus: {
|
||
name: 'Plus',
|
||
locations: 3,
|
||
prices: {
|
||
'1month': 299,
|
||
'3months': 807, // -10%
|
||
'6months': 1523, // -15%
|
||
'1year': 2870, // -20%
|
||
}
|
||
},
|
||
max: {
|
||
name: 'Max',
|
||
locations: 999, // Все
|
||
prices: {
|
||
'1month': 350,
|
||
'3months': 945, // -10%
|
||
'6months': 1785, // -15%
|
||
'1year': 3360, // -20%
|
||
}
|
||
}
|
||
};
|
||
|
||
const handlePlanSelect = (plan: PlanType) => {
|
||
setSelectedPlan(plan);
|
||
};
|
||
|
||
const handlePeriodSelect = (period: Period) => {
|
||
setSelectedPeriod(period);
|
||
};
|
||
|
||
const handleLocationToggle = (locationId: number) => {
|
||
if (!selectedPlan) return;
|
||
|
||
const maxLocations = plans[selectedPlan].locations;
|
||
|
||
if (selectedLocations.includes(locationId)) {
|
||
setSelectedLocations(selectedLocations.filter(id => id !== locationId));
|
||
} else if (maxLocations === 999 || selectedLocations.length < maxLocations) {
|
||
setSelectedLocations([...selectedLocations, locationId]);
|
||
}
|
||
};
|
||
|
||
const handlePurchase = async () => {
|
||
if (!selectedPlan || selectedLocations.length === 0) {
|
||
alert('Выберите тариф и хотя бы одну локацию');
|
||
return;
|
||
}
|
||
|
||
// Отправить на payment gateway
|
||
const response = await fetch('/api/create-payment', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
plan: selectedPlan,
|
||
period: selectedPeriod,
|
||
locations: selectedLocations,
|
||
telegramId: getTelegramId(),
|
||
}),
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
window.location.href = data.paymentUrl;
|
||
}
|
||
};
|
||
|
||
const getDiscountPercent = (period: Period): number => {
|
||
const discounts = {
|
||
'1month': 0,
|
||
'3months': 10,
|
||
'6months': 15,
|
||
'1year': 20,
|
||
};
|
||
return discounts[period];
|
||
};
|
||
|
||
const calculateSavings = (plan: PlanType, period: Period): number => {
|
||
const basePrices = plans[plan].prices;
|
||
const monthlyPrice = basePrices['1month'];
|
||
const months = { '1month': 1, '3months': 3, '6months': 6, '1year': 12 }[period];
|
||
const fullPrice = monthlyPrice * months;
|
||
const discountedPrice = basePrices[period];
|
||
return fullPrice - discountedPrice;
|
||
};
|
||
|
||
return (
|
||
<div className="min-h-screen bg-slate-900 text-white p-4">
|
||
<h1 className="text-2xl font-bold mb-6">Выбор тарифа</h1>
|
||
|
||
{/* Шаг 1: Выбор тарифа */}
|
||
{!selectedPlan && (
|
||
<div className="space-y-4">
|
||
{Object.entries(plans).map(([key, plan]) => (
|
||
<button
|
||
key={key}
|
||
onClick={() => handlePlanSelect(key as PlanType)}
|
||
className="w-full p-4 bg-slate-800 rounded-lg text-left hover:bg-slate-700"
|
||
>
|
||
<h3 className="text-xl font-bold">{plan.name}</h3>
|
||
<p className="text-slate-400">
|
||
{plan.locations === 999 ? 'Все локации' : `${plan.locations} локаций`}
|
||
</p>
|
||
<p className="text-blue-400 mt-2">От {plan.prices['1month']}₽/мес</p>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Шаг 2: Выбор периода */}
|
||
{selectedPlan && !selectedLocations.length && (
|
||
<div className="space-y-4">
|
||
<button onClick={() => setSelectedPlan(null)} className="text-blue-400">← Назад</button>
|
||
|
||
<h2 className="text-xl font-bold mb-4">Выберите период</h2>
|
||
|
||
{Object.entries(plans[selectedPlan].prices).map(([period, price]) => {
|
||
const discount = getDiscountPercent(period as Period);
|
||
const savings = calculateSavings(selectedPlan, period as Period);
|
||
|
||
return (
|
||
<button
|
||
key={period}
|
||
onClick={() => {
|
||
handlePeriodSelect(period as Period);
|
||
// Переходим к выбору локаций
|
||
}}
|
||
className="w-full p-4 bg-slate-800 rounded-lg text-left hover:bg-slate-700"
|
||
>
|
||
<div className="flex justify-between items-center">
|
||
<div>
|
||
<h3 className="text-lg font-bold">
|
||
{period === '1month' && '1 месяц'}
|
||
{period === '3months' && '3 месяца'}
|
||
{period === '6months' && '6 месяцев'}
|
||
{period === '1year' && '1 год'}
|
||
</h3>
|
||
{discount > 0 && (
|
||
<p className="text-green-400 text-sm">
|
||
💰 Экономия {savings}₽ (-{discount}%)
|
||
</p>
|
||
)}
|
||
</div>
|
||
<div className="text-right">
|
||
<p className="text-2xl font-bold">{price}₽</p>
|
||
{discount > 0 && (
|
||
<p className="text-slate-400 line-through text-sm">
|
||
{plans[selectedPlan].prices['1month'] *
|
||
({ '3months': 3, '6months': 6, '1year': 12 }[period] || 1)}₽
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* Шаг 3: Выбор локаций */}
|
||
{selectedPlan && selectedPeriod && (
|
||
<div className="space-y-4">
|
||
<button onClick={() => setSelectedPeriod('1month')} className="text-blue-400">← Назад</button>
|
||
|
||
<h2 className="text-xl font-bold mb-2">Выберите локации</h2>
|
||
<p className="text-slate-400 mb-4">
|
||
{plans[selectedPlan].locations === 999
|
||
? 'Доступны все локации'
|
||
: `Выберите до ${plans[selectedPlan].locations} локаций`}
|
||
</p>
|
||
|
||
<div className="space-y-2">
|
||
{availableLocations.map(location => (
|
||
<button
|
||
key={location.id}
|
||
onClick={() => handleLocationToggle(location.id)}
|
||
disabled={
|
||
!selectedLocations.includes(location.id) &&
|
||
plans[selectedPlan].locations !== 999 &&
|
||
selectedLocations.length >= plans[selectedPlan].locations
|
||
}
|
||
className={`w-full p-4 rounded-lg border transition-colors ${
|
||
selectedLocations.includes(location.id)
|
||
? 'bg-blue-600 border-blue-400'
|
||
: 'bg-slate-800 border-slate-700 hover:border-slate-600'
|
||
}`}
|
||
>
|
||
<div className="flex justify-between items-center">
|
||
<div className="flex items-center gap-3">
|
||
{selectedLocations.includes(location.id) && (
|
||
<Check className="w-5 h-5 text-white" />
|
||
)}
|
||
<span>{location.name}</span>
|
||
</div>
|
||
<span className="text-slate-400 text-sm">{location.ping}</span>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{selectedLocations.length > 0 && (
|
||
<button
|
||
onClick={handlePurchase}
|
||
className="w-full py-4 bg-blue-600 hover:bg-blue-700 rounded-lg font-bold mt-6"
|
||
>
|
||
Перейти к оплате {plans[selectedPlan].prices[selectedPeriod]}₽
|
||
<ChevronRight className="inline ml-2" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function getTelegramId() {
|
||
return (window as any).Telegram?.WebApp?.initDataUnsafe?.user?.id || null;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### **Этап 3: Обновить API создания пользователя**
|
||
|
||
Модифицировать `/app/api/create-user/route.ts`:
|
||
|
||
```typescript
|
||
export async function POST(request: NextRequest) {
|
||
const {
|
||
planType,
|
||
period, // NEW: '1month' | '3months' | '6months' | '1year'
|
||
locationIds, // NEW: [1, 3, 5] - выбранные ID нод
|
||
telegramId,
|
||
telegramUsername,
|
||
firstName,
|
||
lastName
|
||
} = await request.json();
|
||
|
||
// 1. Получить токен админа (same as before)
|
||
|
||
// 2. Определить параметры тарифа с учетом периода
|
||
const planConfig = getPlanConfig(planType, period);
|
||
|
||
// 3. Получить список нод и их inbounds
|
||
const nodesResponse = await fetch(`${MARZBAN_API}/api/nodes`, {
|
||
headers: { 'Authorization': `Bearer ${access_token}` },
|
||
});
|
||
const allNodes = await nodesResponse.json();
|
||
|
||
// 4. Фильтровать только выбранные ноды
|
||
const selectedNodes = allNodes.filter(node => locationIds.includes(node.id));
|
||
|
||
// 5. Получить inbounds выбранных нод
|
||
const inboundsResponse = await fetch(`${MARZBAN_API}/api/inbounds`, {
|
||
headers: { 'Authorization': `Bearer ${access_token}` },
|
||
});
|
||
const allInbounds = await inboundsResponse.json();
|
||
|
||
// 6. Создать mapping inbounds для выбранных нод
|
||
const userInbounds = {
|
||
vless: [],
|
||
vmess: [],
|
||
trojan: []
|
||
};
|
||
|
||
selectedNodes.forEach(node => {
|
||
// Добавить inbounds этой ноды в конфиг пользователя
|
||
allInbounds
|
||
.filter(inbound => inbound.node_id === node.id)
|
||
.forEach(inbound => {
|
||
if (inbound.protocol === 'vless') {
|
||
userInbounds.vless.push(inbound.tag);
|
||
} else if (inbound.protocol === 'vmess') {
|
||
userInbounds.vmess.push(inbound.tag);
|
||
} else if (inbound.protocol === 'trojan') {
|
||
userInbounds.trojan.push(inbound.tag);
|
||
}
|
||
});
|
||
});
|
||
|
||
// 7. Создать пользователя с кастомными inbounds
|
||
const createUserResponse = await fetch(`${MARZBAN_API}/api/user`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${access_token}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
username: username,
|
||
status: 'active',
|
||
expire: planConfig.expireTimestamp,
|
||
data_limit: planConfig.dataLimitBytes,
|
||
data_limit_reset_strategy: 'no_reset',
|
||
proxies: {
|
||
vmess: { id: randomUUID() },
|
||
vless: {},
|
||
trojan: {}
|
||
},
|
||
inbounds: userInbounds, // <-- ГЛАВНОЕ ИЗМЕНЕНИЕ!
|
||
note: `Тариф: ${planConfig.name}, Период: ${period}, Локации: ${selectedNodes.map(n => n.name).join(', ')}, TG: ${telegramId}`,
|
||
}),
|
||
});
|
||
|
||
// Rest of the code...
|
||
}
|
||
|
||
function getPlanConfig(planType: PlanType, period: Period) {
|
||
const basePlans = {
|
||
trial: { name: 'Trial', dataLimitGB: 10, durationDays: 7 },
|
||
start: { name: 'Start', dataLimitGB: 50, basePriceRub: 100 },
|
||
plus: { name: 'Plus', dataLimitGB: 299, basePriceRub: 299 },
|
||
max: { name: 'Max', dataLimitGB: 999999, basePriceRub: 350 },
|
||
};
|
||
|
||
const periodMultipliers = {
|
||
'1month': { months: 1, discount: 0 },
|
||
'3months': { months: 3, discount: 0.10 },
|
||
'6months': { months: 6, discount: 0.15 },
|
||
'1year': { months: 12, discount: 0.20 },
|
||
};
|
||
|
||
const plan = basePlans[planType];
|
||
const periodConfig = periodMultipliers[period];
|
||
|
||
const durationDays = periodConfig.months * 30;
|
||
const fullPrice = plan.basePriceRub * periodConfig.months;
|
||
const discountedPrice = Math.round(fullPrice * (1 - periodConfig.discount));
|
||
|
||
return {
|
||
name: plan.name,
|
||
dataLimitBytes: plan.dataLimitGB * 1024 * 1024 * 1024,
|
||
expireTimestamp: Math.floor(Date.now() / 1000) + (durationDays * 24 * 60 * 60),
|
||
priceRub: discountedPrice,
|
||
periodDays: durationDays,
|
||
};
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### **Этап 4: Payment gateway integration**
|
||
|
||
Создать `/app/api/create-payment/route.ts`:
|
||
|
||
```typescript
|
||
// Интеграция с платежным шлюзом (Stripe / YooKassa / Coinbase)
|
||
export async function POST(request: NextRequest) {
|
||
const { plan, period, locations, telegramId } = await request.json();
|
||
|
||
const planConfig = getPlanConfig(plan, period);
|
||
|
||
// 1. Создать заказ в БД (pending payment)
|
||
const orderId = createOrder({
|
||
telegramId,
|
||
plan,
|
||
period,
|
||
locations,
|
||
amount: planConfig.priceRub,
|
||
status: 'pending',
|
||
});
|
||
|
||
// 2. Создать платежную ссылку
|
||
const paymentUrl = await createStripeCheckout({
|
||
amount: planConfig.priceRub,
|
||
currency: 'RUB',
|
||
metadata: { orderId, telegramId },
|
||
successUrl: `https://app.umbrix.net/payment-success?order=${orderId}`,
|
||
cancelUrl: `https://app.umbrix.net/plans`,
|
||
});
|
||
|
||
return NextResponse.json({ success: true, paymentUrl });
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### **Этап 5: Webhook для обработки оплаты**
|
||
|
||
Создать `/app/api/payment-webhook/route.ts`:
|
||
|
||
```typescript
|
||
// Webhook от платежного провайдера
|
||
export async function POST(request: NextRequest) {
|
||
const payload = await request.json();
|
||
|
||
// 1. Verify signature (Stripe/YooKassa)
|
||
|
||
// 2. Проверить статус оплаты
|
||
if (payload.status === 'succeeded') {
|
||
const { orderId } = payload.metadata;
|
||
|
||
// 3. Получить данные заказа из БД
|
||
const order = getOrder(orderId);
|
||
|
||
// 4. Создать пользователя в Marzban
|
||
await fetch('/api/create-user', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
planType: order.plan,
|
||
period: order.period,
|
||
locationIds: order.locations,
|
||
telegramId: order.telegramId,
|
||
}),
|
||
});
|
||
|
||
// 5. Обновить статус заказа
|
||
updateOrder(orderId, { status: 'completed' });
|
||
|
||
// 6. Отправить уведомление в Telegram
|
||
sendTelegramMessage(order.telegramId, '✅ Оплата прошла! Ваш VPN активирован.');
|
||
}
|
||
|
||
return NextResponse.json({ success: true });
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🤖 Обновление n8n бота
|
||
|
||
### Добавить новые шаги в workflow:
|
||
|
||
```json
|
||
{
|
||
"nodes": [
|
||
{
|
||
"name": "Show Plans Menu",
|
||
"type": "n8n-nodes-base.telegram",
|
||
"parameters": {
|
||
"chatId": "={{ $json.userId }}",
|
||
"text": "💎 Выберите тариф:",
|
||
"replyMarkup": {
|
||
"inline_keyboard": [
|
||
[{"text": "🌍 Start (100₽/мес)", "web_app": {"url": "https://app.umbrix.net/plans?plan=start"}}],
|
||
[{"text": "🌎 Plus (299₽/мес)", "web_app": {"url": "https://app.umbrix.net/plans?plan=plus"}}],
|
||
[{"text": "🌏 Max (350₽/мес)", "web_app": {"url": "https://app.umbrix.net/plans?plan=max"}}]
|
||
]
|
||
}
|
||
}
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
**ВАЖНО**: Все тарифы теперь открывают Mini App с pre-selected планом → выбор периода → выбор локаций → оплата.
|
||
|
||
---
|
||
|
||
## 📋 Чеклист внедрения
|
||
|
||
### Неделя 1: Backend API
|
||
- [ ] Создать `/api/nodes` - список доступных локаций из Marzban
|
||
- [ ] Обновить `/api/create-user` - добавить параметры `period` и `locationIds`
|
||
- [ ] Добавить функцию `getPlanConfig(planType, period)` с расчетом скидок
|
||
- [ ] Реализовать mapping нод → inbounds для создания пользователя
|
||
|
||
### Неделя 2: Frontend UI
|
||
- [ ] Переработать `/app/plans/page.tsx` - мультишаговый выбор:
|
||
- Шаг 1: Тариф (Start/Plus/Max)
|
||
- Шаг 2: Период (1м/3м/6м/1год) с показом скидок
|
||
- Шаг 3: Локации (чекбоксы с ограничением по тарифу)
|
||
- [ ] Добавить компонент `PeriodSelector` с расчетом экономии
|
||
- [ ] Добавить компонент `LocationSelector` с ping и флагами стран
|
||
- [ ] Стилизация под Telegram Design System
|
||
|
||
### Неделя 3: Payment Integration
|
||
- [ ] Выбрать платежный шлюз (YooKassa для RU, Stripe для международных)
|
||
- [ ] Создать `/api/create-payment` - генерация платежных ссылок
|
||
- [ ] Создать `/api/payment-webhook` - обработка успешных оплат
|
||
- [ ] Создать БД таблицу `orders` для хранения pending платежей
|
||
- [ ] Добавить страницу `/payment-success` - редирект после оплаты
|
||
|
||
### Неделя 4: n8n Bot Update
|
||
- [ ] Добавить inline кнопки с pre-selected тарифами
|
||
- [ ] Обновить WebApp URLs с query параметрами `?plan=start`
|
||
- [ ] Добавить уведомления о успешной оплате
|
||
- [ ] Добавить команду `/renew` для продления подписки
|
||
|
||
### Неделя 5: Testing & QA
|
||
- [ ] E2E тесты полной воронки (выбор → оплата → создание пользователя)
|
||
- [ ] Тестирование на разных тарифах и периодах
|
||
- [ ] Проверка корректности создания inbounds для выбранных нод
|
||
- [ ] Load testing (100 одновременных покупок)
|
||
- [ ] Security audit payment webhook
|
||
|
||
---
|
||
|
||
## 💡 Кардинальные предложения
|
||
|
||
### **❌ Предложение 1: Динамическое ценообразование - ОТКЛОНЕНО**
|
||
|
||
~~Вместо фиксированных цен - учитывать нагрузку на ноды~~
|
||
|
||
**Причина отклонения**: Усложняет ценообразование для пользователей, все локации должны быть одинаковы по цене.
|
||
|
||
### **Предложение 2: Гибкие пакеты трафика**
|
||
|
||
Разделить тарифы на "Устройства" и "Трафик":
|
||
|
||
```
|
||
Base Plan (1 устройство):
|
||
├── 50 ГБ - 100₽/мес
|
||
├── 200 ГБ - 250₽/мес
|
||
└── ∞ ГБ - 350₽/мес
|
||
|
||
+100₽ за каждое доп. устройство
|
||
+50₽ за каждую доп. локацию
|
||
```
|
||
|
||
### **⚠️ Предложение 3: Семейные планы - ТРЕБУЕТ АНАЛИЗА**
|
||
|
||
```
|
||
Family Plan (5 устройств, безлимит):
|
||
- 499₽/мес для семьи
|
||
- Каждый член семьи выбирает свои локации
|
||
- Общий аккаунт с sub-users в Marzban
|
||
```
|
||
|
||
**Проблема**: Marzban не имеет встроенного механизма ограничения количества устройств на уровне API.
|
||
|
||
**Варианты решения**:
|
||
|
||
#### Вариант 1: Мониторинг `online_at` (рекомендуемый)
|
||
```typescript
|
||
// GET /api/user/{username} возвращает:
|
||
{
|
||
"online_at": "2026-02-08T10:30:00Z", // Последняя активность
|
||
"sub_last_user_agent": "v2rayNG/1.8.5"
|
||
}
|
||
|
||
// Логика:
|
||
// - online_at обновляется при каждом подключении
|
||
// - Если разница между текущим временем и online_at < 5 минут - устройство активно
|
||
// - Подсчитываем активные сессии через периодический poll
|
||
```
|
||
|
||
**Ограничения**:
|
||
- ❌ НЕТ прямого поля `active_connections` или `device_count`
|
||
- ❌ НЕТ поля `ip_limit` в UserCreate
|
||
- ✅ ЕСТЬ `online_at` - последняя активность
|
||
- ✅ ЕСТЬ `sub_last_user_agent` - информация об устройстве
|
||
|
||
**Реализация** (см. детали ниже в разделе "Семейные планы: Детальный анализ")
|
||
|
||
#### Вариант 2: Ограничение на уровне Xray (требует кастомных патчей)
|
||
- Модифицировать `xray_config.json` с ограничением по IP
|
||
- Требует fork Marzban с кастомной логикой
|
||
- **НЕ рекомендуется** для MVP
|
||
|
||
#### Вариант 3: IP-based limiting (приблизительный)
|
||
- Собирать статистику подключений через `/api/user/{username}/usage`
|
||
- Отслеживать уникальные IP адреса
|
||
- Ограничение: один пользователь может иметь несколько устройств с одним IP (домашняя сеть)
|
||
|
||
**Решение**: Отложить семейные планы на V2.0 до реализации мониторинга устройств.
|
||
|
||
### **✅ Предложение 4: Gamification - УЖЕ РЕАЛИЗОВАНО**
|
||
|
||
**Текущая система рефералов**:
|
||
```typescript
|
||
// /api/referral/track - отслеживание приглашений
|
||
// /api/referral/stats - статистика пользователя
|
||
|
||
Бонусы:
|
||
- +7 дней за каждого друга
|
||
- +30 дней milestone bonus за каждые 5 друзей
|
||
|
||
Формула:
|
||
bonusDays = (referralCount * 7) + (Math.floor(referralCount / 5) * 30)
|
||
|
||
Примеры:
|
||
1 друг → +7 дней
|
||
5 друзей → +35 дней (+30 milestone)
|
||
10 друзей → +70 дней (+60 milestone) = 130 дней
|
||
```
|
||
|
||
**Что можно улучшить**:
|
||
- Добавить визуальные badges (Bronze/Silver/Gold)
|
||
- Добавить leaderboard на странице `/referral`
|
||
- Уведомления в Telegram при достижении milestone
|
||
|
||
---
|
||
|
||
## 🔥 Критические улучшения воронки
|
||
|
||
### **1. A/B Testing периодов оплаты**
|
||
|
||
```typescript
|
||
// Показывать разным пользователям разные default периоды
|
||
const defaultPeriod = userId % 2 === 0 ? '3months' : '6months';
|
||
|
||
// Трекинг конверсии:
|
||
// - Какой период чаще выбирают?
|
||
// - Какая скидка конвертирует лучше?
|
||
```
|
||
|
||
### **2. Urgency триггеры**
|
||
|
||
```
|
||
⏰ Специальное предложение!
|
||
🔥 -30% на годовую подписку (осталось 2 часа)
|
||
💰 При оплате сегодня - бонус +7 дней
|
||
```
|
||
|
||
### **3. Social proof**
|
||
|
||
```
|
||
✅ Уже 1,234 пользователей выбрали тариф Plus
|
||
⭐ 4.9/5 - средняя оценка
|
||
🌍 Топ-3 локации: 🇳🇱 🇩🇪 🇺🇸
|
||
```
|
||
|
||
### **4. Упрощенная воронка для новичков**
|
||
|
||
```
|
||
Вместо 3 шагов (тариф → период → локации):
|
||
|
||
"Быстрый старт" кнопка:
|
||
→ Plus (3 месяца) + Авто-выбор 3 лучших локаций по ping
|
||
→ Сразу на оплату (экономия 807₽ вместо 897₽)
|
||
```
|
||
|
||
---
|
||
|
||
## 🎯 Priority Roadmap
|
||
|
||
### **MVP (2 недели)**:
|
||
1. ✅ API `/api/nodes` - список локаций
|
||
2. ✅ Обновить `/app/plans/page.tsx` - мультишаговый выбор
|
||
3. ✅ Обновить `/api/create-user` - кастомные inbounds
|
||
4. ⚠️ YooKassa integration (payment gateway)
|
||
|
||
### **V1.1 (4 недели)**:
|
||
5. Скидки за период (3м/6м/1год)
|
||
6. Показ экономии при выборе периода
|
||
7. n8n bot updates
|
||
|
||
### **V1.2 (6 недель)**:
|
||
8. ~~Динамическое ценообразование~~ ❌ ОТКЛОНЕНО
|
||
9. A/B testing периодов
|
||
10. Analytics dashboard (конверсия по воронке)
|
||
|
||
### **V2.0 (8 недель)**:
|
||
11. **Device Monitor Service** - мониторинг устройств через Xray логи
|
||
12. Семейные планы (после реализации п.11)
|
||
13. Gamification улучшения (badges, leaderboard)
|
||
14. Автоматическое продление подписки
|
||
|
||
---
|
||
|
||
## ❓ Вопросы для обсуждения
|
||
|
||
1. **Какой payment gateway использовать?**
|
||
- YooKassa (для РФ) ✅
|
||
- Stripe (международный) ✅
|
||
- Crypto (Coinbase Commerce) ?
|
||
|
||
2. **Какие скидки оптимальны?**
|
||
- 3 мес: -10% ✅
|
||
- 6 мес: -15% ✅
|
||
- 1 год: -20% ✅
|
||
|
||
3. **~~Нужна ли локация-специфичная цена?~~** ❌ ОТКЛОНЕНО
|
||
- Все локации одинаковы ✅
|
||
|
||
4. **Trial период - как ограничить abuse?**
|
||
- 1 trial на Telegram ID ✅
|
||
- Требовать номер телефона? ⚠️
|
||
- Требовать payment method (0₽ charge)? ⚠️
|
||
|
||
5. **Семейные планы - реализовывать в MVP?**
|
||
- ❌ Отложить на V2.0
|
||
- ⚠️ Требует Device Monitor Service (40 часов разработки)
|
||
- ✅ Для MVP - добавить в ToS ограничение без технического enforcement
|
||
|
||
---
|
||
|
||
## 📞 Следующие шаги
|
||
|
||
1. ✅ Проанализировать текущее состояние
|
||
2. ✅ Изучить Marzban API
|
||
3. ✅ Спроектировать новую воронку
|
||
4. ✅ Проанализировать ограничения устройств
|
||
5. ✅ Изучить реферальную систему
|
||
6. ⏳ **Согласовать со stakeholder'ами**
|
||
7. ⏳ **Начать разработку MVP**
|
||
|
||
**Готово к обсуждению!** 🚀
|
||
|
||
---
|
||
|
||
## 🔬 ДОПОЛНЕНИЕ: Семейные планы - Детальный анализ ограничения устройств
|
||
|
||
### Проблема:
|
||
Marzban **НЕ имеет встроенного механизма ограничения количества одновременных подключений** (devices/connections).
|
||
|
||
### Анализ Marzban API:
|
||
|
||
#### ❌ Что НЕ доступно в API:
|
||
```json
|
||
// POST /api/user - UserCreate schema НЕ имеет:
|
||
{
|
||
"ip_limit": 5, // ❌ Нет такого поля
|
||
"device_limit": 5, // ❌ Нет такого поля
|
||
"max_connections": 5, // ❌ Нет такого поля
|
||
"active_sessions": [] // ❌ Нет такого поля
|
||
}
|
||
```
|
||
|
||
#### ✅ Что ДОСТУПНО в API:
|
||
```json
|
||
// GET /api/user/{username} - UserResponse:
|
||
{
|
||
"username": "test_user",
|
||
"status": "active",
|
||
"online_at": "2026-02-08T10:30:00Z", // ✅ Последняя активность
|
||
"sub_last_user_agent": "v2rayNG/1.8.5", // ✅ User-Agent устройства
|
||
"used_traffic": 123456789,
|
||
"data_limit": 107374182400
|
||
}
|
||
|
||
// GET /api/user/{username}/usage - UsageResponse:
|
||
{
|
||
"username": "test_user",
|
||
"usages": [
|
||
{
|
||
"date": "2026-02-08",
|
||
"upload": 12345,
|
||
"download": 67890,
|
||
"total": 80235
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
### Варианты решения:
|
||
|
||
---
|
||
|
||
### **Вариант 1: Server-side мониторинг через `online_at` (Рекомендуемый для MVP)**
|
||
|
||
**Как работает**:
|
||
1. Периодически (каждые 30 секунд) опрашиваем `GET /api/user/{username}`
|
||
2. Проверяем `online_at` - если разница < 2 минуты → устройство активно
|
||
3. Если активных устройств > лимита → временно блокируем (`status: 'disabled'`)
|
||
|
||
**Реализация**:
|
||
|
||
```typescript
|
||
// app/api/family-plans/monitor-devices/route.ts
|
||
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
|
||
const MARZBAN_API = process.env.MARZBAN_PANEL_URL;
|
||
const ADMIN_USERNAME = process.env.MARZBAN_ADMIN_USERNAME;
|
||
const ADMIN_PASSWORD = process.env.MARZBAN_ADMIN_PASSWORD;
|
||
|
||
interface FamilyPlanUser {
|
||
username: string;
|
||
deviceLimit: number;
|
||
lastCheck: Date;
|
||
}
|
||
|
||
// В production - хранить в Redis или БД
|
||
const familyPlanUsers: Map<string, FamilyPlanUser> = new Map();
|
||
|
||
// Cron job - запускать каждые 30 секунд
|
||
export async function POST(request: NextRequest) {
|
||
try {
|
||
// 1. Получить токен админа
|
||
const tokenResponse = await fetch(`${MARZBAN_API}/api/admin/token`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: `grant_type=password&username=${ADMIN_USERNAME}&password=${ADMIN_PASSWORD}`,
|
||
});
|
||
|
||
const { access_token } = await tokenResponse.json();
|
||
|
||
// 2. Проверить каждого пользователя Family Plan
|
||
for (const [username, config] of familyPlanUsers.entries()) {
|
||
const userResponse = await fetch(`${MARZBAN_API}/api/user/${username}`, {
|
||
headers: { 'Authorization': `Bearer ${access_token}` },
|
||
});
|
||
|
||
const user = await userResponse.json();
|
||
|
||
// 3. Проверить активность
|
||
const onlineAt = new Date(user.online_at);
|
||
const now = new Date();
|
||
const minutesSinceActive = (now.getTime() - onlineAt.getTime()) / 1000 / 60;
|
||
|
||
// Если пользователь активен (подключался менее 2 минут назад)
|
||
if (minutesSinceActive < 2) {
|
||
// ПРОБЛЕМА: Мы НЕ МОЖЕМ узнать сколько устройств подключено СЕЙЧАС!
|
||
// online_at показывает только ПОСЛЕДНЮЮ активность
|
||
|
||
// Вариант решения: собирать статистику подключений
|
||
// Но это требует доступа к логам Xray на уровне нод
|
||
}
|
||
}
|
||
|
||
return NextResponse.json({ success: true, message: 'Device monitoring completed' });
|
||
} catch (error) {
|
||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||
}
|
||
}
|
||
```
|
||
|
||
**Проблема**: `online_at` показывает только **последнюю** активность, а НЕ **количество активных сессий**.
|
||
|
||
---
|
||
|
||
### **Вариант 2: IP-based мониторинг (Приблизительный)**
|
||
|
||
**Идея**: Отслеживать уникальные IP адреса через usage API.
|
||
|
||
**Проблема**:
|
||
- Несколько устройств могут быть за одним IP (домашний роутер)
|
||
- Один телефон может менять IP (мобильный интернет)
|
||
- **НЕ точный** метод
|
||
|
||
---
|
||
|
||
### **Вариант 3: Кастомизация Xray config (Профессиональный)**
|
||
|
||
**Идея**: Модифицировать `xray_config.json` для ограничения по IP.
|
||
|
||
```json
|
||
{
|
||
"inbounds": [
|
||
{
|
||
"tag": "VLESS TCP",
|
||
"protocol": "vless",
|
||
"settings": {
|
||
"clients": [
|
||
{
|
||
"id": "uuid-here",
|
||
"email": "test_user@marzban",
|
||
"level": 0,
|
||
"maxIps": 5 // ← Кастомное поле (требует патч Xray)
|
||
}
|
||
]
|
||
}
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
**Проблема**:
|
||
- Требует fork Xray с кастомной логикой
|
||
- Нужно пропатчить Marzban для поддержки `maxIps`
|
||
- **Сложно** для MVP
|
||
|
||
---
|
||
|
||
### **Вариант 4: Использовать sub-users (Встроенная функция Marzban)**
|
||
|
||
**Идея**: Создать **родительский аккаунт** + **4 дочерних аккаунта**.
|
||
|
||
```typescript
|
||
// Семейный план:
|
||
// 1. Создать главного пользователя: family_john_doe
|
||
// 2. Создать 4 sub-users:
|
||
// - family_john_doe_device_1
|
||
// - family_john_doe_device_2
|
||
// - family_john_doe_device_3
|
||
// - family_john_doe_device_4
|
||
// - family_john_doe_device_5
|
||
|
||
// Каждый sub-user = отдельное устройство с собственной подпиской
|
||
```
|
||
|
||
**Преимущества**:
|
||
- ✅ Работает "из коробки" в Marzban
|
||
- ✅ Каждое устройство = отдельная подписка
|
||
- ✅ Легко контролировать (5 username'ов)
|
||
|
||
**Недостатки**:
|
||
- ❌ Пользователю нужно выдавать 5 разных subscription URLs
|
||
- ❌ Неудобно для конечного пользователя
|
||
- ❌ Не настоящий "Family Plan" (просто 5 отдельных аккаунтов)
|
||
|
||
---
|
||
|
||
### **Рекомендация для V1.0**:
|
||
|
||
#### MVP подход:
|
||
1. **Отказаться от ограничения устройств на техническом уровне**
|
||
2. **Полагаться на честность пользователей**
|
||
3. **Добавить в ToS**: "Семейный план предназначен для использования в одной семье (до 5 устройств)"
|
||
4. **Мониторить аномальный трафик**: если один аккаунт генерирует 1TB/день - вручную проверить
|
||
|
||
#### V2.0 подход (после MVP):
|
||
1. Реализовать **Server-side webhook** для мониторинга Xray логов
|
||
2. Парсить логи подключений в реальном времени:
|
||
```bash
|
||
# Xray лог показывает:
|
||
[Info] accepted tcp:xxx.xxx.xxx.xxx:12345 [email:test_user@marzban]
|
||
```
|
||
3. Собирать статистику активных IP за последние 5 минут
|
||
4. Если > deviceLimit → отправить уведомление или временно заблокировать
|
||
|
||
---
|
||
|
||
### **Архитектура для V2.0**:
|
||
|
||
```
|
||
┌─────────────────────────────────────────┐
|
||
│ Marzban Panel (API) │
|
||
│ - Создание пользователей │
|
||
│ - Управление подписками │
|
||
└────────────┬────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────┐
|
||
│ Xray Core (на каждой ноде) │
|
||
│ - Обработка трафика │
|
||
│ - Генерация логов подключений │
|
||
└────────────┬────────────────────────────┘
|
||
│
|
||
│ (Webhooks или log streaming)
|
||
▼
|
||
┌─────────────────────────────────────────┐
|
||
│ Device Monitor Service (NEW!) │
|
||
│ - Парсинг логов Xray │
|
||
│ - Подсчет активных IP per user │
|
||
│ - Проверка лимитов устройств │
|
||
│ - Автоблокировка при превышении │
|
||
└────────────┬────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────┐
|
||
│ PostgreSQL / Redis │
|
||
│ - Хранение активных сессий │
|
||
│ - Хранение семейных планов │
|
||
└─────────────────────────────────────────┘
|
||
```
|
||
|
||
**Стоимость разработки**: ~40 часов (Device Monitor Service)
|
||
|
||
---
|
||
|
||
### **Итоговое решение**:
|
||
|
||
| Вариант | Точность | Сложность | Время реализации | Рекомендация |
|
||
|---------|----------|-----------|------------------|--------------|
|
||
| 1. `online_at` мониторинг | ❌ Низкая | Средняя | 8 часов | ⚠️ Не точно |
|
||
| 2. IP-based | ❌ Низкая | Низкая | 4 часа | ⚠️ Не рекомендуется |
|
||
| 3. Xray патчи | ✅ Высокая | Очень высокая | 80+ часов | ❌ Слишком сложно |
|
||
| 4. Sub-users | ✅ Высокая | Средняя | 12 часов | ⚠️ Неудобно для пользователей |
|
||
| 5. **Без ограничений + ToS** | N/A | Очень низкая | 0 часов | ✅ **Для MVP** |
|
||
| 6. Log-based monitor (V2.0) | ✅ Высокая | Высокая | 40 часов | ✅ **Для V2.0** |
|
||
|
||
**Вывод**: Для MVP - **НЕ реализовывать техническое ограничение устройств**. Добавить в ToS и мониторить аномальный трафик вручную.
|
||
|
||
Для V2.0 - разработать Device Monitor Service с парсингом Xray логов.
|
||
|
||
**Готово к обсуждению!** 🚀
|