Files
simple-pos-system/CLAUDE_DESIGN/table-cards-densities.jsx

376 lines
13 KiB
JavaScript
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.
// Table cards at 5 densities. All share the same data model — each card type
// just renders a subset, sized for fast reading at-a-glance.
const { TABLE_STATUS, TABLE_BADGES } = window;
// ---------- shared bits ----------------------------------------------------
function fmtAmount(n) {
if (n == null || n === 0) return '0.00';
return n.toFixed(2);
}
// Splits "12.34" into ["12", ".34"] so we can typeset cents smaller
function splitAmount(n) {
const s = fmtAmount(n);
const [whole, cents] = s.split('.');
return [whole, '.' + cents];
}
function avatarHash(name) {
const palette = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775', '#1d6f3a'];
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return palette[h % palette.length];
}
function WaiterDot({ name, size = 22, ring }) {
const initials = name.split(' ').map(p => p[0]).slice(0, 2).join('').toUpperCase();
return (
<div style={{
width: size, height: size, borderRadius: '50%',
background: avatarHash(name),
color: 'white', fontSize: size * 0.42, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
boxShadow: ring ? `0 0 0 2px ${ring}` : 'none',
}}>{initials}</div>
);
}
function StackedAvatars({ waiters, size = 22, ring }) {
if (!waiters?.length) return null;
if (waiters.length >= 3) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 0 }}>
{waiters.slice(0, 2).map((w, i) => (
<div key={i} style={{ marginLeft: i === 0 ? 0 : -size * 0.35 }}>
<WaiterDot name={w} size={size} ring={ring} />
</div>
))}
<div style={{
marginLeft: -size * 0.35,
height: size, padding: '0 8px',
borderRadius: size,
background: ring || 'rgba(255,255,255,0.9)',
color: '#1a1a1f', fontSize: 11, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: ring ? `0 0 0 2px ${ring}` : 'none',
}}>+{waiters.length - 2}</div>
</div>
);
}
return (
<div style={{ display: 'flex' }}>
{waiters.map((w, i) => (
<div key={i} style={{ marginLeft: i === 0 ? 0 : -size * 0.3 }}>
<WaiterDot name={w} size={size} ring={ring} />
</div>
))}
</div>
);
}
function StatusPill({ status, size = 'md' }) {
const s = TABLE_STATUS[status];
const sizes = {
sm: { h: 18, px: 7, fs: 10 },
md: { h: 22, px: 9, fs: 11 },
lg: { h: 26, px: 11, fs: 12 },
};
const z = sizes[size];
return (
<span style={{
display: 'inline-flex', alignItems: 'center', height: z.h, padding: `0 ${z.px}px`,
borderRadius: 4,
background: s.pillBg, color: s.pillFg,
fontSize: z.fs, fontWeight: 800,
letterSpacing: 0.5, textTransform: 'uppercase',
whiteSpace: 'nowrap',
}}>{s.label}</span>
);
}
function BadgeChip({ kind, size = 'md' }) {
const b = TABLE_BADGES[kind];
if (!b) return null;
const sizes = {
sm: { h: 20, fs: 11, ic: 12 },
md: { h: 24, fs: 12, ic: 14 },
lg: { h: 28, fs: 13, ic: 16 },
};
const z = sizes[size];
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
height: z.h, padding: '0 8px',
borderRadius: z.h / 2,
background: 'rgba(255,255,255,0.95)',
color: b.tone,
fontSize: z.fs, fontWeight: 700,
}}>
<span style={{ fontSize: z.ic, lineHeight: 1 }}>{b.icon}</span>
{b.label}
</span>
);
}
function BadgeDot({ kind, size = 16 }) {
const b = TABLE_BADGES[kind];
if (!b) return null;
return (
<div title={b.label} style={{
width: size, height: size,
borderRadius: '50%',
background: 'rgba(255,255,255,0.95)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.65,
lineHeight: 1,
}}>{b.icon}</div>
);
}
function Amount({ value, size = 22, color }) {
const [w, c] = splitAmount(value);
return (
<div style={{
fontFamily: "'Geist Mono', monospace",
fontWeight: 700,
lineHeight: 1,
color: color || 'inherit',
letterSpacing: -0.5,
}}>
<span style={{ fontSize: size }}>{w}</span>
<span style={{ fontSize: size * 0.55, opacity: 0.85 }}>{c}</span>
</div>
);
}
// ---------- card shell -----------------------------------------------------
// All densities share this shell — just different content + dimensions.
function CardShell({ status, w, h, children, padding }) {
const s = TABLE_STATUS[status];
return (
<div style={{
width: w, height: h,
background: s.bg, color: s.fg,
borderRadius: 14,
padding: padding,
boxShadow: '0 1px 2px rgba(16,20,24,0.05)',
position: 'relative',
overflow: 'hidden',
display: 'flex', flexDirection: 'column',
cursor: 'pointer',
transition: 'transform 100ms ease',
}}>{children}</div>
);
}
// ===========================================================================
// 1×1 — tiniest. Just NAME. Status is purely the card color.
// ===========================================================================
function Card1x1({ table, w, h }) {
const t = table;
// Show one badge dot if present (very subtle, top-right)
const badge = t.badges[0];
return (
<CardShell status={t.status} w={w} h={h} padding={10}>
<div style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontFamily: "'Geist Mono', monospace",
fontWeight: 800, fontSize: 26,
letterSpacing: -1,
}}>{t.name}</div>
{badge && (
<div style={{ position: 'absolute', top: 6, right: 6 }}>
<BadgeDot kind={badge} size={14} />
</div>
)}
</CardShell>
);
}
// ===========================================================================
// 2×1 — wider. NAME + status PILL + maybe one badge dot.
// ===========================================================================
function Card2x1({ table, w, h }) {
const t = table;
return (
<CardShell status={t.status} w={w} h={h} padding={12}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', height: '100%', gap: 10 }}>
<div style={{
fontFamily: "'Geist Mono', monospace",
fontWeight: 800, fontSize: 26,
letterSpacing: -1, lineHeight: 1,
}}>{t.name}</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 4 }}>
<StatusPill status={t.status} size="sm" />
{t.badges.length > 0 && (
<div style={{ display: 'flex', gap: 3 }}>
{t.badges.slice(0, 2).map(b => <BadgeDot key={b} kind={b} size={14} />)}
</div>
)}
</div>
</div>
</CardShell>
);
}
// ===========================================================================
// 2×2 — square. NAME big + status pill + amount + waiter dots + badges
// ===========================================================================
function Card2x2({ table, w, h }) {
const t = table;
const showAmount = t.amount > 0 || t.status === 'paid' || t.status === 'partial';
return (
<CardShell status={t.status} w={w} h={h} padding={12}>
<div style={{ display: 'flex', height: '100%', gap: 8 }}>
{/* left column: name + pill (top), amount (bottom) */}
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<div style={{
fontFamily: "'Geist Mono', monospace",
fontWeight: 800, fontSize: 30,
letterSpacing: -1, lineHeight: 1,
}}>{t.name}</div>
<div style={{ marginTop: 6 }}>
<StatusPill status={t.status} size="sm" />
</div>
<div style={{ marginTop: 'auto', minHeight: 24 }}>
{showAmount && <Amount value={t.amount} size={22} />}
</div>
</div>
{/* right column: badges stacked vertically, bottom-aligned */}
{t.badges.length > 0 && (
<div style={{
display: 'flex', flexDirection: 'column-reverse',
gap: 4, alignItems: 'flex-end',
justifyContent: 'flex-start',
}}>
{t.badges.slice(0, 3).map(b => <BadgeDot key={b} kind={b} size={20} />)}
</div>
)}
</div>
</CardShell>
);
}
// ===========================================================================
// 4×1 — wide horizontal. NAME · AMOUNT · status pill + waiter dots
// ===========================================================================
function Card4x1({ table, w, h }) {
const t = table;
const showAmount = t.amount > 0 || t.status === 'paid' || t.status === 'partial';
return (
<CardShell status={t.status} w={w} h={h} padding={14}>
<div style={{ display: 'flex', alignItems: 'center', height: '100%', gap: 14 }}>
{/* name */}
<div style={{
fontFamily: "'Geist Mono', monospace",
fontWeight: 800, fontSize: 30,
letterSpacing: -1, lineHeight: 1,
minWidth: 70,
}}>{t.name}</div>
{/* amount (or spacer) */}
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 10 }}>
{showAmount && <Amount value={t.amount} size={22} />}
</div>
{/* badges */}
{t.badges.length > 0 && (
<div style={{ display: 'flex', gap: 4 }}>
{t.badges.slice(0, 2).map(b => <BadgeDot key={b} kind={b} size={20} />)}
</div>
)}
{/* status pill */}
<StatusPill status={t.status} size="md" />
</div>
</CardShell>
);
}
// ===========================================================================
// 4×2 — full detail. Name + section + status pill + amount + badges + waiters with names
// ===========================================================================
function Card4x2({ table, w, h }) {
const t = table;
const s = TABLE_STATUS[t.status];
const showAmount = t.amount > 0 || t.status === 'paid' || t.status === 'partial';
// First waiter name (or "Multiple")
const waiterCaption = t.waiters.length === 0
? 'Unassigned'
: t.waiters.length >= 3
? `${t.waiters.length} waiters`
: t.waiters.map(w => w.split(' ')[0]).join(', ');
return (
<CardShell status={t.status} w={w} h={h} padding={16}>
{/* top row: name + section + status pill | amount */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{
fontFamily: "'Geist Mono', monospace",
fontWeight: 800, fontSize: 38,
letterSpacing: -1.5, lineHeight: 1,
}}>{t.name}</div>
<div style={{
fontSize: 11, fontWeight: 700,
opacity: 0.7,
textTransform: 'uppercase', letterSpacing: 0.8,
marginTop: 4,
}}>{t.section}</div>
<div style={{ marginTop: 8 }}>
<StatusPill status={t.status} size="lg" />
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
{showAmount && <Amount value={t.amount} size={38} />}
</div>
</div>
{/* badges block — right-aligned, up to 4 in 2×2 grid, sits above waiter line */}
<div style={{
marginTop: 'auto',
display: 'flex', justifyContent: 'flex-end',
paddingBottom: 10,
minHeight: 24,
}}>
{t.badges.length > 0 && (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, max-content)',
gridAutoRows: 'min-content',
gap: 6,
justifyItems: 'end',
direction: 'rtl', // fill right column first, then wrap left
}}>
{t.badges.slice(0, 4).map(b => (
<div key={b} style={{ direction: 'ltr' }}>
<BadgeChip kind={b} size="sm" />
</div>
))}
</div>
)}
</div>
{/* bottom: waiters with names */}
<div style={{
paddingTop: 10,
borderTop: '1px solid rgba(255,255,255,0.18)',
display: 'flex', alignItems: 'center', gap: 10,
}}>
{t.waiters.length === 0 ? (
<span style={{ fontSize: 13, opacity: 0.7, fontWeight: 500 }}>Unassigned</span>
) : (
<>
<StackedAvatars waiters={t.waiters} size={26} ring={s.bg} />
<span style={{ fontSize: 14, fontWeight: 600 }}>{waiterCaption}</span>
</>
)}
</div>
</CardShell>
);
}
window.TableCards = { Card1x1, Card2x1, Card2x2, Card4x1, Card4x2 };