This commit is contained in:
Ilnar
2026-03-05 06:55:42 +03:00
commit 36a7d530c1
50 changed files with 3091 additions and 0 deletions

1
frontend/.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8000

1
frontend/.env.production Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8000

11
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json tsconfig.json vite.config.ts index.html ./
COPY src ./src
RUN npm install
RUN npm run build
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

15
frontend/README.md Normal file
View File

@@ -0,0 +1,15 @@
# Frontend
```bash
npm install
cp .env.example .env
npm run dev
```
Docker: UI served by nginx on http://localhost:5174
## Тестирование
- /tests — создание теста
- /tests/:id — сегменты, распределение, ручной ввод результатов, Analyze

4
frontend/index.html Normal file
View File

@@ -0,0 +1,4 @@
<!doctype html><html lang="ru"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>AdsAssistant</title> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
</head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>

10
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,10 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

23
frontend/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "adsassistant-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
},
"devDependencies": {
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.4",
"vite": "^5.4.3"
}
}

0
frontend/public/.keep Normal file
View File

View File

@@ -0,0 +1,26 @@
export const API_BASE = (import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000').replace(/\/$/, '')
const TOKEN_KEY = 'adsassistant_token'
export function setToken(t: string){ localStorage.setItem(TOKEN_KEY,t) }
export function getToken(){ return localStorage.getItem(TOKEN_KEY) }
export function clearToken(){ localStorage.removeItem(TOKEN_KEY) }
async function handle(res: Response){
const text = await res.text()
let data: any = null
try{ data = text ? JSON.parse(text) : null } catch { data = text }
if(!res.ok) throw new Error((data && (data.detail||data.error)) || res.statusText)
return data
}
export async function apiPost(path:string, body?:any){
const token = getToken()
const res = await fetch(`${API_BASE}${path}`,{
method:'POST',
headers:{'Content-Type':'application/json', ...(token?{Authorization:`Bearer ${token}`}:{})},
body: JSON.stringify(body ?? {}),
})
return handle(res)
}
export async function apiGet(path:string){
const token = getToken()
const res = await fetch(`${API_BASE}${path}`,{ headers: token?{Authorization:`Bearer ${token}`}:{}})
return handle(res)
}

View File

@@ -0,0 +1,8 @@
import React, { useEffect, useRef } from 'react'
type Props = React.TextareaHTMLAttributes<HTMLTextAreaElement> & { value: string; onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void }
export default function AutoTextarea(props: Props){
const ref = useRef<HTMLTextAreaElement|null>(null)
const resize=()=>{ const el=ref.current; if(!el) return; el.style.height='0px'; el.style.height=el.scrollHeight+'px' }
useEffect(()=>{ resize() },[props.value])
return <textarea {...props} ref={ref} onChange={(e)=>{ props.onChange(e); requestAnimationFrame(resize) }} style={{...(props.style||{}), overflow:'hidden'}}/>
}

View File

@@ -0,0 +1,62 @@
import React from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { clearToken, getToken } from '../api/client'
function NavItem({ to, label }: { to: string; label: string }) {
const loc = useLocation()
const active = loc.pathname === to || loc.pathname.startsWith(to + '/')
return (
<Link
to={to}
className="small"
style={{
padding: '10px 12px',
borderRadius: 12,
border: '1px solid transparent',
background: active ? 'rgba(45,96,255,.10)' : 'transparent',
color: active ? '#2d60ff' : undefined,
fontWeight: active ? 700 : 600,
textDecoration: 'none',
}}
>
{label}
</Link>
)
}
export default function NavBar() {
const nav = useNavigate()
const token = getToken()
return (
<div className="card compact" style={{ marginBottom: 16 }}>
<div className="row" style={{ justifyContent: 'space-between', alignItems: 'center' }}>
<div className="row" style={{ alignItems: 'center' }}>
<div style={{ fontWeight: 800, letterSpacing: '-0.03em' }}>AdsAssistant</div>
<div className="row" style={{ marginLeft: 10, gap: 8 }}>
<NavItem to="/briefs" label="Брифы" />
<NavItem to="/tests" label="Тесты" />
</div>
</div>
<div className="row" style={{ alignItems: 'center' }}>
{token ? (
<button
className="btn secondary"
onClick={() => {
clearToken()
nav('/login')
}}
>
Выйти
</button>
) : (
<Link to="/login" className="small">
Вход
</Link>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,31 @@
import React, { useId, useState } from 'react'
export default function Tooltip({ text }: { text: string }) {
const [open, setOpen] = useState(false)
const id = useId()
return (
<span
style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
>
<button
type="button"
aria-describedby={id}
className="tipbtn"
title={text}
onClick={() => setOpen((v) => !v)}
>
i
</button>
{open && (
<span id={id} role="tooltip" className="tooltip">
{text}
</span>
)}
</span>
)
}

28
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,28 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import './styles/app.css'
import Login from './pages/Login'
import Briefs from './pages/Briefs'
import Tests from './pages/Tests'
import TestDetail from './pages/TestDetail'
function App() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/briefs" element={<Briefs />} />
<Route path="/tests" element={<Tests />} />
<Route path="/tests/:id" element={<TestDetail />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
)
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)

View File

@@ -0,0 +1,392 @@
import React, { useEffect, useMemo, useState } from 'react'
import { apiGet, apiPost } from '../api/client'
import AutoTextarea from '../components/AutoTextarea'
import NavBar from '../components/NavBar'
import Tooltip from '../components/Tooltip'
const FORMATS = [
{ id: 'social_post', label: 'Пост для соцсетей' },
{ id: 'search_ad', label: 'Поисковое объявление' },
{ id: 'email', label: 'Emailписьмо' },
]
function formatRu(fmt: string) {
const v = (fmt || '').toLowerCase()
if (v === 'search_ad') return 'Поисковое объявление'
if (v === 'social_post') return 'Пост для соцсетей'
if (v === 'email') return 'Emailписьмо'
return fmt
}
function PayloadView({ format, payload }: { format: string; payload: any }) {
const p = payload || {}
if (format === 'search_ad') {
const headlines: string[] = p.headlines || []
const descriptions: string[] = p.descriptions || []
return (
<div>
<div className="small" style={{ marginBottom: 8 }}>
Заголовки
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{headlines.map((h, i) => (
<span key={i} className="pill">
{h}
</span>
))}
{!headlines.length && <span className="small"></span>}
</div>
<div className="small" style={{ marginTop: 12, marginBottom: 8 }}>
Описания
</div>
<div className="list" style={{ gap: 8 }}>
{descriptions.map((d, i) => (
<div key={i} className="item" style={{ boxShadow: 'none', padding: 12 }}>
<div className="meta">
<div className="s">{d}</div>
</div>
</div>
))}
{!descriptions.length && <div className="small"></div>}
</div>
{p.cta && (
<div style={{ marginTop: 12 }}>
<span className="small">CTA: </span>
<span style={{ fontWeight: 700 }}>{p.cta}</span>
</div>
)}
</div>
)
}
if (format === 'social_post') {
return (
<div className="list">
<div className="item" style={{ boxShadow: 'none' }}>
<div className="avatar">H</div>
<div className="meta">
<div className="h">Hook</div>
<div className="s">{p.hook || '—'}</div>
</div>
</div>
<div className="item" style={{ boxShadow: 'none' }}>
<div className="avatar">B</div>
<div className="meta">
<div className="h">Body</div>
<div className="s" style={{ whiteSpace: 'pre-wrap' }}>
{p.body || '—'}
</div>
</div>
</div>
<div className="item" style={{ boxShadow: 'none' }}>
<div className="avatar">C</div>
<div className="meta">
<div className="h">CTA</div>
<div className="s">{p.cta || '—'}</div>
</div>
</div>
</div>
)
}
if (format === 'email') {
return (
<div className="list">
<div className="item" style={{ boxShadow: 'none' }}>
<div className="avatar">S</div>
<div className="meta">
<div className="h">Subject</div>
<div className="s">{p.subject || '—'}</div>
</div>
</div>
<div className="item" style={{ boxShadow: 'none' }}>
<div className="avatar">P</div>
<div className="meta">
<div className="h">Preheader</div>
<div className="s">{p.preheader || '—'}</div>
</div>
</div>
<div className="item" style={{ boxShadow: 'none' }}>
<div className="avatar">B</div>
<div className="meta">
<div className="h">Body</div>
<div className="s" style={{ whiteSpace: 'pre-wrap' }}>
{p.body || '—'}
</div>
</div>
</div>
<div className="item" style={{ boxShadow: 'none' }}>
<div className="avatar">C</div>
<div className="meta">
<div className="h">CTA</div>
<div className="s">{p.cta || '—'}</div>
</div>
</div>
</div>
)
}
return <pre style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{JSON.stringify(p, null, 2)}</pre>
}
export default function Briefs() {
const [briefs, setBriefs] = useState<any[]>([])
const [variants, setVariants] = useState<any[]>([])
const [selectedBrief, setSelectedBrief] = useState<number | null>(null)
const [err, setErr] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [form, setForm] = useState<any>({
product: '',
audience: '',
usp: '',
benefits: [],
constraints: '',
tone: 'дружелюбный',
formats: ['social_post', 'search_ad'],
variants_per_format: 3,
})
const variantsSorted = useMemo(() => [...variants].sort((a, b) => Number(a.id) - Number(b.id)), [variants])
const loadBriefs = async () => {
const bs = await apiGet('/api/briefs/')
setBriefs(bs)
if (bs.length && selectedBrief == null) setSelectedBrief(bs[0].id)
}
const loadVariants = async (briefId: number) => {
const vs = await apiGet('/api/variants/?brief=' + briefId)
setVariants(vs)
}
useEffect(() => {
loadBriefs().catch((e) => setErr(e.message))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (selectedBrief) loadVariants(selectedBrief).catch((e) => setErr(e.message))
}, [selectedBrief])
return (
<div className="container">
<NavBar />
<div className="row">
<div className="card" style={{ flex: 1, minWidth: 380 }}>
<div className="cardtitle">
<h2 style={{ margin: 0 }}>Бриф</h2>
<div className="small">Генерация рекламных текстов</div>
</div>
<div className="row" style={{ justifyContent: 'flex-end', marginBottom: 10 }}>
<button
className="btn secondary"
type="button"
onClick={() =>
setForm({
...form,
product: 'Онлайн‑курс по английскому',
audience: 'Взрослые 2545, хотят разговорный английский для работы и поездок',
usp: '1й урок бесплатно',
constraints: 'Без обещаний гарантированного результата',
tone: 'дружелюбный',
formats: ['search_ad', 'social_post'],
variants_per_format: 3,
})
}
>
Пресет 1
</button>
<button
className="btn secondary"
type="button"
onClick={() =>
setForm({
...form,
product: 'Сервис доставки здорового питания',
audience: 'Мужчины и женщины 2340, следят за питанием, мало времени готовить',
usp: 'Скидка 20% на первый заказ',
constraints: 'Не использовать медицинские обещания. Без “гарантируем похудение”.',
tone: 'энергичный',
formats: ['social_post', 'email'],
variants_per_format: 3,
})
}
>
Пресет 2
</button>
</div>
{err && <div style={{ color: '#fe5c73', marginBottom: 10 }}>{err}</div>}
<div style={{ display: 'grid', gap: 12 }}>
<div>
<div className="tiprow">
<div className="small">Описание продукта</div>
<Tooltip text="Что вы продаёте и в чём суть предложения. Чем точнее — тем лучше тексты." />
</div>
<AutoTextarea className="input" value={form.product} onChange={(e) => setForm({ ...form, product: e.target.value })} />
</div>
<div>
<div className="tiprow">
<div className="small">Аудитория</div>
<Tooltip text="Кто покупает: возраст, гео, интересы, боль. Это влияет на стиль и формулировки." />
</div>
<AutoTextarea className="input" value={form.audience} onChange={(e) => setForm({ ...form, audience: e.target.value })} />
</div>
<div className="row">
<div style={{ flex: 1 }}>
<div className="tiprow">
<div className="small">Tone</div>
<Tooltip text="Тональность: дружелюбный, строгий, экспертный и т.п. Меняет стиль текста." />
</div>
<input className="input" value={form.tone} onChange={(e) => setForm({ ...form, tone: e.target.value })} />
</div>
<div style={{ width: 190 }}>
<div className="tiprow">
<div className="small">Вариантов/формат</div>
<Tooltip text="Сколько вариантов агент генерирует на каждый выбранный формат." />
</div>
<input
className="input"
type="number"
value={form.variants_per_format}
onChange={(e) => setForm({ ...form, variants_per_format: Number(e.target.value) })}
/>
</div>
</div>
<div>
<div className="tiprow">
<div className="small">УТП</div>
<Tooltip text="Уникальное торговое предложение: почему выбрать вас (например: 1-й урок бесплатно)." />
</div>
<AutoTextarea className="input" value={form.usp} onChange={(e) => setForm({ ...form, usp: e.target.value })} />
</div>
<div>
<div className="tiprow">
<div className="small">Ограничения</div>
<Tooltip text="Что нельзя обещать/упоминать (например: без гарантий результата, без “лучшие”)." />
</div>
<AutoTextarea className="input" value={form.constraints} onChange={(e) => setForm({ ...form, constraints: e.target.value })} />
</div>
<div className="card compact">
<div className="tiprow" style={{ marginBottom: 8 }}>
<div className="small">Форматы (выбирает клиент)</div>
<Tooltip text="Какие типы текстов генерировать: соцсети / поиск / email. Можно выбрать несколько." />
</div>
<div className="row" style={{ gap: 14 }}>
{FORMATS.map((f) => (
<label key={f.id} className="small" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<input
type="checkbox"
checked={form.formats.includes(f.id)}
onChange={(e) => {
const set = new Set(form.formats)
if (e.target.checked) set.add(f.id)
else set.delete(f.id)
setForm({ ...form, formats: Array.from(set) })
}}
/>
{f.label}
</label>
))}
</div>
</div>
<button
className="btn"
disabled={busy || !form.product || !form.audience || !form.formats.length}
onClick={async () => {
setBusy(true)
setErr(null)
try {
const b = await apiPost('/api/briefs/', form)
await loadBriefs()
setSelectedBrief(b.id)
} catch (e: any) {
setErr(e.message || String(e))
} finally {
setBusy(false)
}
}}
>
Сохранить бриф
</button>
{selectedBrief && (
<button
className="btn secondary"
disabled={busy}
onClick={async () => {
setBusy(true)
setErr(null)
try {
await apiPost(`/api/briefs/${selectedBrief}/generate/`)
await loadVariants(selectedBrief)
} catch (e: any) {
setErr(e.message || String(e))
} finally {
setBusy(false)
}
}}
>
Сгенерировать тексты
</button>
)}
</div>
</div>
<div className="card" style={{ flex: 1.2, minWidth: 420 }}>
<div className="cardtitle">
<h2 style={{ margin: 0 }}>Тексты</h2>
<div className="small">Структурированный вывод</div>
</div>
<div style={{ marginTop: 10 }}>
<div className="small">Выбранный бриф</div>
<select className="input" value={selectedBrief ?? ''} onChange={(e) => setSelectedBrief(Number(e.target.value))}>
{briefs.map((b) => (
<option key={b.id} value={b.id}>
{String(b.product).slice(0, 60)}
</option>
))}
</select>
</div>
<div className="list" style={{ marginTop: 14 }}>
{variantsSorted.map((v, idx) => (
<div key={v.id} className="item">
<div className="avatar">{String(formatRu(v.format || '?')).slice(0, 1).toUpperCase()}</div>
<div className="meta">
<div className="h">
Текст {idx + 1} <span className="small"> {formatRu(v.format)}</span>
</div>
<div style={{ marginTop: 10 }}>
<PayloadView format={v.format} payload={v.payload} />
</div>
</div>
</div>
))}
{!variantsSorted.length && (
<div className="small" style={{ padding: 12 }}>
Пока нет текстов. Сгенерируй варианты.
</div>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,29 @@
import React, { useState } from 'react'
import { apiPost, setToken } from '../api/client'
import { useNavigate } from 'react-router-dom'
export default function Login(){
const [username,setUsername]=useState('')
const [password,setPassword]=useState('')
const [err,setErr]=useState<string|null>(null)
const nav=useNavigate()
return (
<div className="container" style={{maxWidth:520,paddingTop:60}}>
<div className="card">
<h2 style={{marginTop:0}}>Вход</h2>
<div style={{display:'grid',gap:10,marginTop:12}}>
<input className="input" placeholder="username" value={username} onChange={e=>setUsername(e.target.value)} />
<input className="input" placeholder="password" type="password" value={password} onChange={e=>setPassword(e.target.value)} />
{err && <div style={{color:'#fca5a5'}}>{err}</div>}
<button className="btn" onClick={async()=>{
setErr(null)
try{
const res = await apiPost('/api/auth/token/',{username,password})
setToken(res.access)
nav('/briefs')
}catch(e:any){ setErr(e.message||String(e)) }
}}>Войти</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,618 @@
import React, { useEffect, useMemo, useState } from 'react'
import { apiGet, apiPost } from '../api/client'
import NavBar from '../components/NavBar'
import Tooltip from '../components/Tooltip'
import { Link, useParams } from 'react-router-dom'
function formatRu(fmt: string) {
const v = (fmt || '').toLowerCase()
if (v === 'search_ad') return 'Поисковое объявление'
if (v === 'social_post') return 'Пост для соцсетей'
if (v === 'email') return 'Emailписьмо'
return fmt
}
function objectiveRu(x: string) {
const v = (x || '').toLowerCase()
if (v === 'leads') return 'Лиды'
if (v === 'conversions') return 'Конверсии'
if (v === 'clicks') return 'Клики'
return x
}
function statusRu(x: string) {
const v = (x || '').toLowerCase()
if (v === 'draft') return 'Черновик'
if (v === 'running') return 'Запущен'
if (v === 'done' || v === 'finished') return 'Завершён'
return x
}
function safePct01(x: any) {
if (x == null || Number.isNaN(Number(x))) return '—'
return (Number(x) * 100).toFixed(2) + '%'
}
function safeNum(x: any) {
if (x == null || Number.isNaN(Number(x))) return '—'
const n = Number(x)
if (!Number.isFinite(n)) return '—'
return Math.abs(n - Math.round(n)) < 1e-9 ? String(Math.round(n)) : n.toFixed(2)
}
type Agg = {
segment_id: number
variant_id: number
impressions: number
clicks: number
conversions: number
leads: number
spend: number
ctr: number | null
cpc: number | null
cr: number | null
cpl: number | null
cpa: number | null
}
export default function TestDetail() {
const { id } = useParams()
const testId = Number(id)
const [test, setTest] = useState<any | null>(null)
const [brief, setBrief] = useState<any | null>(null)
const [variants, setVariants] = useState<any[]>([])
const [segments, setSegments] = useState<any[]>([])
const [assignments, setAssignments] = useState<any[]>([])
const [results, setResults] = useState<any[]>([])
const [snapshot, setSnapshot] = useState<any | null>(null)
const [err, setErr] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const todayISO = new Date().toISOString().slice(0, 10)
const [segName, setSegName] = useState('Группа A')
const [segDesc, setSegDesc] = useState('')
const [assignForm, setAssignForm] = useState<any>({ segment_id: '', variant_id: '' })
const [result, setResult] = useState<any>({
segment_id: '',
variant_id: '',
date: todayISO,
impressions: 0,
clicks: 0,
conversions: 0,
leads: 0,
spend: 0,
})
const load = async () => {
setErr(null)
const t = await apiGet(`/api/tests/${testId}/`)
setTest(t)
const b = await apiGet(`/api/briefs/${t.brief}/`)
setBrief(b)
const vs = await apiGet(`/api/variants/?brief=${t.brief}`)
setVariants(vs)
let segs: any[] = []
let asg: any[] = []
let rs: any[] = []
try {
segs = await apiGet(`/api/tests/${testId}/segments/`)
asg = await apiGet(`/api/tests/${testId}/assignments/`)
rs = await apiGet(`/api/tests/${testId}/results/`)
} catch (e: any) {
segs = []
asg = []
rs = []
}
setSegments(segs)
setAssignments(asg)
setResults(rs)
if (segs?.length) {
setAssignForm((a: any) => ({ ...a, segment_id: a.segment_id || segs[0].id }))
setResult((r: any) => ({ ...r, segment_id: r.segment_id || segs[0].id }))
}
if (vs?.length) {
setAssignForm((a: any) => ({ ...a, variant_id: a.variant_id || vs[0].id }))
}
}
useEffect(() => {
if (!Number.isFinite(testId)) return
load().catch((e: any) => setErr(e.message || String(e)))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [testId])
const variantsSorted = useMemo(() => [...variants].sort((a, b) => Number(a.id) - Number(b.id)), [variants])
const textNumberByVariantId = useMemo(() => {
const map = new Map<number, number>()
variantsSorted.forEach((v, idx) => map.set(Number(v.id), idx + 1))
return map
}, [variantsSorted])
function variantDisplay(variantId: any) {
const v = variants.find((x) => Number(x.id) === Number(variantId))
const n = textNumberByVariantId.get(Number(variantId))
if (!v) return n ? `Текст №${n}` : `Текст`
const fmt = formatRu(v.format)
return n ? `Текст №${n}${fmt}` : fmt
}
function segmentName(segId: any) {
const s = segments.find((x) => Number(x.id) === Number(segId))
return s?.name || 'Сегмент'
}
const assignedVariantIdsForSegment = useMemo(() => {
const segId = Number(result.segment_id || assignForm.segment_id)
const ids = assignments
.filter((a) => Number(a.segment) === segId || Number(a.segment_id) === segId)
.map((a) => Number(a.variant) || Number(a.variant_id))
return new Set(ids)
}, [assignForm.segment_id, assignments, result.segment_id])
const variantsForSelectedSegment = useMemo(() => {
const ids = assignedVariantIdsForSegment
const filtered = variantsSorted.filter((v) => ids.has(Number(v.id)))
return filtered.length ? filtered : variantsSorted
}, [assignedVariantIdsForSegment, variantsSorted])
useEffect(() => {
if (!result.segment_id) return
const list = variantsForSelectedSegment
if (!list.length) return
const ok = list.some((v) => Number(v.id) === Number(result.variant_id))
if (!ok) setResult((r: any) => ({ ...r, variant_id: list[0].id }))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [result.segment_id, variantsForSelectedSegment.length])
const aggByVariant = useMemo(() => {
const map = new Map<number, Agg[]>()
const tmp = new Map<string, Agg>()
for (const r of results) {
const segId = Number(r.segment)
const varId = Number(r.variant)
const key = `${segId}:${varId}`
const cur =
tmp.get(key) ||
({
segment_id: segId,
variant_id: varId,
impressions: 0,
clicks: 0,
conversions: 0,
leads: 0,
spend: 0,
ctr: null,
cpc: null,
cr: null,
cpl: null,
cpa: null,
} as Agg)
cur.impressions += Number(r.impressions || 0)
cur.clicks += Number(r.clicks || 0)
cur.conversions += Number(r.conversions || 0)
cur.leads += Number(r.leads || 0)
cur.spend += Number(r.spend || 0)
tmp.set(key, cur)
}
for (const cur of tmp.values()) {
cur.ctr = cur.impressions > 0 ? cur.clicks / cur.impressions : null
cur.cpc = cur.clicks > 0 ? cur.spend / cur.clicks : null
cur.cr = cur.clicks > 0 ? cur.conversions / cur.clicks : null
cur.cpl = cur.leads > 0 ? cur.spend / cur.leads : null
cur.cpa = cur.conversions > 0 ? cur.spend / cur.conversions : null
const arr = map.get(cur.variant_id) || []
arr.push(cur)
map.set(cur.variant_id, arr)
}
for (const [varId, arr] of map.entries()) {
arr.sort((a, b) => segmentName(a.segment_id).localeCompare(segmentName(b.segment_id)))
map.set(varId, arr)
}
return map
}, [results, segments])
const ranking = snapshot?.ranking || []
const recs = snapshot?.recommendations || []
const cleanedReason = (s: string) => {
if (!s) return ''
return s.replace(/Primary[^;]*;?\s*/gi, '').replace(/\s{2,}/g, ' ').trim()
}
const applyResultPreset = (n: 1 | 2) => {
if (n === 1) setResult((r: any) => ({ ...r, impressions: 5000, clicks: 60, leads: 5, conversions: 2, spend: 2400 }))
else setResult((r: any) => ({ ...r, impressions: 3000, clicks: 30, leads: 3, conversions: 3, spend: 3400 }))
}
const presetSegmentsAB = async () => {
setBusy(true)
setErr(null)
try {
await apiPost(`/api/tests/${testId}/add_segments/`, {
segments: [
{ name: 'Группа A', description: 'Аудитория A' },
{ name: 'Группа B', description: 'Аудитория B' },
],
})
await load()
} catch (e: any) {
setErr(e.message || String(e))
} finally {
setBusy(false)
}
}
const presetAssignmentsAB = async () => {
if (segments.length < 2 || variantsSorted.length < 2) {
setErr('Нужно минимум 2 сегмента и 2 текста, чтобы сделать пресет распределения.')
return
}
setBusy(true)
setErr(null)
try {
const segA = segments[0].id
const segB = segments[1].id
const vA = variantsSorted[0].id
const vB = variantsSorted[1].id
await apiPost(`/api/tests/${testId}/assign/`, {
assignments: [
{ segment_id: segA, variant_id: vA },
{ segment_id: segB, variant_id: vB },
],
})
await load()
setAssignForm({ segment_id: segA, variant_id: vA })
setResult((r: any) => ({ ...r, segment_id: segA, variant_id: vA }))
} catch (e: any) {
setErr(e.message || String(e))
} finally {
setBusy(false)
}
}
return (
<div className="container">
<NavBar />
<div className="card">
<div className="row" style={{ justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h2 style={{ margin: 0 }}>{test?.name || 'Тест'}</h2>
{test && (
<div className="small">
цель: <b>{objectiveRu(test.objective)}</b> бриф: <b>{brief ? String(brief.product).slice(0, 60) : ''}</b> статус:{' '}
<b>{statusRu(test.status)}</b>
</div>
)}
</div>
<div className="row">
<Link to="/tests" className="btn secondary">
Назад
</Link>
<button className="btn" disabled={busy} onClick={async () => {
setBusy(true); setErr(null);
try { const res = await apiPost(`/api/tests/${testId}/analyze/`); setSnapshot(res); }
catch(e:any){ setErr(e.message||String(e)); }
finally{ setBusy(false); }
}}>
Проанализировать
</button>
</div>
</div>
{err && <div style={{ color: '#fe5c73', marginTop: 10 }}>{err}</div>}
</div>
<div className="row section">
<div className="card" style={{ flex: 1, minWidth: 320 }}>
<div className="cardtitle">
<h3 style={{ margin: 0 }}>Сегменты</h3>
<Tooltip text="Создайте аудитории/группы (A/B). Потом распределите тексты по сегментам." />
</div>
<div className="row" style={{ alignItems: 'flex-end' }}>
<div style={{ flex: 1, minWidth: 160 }}>
<div className="tiprow"><div className="small">Название</div><Tooltip text="Название аудитории/группы, например: “Группа A: 2534 Москва”." /></div>
<input className="input" value={segName} onChange={(e) => setSegName(e.target.value)} />
</div>
<div style={{ flex: 1, minWidth: 160 }}>
<div className="tiprow"><div className="small">Описание (опц.)</div><Tooltip text="Коротко: интересы/гео/признак сегмента." /></div>
<input className="input" value={segDesc} onChange={(e) => setSegDesc(e.target.value)} />
</div>
<button className="btn" disabled={busy || !segName.trim()} onClick={async () => {
setBusy(true); setErr(null);
try { await apiPost(`/api/tests/${testId}/add_segments/`, { segments: [{ name: segName, description: segDesc }] }); await load(); }
catch(e:any){ setErr(e.message||String(e)); }
finally{ setBusy(false); }
}}>
Добавить
</button>
<button className="btn secondary" disabled={busy} onClick={presetSegmentsAB}>Пресет A/B</button>
</div>
</div>
<div className="card" style={{ flex: 1, minWidth: 320 }}>
<div className="cardtitle">
<h3 style={{ margin: 0 }}>Распределение текстов</h3>
<Tooltip text="Привяжите текст к сегменту (A/B). Это влияет на список текстов при вводе результатов." />
</div>
<div className="row" style={{ alignItems: 'flex-end' }}>
<div style={{ flex: 1, minWidth: 160 }}>
<div className="tiprow"><div className="small">Сегмент</div><Tooltip text="На какую аудиторию показываем выбранный текст." /></div>
<select className="input" value={assignForm.segment_id || ''} onChange={(e) => setAssignForm({ ...assignForm, segment_id: Number(e.target.value) })} disabled={!segments.length}>
{segments.length ? segments.map((s) => <option key={s.id} value={s.id}>{s.name}</option>) : <option value="">Сначала добавьте сегменты</option>}
</select>
</div>
<div style={{ flex: 1, minWidth: 220 }}>
<div className="tiprow"><div className="small">Текст</div><Tooltip text="Какой текст показываем этому сегменту." /></div>
<select className="input" value={assignForm.variant_id || ''} onChange={(e) => setAssignForm({ ...assignForm, variant_id: Number(e.target.value) })} disabled={!variantsSorted.length}>
{variantsSorted.length ? variantsSorted.map((v) => <option key={v.id} value={v.id}>{variantDisplay(v.id)}</option>) : <option value="">Сначала сгенерируйте тексты</option>}
</select>
</div>
<button className="btn" disabled={busy || !assignForm.segment_id || !assignForm.variant_id} onClick={async () => {
setBusy(true); setErr(null);
try { await apiPost(`/api/tests/${testId}/assign/`, { assignments: [{ segment_id: assignForm.segment_id, variant_id: assignForm.variant_id }] }); await load(); setResult((r:any)=>({...r, segment_id: assignForm.segment_id, variant_id: assignForm.variant_id})); }
catch(e:any){ setErr(e.message||String(e)); }
finally{ setBusy(false); }
}}>
Добавить
</button>
<button className="btn secondary" disabled={busy} onClick={presetAssignmentsAB}>Пресет A/B</button>
</div>
</div>
</div>
<div className="row section">
<div className="card" style={{ flex: 1, minWidth: 320 }}>
<div className="cardtitle">
<h3 style={{ margin: 0 }}>Результаты (ручной ввод)</h3>
<Tooltip text="Введите показатели по тексту и сегменту: показы, клики, конверсии, лиды, расход." />
</div>
<div className="row" style={{ alignItems: 'flex-end' }}>
<div style={{ width: 170 }}>
<div className="tiprow"><div className="small">Дата</div><Tooltip text="Дата, к которой относится статистика (если вводите по дням)." /></div>
<input className="input" type="date" value={result.date} onChange={(e) => setResult({ ...result, date: e.target.value })} />
</div>
<div style={{ flex: 1, minWidth: 180 }}>
<div className="tiprow"><div className="small">Сегмент</div><Tooltip text="Сегмент аудитории. Список текстов справа фильтруется по распределению." /></div>
<select className="input" value={result.segment_id || ''} onChange={(e) => setResult({ ...result, segment_id: Number(e.target.value) })} disabled={!segments.length}>
{segments.length ? segments.map((s) => <option key={s.id} value={s.id}>{s.name}</option>) : <option value="">Сначала добавьте сегменты</option>}
</select>
</div>
<div style={{ flex: 1, minWidth: 260 }}>
<div className="tiprow"><div className="small">Текст</div><Tooltip text="Показывает только тексты, распределённые выбранному сегменту." /></div>
<select className="input" value={result.variant_id || ''} onChange={(e) => setResult({ ...result, variant_id: Number(e.target.value) })} disabled={!variantsForSelectedSegment.length}>
{variantsForSelectedSegment.length ? variantsForSelectedSegment.map((v) => <option key={v.id} value={v.id}>{variantDisplay(v.id)}</option>) : <option value="">Нет текстов для сегмента</option>}
</select>
</div>
</div>
<div className="row" style={{ marginTop: 12 }}>
<div style={{ width: 160 }}>
<div className="tiprow"><div className="small">Показы</div><Tooltip text="Impressions — нужно для CTR (клики/показы)." /></div>
<input className="input" type="number" value={result.impressions} onChange={(e) => setResult({ ...result, impressions: Number(e.target.value) })} />
</div>
<div style={{ width: 160 }}>
<div className="tiprow"><div className="small">Клики</div><Tooltip text="Clicks — нужно для CTR и CPC (расход/клики)." /></div>
<input className="input" type="number" value={result.clicks} onChange={(e) => setResult({ ...result, clicks: Number(e.target.value) })} />
</div>
<div style={{ width: 160 }}>
<div className="tiprow"><div className="small">Конверсии</div><Tooltip text="Conversions — нужно для CR и CPA (расход/конверсии)." /></div>
<input className="input" type="number" value={result.conversions} onChange={(e) => setResult({ ...result, conversions: Number(e.target.value) })} />
</div>
<div style={{ width: 160 }}>
<div className="tiprow"><div className="small">Лиды</div><Tooltip text="Leads — для лидогенерации (CPL = расход/лиды)." /></div>
<input className="input" type="number" value={result.leads} onChange={(e) => setResult({ ...result, leads: Number(e.target.value) })} />
</div>
<div style={{ width: 180 }}>
<div className="tiprow"><div className="small">Расход</div><Tooltip text="Spend — расходы на этот текст (для CPC/CPL/CPA)." /></div>
<input className="input" type="number" value={result.spend} onChange={(e) => setResult({ ...result, spend: Number(e.target.value) })} />
</div>
</div>
<div className="row" style={{ marginTop: 12 }}>
<button className="btn" disabled={busy || !result.segment_id || !result.variant_id || !result.date} onClick={async () => {
setBusy(true); setErr(null);
try { await apiPost(`/api/tests/${testId}/add_results/`, { results: [result] }); await load(); }
catch(e:any){ setErr(e.message||String(e)); }
finally{ setBusy(false); }
}}>
Сохранить
</button>
<button className="btn secondary" disabled={busy} onClick={() => applyResultPreset(1)}>Пресет 1</button>
<button className="btn secondary" disabled={busy} onClick={() => applyResultPreset(2)}>Пресет 2</button>
</div>
<div className="small" style={{ marginTop: 10 }}>
После ввода результатов нажмите <b>«Проанализировать»</b> сверху появится рейтинг и метрики.
</div>
</div>
</div>
<div className="card section">
<div className="cardtitle">
<h3 style={{ margin: 0 }}>Сводка и таблицы</h3>
<div className="small">Результаты теста и история</div>
</div>
<div className="row">
<div style={{ flex: 1, minWidth: 340 }}>
<div className="small" style={{ marginBottom: 8 }}>Сегменты</div>
<div className="tx-wrap">
<table className="tx-table">
<thead><tr><th>#</th><th>Название</th><th>Описание</th></tr></thead>
<tbody>
{segments.map((s) => (
<tr key={s.id}>
<td className="small">{s.id}</td>
<td style={{ fontWeight: 800 }}>{s.name}</td>
<td className="small">{s.description}</td>
</tr>
))}
{!segments.length && <tr><td colSpan={3} className="small">Пока нет сегментов.</td></tr>}
</tbody>
</table>
</div>
</div>
<div style={{ flex: 1, minWidth: 340 }}>
<div className="small" style={{ marginBottom: 8 }}>Распределение (A/B)</div>
<div className="tx-wrap">
<table className="tx-table">
<thead><tr><th>#</th><th>Сегмент</th><th>Текст</th></tr></thead>
<tbody>
{assignments.map((a) => (
<tr key={a.id}>
<td className="small">{a.id}</td>
<td className="small">{segmentName(a.segment || a.segment_id)}</td>
<td style={{ fontWeight: 800 }} className="small">{variantDisplay(a.variant || a.variant_id)}</td>
</tr>
))}
{!assignments.length && <tr><td colSpan={3} className="small">Пока нет распределения.</td></tr>}
</tbody>
</table>
</div>
</div>
</div>
<div className="row section">
<div style={{ flex: 1, minWidth: 340 }}>
<div className="small" style={{ marginBottom: 8 }}>История результатов</div>
<div className="tx-wrap">
<table className="tx-table">
<thead>
<tr>
<th>Дата</th><th>Сегмент</th><th>Текст</th><th>Показы</th><th>Клики</th><th>Лиды</th><th>Конв.</th><th>Расход</th>
</tr>
</thead>
<tbody>
{results.map((r) => (
<tr key={r.id}>
<td className="small">{r.date}</td>
<td className="small">{segmentName(r.segment)}</td>
<td className="small">{variantDisplay(r.variant)}</td>
<td>{r.impressions}</td>
<td>{r.clicks}</td>
<td>{r.leads}</td>
<td>{r.conversions}</td>
<td style={{ fontWeight: 800 }}>{safeNum(r.spend)}</td>
</tr>
))}
{!results.length && <tr><td colSpan={8} className="small">Пока нет введённых результатов.</td></tr>}
</tbody>
</table>
</div>
</div>
</div>
<div className="row section">
<div style={{ flex: 1, minWidth: 340 }}>
<div className="cardtitle">
<div className="small">Рейтинг текстов (лучший худший)</div>
<div className="meta2">
<span className="kv"><b>CTR</b> <Tooltip text="ClickThrough Rate: клики / показы." /></span>
<span className="kv"><b>CPC</b> <Tooltip text="Cost per Click: расход / клики." /></span>
<span className="kv"><b>CR</b> <Tooltip text="Conversion Rate: конверсии / клики." /></span>
<span className="kv"><b>CPL</b> <Tooltip text="Cost per Lead: расход / лиды." /></span>
<span className="kv"><b>CPA</b> <Tooltip text="Cost per Action: расход / конверсии." /></span>
</div>
</div>
{!snapshot ? (
<div className="small">Пока анализа нет. Нажмите «Проанализировать».</div>
) : (
<>
<div className="list" style={{ marginTop: 12 }}>
{ranking.map((r: any) => {
const varId = Number(r.variant_id)
const perSeg = aggByVariant.get(varId) || []
const reason = cleanedReason(r.reason || '')
return (
<div key={varId} className="item">
<div className="avatar">{r.rank}</div>
<div className="meta">
<div className="h">{variantDisplay(varId)}</div>
{reason && <div className="s">{reason}</div>}
<div className="sep" />
<div className="small" style={{ marginBottom: 8 }}>Показатели по сегментам</div>
{!perSeg.length ? (
<div className="small">Нет введённых результатов для этого текста.</div>
) : (
<div className="tx-wrap" style={{ boxShadow: 'none' }}>
<table className="tx-table">
<thead>
<tr>
<th>Сегмент</th>
<th>CTR <Tooltip text="клики / показы" /></th>
<th>CPC <Tooltip text="расход / клики" /></th>
<th>CR <Tooltip text="конверсии / клики" /></th>
<th>CPL <Tooltip text="расход / лиды" /></th>
<th>CPA <Tooltip text="расход / конверсии" /></th>
</tr>
</thead>
<tbody>
{perSeg.map((m) => (
<tr key={`${m.segment_id}:${m.variant_id}`}>
<td className="small">{segmentName(m.segment_id)}</td>
<td className="small">{safePct01(m.ctr)}</td>
<td className="small">{safeNum(m.cpc)}</td>
<td className="small">{safePct01(m.cr)}</td>
<td className="small">{safeNum(m.cpl)}</td>
<td className="small">{safeNum(m.cpa)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)
})}
</div>
<div className="card compact" style={{ marginTop: 12, border: '1px solid var(--border)', boxShadow: 'none' }}>
<div style={{ fontWeight: 800, marginBottom: 6 }}>Рекомендации</div>
<ul style={{ margin: 0, paddingLeft: 18 }}>
{recs.map((x: string, i: number) => (
<li key={i} className="small" style={{ marginBottom: 6 }}>
{x}
</li>
))}
</ul>
</div>
</>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,169 @@
import React, { useEffect, useState } from 'react'
import { apiGet, apiPost } from '../api/client'
import NavBar from '../components/NavBar'
import Tooltip from '../components/Tooltip'
import { Link, useNavigate } from 'react-router-dom'
function briefLabel(briefs: any[], id: any) {
const b = briefs.find((x) => Number(x.id) === Number(id))
if (!b) return `Бриф`
const name = String(b.product || '').trim()
const short = name.length > 42 ? name.slice(0, 42) + '…' : name
return short || 'Бриф'
}
export default function Tests() {
const [tests, setTests] = useState<any[]>([])
const [briefs, setBriefs] = useState<any[]>([])
const [err, setErr] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const nav = useNavigate()
const [form, setForm] = useState<any>({
brief: '',
name: 'Тест',
channel: '',
duration_days: 3,
sample_size: 0,
objective: 'leads',
status: 'draft',
})
const load = async () => {
setErr(null)
const [ts, bs] = await Promise.all([apiGet('/api/tests/'), apiGet('/api/briefs/')])
setTests(ts)
setBriefs(bs)
if (!form.brief && bs.length) setForm((f: any) => ({ ...f, brief: bs[0].id }))
}
useEffect(() => {
load().catch((e: any) => setErr(e.message || String(e)))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div className="container">
<NavBar />
<div className="stats">
<div className="stat"><div className="dot">B</div><div><div className="label">Briefs</div><div className="value">{briefs.length}</div></div></div>
<div className="stat"><div className="dot">T</div><div><div className="label">Tests</div><div className="value">{tests.length}</div></div></div>
<div className="stat"><div className="dot">A</div><div><div className="label">Objective</div><div className="value">{String(form.objective).toUpperCase()}</div></div></div>
<div className="stat"><div className="dot">D</div><div><div className="label">Duration</div><div className="value">{form.duration_days}d</div></div></div>
</div>
<div style={{ height: 16 }} />
<div className="row">
<div className="card" style={{ flex: 1, minWidth: 340 }}>
<h2 style={{ marginTop: 0 }}>Создать тест</h2>
{err && <div style={{ color: '#fca5a5' }}>{err}</div>}
<div style={{ display: 'grid', gap: 10 }}>
<div>
<div className="tiprow"><div className="small">Бриф</div><Tooltip text="Выбираем бриф (продукт/аудитория/УТП), из которого берём варианты текстов для теста." /></div>
<select
className="input"
value={form.brief ?? ''}
onChange={(e) => setForm({ ...form, brief: Number(e.target.value) })}
>
{briefs.map((b) => (
<option key={b.id} value={b.id}>{String(b.product).slice(0, 50)}</option>
))}
</select>
</div>
<div className="row">
<div style={{ flex: 1 }}>
<div className="tiprow"><div className="small">Название</div><Tooltip text="Короткое имя теста для удобства: например “Яндексоиск_март_1”." /></div>
<input className="input" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
<div style={{ width: 180 }}>
<div className="tiprow"><div className="small">Цель</div><Tooltip text="По чему ранжируем варианты: лиды / конверсии / клики. Агент №2 будет оптимизировать под эту цель." /></div>
<select className="input" value={form.objective} onChange={(e) => setForm({ ...form, objective: e.target.value })}>
<option value="leads">Лиды</option>
<option value="conversions">Конверсии</option>
<option value="clicks">Клики</option>
</select>
</div>
</div>
<div className="row">
<div style={{ flex: 1 }}>
<div className="tiprow"><div className="small">Канал (опц.)</div><Tooltip text="Где тестируете: Яндекс, VK, Email и т.д. Нужно для истории и будущих рекомендаций по каналу." /></div>
<input className="input" value={form.channel} onChange={(e) => setForm({ ...form, channel: e.target.value })} />
</div>
<div style={{ width: 160 }}>
<div className="tiprow"><div className="small">Дней</div><Tooltip text="Плановая длительность теста (сколько дней собираете статистику)." /></div>
<input
className="input"
type="number"
value={form.duration_days}
onChange={(e) => setForm({ ...form, duration_days: Number(e.target.value) })}
/>
</div>
<div style={{ width: 160 }}>
<div className="tiprow"><div className="small">Выборка</div><Tooltip text="Плановый объём данных (например показы/клики). Ориентир “достаточно ли данных” для выводов." /></div>
<input
className="input"
type="number"
value={form.sample_size}
onChange={(e) => setForm({ ...form, sample_size: Number(e.target.value) })}
/>
</div>
</div>
<button
className="btn"
disabled={busy || !form.brief}
onClick={async () => {
setBusy(true)
setErr(null)
try {
const res = await apiPost('/api/tests/', form)
await load()
nav(`/tests/${res.id}`)
} catch (e: any) {
setErr(e.message || String(e))
} finally {
setBusy(false)
}
}}
>
Создать
</button>
</div>
</div>
<div className="card" style={{ flex: 1, minWidth: 340 }}>
<h2 style={{ marginTop: 0 }}>Тесты</h2>
<div className="small">Открой тест, чтобы добавить сегменты, распределение, результаты и анализ.</div>
<table className="table" style={{ marginTop: 12 }}>
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Objective</th>
<th>Статус</th>
</tr>
</thead>
<tbody>
{tests.map((t) => (
<tr key={t.id}>
<td>{t.id}</td>
<td>
<Link to={`/tests/${t.id}`} style={{ fontWeight: 700 }}>
{t.name}
</Link>
<div className="small">{briefLabel(briefs, t.brief)}</div>
</td>
<td>{t.objective}</td>
<td className="small">{t.status}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}

402
frontend/src/styles/app.css Normal file
View File

@@ -0,0 +1,402 @@
:root{
--bg: #f5f7fa;
--card: #ffffff;
--text: #343c6a;
--muted: #718ebf;
--border: #e6eff5;
--primary: #2d60ff;
--danger: #fe5c73;
--shadow: 0 10px 30px rgba(0,0,0,.06);
--radius-lg: 16px;
--radius-md: 12px;
--radius-sm: 10px;
--pad: 16px;
--pad-lg: 22px;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
background: var(--bg);
color: var(--text);
}
a{color:inherit;text-decoration:none}
a:hover{opacity:.9;text-decoration:underline}
.container{max-width:1180px;margin:0 auto;padding:24px}
.row{display:flex;gap:16px;flex-wrap:wrap}
.card{
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--pad-lg);
box-shadow: var(--shadow);
}
.card.compact{padding: var(--pad)}
h2,h3{letter-spacing:-0.02em}
h2{font-size:22px;line-height:28px}
h3{font-size:18px;line-height:24px}
.small{color:var(--muted);font-size:12px}
.input{
width:100%;
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 12px 14px;
background:#fff;
color: var(--text);
outline: none;
}
.input:focus{
border-color: rgba(45,96,255,.55);
box-shadow: 0 0 0 4px rgba(45,96,255,.10);
}
textarea{resize:none}
.btn{
border: none;
border-radius: 12px;
padding: 11px 14px;
cursor: pointer;
font-weight: 600;
background: var(--primary);
color: #fff;
transition: transform .04s ease, opacity .15s ease;
}
.btn:hover{opacity:.95}
.btn:active{transform: translateY(1px)}
.btn:disabled{opacity:.55;cursor:not-allowed}
.btn.secondary{
background:#eef3ff;
color: var(--primary);
border: 1px solid rgba(45,96,255,.18);
}
.badge{
display:inline-flex;
align-items:center;
gap:6px;
padding: 6px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
border: 1px solid var(--border);
background: #fff;
}
.badge::before{
content:"";
width:8px;height:8px;border-radius:999px;
background: var(--muted);
}
.badge.active::before{background: #16a34a}
.badge.paused::before{background: var(--danger)}
.table{width:100%;border-collapse:separate;border-spacing:0}
.table th{
text-align:left;
font-size:12px;
color: var(--muted);
font-weight:700;
padding: 10px 10px;
border-bottom: 1px solid var(--border);
}
.table td{
padding: 12px 10px;
border-bottom: 1px solid var(--border);
vertical-align: top;
font-size: 14px;
}
pre{
background: #f8fafc;
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px;
overflow:auto;
}
.code{
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
background:#f8fafc;
border:1px solid var(--border);
padding:2px 6px;
border-radius: 8px;
}
/* --- BankDash-like dashboard layout helpers --- */
.shell{
display:flex;
gap: 18px;
align-items: flex-start;
}
.sidebar{
width: 230px;
position: sticky;
top: 18px;
}
.main{
flex: 1;
min-width: 340px;
}
.brand{
display:flex;
align-items:center;
gap:10px;
font-weight:800;
letter-spacing:-0.03em;
}
.nav{
display:grid;
gap: 6px;
margin-top: 14px;
}
.nav a{
padding: 10px 12px;
border-radius: 12px;
border: 1px solid transparent;
color: var(--muted);
font-weight: 600;
}
.nav a.active{
background: rgba(45,96,255,.10);
color: var(--primary);
border-color: rgba(45,96,255,.12);
}
.topbar{
display:flex;
justify-content: space-between;
align-items:center;
gap: 12px;
margin-bottom: 16px;
}
.search{
display:flex;
align-items:center;
gap: 10px;
background:#fff;
border: 1px solid var(--border);
border-radius: 999px;
padding: 10px 14px;
min-width: 280px;
color: var(--muted);
}
.iconbtn{
width: 40px;
height: 40px;
border-radius: 999px;
border: 1px solid var(--border);
background: #fff;
cursor: pointer;
}
.iconbtn:hover{background:#f8fafc}
.stats{
display:grid;
grid-template-columns: repeat(4, minmax(160px, 1fr));
gap: 14px;
}
@media (max-width: 1100px){
.stats{ grid-template-columns: repeat(2, minmax(160px, 1fr)); }
.sidebar{ display:none; }
}
.stat{
display:flex;
align-items:center;
gap: 12px;
padding: 14px;
border-radius: 14px;
background: #fff;
border: 1px solid var(--border);
box-shadow: var(--shadow);
}
.stat .dot{
width: 42px;
height: 42px;
border-radius: 14px;
display:flex;
align-items:center;
justify-content:center;
background: rgba(45,96,255,.10);
color: var(--primary);
font-weight: 800;
}
.stat .label{ font-size: 12px; color: var(--muted); }
.stat .value{ font-size: 18px; font-weight: 800; letter-spacing: -0.02em; }
.cardtitle{
display:flex;
justify-content: space-between;
align-items:center;
gap: 12px;
margin-bottom: 10px;
}
.pill{
border: 1px solid rgba(45,96,255,.20);
background: #fff;
color: var(--primary);
border-radius: 999px;
padding: 7px 12px;
font-weight: 700;
cursor:pointer;
}
.pill:hover{background: rgba(45,96,255,.06)}
.pill.primary{
background: var(--primary);
color:#fff;
border-color: transparent;
}
.pill.danger{
background: rgba(254,92,115,.10);
color: var(--danger);
border-color: rgba(254,92,115,.20);
}
.list{
display:grid;
gap: 10px;
}
.item{
display:flex;
gap: 12px;
align-items:flex-start;
padding: 14px;
border-radius: 16px;
border: 1px solid var(--border);
background:#fff;
box-shadow: var(--shadow);
}
.item .avatar{
width: 44px; height: 44px;
border-radius: 16px;
background: rgba(45,96,255,.10);
display:flex;
align-items:center;
justify-content:center;
font-weight: 800;
color: var(--primary);
}
.item .meta{ flex:1; }
.item .meta .h{ font-weight: 800; margin-bottom: 2px; }
.item .meta .s{ color: var(--muted); font-size: 12px; }
.item .actions{ display:flex; gap: 8px; align-items:center; }
/* Tooltips */
.tiprow{display:flex;align-items:center;justify-content:space-between;gap:10px}
.tipbtn{
width: 22px;
height: 22px;
border-radius: 999px;
border: 1px solid var(--border);
background: #fff;
color: var(--muted);
font-weight: 800;
cursor: pointer;
line-height: 1;
display:inline-flex;
align-items:center;
justify-content:center;
}
.tipbtn:hover{background:#f8fafc;color:var(--text)}
.tooltip{
position: absolute;
top: 28px;
right: 0;
min-width: 240px;
max-width: 320px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: #fff;
color: var(--text);
box-shadow: var(--shadow);
font-size: 12px;
line-height: 1.35;
z-index: 50;
}
.tooltip::before{
content:"";
position:absolute;
top:-6px;
right:10px;
width:10px;height:10px;
transform: rotate(45deg);
background:#fff;
border-left: 1px solid var(--border);
border-top: 1px solid var(--border);
}
/* BankDash-like transaction table */
.tx-wrap{
background:#fff;
border:1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
overflow:hidden;
}
.tx-table{
width:100%;
border-collapse:separate;
border-spacing:0;
}
.tx-table th{
font-size:12px;
color: var(--muted);
font-weight:800;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
background: #fff;
}
.tx-table td{
padding: 14px 16px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
font-size: 14px;
}
.tx-table tr:last-child td{border-bottom:none}
.tx-row{
display:flex;
align-items:center;
gap: 12px;
}
.tx-ic{
width: 34px;
height: 34px;
border-radius: 999px;
border: 1px solid rgba(45,96,255,.25);
display:flex;
align-items:center;
justify-content:center;
color: var(--primary);
background: rgba(45,96,255,.06);
font-weight: 900;
}
.tx-btn{
border: 1px solid rgba(45,96,255,.28);
background: #fff;
color: var(--primary);
padding: 8px 12px;
border-radius: 999px;
font-weight: 700;
}
.tx-btn:hover{background: rgba(45,96,255,.06)}
.section{
margin-top: 16px;
}
.meta2{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-top:6px}
.kv{display:inline-flex;gap:6px;align-items:center;color:var(--muted);font-size:12px}
.kv b{color:var(--text);font-weight:800}
.sep{height:1px;background:var(--border);margin:14px 0}

1
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1 @@
{"compilerOptions":{"target":"ES2022","lib":["ES2022","DOM","DOM.Iterable"],"module":"ESNext","moduleResolution":"Bundler","resolveJsonModule":true,"isolatedModules":true,"noEmit":true,"jsx":"react-jsx","strict":true,"skipLibCheck":true},"include":["src"]}

3
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,3 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({ plugins: [react()], server: { port: 5174 } })