// tasting-card.jsx โ€” BeanScriptor professional tasting form const METHODS = [ 'Espresso', 'V60', 'Kalita', 'Chemex', 'Origami', 'Clever', 'AeroPress', 'French Press', 'Moka Pot', 'Siphon', 'Cold Brew', 'Cupping', 'Ibrik', ]; const CVA_ATTRS = [ { key: 'fragrance_aroma', label: 'Fragrance / Aroma' }, { key: 'flavor', label: 'Flavor' }, { key: 'aftertaste', label: 'Aftertaste' }, { key: 'acidity', label: 'Acidity' }, { key: 'body', label: 'Body' }, { key: 'balance', label: 'Balance' }, { key: 'sweetness', label: 'Sweetness' }, { key: 'uniformity', label: 'Uniformity' }, { key: 'clean_cup', label: 'Clean Cup' }, { key: 'overall', label: 'Overall' }, ]; function cvaGradeInfo(score) { if (score >= 90) return { label: 'Outstanding', color: '#1B5E20', bg: 'rgba(76,175,80,0.12)' }; if (score >= 85) return { label: 'Excellent', color: PALETTE.sageDeep, bg: 'rgba(124,154,98,0.14)' }; if (score >= 80) return { label: 'Very Good', color: '#7A4A2A', bg: 'rgba(184,121,74,0.14)' }; if (score >= 75) return { label: 'Good', color: PALETTE.cocoa, bg: 'rgba(122,74,54,0.10)' }; if (score >= 70) return { label: 'Fair', color: PALETTE.inkMuted, bg: 'rgba(36,23,18,0.06)' }; return { label: 'Below Std.', color: '#B71C1C', bg: 'rgba(183,28,28,0.08)' }; } function DefectStepper({ label, sublabel, value, onChange }) { const btn = (glyph, action) => ( ); return (
{label}
{sublabel} ยท โˆ’{value} pt{value !== 1 ? 's' : ''}
{btn('โˆ’', () => onChange(Math.max(0, value - 1)))} {value} {btn('+', () => onChange(Math.min(5, value + 1)))}
); } const NOTE_CATEGORIES = [ { key: 'berries', label: 'Berries', emoji: '๐Ÿ“', items: ['Strawberry', 'Raspberry', 'Blueberry', 'Blackcurrant'] }, { key: 'stoneFruit', label: 'Stone fruit', emoji: '๐Ÿ‘', items: ['Peach', 'Apricot', 'Plum', 'Cherry'] }, { key: 'citrus', label: 'Citrus', emoji: '๐Ÿ‹', items: ['Lemon', 'Orange', 'Grapefruit', 'Bergamot'] }, { key: 'tropical', label: 'Tropical', emoji: '๐Ÿฅญ', items: ['Mango', 'Pineapple', 'Lychee', 'Passion fruit'] }, { key: 'syrupy', label: 'Syrupy', emoji: '๐Ÿฏ', items: ['Honey', 'Maple', 'Caramel', 'Molasses'] }, { key: 'choco', label: 'Cocoa', emoji: '๐Ÿซ', items: ['Dark chocolate', 'Milk chocolate', 'Cocoa nib', 'Brownie'] }, { key: 'spice', label: 'Spice', emoji: '๐ŸŒถ', items: ['Cinnamon', 'Cardamom', 'Clove', 'Pepper'] }, { key: 'floral', label: 'Floral', emoji: '๐ŸŒธ', items: ['Jasmine', 'Rose', 'Hibiscus', 'Chamomile'] }, ]; function NotePicker({ open, onClose, onPick }) { const [cat, setCat] = React.useState(NOTE_CATEGORIES[0].key); if (!open) return null; const active = NOTE_CATEGORIES.find(c => c.key === cat); return ReactDOM.createPortal(
e.stopPropagation()} style={{ width: '100%', background: PALETTE.cream, borderTopLeftRadius: 28, borderTopRightRadius: 28, padding: '14px 16px 28px', boxShadow: '0 -10px 40px rgba(36,23,18,0.25)', }}>
Add tasting note
{/* category tabs */}
{NOTE_CATEGORIES.map((c) => ( setCat(c.key)}> {c.label} ))}
{/* items grid */}
{active.items.map((it) => ( ))}
, document.body ); } function HotColdNotes({ icon, label, hot, cold, onAddHot, onAddCold, onRemoveHot, onRemoveCold }) { const Bucket = ({ tone, notes, onAdd, onRemove }) => (
{tone === 'hot' ? Icon.flame(12) : Icon.snow(12)} {tone === 'hot' ? 'Hot' : 'Cold'}
{notes.map((n, i) => ( ))}
); return (
{icon}
{label}
); } function MethodSelect({ value, onChange }) { const [open, setOpen] = React.useState(false); const [pos, setPos] = React.useState({ top: 0, left: 0, width: 180 }); const btnRef = React.useRef(null); const toggle = () => { if (btnRef.current) { const shell = btnRef.current.parentElement?.parentElement ?? btnRef.current; const r = shell.getBoundingClientRect(); setPos({ top: r.bottom + 6, left: r.left, width: Math.max(r.width, 180) }); } setOpen(o => !o); }; // Close when the page scrolls โ€” pos is stale after any scroll React.useEffect(() => { if (!open) return; const close = () => setOpen(false); window.addEventListener('scroll', close, true); return () => window.removeEventListener('scroll', close, true); }, [open]); return ( {open && ReactDOM.createPortal( <> {/* transparent backdrop โ€” closes menu on outside click */}
setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 9998 }} />
{METHODS.map((m) => (
{ onChange(m); setOpen(false); }} style={{ padding: '8px 12px', borderRadius: 10, fontSize: 14, fontWeight: 500, color: PALETTE.espresso, cursor: 'pointer', background: m === value ? PALETTE.crema : 'transparent', }}>{m}
))}
, document.body )} ); } function TastingCard({ onBack, onSave }) { const [coffeeName, setCoffeeName] = React.useState('Gesha ยท Lot 47'); const [roaster, setRoaster] = React.useState('April Coffee'); const [altitude, setAltitude] = React.useState('1850'); const [dose, setDose] = React.useState('18.0'); const [yield_, setYield] = React.useState('300'); const [grind, setGrind] = React.useState('24 clicks'); const [temp, setTemp] = React.useState('94'); const [tempUnit, setTempUnit] = React.useState('ยฐC'); const [time, setTime] = React.useState('3:15'); const [tds, setTds] = React.useState('1.35'); const [method, setMethod] = React.useState('V60'); const [flavors, setFlavors] = React.useState(['fruity', 'floral', 'sweet']); const [acidity, setAcidity] = React.useState(7); const [body, setBody] = React.useState(5); const [balance, setBalance] = React.useState(8); const [sweetness, setSweetness] = React.useState(7); const [aromaHot, setAromaHot] = React.useState(['๐ŸŒธ Jasmine', '๐Ÿ‘ Peach']); const [aromaCold, setAromaCold] = React.useState(['๐Ÿฏ Honey']); const [tasteHot, setTasteHot] = React.useState(['๐Ÿ‹ Bergamot', '๐Ÿ‘ Apricot']); const [tasteCold, setTasteCold] = React.useState(['๐Ÿ“ Strawberry', '๐Ÿฏ Maple']); const [obs, setObs] = React.useState('Rinsed paper at 96ยฐC. 50g bloom ร— 30s, then 4 pulses to 300g. Drawdown 2:50.'); const [cvaEnabled, setCvaEnabled] = React.useState(false); const [cvaScores, setCvaScores] = React.useState({ fragrance_aroma: 8.0, flavor: 8.0, aftertaste: 8.0, acidity: 8.0, body: 8.0, balance: 8.0, sweetness: 8.0, uniformity: 8.0, clean_cup: 8.0, overall: 8.0, }); const [cvaDefects, setCvaDefects] = React.useState({ taint: 0, fault: 0 }); const [pickerCtx, setPickerCtx] = React.useState(null); const cvaTotal = React.useMemo(() => { const sum = Object.values(cvaScores).reduce((a, v) => a + v, 0); return Math.max(0, sum - cvaDefects.taint - cvaDefects.fault); }, [cvaScores, cvaDefects]); const handleSave = () => onSave({ coffee_name: coffeeName, roaster: roaster || null, brew_method: method || null, altitude: altitude ? parseInt(altitude) : null, dose: dose ? parseFloat(dose) : null, yield_g: yield_ ? parseFloat(yield_) : null, grind: grind || null, water_temp: temp ? parseInt(temp) : null, temp_unit: tempUnit, brew_time: time || null, tds: tds ? parseFloat(tds) : null, flavors, acidity: cvaEnabled ? null : acidity, body: cvaEnabled ? null : body, balance: cvaEnabled ? null : balance, sweetness: cvaEnabled ? null : sweetness, aroma_hot: aromaHot, aroma_cold: aromaCold, taste_hot: tasteHot, taste_cold: tasteCold, observations: obs || null, cva_enabled: cvaEnabled, cva_fragrance_aroma: cvaEnabled ? cvaScores.fragrance_aroma : null, cva_flavor: cvaEnabled ? cvaScores.flavor : null, cva_aftertaste: cvaEnabled ? cvaScores.aftertaste : null, cva_acidity: cvaEnabled ? cvaScores.acidity : null, cva_body: cvaEnabled ? cvaScores.body : null, cva_balance: cvaEnabled ? cvaScores.balance : null, cva_sweetness: cvaEnabled ? cvaScores.sweetness : null, cva_uniformity: cvaEnabled ? cvaScores.uniformity : null, cva_clean_cup: cvaEnabled ? cvaScores.clean_cup : null, cva_overall: cvaEnabled ? cvaScores.overall : null, cva_defects_taint: cvaEnabled ? cvaDefects.taint : null, cva_defects_fault: cvaEnabled ? cvaDefects.fault : null, cva_total_score: cvaEnabled ? cvaTotal : null, }); const toggleFlavor = (k) => { setFlavors(f => f.includes(k) ? f.filter(x => x !== k) : [...f, k]); }; const openPicker = (target) => setPickerCtx(target); const handlePick = (note) => { if (!pickerCtx) return; const map = { 'aromaHot': [aromaHot, setAromaHot], 'aromaCold': [aromaCold, setAromaCold], 'tasteHot': [tasteHot, setTasteHot], 'tasteCold': [tasteCold, setTasteCold], }; const [arr, setter] = map[pickerCtx]; setter([...arr, note]); }; const removeFrom = (target, i) => { const map = { 'aromaHot': [aromaHot, setAromaHot], 'aromaCold': [aromaCold, setAromaCold], 'tasteHot': [tasteHot, setTasteHot], 'tasteCold': [tasteCold, setTasteCold], }; const [arr, setter] = map[target]; setter(arr.filter((_, idx) => idx !== i)); }; // Live brew ratio โ€” recomputes whenever dose or yield changes. const ratio = React.useMemo(() => { const d = parseFloat(dose); const y = parseFloat(yield_); if (!d || !y || d <= 0) return null; return (y / d).toFixed(2); }, [dose, yield_]); const ratioLabel = ratio ? `1:${ratio}` : 'โ€”'; const ratioKind = !ratio ? 'neutral' : parseFloat(ratio) <= 3 ? 'espresso' : parseFloat(ratio) <= 8 ? 'concentrated' : parseFloat(ratio) <= 14 ? 'short' : parseFloat(ratio) <= 18 ? 'classic' : 'long'; const ratioTone = { neutral: { bg: 'rgba(36,23,18,0.06)', fg: PALETTE.inkMuted, lbl: 'enter dose & yield' }, espresso: { bg: 'rgba(122,74,54,0.16)', fg: '#5A2F1E', lbl: 'espresso range' }, concentrated: { bg: 'rgba(184,121,74,0.18)', fg: '#7A4A2A', lbl: 'concentrated' }, short: { bg: 'rgba(124,154,98,0.16)', fg: '#3F5E2C', lbl: 'short brew' }, classic: { bg: 'rgba(124,154,98,0.22)', fg: '#2E4A1F', lbl: 'classic filter' }, long: { bg: 'rgba(43,124,217,0.14)', fg: '#1F4F8F', lbl: 'long brew' }, }[ratioKind]; return (
{/* Top nav */}
Tasting ยท Draft
{/* Header info */}
{/* Pro brew section */} Pro brew
{/* Live ratio readout โ€” auto-updates from Dose & Yield */}
Brew ratio
{ratioTone.lbl}
{ratioLabel}
{/* Grind is free text by design โ€” "24 clicks", "fine", grinder model etc. */} setTemp(sanitizeNumeric(e.target.value, 'int'))} style={{ background: 'transparent', border: 'none', outline: 'none', fontSize: 18, fontWeight: 600, color: PALETTE.espresso, fontFeatureSettings: '"tnum"', width: 32, padding: 0, fontFamily: '-apple-system, system-ui' }} /> {/* digits + one colon ("3:15"); keeps the default keyboard โ€” numeric keypads have no colon */} { let v = e.target.value.replace(/[^\d:]/g, ''); const i = v.indexOf(':'); if (i !== -1) v = v.slice(0, i + 1) + v.slice(i + 1).replace(/:/g, ''); setTime(v); }} style={{ background: 'transparent', border: 'none', outline: 'none', fontSize: 18, fontWeight: 600, color: PALETTE.espresso, fontFeatureSettings: '"tnum"', width: '100%', padding: 0, fontFamily: '-apple-system, system-ui' }} /> {Icon.clock(14)}
{/* Flavor wheel */} Flavor wheel
{flavors.map(k => { const f = FLAVORS.find(x => x.key === k); return ( {f.label} ); })} {flavors.length === 0 && ( none yet โ€” tap a sector )}
{/* Sensory / CVA toggle */}
Sensory scales
SCA CVA
setCvaEnabled(e => !e)} style={{ width: 44, height: 26, borderRadius: 13, background: cvaEnabled ? PALETTE.sageDeep : PALETTE.inkSoft, position: 'relative', cursor: 'pointer', transition: 'background 200ms', flexShrink: 0, }}>
{!cvaEnabled ? (
{[ { label: 'Acidity', val: acidity, set: setAcidity, l: 'Soft', r: 'Bright' }, { label: 'Body', val: body, set: setBody, l: 'Tea-like', r: 'Syrupy' }, { label: 'Balance', val: balance, set: setBalance, l: 'Loose', r: 'Resolved' }, { label: 'Sweetness', val: sweetness, set: setSweetness, l: 'Dry', r: 'Sugary' }, ].map(s => (
{s.label}
{s.val} /10
))}
) : ( {/* Score hero */} {(() => { const g = cvaGradeInfo(cvaTotal); return (
CVA Total
{g.label}
{cvaTotal.toFixed(2)}
); })()} {/* 10 attribute sliders */}
{CVA_ATTRS.map(({ key, label }) => (
{label}
{cvaScores[key].toFixed(2)} /10
setCvaScores(s => ({ ...s, [key]: v }))} min={6} max={10} step={0.25} ticks={[6, 7, 8, 9, 10]} />
))}
{/* Defect counters */}
Defects
setCvaDefects(d => ({ ...d, taint: v }))} /> setCvaDefects(d => ({ ...d, fault: v }))} /> {(cvaDefects.taint > 0 || cvaDefects.fault > 0) && (
Penalty: โˆ’{cvaDefects.taint + cvaDefects.fault} pts
)}
)}
{/* Hot/Cold descriptors */} Aromas
openPicker('aromaHot')} onAddCold={() => openPicker('aromaCold')} onRemoveHot={(i) => removeFrom('aromaHot', i)} onRemoveCold={(i) => removeFrom('aromaCold', i)} />
Tasting notes
openPicker('tasteHot')} onAddCold={() => openPicker('tasteCold')} onRemoveHot={(i) => removeFrom('tasteHot', i)} onRemoveCold={(i) => removeFrom('tasteCold', i)} />
{/* Notes */} Brewing observations