// components.jsx — shared UI for BeanScriptor // CoffeeBean glyph, BeanSlider, FlavorWheel, glass surfaces. const PALETTE = { espresso: '#241712', coffee: '#4A2D22', cocoa: '#7A4A36', caramel: '#B8794A', crema: '#F4E8D5', cream: '#FBF7EF', paper: '#FFFCF6', ink: '#1B120D', inkMuted: 'rgba(36, 23, 18, 0.55)', inkSoft: 'rgba(36, 23, 18, 0.32)', hairline: 'rgba(36, 23, 18, 0.08)', sage: 'oklch(62% 0.06 145)', sageDeep: 'oklch(48% 0.07 148)', tg: '#2B7CD9', tgSoft: 'rgba(43, 124, 217, 0.10)', }; // ───────────────────────────────────────────────────────────── // CoffeeBean — the signature glyph // ───────────────────────────────────────────────────────────── function CoffeeBean({ size = 20, color = PALETTE.coffee, fill = true, tilt = -18, style = {} }) { const c = color; return ( {fill && ( )} ); } // ───────────────────────────────────────────────────────────── // Glass surface — frosted card // ───────────────────────────────────────────────────────────── function GlassCard({ children, style = {}, padding = 16, radius = 22 }) { return (
{children}
); } // ───────────────────────────────────────────────────────────── // Section heading (label + optional kicker) // ───────────────────────────────────────────────────────────── function SectionLabel({ children, kicker, style = {} }) { return (
{children}
{kicker &&
{kicker}
}
); } // ───────────────────────────────────────────────────────────── // BeanSlider — horizontal slider with bean thumb + tick marks // ───────────────────────────────────────────────────────────── function BeanSlider({ value, onChange, min = 0, max = 10, step = 1, ticks, leftLabel, rightLabel }) { const trackRef = React.useRef(null); const [drag, setDrag] = React.useState(false); const pct = (value - min) / (max - min); const handleMove = (clientX) => { if (!trackRef.current) return; const rect = trackRef.current.getBoundingClientRect(); let p = (clientX - rect.left) / rect.width; p = Math.max(0, Math.min(1, p)); const raw = min + p * (max - min); const snapped = Math.round(raw / step) * step; onChange(Math.max(min, Math.min(max, snapped))); }; // The drag effect only re-runs when drag flips — route through a ref so // mid-drag moves always see the current onChange/min/max/step. const handleMoveRef = React.useRef(handleMove); handleMoveRef.current = handleMove; React.useEffect(() => { if (!drag) return; const onMove = (e) => { if (e.touches) e.preventDefault(); // keep the page from scrolling under the drag handleMoveRef.current(e.touches ? e.touches[0].clientX : e.clientX); }; const onUp = () => setDrag(false); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); window.addEventListener('touchmove', onMove, { passive: false }); window.addEventListener('touchend', onUp); return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); window.removeEventListener('touchmove', onMove); window.removeEventListener('touchend', onUp); }; }, [drag]); const tickMarks = ticks || Array.from({ length: max - min + 1 }, (_, i) => min + i); return (
{ setDrag(true); handleMove(e.clientX); }} onTouchStart={(e) => { setDrag(true); handleMove(e.touches[0].clientX); }} style={{ position: 'relative', height: 36, cursor: 'pointer', display: 'flex', alignItems: 'center', }} > {/* track */}
{/* fill */}
{/* ticks */} {tickMarks.map((t) => { const tp = (t - min) / (max - min); const active = t <= value; return (
); })} {/* thumb */}
{(leftLabel || rightLabel) && (
{leftLabel} {rightLabel}
)}
); } // ───────────────────────────────────────────────────────────── // FlavorWheel — 8 clickable sectors with optional sub-petals // ───────────────────────────────────────────────────────────── const FLAVORS = [ { key: 'fruity', label: 'Fruity', hue: 8, notes: ['Berry', 'Citrus', 'Stone Fruit', 'Tropical'] }, { key: 'sweet', label: 'Sweet', hue: 35, notes: ['Honey', 'Caramel', 'Brown Sugar', 'Maple'] }, { key: 'nutty', label: 'Nutty', hue: 28, notes: ['Almond', 'Hazelnut', 'Cocoa', 'Chocolate'] }, { key: 'roasty', label: 'Roasty', hue: 18, notes: ['Smoke', 'Tobacco', 'Burnt', 'Ash'] }, { key: 'spicy', label: 'Spicy', hue: 348, notes: ['Pepper', 'Clove', 'Anise', 'Cardamom'] }, { key: 'veggie', label: 'Veggie', hue: 130, notes: ['Herb', 'Grass', 'Pea', 'Olive Oil'] }, { key: 'sour', label: 'Sour', hue: 60, notes: ['Citric', 'Malic', 'Lactic', 'Phosphoric'] }, { key: 'floral', label: 'Floral', hue: 320, notes: ['Jasmine', 'Rose', 'Chamomile', 'Bergamot'] }, ]; function flavorColor(hue, sat = 0.08, l = 70, a = 1) { return `oklch(${l}% ${sat} ${hue} / ${a})`; } function FlavorWheel({ active = [], onToggle, size = 280 }) { const cx = size / 2, cy = size / 2; const rOuter = size / 2 - 4; const rInner = size / 2 - 70; const rCore = 28; const sectors = FLAVORS.length; const sweep = (2 * Math.PI) / sectors; // build sector path const sectorPath = (i, r1, r2) => { const a0 = -Math.PI / 2 + i * sweep - sweep / 2; const a1 = a0 + sweep; const x0 = cx + r2 * Math.cos(a0), y0 = cy + r2 * Math.sin(a0); const x1 = cx + r2 * Math.cos(a1), y1 = cy + r2 * Math.sin(a1); const x2 = cx + r1 * Math.cos(a1), y2 = cy + r1 * Math.sin(a1); const x3 = cx + r1 * Math.cos(a0), y3 = cy + r1 * Math.sin(a0); return `M ${x0} ${y0} A ${r2} ${r2} 0 0 1 ${x1} ${y1} L ${x2} ${y2} A ${r1} ${r1} 0 0 0 ${x3} ${y3} Z`; }; const labelPos = (i, r) => { const a = -Math.PI / 2 + i * sweep; return [cx + r * Math.cos(a), cy + r * Math.sin(a)]; }; return ( {/* outer ring backdrop */} {FLAVORS.map((f, i) => { const isOn = active.includes(f.key); const fill = isOn ? flavorColor(f.hue, 0.16, 72, 1) : flavorColor(f.hue, 0.04, 92, 1); const stroke = isOn ? flavorColor(f.hue, 0.18, 50) : 'rgba(36,23,18,0.08)'; return ( onToggle && onToggle(f.key)}> ); })} {/* labels */} {FLAVORS.map((f, i) => { const [x, y] = labelPos(i, (rInner + rOuter) / 2); const isOn = active.includes(f.key); return ( {f.label} ); })} {/* core */} {/* center bean */} {/* count badge */} {active.length} SELECTED ); } // ───────────────────────────────────────────────────────────── // Tag chip // ───────────────────────────────────────────────────────────── function Chip({ children, active, icon, onClick, style = {} }) { return ( ); } // ───────────────────────────────────────────────────────────── // Field helpers // ───────────────────────────────────────────────────────────── function FieldShell({ label, suffix, children, span = 1 }) { return (
{label}
{children} {suffix && {suffix}}
); } // Character filter for numeric fields. mode 'int' keeps digits only; // 'decimal' keeps digits and at most one dot. Commas become dots — the iOS // decimal keypad shows only a comma in many locales (ru, de, fr, …), so // blocking it would make decimal input impossible on those devices. // Applied to the whole value on change, so pasted text is filtered too. function sanitizeNumeric(raw, mode = 'decimal') { let v = String(raw).replace(/,/g, '.'); v = mode === 'int' ? v.replace(/[^\d]/g, '') : v.replace(/[^\d.]/g, ''); if (mode !== 'int') { const i = v.indexOf('.'); if (i !== -1) v = v.slice(0, i + 1) + v.slice(i + 1).replace(/\./g, ''); } return v; } function NumField({ label, suffix, value, onChange, span = 1, mode = 'decimal', maxLength }) { return ( onChange(sanitizeNumeric(e.target.value, mode))} style={{ background: 'transparent', border: 'none', outline: 'none', fontSize: 18, fontWeight: 600, color: PALETTE.espresso, fontFeatureSettings: '"tnum"', width: '100%', fontFamily: '-apple-system, system-ui', padding: 0, }} /> ); } function TextField({ label, value, onChange, placeholder, span = 1 }) { return ( onChange(e.target.value)} placeholder={placeholder} style={{ background: 'transparent', border: 'none', outline: 'none', fontSize: 16, fontWeight: 500, color: PALETTE.espresso, width: '100%', padding: 0, fontFamily: '-apple-system, system-ui', }} /> ); } // ───────────────────────────────────────────────────────────── // Tiny inline icons (not lucide; matched for simplicity) // ───────────────────────────────────────────────────────────── const Icon = { back: (s = 22) => ( ), plus: (s = 18) => ( ), flame: (s = 14) => ( ), snow: (s = 14) => ( ), clock: (s = 14) => ( ), drop: (s = 14) => ( ), chevron: (s = 16) => ( ), }; Object.assign(window, { PALETTE, CoffeeBean, GlassCard, SectionLabel, BeanSlider, FlavorWheel, FLAVORS, Chip, FieldShell, NumField, TextField, Icon, sanitizeNumeric, });