Files
OnBudget/design/common.jsx
T
2026-05-27 09:19:19 +03:00

335 lines
15 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
});