MVP0
This commit is contained in:
1
frontend/.env.example
Normal file
1
frontend/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
1
frontend/.env.production
Normal file
1
frontend/.env.production
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
11
frontend/Dockerfile
Normal file
11
frontend/Dockerfile
Normal 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
15
frontend/README.md
Normal 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
4
frontend/index.html
Normal 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
10
frontend/nginx.conf
Normal 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
23
frontend/package.json
Normal 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
0
frontend/public/.keep
Normal file
26
frontend/src/api/client.ts
Normal file
26
frontend/src/api/client.ts
Normal 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)
|
||||
}
|
||||
8
frontend/src/components/AutoTextarea.tsx
Normal file
8
frontend/src/components/AutoTextarea.tsx
Normal 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'}}/>
|
||||
}
|
||||
62
frontend/src/components/NavBar.tsx
Normal file
62
frontend/src/components/NavBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
31
frontend/src/components/Tooltip.tsx
Normal file
31
frontend/src/components/Tooltip.tsx
Normal 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
28
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
392
frontend/src/pages/Briefs.tsx
Normal file
392
frontend/src/pages/Briefs.tsx
Normal 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: 'Взрослые 25–45, хотят разговорный английский для работы и поездок',
|
||||
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: 'Мужчины и женщины 23–40, следят за питанием, мало времени готовить',
|
||||
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>
|
||||
)
|
||||
}
|
||||
29
frontend/src/pages/Login.tsx
Normal file
29
frontend/src/pages/Login.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
618
frontend/src/pages/TestDetail.tsx
Normal file
618
frontend/src/pages/TestDetail.tsx
Normal 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: 25–34 Москва”." /></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="Click‑Through 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>
|
||||
)
|
||||
}
|
||||
169
frontend/src/pages/Tests.tsx
Normal file
169
frontend/src/pages/Tests.tsx
Normal 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
402
frontend/src/styles/app.css
Normal 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
1
frontend/tsconfig.json
Normal 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
3
frontend/vite.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({ plugins: [react()], server: { port: 5174 } })
|
||||
Reference in New Issue
Block a user