335 lines
15 KiB
React
335 lines
15 KiB
React
// Shared bits for budget app wireframes
|
||
// Tokens come from CSS vars set on .wf-root in index.html so dark/light can swap.
|
||
|
||
// ─── Icons (thin line, 24px stroke 1.5) ────────────────────────────
|
||
const Ico = ({ d, size = 20, stroke = 1.5, fill = 'none', style }) => (
|
||
<svg width={size} height={size} viewBox="0 0 24 24" fill={fill}
|
||
stroke="currentColor" strokeWidth={stroke}
|
||
strokeLinecap="round" strokeLinejoin="round" style={style}>
|
||
{d}
|
||
</svg>
|
||
);
|
||
|
||
const Icons = {
|
||
search: <Ico d={<><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /></>} />,
|
||
bell: <Ico d={<><path d="M6 8a6 6 0 0 1 12 0c0 7 3 7 3 9H3c0-2 3-2 3-9z" /><path d="M10 21a2 2 0 0 0 4 0" /></>} />,
|
||
menu: <Ico d={<><path d="M4 7h16M4 12h16M4 17h16"/></>} />,
|
||
chev: <Ico d={<><path d="m6 9 6 6 6-6"/></>} />,
|
||
chevRt: <Ico d={<><path d="m9 6 6 6-6 6"/></>} size={16} />,
|
||
plus: <Ico d={<><path d="M12 5v14M5 12h14"/></>} stroke={2} />,
|
||
filter: <Ico d={<><path d="M4 5h16M7 12h10M10 19h4"/></>} />,
|
||
home: <Ico d={<><path d="M4 10 12 4l8 6v9a1 1 0 0 1-1 1h-4v-6h-6v6H5a1 1 0 0 1-1-1z"/></>} />,
|
||
stats: <Ico d={<><path d="M4 20V10M10 20V4M16 20v-8M22 20H2"/></>} />,
|
||
wallet: <Ico d={<><rect x="3" y="6" width="18" height="14" rx="2"/><path d="M16 13h2M3 10h18"/></>} />,
|
||
user: <Ico d={<><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></>} />,
|
||
card: <Ico d={<><rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20M6 15h3"/></>} />,
|
||
cash: <Ico d={<><rect x="2" y="6" width="20" height="12" rx="1"/><circle cx="12" cy="12" r="3"/></>} />,
|
||
bank: <Ico d={<><path d="M3 10h18L12 3 3 10z"/><path d="M5 10v8M9 10v8M15 10v8M19 10v8M3 21h18"/></>} />,
|
||
pig: <Ico d={<><path d="M4 13a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v2a4 4 0 0 1-4 4h-1l-1 2h-2l-1-2H9l-1 2H6l-1-2a4 4 0 0 1-1-3v-1z"/><circle cx="16" cy="13" r=".7" fill="currentColor"/></>} />,
|
||
food: <Ico d={<><path d="M5 3v8a3 3 0 0 0 6 0V3M8 11v10M16 3c-2 2-2 6 0 8v10"/></>} />,
|
||
cart: <Ico d={<><path d="M3 4h2l2.4 11.2a2 2 0 0 0 2 1.6h7.2a2 2 0 0 0 2-1.5L21 8H6"/><circle cx="9" cy="20" r="1.2"/><circle cx="18" cy="20" r="1.2"/></>} />,
|
||
car: <Ico d={<><path d="M3 14l2-6a2 2 0 0 1 2-1.5h10a2 2 0 0 1 2 1.5l2 6v4H3v-4z"/><circle cx="7.5" cy="17" r="1.3"/><circle cx="16.5" cy="17" r="1.3"/></>} />,
|
||
house: <Ico d={<><path d="M4 11 12 4l8 7v9h-5v-6H9v6H4z"/></>} />,
|
||
film: <Ico d={<><rect x="3" y="5" width="18" height="14" rx="1"/><path d="M3 9h18M3 15h18M7 5v14M17 5v14"/></>} />,
|
||
health: <Ico d={<><path d="M12 4v16M4 12h16"/></>} />,
|
||
gift: <Ico d={<><rect x="3" y="8" width="18" height="5"/><path d="M12 8v13M3 13h18v8H3zM7 8a3 3 0 1 1 5-2 3 3 0 1 1 5 2"/></>} />,
|
||
more: <Ico d={<><circle cx="6" cy="12" r="1.2" fill="currentColor"/><circle cx="12" cy="12" r="1.2" fill="currentColor"/><circle cx="18" cy="12" r="1.2" fill="currentColor"/></>} />,
|
||
arrowUp: <Ico d={<><path d="M7 17 17 7M9 7h8v8"/></>} size={14} />,
|
||
arrowDn: <Ico d={<><path d="M7 7l10 10M9 17h8V9"/></>} size={14} />,
|
||
eye: <Ico d={<><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></>} />,
|
||
};
|
||
|
||
// ─── Caveat annotation tag (handdrawn comment) ─────────────────────
|
||
function Note({ children, style }) {
|
||
return (
|
||
<span style={{
|
||
fontFamily: 'Caveat, cursive', fontSize: 15, lineHeight: 1,
|
||
color: 'var(--accent)', letterSpacing: 0.2,
|
||
...style,
|
||
}}>{children}</span>
|
||
);
|
||
}
|
||
|
||
// Small arrow used with Note callouts
|
||
function NoteArrow({ rot = 0, len = 28, style }) {
|
||
return (
|
||
<svg width={len + 8} height={20} viewBox={`0 0 ${len + 8} 20`}
|
||
style={{ transform: `rotate(${rot}deg)`, ...style }}>
|
||
<path d={`M2 10 Q ${len * 0.5} 2 ${len} 12`} fill="none"
|
||
stroke="var(--accent)" strokeWidth="1.2" strokeLinecap="round" />
|
||
<path d={`M${len - 5} 8 L${len + 2} 12 L${len - 3} 14`} fill="none"
|
||
stroke="var(--accent)" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"/>
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
// ─── Money formatting ──────────────────────────────────────────────
|
||
const fmt = (n) => {
|
||
const s = Math.abs(n).toLocaleString('ru-RU').replace(/,/g, ' ');
|
||
return (n < 0 ? '−' : '') + s + ' ₽';
|
||
};
|
||
const fmtNoCur = (n) => {
|
||
return Math.abs(n).toLocaleString('ru-RU').replace(/,/g, ' ');
|
||
};
|
||
|
||
// ─── Donut chart with hover ────────────────────────────────────────
|
||
function Donut({ data, size = 180, thickness = 26, active = null, onSegment }) {
|
||
const total = data.reduce((s, d) => s + d.value, 0);
|
||
const r = size / 2;
|
||
const ri = r - thickness;
|
||
let a0 = -Math.PI / 2;
|
||
const arcs = data.map((d, i) => {
|
||
const sweep = (d.value / total) * Math.PI * 2;
|
||
const a1 = a0 + sweep;
|
||
const big = sweep > Math.PI ? 1 : 0;
|
||
const isActive = active === i;
|
||
const rOff = isActive ? 4 : 0;
|
||
const ro = r + rOff;
|
||
const rii = ri + rOff;
|
||
const x0 = r + ro * Math.cos(a0), y0 = r + ro * Math.sin(a0);
|
||
const x1 = r + ro * Math.cos(a1), y1 = r + ro * Math.sin(a1);
|
||
const x2 = r + rii * Math.cos(a1), y2 = r + rii * Math.sin(a1);
|
||
const x3 = r + rii * Math.cos(a0), y3 = r + rii * Math.sin(a0);
|
||
const path = `M${x0},${y0} A${ro},${ro} 0 ${big} 1 ${x1},${y1} L${x2},${y2} A${rii},${rii} 0 ${big} 0 ${x3},${y3} Z`;
|
||
a0 = a1;
|
||
return { path, color: d.color, i };
|
||
});
|
||
return (
|
||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||
{arcs.map(a => (
|
||
<path key={a.i} d={a.path} fill={a.color}
|
||
opacity={active === null || active === a.i ? 1 : 0.35}
|
||
onClick={() => onSegment && onSegment(a.i)}
|
||
style={{ cursor: onSegment ? 'pointer' : 'default', transition: 'opacity .2s' }} />
|
||
))}
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
// Stacked horizontal bar — alt visualization
|
||
function StackBar({ data, height = 16, radius = 8 }) {
|
||
const total = data.reduce((s, d) => s + d.value, 0);
|
||
let acc = 0;
|
||
return (
|
||
<div style={{
|
||
width: '100%', height, borderRadius: radius, overflow: 'hidden',
|
||
display: 'flex', background: 'var(--line)',
|
||
}}>
|
||
{data.map((d, i) => {
|
||
const w = (d.value / total) * 100;
|
||
const el = (
|
||
<div key={i} title={d.label}
|
||
style={{ width: `${w}%`, background: d.color, height: '100%' }} />
|
||
);
|
||
acc += w;
|
||
return el;
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Category palette (sage/terracotta family — calm) ───────────────
|
||
const CATS = [
|
||
{ id: 'food', label: 'Продукты', icon: Icons.cart, color: '#8aa6a0' },
|
||
{ id: 'rent', label: 'Жильё', icon: Icons.house, color: '#c89a86' },
|
||
{ id: 'transp', label: 'Транспорт', icon: Icons.car, color: '#b3a589' },
|
||
{ id: 'cafe', label: 'Кафе', icon: Icons.food, color: '#9fb38a' },
|
||
{ id: 'enter', label: 'Досуг', icon: Icons.film, color: '#a99cb9' },
|
||
{ id: 'other', label: 'Другое', icon: Icons.more, color: '#b8b5ac' },
|
||
];
|
||
|
||
// Mock month spend by category
|
||
const SPEND = [
|
||
{ id: 'food', value: 14_200 },
|
||
{ id: 'rent', value: 32_000 },
|
||
{ id: 'transp', value: 5_600 },
|
||
{ id: 'cafe', value: 8_400 },
|
||
{ id: 'enter', value: 4_200 },
|
||
{ id: 'other', value: 2_800 },
|
||
];
|
||
const SPEND_TOTAL = SPEND.reduce((s, d) => s + d.value, 0);
|
||
const DONUT_DATA = SPEND.map(s => {
|
||
const c = CATS.find(c => c.id === s.id);
|
||
return { value: s.value, color: c.color, label: c.label, id: s.id };
|
||
});
|
||
|
||
// ─── Accounts ──────────────────────────────────────────────────────
|
||
const ACCOUNTS = [
|
||
{ id: 'all', label: 'Все счета', short: 'Все', icon: Icons.wallet, balance: 184_320 },
|
||
{ id: 'card', label: 'Карта', short: 'Карта', icon: Icons.card, balance: 142_500 },
|
||
{ id: 'cash', label: 'Наличные', short: 'Кэш', icon: Icons.cash, balance: 12_820 },
|
||
{ id: 'save', label: 'Копилка', short: 'Копилка', icon: Icons.pig, balance: 29_000 },
|
||
];
|
||
|
||
// ─── Transactions (compact list) ────────────────────────────────────
|
||
const TX = [
|
||
{ id: 1, cat: 'food', merchant: 'Лента', acc: 'card', amount: -2_340, when: 'Сегодня, 19:42' },
|
||
{ id: 2, cat: 'cafe', merchant: 'Кофе Хауз', acc: 'card', amount: -480, when: 'Сегодня, 09:15' },
|
||
{ id: 3, cat: 'transp', merchant: 'Метро', acc: 'card', amount: -62, when: 'Сегодня, 08:50' },
|
||
{ id: 4, cat: 'food', merchant: 'Перекрёсток', acc: 'cash', amount: -1_120, when: 'Вчера, 21:08' },
|
||
{ id: 5, cat: 'enter', merchant: 'Кинотеатр', acc: 'card', amount: -650, when: 'Вчера, 19:30' },
|
||
{ id: 6, cat: 'rent', merchant: 'Аренда квартиры',acc: 'card', amount: -32_000,when: '21 мая' },
|
||
{ id: 7, cat: 'other', merchant: 'Зарплата', acc: 'card', amount: 95_000, when: '20 мая' },
|
||
{ id: 8, cat: 'transp', merchant: 'Яндекс Такси', acc: 'card', amount: -340, when: '20 мая' },
|
||
{ id: 9, cat: 'cafe', merchant: 'Шоколадница', acc: 'cash', amount: -720, when: '19 мая' },
|
||
{ id: 10, cat: 'food', merchant: 'Магнит', acc: 'card', amount: -890, when: '19 мая' },
|
||
];
|
||
|
||
// ─── Compact transaction row ───────────────────────────────────────
|
||
function TxRow({ tx, dense = true }) {
|
||
const cat = CATS.find(c => c.id === tx.cat) || CATS[CATS.length - 1];
|
||
return (
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', gap: 12,
|
||
padding: dense ? '8px 16px' : '12px 16px',
|
||
borderBottom: '1px solid var(--line)',
|
||
}}>
|
||
<div style={{
|
||
width: 32, height: 32, borderRadius: 8,
|
||
background: cat.color + '26', color: cat.color,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
flexShrink: 0,
|
||
}}>{cat.icon}</div>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{
|
||
fontSize: 14, color: 'var(--ink)', fontWeight: 500,
|
||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||
}}>{tx.merchant}</div>
|
||
<div style={{ fontSize: 11, color: 'var(--ink-2)', display: 'flex', gap: 6 }}>
|
||
<span>{cat.label}</span><span>·</span><span>{tx.when}</span>
|
||
</div>
|
||
</div>
|
||
<div style={{
|
||
fontFamily: 'JetBrains Mono, monospace',
|
||
fontSize: 14, fontVariantNumeric: 'tabular-nums',
|
||
color: tx.amount > 0 ? 'var(--pos)' : 'var(--ink)',
|
||
fontWeight: 500,
|
||
}}>
|
||
{tx.amount > 0 ? '+' : '−'}{fmtNoCur(tx.amount)} ₽
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Day group header ──────────────────────────────────────────────
|
||
function DayHeader({ label, total }) {
|
||
return (
|
||
<div style={{
|
||
display: 'flex', justifyContent: 'space-between',
|
||
padding: '10px 16px 4px',
|
||
fontSize: 11, letterSpacing: 0.6, textTransform: 'uppercase',
|
||
color: 'var(--ink-2)',
|
||
}}>
|
||
<span>{label}</span>
|
||
<span style={{ fontFamily: 'JetBrains Mono, monospace' }}>−{fmtNoCur(total)} ₽</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Bottom nav ────────────────────────────────────────────────────
|
||
function BottomNav({ active = 0 }) {
|
||
const items = [
|
||
{ icon: Icons.home, label: 'Главная' },
|
||
{ icon: Icons.stats, label: 'Аналитика' },
|
||
{ icon: Icons.wallet, label: 'Счета' },
|
||
{ icon: Icons.user, label: 'Профиль' },
|
||
];
|
||
return (
|
||
<div style={{
|
||
display: 'flex', borderTop: '1px solid var(--line)',
|
||
background: 'var(--paper)',
|
||
}}>
|
||
{items.map((it, i) => (
|
||
<div key={i} style={{
|
||
flex: 1, display: 'flex', flexDirection: 'column',
|
||
alignItems: 'center', justifyContent: 'center',
|
||
gap: 3, padding: '8px 0 6px',
|
||
color: i === active ? 'var(--accent)' : 'var(--ink-2)',
|
||
}}>
|
||
<div style={{
|
||
padding: i === active ? '2px 14px' : 0,
|
||
background: i === active ? 'var(--accent-soft)' : 'transparent',
|
||
borderRadius: 12, display: 'flex',
|
||
}}>{it.icon}</div>
|
||
<span style={{ fontSize: 10, fontWeight: i === active ? 600 : 400 }}>{it.label}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── FAB ───────────────────────────────────────────────────────────
|
||
function FAB({ bottom = 70, right = 16 }) {
|
||
return (
|
||
<div style={{
|
||
position: 'absolute', bottom, right,
|
||
width: 52, height: 52, borderRadius: 16,
|
||
background: 'var(--accent)', color: 'var(--paper)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
boxShadow: '0 6px 16px rgba(0,0,0,0.18)',
|
||
}}>{Icons.plus}</div>
|
||
);
|
||
}
|
||
|
||
// ─── Wireframe app bar (compact, neutral) ──────────────────────────
|
||
function WfBar({ title, sub, right }) {
|
||
return (
|
||
<div style={{
|
||
padding: '14px 16px 6px',
|
||
display: 'flex', alignItems: 'flex-start', gap: 12,
|
||
}}>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
{sub && (
|
||
<div style={{ fontSize: 11, color: 'var(--ink-2)', letterSpacing: 0.6, textTransform: 'uppercase' }}>
|
||
{sub}
|
||
</div>
|
||
)}
|
||
<div style={{ fontSize: 22, fontWeight: 600, color: 'var(--ink)', letterSpacing: -0.3 }}>
|
||
{title}
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 4, color: 'var(--ink-2)' }}>
|
||
{right}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Variation label (printed below frame on canvas) ────────────────
|
||
function VLabel({ n, title, axes }) {
|
||
return (
|
||
<div style={{
|
||
width: 412, padding: '14px 4px 0',
|
||
fontFamily: 'DM Sans, sans-serif',
|
||
}}>
|
||
<div style={{
|
||
display: 'flex', gap: 8, alignItems: 'baseline',
|
||
}}>
|
||
<span style={{
|
||
fontFamily: 'JetBrains Mono, monospace', fontSize: 11,
|
||
color: 'var(--ink-2)',
|
||
}}>0{n}</span>
|
||
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--ink)' }}>{title}</span>
|
||
</div>
|
||
<div style={{ marginTop: 4, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||
{axes.map((a, i) => (
|
||
<span key={i} style={{
|
||
fontSize: 10, padding: '2px 6px',
|
||
border: '1px dashed var(--line-2)', borderRadius: 6,
|
||
color: 'var(--ink-2)', letterSpacing: 0.2,
|
||
}}>{a}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
Object.assign(window, {
|
||
Ico, Icons, Note, NoteArrow,
|
||
fmt, fmtNoCur,
|
||
Donut, StackBar,
|
||
CATS, SPEND, SPEND_TOTAL, DONUT_DATA,
|
||
ACCOUNTS, TX,
|
||
TxRow, DayHeader, BottomNav, FAB, WfBar, VLabel,
|
||
});
|