Merge pull request #7 from evansvl/main

Пофиксил UX хедера для десктопа и исправлен текстовый баг при попытке поделиться реф. ссылкой
This commit is contained in:
Egor
2026-01-18 02:33:11 +03:00
committed by GitHub
9 changed files with 4629 additions and 4291 deletions

View File

@@ -20,5 +20,5 @@ VITE_APP_LOGO=V
# ===== RUNTIME VARIABLES ===== # ===== RUNTIME VARIABLES =====
# Port to expose the frontend on host machine # Port to expose the frontend on host machine
# Default: 3000 (http://localhost:3000) # Default: 3020 (http://localhost:3020)
CABINET_PORT=3000 CABINET_PORT=3020

View File

@@ -70,7 +70,7 @@ VITE_APP_NAME=My VPN Cabinet
VITE_APP_LOGO=V VITE_APP_LOGO=V
# Порт для Docker контейнера # Порт для Docker контейнера
CABINET_PORT=3000 CABINET_PORT=3020
``` ```
#### 3. Запуск в Docker #### 3. Запуск в Docker
@@ -101,6 +101,7 @@ CABINET_ALLOWED_ORIGINS=http://localhost:3000,https://cabinet.yourdomain.com
## Настройка прокси для production ## Настройка прокси для production
Frontend - это статические файлы (HTML, JS, CSS). Для работы нужно: Frontend - это статические файлы (HTML, JS, CSS). Для работы нужно:
1. Раздавать статику через веб-сервер 1. Раздавать статику через веб-сервер
2. Проксировать `/api/*` запросы на backend бота 2. Проксировать `/api/*` запросы на backend бота
@@ -153,8 +154,8 @@ services:
- ./cabinet-dist:/srv/cabinet:ro - ./cabinet-dist:/srv/cabinet:ro
- caddy_data:/data - caddy_data:/data
ports: ports:
- "80:80" - '80:80'
- "443:443" - '443:443'
networks: networks:
- bot_network - bot_network
``` ```
@@ -164,6 +165,7 @@ services:
Если хотите использовать готовый Docker контейнер с nginx внутри. Если хотите использовать готовый Docker контейнер с nginx внутри.
**⚠️ Важно:** **⚠️ Важно:**
- Порт `80` в примерах - это **внутренний порт контейнера** (nginx внутри), не хост-порт! - Порт `80` в примерах - это **внутренний порт контейнера** (nginx внутри), не хост-порт!
- Контейнеры должны быть в одной Docker сети для связи друг с другом - Контейнеры должны быть в одной Docker сети для связи друг с другом
- Имена контейнеров используются как DNS внутри Docker сети - Имена контейнеров используются как DNS внутри Docker сети
@@ -194,11 +196,12 @@ services:
networks: networks:
bot_network: bot_network:
external: true # Используем существующую сеть external: true # Используем существующую сеть
name: remnawave-bedolaga-telegram-bot_bot_network # Пример для bedolaga bot name: remnawave-bedolaga-telegram-bot_bot_network # Пример для bedolaga bot
``` ```
**Важно:** Замените имя сети на вашу: **Важно:** Замените имя сети на вашу:
- Если у вас bot + caddy: используйте сеть бота (обычно `<название_проекта>_bot_network`) - Если у вас bot + caddy: используйте сеть бота (обычно `<название_проекта>_bot_network`)
- Если отдельный Caddy: узнайте через `docker network ls` - Если отдельный Caddy: узнайте через `docker network ls`
- Если используете Traefik: обычно `traefik` или `web` - Если используете Traefik: обычно `traefik` или `web`
@@ -212,6 +215,7 @@ docker compose up -d
**Шаг 4:** Добавьте в конфигурацию Caddy/Nginx: **Шаг 4:** Добавьте в конфигурацию Caddy/Nginx:
Caddy проксирует на контейнер: Caddy проксирует на контейнер:
```caddyfile ```caddyfile
cabinet.yourdomain.com { cabinet.yourdomain.com {
# API на backend # API на backend
@@ -228,6 +232,7 @@ cabinet.yourdomain.com {
``` ```
Nginx (добавьте в существующий конфиг): Nginx (добавьте в существующий конфиг):
```nginx ```nginx
server { server {
listen 443 ssl http2; listen 443 ssl http2;
@@ -269,6 +274,7 @@ docker exec <nginx_container> nginx -s reload
#### B. Если Caddy/Nginx ещё НЕ запущен: #### B. Если Caddy/Nginx ещё НЕ запущен:
docker-compose.yml: docker-compose.yml:
```yaml ```yaml
services: services:
cabinet-frontend: cabinet-frontend:
@@ -292,18 +298,18 @@ networks:
### Build-time (используются при сборке) ### Build-time (используются при сборке)
| Переменная | Описание | По умолчанию | | Переменная | Описание | По умолчанию |
|------------|----------|--------------| | ---------------------------- | ---------------------------------- | ------------ |
| `VITE_API_URL` | Путь к API (`/api` или полный URL) | `/api` | | `VITE_API_URL` | Путь к API (`/api` или полный URL) | `/api` |
| `VITE_TELEGRAM_BOT_USERNAME` | Username Telegram бота (без @) | - | | `VITE_TELEGRAM_BOT_USERNAME` | Username Telegram бота (без @) | - |
| `VITE_APP_NAME` | Название приложения | `Cabinet` | | `VITE_APP_NAME` | Название приложения | `Cabinet` |
| `VITE_APP_LOGO` | Логотип (короткий текст) | `V` | | `VITE_APP_LOGO` | Логотип (короткий текст) | `V` |
### Runtime (только для Docker) ### Runtime (только для Docker)
| Переменная | Описание | По умолчанию | | Переменная | Описание | По умолчанию |
|------------|----------|--------------| | -------------- | --------------- | ------------ |
| `CABINET_PORT` | Порт контейнера | `3000` | | `CABINET_PORT` | Порт контейнера | `3000` |
## Структура проекта ## Структура проекта
@@ -337,6 +343,7 @@ bedolaga-cabinet/
### 502 Bad Gateway ### 502 Bad Gateway
Убедитесь что: Убедитесь что:
1. Backend бот запущен и работает 1. Backend бот запущен и работает
2. Контейнеры находятся в одной Docker сети 2. Контейнеры находятся в одной Docker сети
3. Имя сервиса backend в прокси конфигурации правильное 3. Имя сервиса backend в прокси конфигурации правильное

View File

@@ -16,13 +16,21 @@ services:
container_name: cabinet_frontend container_name: cabinet_frontend
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- .env # Загрузить переменные из .env файла - .env # Загрузить переменные из .env файла
ports: ports:
# Маппинг: <хост-порт>:<контейнер-порт> # Маппинг: <хост-порт>:<контейнер-порт>
# Внутри контейнера nginx слушает на порту 80 # Внутри контейнера nginx слушает на порту 80
- "${CABINET_PORT:-3000}:80" - '${CABINET_PORT:-3020}:80'
healthcheck: healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80/"] test:
[
'CMD',
'wget',
'--no-verbose',
'--tries=1',
'--spider',
'http://localhost:80/',
]
interval: 30s interval: 30s
timeout: 3s timeout: 3s
retries: 3 retries: 3

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,247 +2,341 @@ import { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { referralApi } from '../api/referral' import { referralApi } from '../api/referral'
import { brandingApi } from '../api/branding'
import { useCurrency } from '../hooks/useCurrency' import { useCurrency } from '../hooks/useCurrency'
const CopyIcon = () => ( const CopyIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" /> className='w-4 h-4'
</svg> fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
strokeWidth={1.5}
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184'
/>
</svg>
) )
const CheckIcon = () => ( const CheckIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" /> className='w-4 h-4'
</svg> fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
strokeWidth={2}
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='M4.5 12.75l6 6 9-13.5'
/>
</svg>
) )
const ShareIcon = () => ( const ShareIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8l5-5m0 0l5 5m-5-5v12" /> className='w-4 h-4'
<path strokeLinecap="round" strokeLinejoin="round" d="M4 15v3a2 2 0 002 2h12a2 2 0 002-2v-3" /> fill='none'
</svg> viewBox='0 0 24 24'
stroke='currentColor'
strokeWidth={1.5}
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='M7 8l5-5m0 0l5 5m-5-5v12'
/>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='M4 15v3a2 2 0 002 2h12a2 2 0 002-2v-3'
/>
</svg>
) )
export default function Referral() { export default function Referral() {
const { t } = useTranslation() const { t } = useTranslation()
const { formatAmount, currencySymbol, formatPositive } = useCurrency() const { formatAmount, currencySymbol, formatPositive } = useCurrency()
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const { data: info, isLoading } = useQuery({ const { data: info, isLoading } = useQuery({
queryKey: ['referral-info'], queryKey: ['referral-info'],
queryFn: referralApi.getReferralInfo, queryFn: referralApi.getReferralInfo,
}) })
const { data: terms } = useQuery({ const { data: terms } = useQuery({
queryKey: ['referral-terms'], queryKey: ['referral-terms'],
queryFn: referralApi.getReferralTerms, queryFn: referralApi.getReferralTerms,
}) })
const { data: referralList } = useQuery({ const { data: referralList } = useQuery({
queryKey: ['referral-list'], queryKey: ['referral-list'],
queryFn: () => referralApi.getReferralList({ per_page: 10 }), queryFn: () => referralApi.getReferralList({ per_page: 10 }),
}) })
const { data: earnings } = useQuery({ const { data: earnings } = useQuery({
queryKey: ['referral-earnings'], queryKey: ['referral-earnings'],
queryFn: () => referralApi.getReferralEarnings({ per_page: 10 }), queryFn: () => referralApi.getReferralEarnings({ per_page: 10 }),
}) })
const copyLink = () => { const { data: branding } = useQuery({
if (info?.referral_link) { queryKey: ['branding'],
navigator.clipboard.writeText(info.referral_link) queryFn: brandingApi.getBranding,
setCopied(true) staleTime: 60000,
setTimeout(() => setCopied(false), 2000) })
}
}
const shareLink = () => { const copyLink = () => {
if (!info?.referral_link) return if (info?.referral_link) {
const shareText = t('referral.shareMessage', { navigator.clipboard.writeText(info.referral_link)
percent: info?.commission_percent || 0, setCopied(true)
}) setTimeout(() => setCopied(false), 2000)
}
}
if (navigator.share) { const shareLink = () => {
navigator if (!info?.referral_link) return
.share({ const shareText = t('referral.shareMessage', {
title: t('referral.title'), percent: info?.commission_percent || 0,
text: shareText, botName: branding?.name || import.meta.env.VITE_APP_NAME || 'Cabinet',
url: info.referral_link, })
})
.catch(() => {
// ignore cancellation errors
})
return
}
const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent( if (navigator.share) {
info.referral_link navigator
)}&text=${encodeURIComponent(shareText)}` .share({
window.open(telegramUrl, '_blank', 'noopener,noreferrer') title: t('referral.title'),
} text: shareText,
url: info.referral_link,
})
.catch(() => {
// ignore cancellation errors
})
return
}
if (isLoading) { const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(
return ( info.referral_link
<div className="flex items-center justify-center min-h-64"> )}&text=${encodeURIComponent(shareText)}`
<div className="w-10 h-10 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" /> window.open(telegramUrl, '_blank', 'noopener,noreferrer')
</div> }
)
}
return ( if (isLoading) {
<div className="space-y-6"> return (
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('referral.title')}</h1> <div className='flex items-center justify-center min-h-64'>
<div className='w-10 h-10 border-2 border-accent-500 border-t-transparent rounded-full animate-spin' />
</div>
)
}
{/* Stats Cards */} return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4"> <div className='space-y-6'>
<div className="card"> <h1 className='text-2xl sm:text-3xl font-bold text-dark-50'>
<div className="text-sm text-dark-400">{t('referral.stats.totalReferrals')}</div> {t('referral.title')}
<div className="stat-value mt-1">{info?.total_referrals || 0}</div> </h1>
<div className="text-sm text-dark-500 mt-1">
{info?.active_referrals || 0} {t('referral.stats.activeReferrals').toLowerCase()}
</div>
</div>
<div className="card">
<div className="text-sm text-dark-400">{t('referral.stats.totalEarnings')}</div>
<div className="stat-value text-success-400 mt-1">
{formatPositive(info?.total_earnings_rubles || 0)}
</div>
</div>
<div className="card col-span-2 sm:col-span-1">
<div className="text-sm text-dark-400">{t('referral.stats.commissionRate')}</div>
<div className="stat-value text-accent-400 mt-1">
{info?.commission_percent || 0}%
</div>
</div>
</div>
{/* Referral Link */} {/* Stats Cards */}
<div className="card"> <div className='grid grid-cols-2 sm:grid-cols-3 gap-4'>
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('referral.yourLink')}</h2> <div className='card'>
<div className="flex flex-col gap-2 sm:flex-row"> <div className='text-sm text-dark-400'>
<input {t('referral.stats.totalReferrals')}
type="text" </div>
readOnly <div className='stat-value mt-1'>{info?.total_referrals || 0}</div>
value={info?.referral_link || ''} <div className='text-sm text-dark-500 mt-1'>
className="input flex-1" {info?.active_referrals || 0}{' '}
/> {t('referral.stats.activeReferrals').toLowerCase()}
<div className="flex gap-2"> </div>
<button </div>
onClick={copyLink} <div className='card'>
disabled={!info?.referral_link} <div className='text-sm text-dark-400'>
className={`btn-primary px-5 ${ {t('referral.stats.totalEarnings')}
copied ? 'bg-success-500 hover:bg-success-500' : '' </div>
} ${!info?.referral_link ? 'opacity-50 cursor-not-allowed' : ''}`} <div className='stat-value text-success-400 mt-1'>
> {formatPositive(info?.total_earnings_rubles || 0)}
{copied ? <CheckIcon /> : <CopyIcon />} </div>
<span className="ml-2">{copied ? t('referral.copied') : t('referral.copyLink')}</span> </div>
</button> <div className='card col-span-2 sm:col-span-1'>
<button <div className='text-sm text-dark-400'>
onClick={shareLink} {t('referral.stats.commissionRate')}
disabled={!info?.referral_link} </div>
className={`btn-secondary px-5 flex items-center ${ <div className='stat-value text-accent-400 mt-1'>
!info?.referral_link ? 'opacity-50 cursor-not-allowed' : '' {info?.commission_percent || 0}%
}`} </div>
> </div>
<ShareIcon /> </div>
<span className="ml-2">{t('referral.shareButton')}</span>
</button>
</div>
</div>
<p className="mt-3 text-sm text-dark-500">
{t('referral.shareHint', { percent: info?.commission_percent || 0 })}
</p>
</div>
{/* Program Terms */} {/* Referral Link */}
{terms && ( <div className='card'>
<div className="card"> <h2 className='text-lg font-semibold text-dark-100 mb-4'>
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('referral.terms.title')}</h2> {t('referral.yourLink')}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> </h2>
<div className="p-3 rounded-xl bg-dark-800/30"> <div className='flex flex-col gap-2 sm:flex-row'>
<div className="text-sm text-dark-500">{t('referral.terms.commission')}</div> <input
<div className="text-lg font-semibold text-dark-100 mt-1">{terms.commission_percent}%</div> type='text'
</div> readOnly
<div className="p-3 rounded-xl bg-dark-800/30"> value={info?.referral_link || ''}
<div className="text-sm text-dark-500">{t('referral.terms.minTopup')}</div> className='input flex-1'
<div className="text-lg font-semibold text-dark-100 mt-1">{formatAmount(terms.minimum_topup_rubles)} {currencySymbol}</div> />
</div> <div className='flex gap-2'>
<div className="p-3 rounded-xl bg-dark-800/30"> <button
<div className="text-sm text-dark-500">{t('referral.terms.newUserBonus')}</div> onClick={copyLink}
<div className="text-lg font-semibold text-success-400 mt-1">{formatPositive(terms.first_topup_bonus_rubles)}</div> disabled={!info?.referral_link}
</div> className={`btn-primary px-5 ${
<div className="p-3 rounded-xl bg-dark-800/30"> copied ? 'bg-success-500 hover:bg-success-500' : ''
<div className="text-sm text-dark-500">{t('referral.terms.inviterBonus')}</div> } ${!info?.referral_link ? 'opacity-50 cursor-not-allowed' : ''}`}
<div className="text-lg font-semibold text-success-400 mt-1">{formatPositive(terms.inviter_bonus_rubles)}</div> >
</div> {copied ? <CheckIcon /> : <CopyIcon />}
</div> <span className='ml-2'>
</div> {copied ? t('referral.copied') : t('referral.copyLink')}
)} </span>
</button>
<button
onClick={shareLink}
disabled={!info?.referral_link}
className={`btn-secondary px-5 flex items-center ${
!info?.referral_link ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
<ShareIcon />
<span className='ml-2'>{t('referral.shareButton')}</span>
</button>
</div>
</div>
<p className='mt-3 text-sm text-dark-500'>
{t('referral.shareHint', { percent: info?.commission_percent || 0 })}
</p>
</div>
{/* Referrals List */} {/* Program Terms */}
<div className="card"> {terms && (
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('referral.yourReferrals')}</h2> <div className='card'>
{referralList?.items && referralList.items.length > 0 ? ( <h2 className='text-lg font-semibold text-dark-100 mb-4'>
<div className="space-y-3"> {t('referral.terms.title')}
{referralList.items.map((ref) => ( </h2>
<div <div className='grid grid-cols-2 md:grid-cols-4 gap-4'>
key={ref.id} <div className='p-3 rounded-xl bg-dark-800/30'>
className="flex items-center justify-between p-3 rounded-xl bg-dark-800/30 border border-dark-700/30" <div className='text-sm text-dark-500'>
> {t('referral.terms.commission')}
<div> </div>
<div className="text-dark-100 font-medium"> <div className='text-lg font-semibold text-dark-100 mt-1'>
{ref.first_name || ref.username || `User #${ref.id}`} {terms.commission_percent}%
</div> </div>
<div className="text-xs text-dark-500 mt-0.5"> </div>
{new Date(ref.created_at).toLocaleDateString()} <div className='p-3 rounded-xl bg-dark-800/30'>
</div> <div className='text-sm text-dark-500'>
</div> {t('referral.terms.minTopup')}
{ref.has_paid ? ( </div>
<span className="badge-success">{t('referral.status.paid')}</span> <div className='text-lg font-semibold text-dark-100 mt-1'>
) : ( {formatAmount(terms.minimum_topup_rubles)} {currencySymbol}
<span className="badge-neutral">{t('referral.status.pending')}</span> </div>
)} </div>
</div> <div className='p-3 rounded-xl bg-dark-800/30'>
))} <div className='text-sm text-dark-500'>
</div> {t('referral.terms.newUserBonus')}
) : ( </div>
<div className="text-center py-12"> <div className='text-lg font-semibold text-success-400 mt-1'>
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center"> {formatPositive(terms.first_topup_bonus_rubles)}
<svg className="w-8 h-8 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> </div>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" /> </div>
</svg> <div className='p-3 rounded-xl bg-dark-800/30'>
</div> <div className='text-sm text-dark-500'>
<div className="text-dark-400">{t('referral.noReferrals')}</div> {t('referral.terms.inviterBonus')}
</div> </div>
)} <div className='text-lg font-semibold text-success-400 mt-1'>
</div> {formatPositive(terms.inviter_bonus_rubles)}
</div>
</div>
</div>
</div>
)}
{/* Earnings History */} {/* Referrals List */}
{earnings?.items && earnings.items.length > 0 && ( <div className='card'>
<div className="card"> <h2 className='text-lg font-semibold text-dark-100 mb-4'>
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('referral.earningsHistory')}</h2> {t('referral.yourReferrals')}
<div className="space-y-3"> </h2>
{earnings.items.map((earning) => ( {referralList?.items && referralList.items.length > 0 ? (
<div <div className='space-y-3'>
key={earning.id} {referralList.items.map(ref => (
className="flex items-center justify-between p-3 rounded-xl bg-dark-800/30 border border-dark-700/30" <div
> key={ref.id}
<div> className='flex items-center justify-between p-3 rounded-xl bg-dark-800/30 border border-dark-700/30'
<div className="text-dark-100"> >
{earning.referral_first_name || earning.referral_username || 'Referral'} <div>
</div> <div className='text-dark-100 font-medium'>
<div className="text-xs text-dark-500 mt-0.5"> {ref.first_name || ref.username || `User #${ref.id}`}
{t(`referral.reasons.${earning.reason}`, earning.reason)} {new Date(earning.created_at).toLocaleDateString()} </div>
</div> <div className='text-xs text-dark-500 mt-0.5'>
</div> {new Date(ref.created_at).toLocaleDateString()}
<div className="text-success-400 font-semibold"> </div>
{formatPositive(earning.amount_rubles)} </div>
</div> {ref.has_paid ? (
</div> <span className='badge-success'>
))} {t('referral.status.paid')}
</div> </span>
</div> ) : (
)} <span className='badge-neutral'>
</div> {t('referral.status.pending')}
) </span>
)}
</div>
))}
</div>
) : (
<div className='text-center py-12'>
<div className='w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center'>
<svg
className='w-8 h-8 text-dark-500'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
strokeWidth={1.5}
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z'
/>
</svg>
</div>
<div className='text-dark-400'>{t('referral.noReferrals')}</div>
</div>
)}
</div>
{/* Earnings History */}
{earnings?.items && earnings.items.length > 0 && (
<div className='card'>
<h2 className='text-lg font-semibold text-dark-100 mb-4'>
{t('referral.earningsHistory')}
</h2>
<div className='space-y-3'>
{earnings.items.map(earning => (
<div
key={earning.id}
className='flex items-center justify-between p-3 rounded-xl bg-dark-800/30 border border-dark-700/30'
>
<div>
<div className='text-dark-100'>
{earning.referral_first_name ||
earning.referral_username ||
'Referral'}
</div>
<div className='text-xs text-dark-500 mt-0.5'>
{t(`referral.reasons.${earning.reason}`, earning.reason)} {' '}
{new Date(earning.created_at).toLocaleDateString()}
</div>
</div>
<div className='text-success-400 font-semibold'>
{formatPositive(earning.amount_rubles)}
</div>
</div>
))}
</div>
</div>
)}
</div>
)
} }