diff --git a/app/index.html b/app/index.html index 91f9a27..afb160d 100644 --- a/app/index.html +++ b/app/index.html @@ -2,8 +2,10 @@
404
Page not found.
@@ -22,8 +22,8 @@ const About = () => { {/* Origin Story */}
After some time away from active development, the project was{' '} - reinvigorated + reinvigorated by a conversation on the listserv of the Local Catch network. The discussions about pricing transparency and the challenges fishers face when calculating fair prices for their catch reminded me why this tool matters. @@ -60,7 +60,7 @@ const About = () => { href="https://localcatch.org" target="_blank" rel="noopener noreferrer" - className="inline-flex items-center gap-2 text-brand-terracotta hover:underline text-sm font-medium" + className="inline-flex items-center gap-2 text-link hover:underline text-sm font-medium" > Visit Local Catch Network @@ -73,13 +73,13 @@ const About = () => { {/* Open Source */} - - Open Source & Free Forever + + Open Source & Free Forever This calculator is and will always be{' '} - free and open source. + free and open source. It's built for and by the fishing community. Whether you're a small-scale fisher calculating prices for direct sales, or a processor working with multiple species, this tool is here to help. @@ -103,7 +103,7 @@ const About = () => {
This calculator is and will always be{' '} - free and open source. + free and open source. It's built for and by the fishing community. Whether you're a small-scale fisher calculating prices for direct sales, or a processor working with multiple species, this tool is here to help.
If you find this tool useful and want to support continued development, consider buying me a coffee! @@ -114,7 +114,7 @@ const About = () => { href="https://buymeacoffee.com/pcswny" target="_blank" rel="noopener noreferrer" - className="inline-flex items-center gap-2 bg-brand-terracotta hover:bg-brand-terracotta-light text-white font-semibold px-6 py-3 rounded-md transition" + className="inline-flex items-center gap-2 bg-brand-cta hover:bg-brand-cta-hover text-white font-semibold px-6 py-3 rounded-md transition" > Buy Me a Coffee diff --git a/app/src/components/Calculator.jsx b/app/src/components/Calculator.jsx index e2dc05e..030d469 100644 --- a/app/src/components/Calculator.jsx +++ b/app/src/components/Calculator.jsx @@ -1,22 +1,95 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useId } from 'react'; +import { Link } from 'react-router-dom'; import { ACRONYMS, FISH_DATA_V3, PROFILES_DATA } from '../data/fish_data_v3'; -import { Info, Calculator as CalcIcon, Save, HelpCircle, Download, ChevronRight } from 'lucide-react'; +import { Info, Calculator as CalcIcon, Save, HelpCircle, Download, ChevronRight, ChevronDown } from 'lucide-react'; import { useAuth } from '../context/AuthContext'; import { apiUrl } from '../config/api'; -const Tooltip = ({ text, children }) => { +/** + * Help bubble that works for mouse (hover), keyboard (focus) and touch (tap). + * Hover-only tooltips are invisible on phones, which is where most people use this. + * + * The bubble is `position: fixed` and placed from the trigger's rect. Fixed elements never add + * scrollable overflow, so a bubble near the screen edge can't widen the page on mobile. + */ +const Tooltip = ({ text, label, iconOnly = false, children }) => { const [show, setShow] = useState(false); + const bubbleId = useId(); + const wrapperRef = useRef(null); + const bubbleRef = useRef(null); + const lastPointer = useRef('mouse'); + + useLayoutEffect(() => { + const bubble = bubbleRef.current; + const trigger = wrapperRef.current; + if (!show || !bubble || !trigger) return; + const gap = 8; + const vv = window.visualViewport; + const viewportWidth = vv?.width ?? document.documentElement.clientWidth; + const minX = (vv?.offsetLeft ?? 0) + gap; + const maxX = (vv?.offsetLeft ?? 0) + viewportWidth - gap; + // The CSS cap is in layout units; when zoomed in the visible area is narrower, so cap it here too + bubble.style.maxWidth = `${Math.min(parseFloat(getComputedStyle(bubble).maxWidth) || Infinity, maxX - minX)}px`; + const t = trigger.getBoundingClientRect(); + const { width, height } = bubble.getBoundingClientRect(); + const left = Math.min(Math.max(t.left, minX), Math.max(minX, maxX - width)); + // Prefer above the trigger; drop below when it would slide under the 56px sticky navbar + const fitsAbove = t.top - height - gap >= (vv?.offsetTop ?? 0) + 64; + bubble.style.left = `${left}px`; + bubble.style.top = `${fitsAbove ? t.top - height - gap : t.bottom + gap}px`; + }, [show]); + + // Tapping elsewhere closes it (iOS never blurs a tapped button); scrolling would detach it + useEffect(() => { + if (!show) return undefined; + const close = (e) => { + if (e.type === 'scroll' || !wrapperRef.current?.contains(e.target)) setShow(false); + }; + document.addEventListener('pointerdown', close); + window.addEventListener('scroll', close, true); + window.addEventListener('resize', close); + return () => { + document.removeEventListener('pointerdown', close); + window.removeEventListener('scroll', close, true); + window.removeEventListener('resize', close); + }; + }, [show]); + return ( - setShow(true)} - onMouseLeave={() => setShow(false)} - > - {children} + + { lastPointer.current = e.pointerType; }} + onPointerEnter={(e) => { if (e.pointerType === 'mouse') setShow(true); }} + onPointerLeave={(e) => { if (e.pointerType === 'mouse') setShow(false); }} + onFocus={(e) => { if (e.currentTarget.matches(':focus-visible')) setShow(true); }} + onBlur={() => setShow(false)} + onKeyDown={(e) => { if (e.key === 'Escape') setShow(false); }} + onClick={(e) => { + // detail === 0 means keyboard or assistive-tech activation: always open (Esc closes) + if (e.detail === 0) setShow(true); + else if (lastPointer.current !== 'mouse') setShow((s) => !s); + }} + > + {children} + {show && ( - + {text} - )} @@ -46,8 +119,8 @@ const TextWithTooltips = ({ text }) => { <> {parts.map((part, i) => part.isAcronym ? ( - - {part.text} + + {part.text} ) : ( {part.text} @@ -59,17 +132,31 @@ const TextWithTooltips = ({ text }) => { const RangeButton = ({ active, onClick, label }) => ( {label} ); +const StepHeading = ({ number, children }) => ( + + + {number} + + {children} + +); + const Calculator = () => { const { user, getAuthHeaders } = useAuth(); const [mode, setMode] = useState('cost'); @@ -85,6 +172,9 @@ const Calculator = () => { const [shipping, _setShipping] = useState(''); const [weightType, setWeightType] = useState('incoming'); const [result, setResult] = useState(null); + // Yield and target as they were when the result was computed, so the description never drifts from the number + const [resultMeta, setResultMeta] = useState(null); + const resultRef = useRef(null); const [saveStatus, setSaveStatus] = useState(''); const [useRangeMin, setUseRangeMin] = useState(false); const [useRangeMax, setUseRangeMax] = useState(false); @@ -223,6 +313,7 @@ const Calculator = () => { if (mode === 'weight') { const target = parseFloat(targetWeight) || 0; setResult(y > 0 ? target / y : 0); + setResultMeta({ yieldPercent, targetWeight }); setSaveStatus(''); return; } @@ -238,9 +329,17 @@ const Calculator = () => { baseRes += cold + ship; setResult(baseRes); + setResultMeta({ yieldPercent, targetWeight }); setSaveStatus(''); }; + // Bring a fresh result into view: on a phone it lands below the fold, under the keyboard + useEffect(() => { + if (result === null || !resultRef.current) return; + const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + resultRef.current.scrollIntoView?.({ block: 'nearest', behavior: reduceMotion ? 'auto' : 'smooth' }); + }, [result]); + const handleSave = async () => { if (!user || !result) return; try { @@ -302,364 +401,424 @@ const Calculator = () => { const canCalculate = species && toState; + const handleSubmit = (e) => { + e.preventDefault(); + if (canCalculate) calculate(); + }; + return ( - + {/* Page header */} - - Fish Cost Calculator - - Estimate processed cost per pound from raw input cost and yield data. + + Fish Cost Calculator + + See what your fish really costs per pound after cutting and trimming. - + {/* Main calculator card */} {/* Mode toggle */} - - { setMode('cost'); setResult(null); }} - className={`flex-1 py-2 px-3 rounded text-sm font-medium transition-colors ${ - mode === 'cost' - ? 'bg-brand-teal text-white shadow-sm' - : 'text-text-secondary hover:text-text-primary' - }`} - > - Cost per Pound - - { setMode('weight'); setResult(null); }} - className={`flex-1 py-2 px-3 rounded text-sm font-medium transition-colors ${ - mode === 'weight' - ? 'bg-brand-teal text-white shadow-sm' - : 'text-text-secondary hover:text-text-primary' - }`} - > - Required Input Weight - + + {[ + { id: 'cost', label: 'Cost per pound' }, + { id: 'weight', label: 'Pounds to buy' }, + ].map(({ id, label }) => ( + { setMode(id); setResult(null); }} + className={`min-h-[2.75rem] flex-1 rounded-lg px-2 text-sm font-semibold transition-colors sm:px-3 sm:text-base ${ + mode === id + ? 'bg-primary text-white shadow-sm' + : 'text-text-secondary hover:text-text-primary' + }`} + > + {label} + + ))} - - {/* Species */} - - Species - {dataLoading ? ( - Loading species data… - ) : ( - - Select a species - {speciesList.map(s => {s})} - - )} - {scientificName && ( - {scientificName} - )} - - - {/* From / To */} - - - - From State - - - - - - {species ? 'Select starting form' : '— select species first —'} - {fromStates.map(f => {f})} - - + + {/* Step 1: the fish */} + + Your fish - - To Product - - - - - - {fromState ? 'Select product form' : '— select from state first —'} - {toStates.map(t => ( - - {t.to} ({t.yield}%{t.range ? `, ${t.range[0]}–${t.range[1]}%` : ''}) - - ))} - - - - - {/* Conversion info */} - {currentConversion && ( - - - - Conversion Details - - - - Conversion: - - - - - - Avg yield: - {currentConversion.yield}% - - {currentConversion.range && ( - - Range: - {currentConversion.range[0]}–{currentConversion.range[1]}% - - )} - - - {currentConversion.range && ( - - { setUseRangeMin(true); setUseRangeMax(false); }} - label={`Min (${currentConversion.range[0]}%)`} - /> - { setUseRangeMin(false); setUseRangeMax(false); }} - label={`Avg (${currentConversion.yield}%)`} - /> - { setUseRangeMax(true); setUseRangeMin(false); }} - label={`Max (${currentConversion.range[1]}%)`} - /> - - )} - - )} - - {/* Species profile */} - {profile && ( - - {profile.description && {profile.description}} - {profile.edible_portions && ( - Edible portions: {profile.edible_portions} + Species + {dataLoading ? ( + Loading species data… + ) : ( + + Choose a species + {speciesList.map(s => {s})} + )} - {profile.url && ( - - Read more → - + {scientificName && ( + {scientificName} )} - )} - - - {/* Cost/weight + yield row */} - - {mode === 'cost' ? ( + - - Cost per lb ({fromState || 'whole fish'}) - - - $ - setCost(e.target.value)} - className="form-input pl-7" - placeholder="0.00" - inputMode="decimal" - /> + + What you have + + + + + {species ? 'Choose starting form' : 'Choose a species first'} + {fromStates.map(f => {f})} + - ) : ( + - - Target output (lbs of {toState || 'product'}) - - - setTargetWeight(e.target.value)} - className="form-input pr-10" - placeholder="e.g. 100" - inputMode="decimal" - /> - lbs + + What you're making + + + + + + {fromState ? 'Choose finished product' : 'Choose what you have first'} + {toStates.map(t => ( + + {t.to} ({t.yield}%{t.range ? `, ${t.range[0]}–${t.range[1]}%` : ''}) + + ))} + + + + + {/* Conversion info */} + {currentConversion && ( + + + + Yield for this cut + + + + + + + + Average yield + {currentConversion.yield}% + + {currentConversion.range && ( + + Typical range + {currentConversion.range[0]}–{currentConversion.range[1]}% + + )} + + + {currentConversion.range && ( + + + Yield changes with fish size and cutting skill. Pick what fits your shop: + + + { setUseRangeMin(true); setUseRangeMax(false); }} + label={`Low ${currentConversion.range[0]}%`} + /> + { setUseRangeMin(false); setUseRangeMax(false); }} + label={`Average ${currentConversion.yield}%`} + /> + { setUseRangeMax(true); setUseRangeMin(false); }} + label={`High ${currentConversion.range[1]}%`} + /> + + + )} )} - - Yield percentage - - { setYieldPercent(e.target.value); setUseRangeMin(false); setUseRangeMax(false); }} - className="form-input pr-8" - placeholder="0" - inputMode="decimal" - /> - % + {/* Species profile */} + {profile && ( + + {profile.description && {profile.description}} + {profile.edible_portions && ( + Edible portions: {profile.edible_portions} + )} + {profile.url && ( + + Read more → + + )} - {yieldRange && ( - - Reported range: {yieldRange[0]}–{yieldRange[1]}% - + )} + + + + + {/* Step 2: the numbers */} + + Your numbers + + + {mode === 'cost' ? ( + + + What you pay per lb + ({fromState || 'whole fish'}) + + + $ + setCost(e.target.value)} + className="form-input pl-8 tabular-nums" + placeholder="0.00" + inputMode="decimal" + /> + + + ) : ( + + + Pounds of {toState || 'finished product'} you need + + + setTargetWeight(e.target.value)} + className="form-input pr-12 tabular-nums" + placeholder="e.g. 100" + inputMode="decimal" + /> + lbs + + )} - - - {/* Additional costs (cost mode only) */} - {mode === 'cost' && ( - - Processing cost (optional) + Yield - $ setProcessingCost(e.target.value)} - className="form-input pl-7" - placeholder="0.00" + min="0" + step="any" + value={yieldPercent} + onChange={(e) => { setYieldPercent(e.target.value); setUseRangeMin(false); setUseRangeMax(false); }} + className="form-input pr-10 tabular-nums" + placeholder="0" inputMode="decimal" + aria-describedby={yieldRange ? 'calc-yield-hint' : undefined} /> + % + {yieldRange && ( + + Typical range: {yieldRange[0]}–{yieldRange[1]}% + + )} + - - Applied to weight - setWeightType(e.target.value)} - className="form-select" - > - Incoming ({fromState || 'whole'}) - Outgoing ({toState || 'product'}) - + {/* Additional costs (cost mode only) */} + {mode === 'cost' && ( + + + + Processing cost per lb + (optional) + + + $ + setProcessingCost(e.target.value)} + className="form-input pl-8 tabular-nums" + placeholder="0.00" + inputMode="decimal" + /> + + + + {/* Only ask which weight it applies to once there is a processing cost */} + {processingCost !== '' && ( + + Processing is charged per lb of + setWeightType(e.target.value)} + className="form-select" + > + Starting fish ({fromState || 'whole'}) + Finished product ({toState || 'product'}) + + + )} - - )} + )} + {/* Calculate */} - - {mode === 'cost' ? 'Calculate Cost per Pound' : 'Calculate Required Input Weight'} - - - {/* Result */} - {result !== null && ( - - - {mode === 'cost' ? `Cost per lb — ${toState}` : `Required ${fromState} input`} - - - {mode === 'cost' ? `$${result.toFixed(2)}` : `${result.toFixed(1)} lbs`} - - - {mode === 'cost' - ? `At ${yieldPercent}% yield from ${fromState} to ${toState}` - : `${result.toFixed(1)} lbs ${fromState} needed to yield ${targetWeight} lbs of ${toState}` - } + + + + {mode === 'cost' ? 'Calculate cost per pound' : 'Calculate pounds to buy'} + + {!canCalculate && ( + + Choose a species, what you have, and what you're making to get started. - - {user ? ( - - - Save calculation - - - Export CSV - - - Export Excel - - {saveStatus && ( - {saveStatus} - )} - - ) : ( - - Sign in to save calculations - + )} + + + + {/* Result: the live region stays mounted (so the first result is announced) and holds only the + answer. Actions and save status live outside it to avoid duplicate announcements. */} + + + + {result !== null && ( + <> + + {mode === 'cost' ? `Your cost per lb of ${toState}` : `You need to buy (${fromState})`} + + + {mode === 'cost' ? `$${result.toFixed(2)}` : `${result.toFixed(1)} lbs`} + + + {mode === 'cost' + ? `At ${resultMeta?.yieldPercent}% yield from ${fromState} to ${toState}` + : `${result.toFixed(1)} lbs of ${fromState} makes ${resultMeta?.targetWeight} lbs of ${toState}` + } + + > )} - )} + + {result !== null && (user ? ( + + + Save calculation + + + CSV + + + Excel + + + {saveStatus} + + + ) : ( + + Want to keep this? Sign in to save your calculations. + + ))} + - {/* Acronym reference */} - - - - Acronym Reference - - - {Object.entries(ACRONYMS).slice(0, 9).map(([abbr, full]) => ( - - {abbr} - {full.split(' - ')[0]} + {/* Abbreviation reference (collapsed: regulars know these, newcomers can open it) */} + + + + What do D/H-On, S/B and Round mean? + + + + {Object.entries(ACRONYMS).map(([abbr, full]) => ( + + {abbr} + {full.split(' - ')[0]} ))} - - + + {/* Community recent calculations */} {publicHistory.length > 0 && ( - - - - Recent Community Calculations - - + + + + Recent community calculations + + {publicHistory.slice(0, 8).map((calc) => ( - + - {calc.name || calc.species} - {calc.product} · {calc.yield}% yield + {calc.name || calc.species} + {calc.product} · {calc.yield}% yield - - + + ${parseFloat(calc.result).toFixed(2)}/lb - + {new Date(calc.date).toLocaleDateString()} - + ))} - + {!user && ( - - Sign in to save your own calculations + + Sign in to save your own calculations )} - + )} ); diff --git a/app/src/components/CommunityData.jsx b/app/src/components/CommunityData.jsx index 240629a..93adb24 100644 --- a/app/src/components/CommunityData.jsx +++ b/app/src/components/CommunityData.jsx @@ -57,8 +57,8 @@ const CommunityData = () => { - - + + Community Data Pool @@ -75,7 +75,7 @@ const CommunityData = () => { Download CSV {downloadError && ( - {downloadError} + {downloadError} )} @@ -101,7 +101,7 @@ const CommunityData = () => { - Shared Yield Data + Shared Yield Data {search && ( {filtered.length} of {data.length} results )} @@ -134,7 +134,7 @@ const CommunityData = () => { > {row.species} {row.product} - {row.yield}% + {row.yield}% {row.contributor || 'Anonymous'} {row.organization && ( @@ -152,7 +152,7 @@ const CommunityData = () => { Want to contribute?{' '} - Log in, + Log in, add yield data in My Data, then toggle the share icon. diff --git a/app/src/components/ContributorProfile.jsx b/app/src/components/ContributorProfile.jsx index 80d13db..7c3117c 100644 --- a/app/src/components/ContributorProfile.jsx +++ b/app/src/components/ContributorProfile.jsx @@ -96,11 +96,11 @@ const ContributorProfile = () => { - Login Required + Login Required You need to be logged in to create a contributor profile. - + Go to Login @@ -110,8 +110,8 @@ const ContributorProfile = () => { return ( - - + + Contributor Profile diff --git a/app/src/components/DataManagement.jsx b/app/src/components/DataManagement.jsx index 696d3c7..1acffbf 100644 --- a/app/src/components/DataManagement.jsx +++ b/app/src/components/DataManagement.jsx @@ -99,7 +99,7 @@ const DataManagement = () => { - Login Required + Login Required You need to be logged in to manage your custom yield data. @@ -114,13 +114,13 @@ const DataManagement = () => { - - + + Manage Your Data Add, edit, or delete your custom yield data •{' '} - Edit Contributor Profile + Edit Contributor Profile @@ -165,7 +165,7 @@ const DataManagement = () => { {/* Add/Edit Form */} {showForm && ( - + {editingId ? 'Edit Entry' : 'Add New Entry'} @@ -246,7 +246,7 @@ const DataManagement = () => { {/* Saved Calculations */} - Saved Calculations + Saved Calculations Publish a calculation to share it anonymously on the community feed. @@ -267,7 +267,7 @@ const DataManagement = () => { {calc.name || `${calc.species} — ${calc.product}`} {calc.is_private === false ? ( - + Public ) : ( @@ -277,7 +277,7 @@ const DataManagement = () => { )} - {calc.species} · {calc.product} → ${Number(calc.result).toFixed(2)}/lb + {calc.species} · {calc.product} → ${Number(calc.result).toFixed(2)}/lb @@ -294,7 +294,7 @@ const DataManagement = () => { ) : ( requestPublish(calc)} - className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded border border-brand-teal/40 text-brand-teal hover:bg-brand-teal/10 transition" + className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded border border-brand-teal/40 text-accent hover:bg-brand-teal/10 transition" title="Publish to community feed" > @@ -312,7 +312,7 @@ const DataManagement = () => { {/* Data List */} - Your Custom Data + Your Custom Data {!dataLoaded ? ( @@ -330,7 +330,7 @@ const DataManagement = () => { {item.species} {item.is_shared ? ( - Shared + Shared ) : null} {item.syncStatus === 'local' || item.syncStatus === 'pending-delete' ? ( @@ -339,7 +339,7 @@ const DataManagement = () => { ) : null} - {item.product} → {item.yield}% + {item.product} → {item.yield}% {item.source && ({item.source})} @@ -347,7 +347,7 @@ const DataManagement = () => { {item.serverId && ( handleToggleShare(item)} - className={`p-2 rounded transition ${item.is_shared ? 'text-brand-teal hover:text-text-secondary' : 'text-text-secondary hover:text-brand-teal'}`} + className={`p-2 rounded transition ${item.is_shared ? 'text-accent hover:text-text-secondary' : 'text-text-secondary hover:text-accent'}`} title={item.is_shared ? 'Remove from community' : 'Share with community'} > {item.is_shared ? : } @@ -355,14 +355,14 @@ const DataManagement = () => { )} handleEdit(item)} - className="p-2 rounded text-text-secondary hover:text-brand-teal transition" + className="p-2 rounded text-text-secondary hover:text-accent transition" title="Edit" > handleDelete(item.id)} - className="p-2 rounded text-text-secondary hover:text-red-500 transition" + className="p-2 rounded text-text-secondary hover:text-danger transition" title="Delete" > diff --git a/app/src/components/DataTransparency.jsx b/app/src/components/DataTransparency.jsx index 6a41fec..9060bb5 100644 --- a/app/src/components/DataTransparency.jsx +++ b/app/src/components/DataTransparency.jsx @@ -22,7 +22,7 @@ const DataTransparency = () => { - Data Sources & Methodology + Data Sources & Methodology Transparency in data sourcing for accurate yield calculations @@ -31,14 +31,14 @@ const DataTransparency = () => { {/* Primary Source */} - - Primary Data Source + + Primary Data Source {DATA_SOURCE.title} - Publication: {DATA_SOURCE.publication} + Publication: {DATA_SOURCE.publication} @@ -74,23 +74,23 @@ const DataTransparency = () => { {/* Methodology */} - - Methodology Notes + + Methodology Notes - Average Yields + Average Yields Yields represent high quality, properly handled fresh fish and shellfish in good physiological condition. If fish condition is abnormal (post-spawning or starving state), actual yields may differ. - Yield Ranges + Yield Ranges Ranges represent typical variations found within fish populations during the year. Many factors including handling, processing conditions, filleting skills, and refrigeration affect actual yields. - Smoked Products + Smoked Products Smoked fish yields were calculated using an average 15% weight loss during salting/brining and 10% during the smoking process. @@ -98,14 +98,14 @@ const DataTransparency = () => { {/* Acronym Glossary */} - - A–Z Acronym Glossary + + A–Z Acronym Glossary {Object.entries(ACRONYMS).map(([abbr, definition]) => ( - {abbr} + {abbr} {definition} ))} @@ -117,7 +117,7 @@ const DataTransparency = () => { - Data Quality Notes + Data Quality Notes @@ -129,7 +129,7 @@ const DataTransparency = () => { {item.species} - {item.field}: {item.note} + {item.field}: {item.note} ))} @@ -140,8 +140,8 @@ const DataTransparency = () => { {/* User Data */} - - User-Contributed Data + + User-Contributed Data @@ -150,15 +150,15 @@ const DataTransparency = () => { - • + • Displayed separately from source data in the calculator - • + • Associated with the user's account - • + • Can be edited or deleted by the contributing user @@ -169,7 +169,7 @@ const DataTransparency = () => { - Community Contributors + Community Contributors @@ -188,7 +188,7 @@ const DataTransparency = () => { {contributor.display_name} {contributor.organization && ( - + {contributor.organization} )} @@ -199,7 +199,7 @@ const DataTransparency = () => { )} - + {contributor.contribution_count} diff --git a/app/src/components/Login.jsx b/app/src/components/Login.jsx index 7a63583..999293a 100644 --- a/app/src/components/Login.jsx +++ b/app/src/components/Login.jsx @@ -92,9 +92,9 @@ const Login = () => { return ( - Check your email + Check your email {verificationFailed ? ( - + Your account was created but we couldn't send the verification email to {email}. Use the button below to try again. ) : ( @@ -102,7 +102,7 @@ const Login = () => { We sent a verification link to {email}. Click it to activate your account, then sign in. )} - {error && {error}} + {error && {error}} { { setVerificationSent(false); setVerificationFailed(false); setIsRegister(false); setError(''); }} - className="text-brand-terracotta hover:underline text-sm" + className="text-link hover:underline text-sm" > Back to sign in @@ -123,7 +123,7 @@ const Login = () => { return ( - + {isRegister ? 'Create Account' : 'Welcome Back'} @@ -133,10 +133,11 @@ const Login = () => { {isRegister ? 'Email' : 'Email or Username'} - + setEmail(e.target.value)} className="form-input pl-10" @@ -146,11 +147,13 @@ const Login = () => { - Password + Password - + setPassword(e.target.value)} className="form-input pl-10" @@ -159,7 +162,7 @@ const Login = () => { - {error && {error}} + {error && {error}} {isRegister ? 'Sign Up' : 'Sign In'} @@ -193,7 +196,7 @@ const Login = () => { { setIsRegister(!isRegister); setError(''); setVerificationSent(false); setVerificationFailed(false); }} - className="text-text-secondary hover:text-brand-terracotta text-sm transition" + className="text-text-secondary hover:text-link text-sm transition" > {isRegister ? "Already have an account? Sign In" : "Need an account? Sign Up"} diff --git a/app/src/components/PreviewPublishModal.jsx b/app/src/components/PreviewPublishModal.jsx index 1801d65..6337cdf 100644 --- a/app/src/components/PreviewPublishModal.jsx +++ b/app/src/components/PreviewPublishModal.jsx @@ -62,7 +62,7 @@ export default function PreviewPublishModal({ calc, loading, onConfirm, onCancel className="bg-surface border border-border rounded-xl shadow-xl p-6 max-w-sm w-full mx-4" > - + Publish calculation diff --git a/app/src/components/RecoveryModal.jsx b/app/src/components/RecoveryModal.jsx index 58eaaf7..cfc24ab 100644 --- a/app/src/components/RecoveryModal.jsx +++ b/app/src/components/RecoveryModal.jsx @@ -30,7 +30,7 @@ export default function RecoveryModal({ calcs, yields, isAuthenticated, assignin > - + Recovered data from a previous session @@ -63,7 +63,7 @@ export default function RecoveryModal({ calcs, yields, isAuthenticated, assignin Discard @@ -92,7 +92,7 @@ export default function RecoveryModal({ calcs, yields, isAuthenticated, assignin Discard diff --git a/app/src/components/SignOutGuardModal.jsx b/app/src/components/SignOutGuardModal.jsx index 8782dcd..c6a2140 100644 --- a/app/src/components/SignOutGuardModal.jsx +++ b/app/src/components/SignOutGuardModal.jsx @@ -79,7 +79,7 @@ export default function SignOutGuardModal({ calcs, yields, onKeep, onDiscard, on Discard unsaved data and sign out diff --git a/app/src/components/SubmitRequest.jsx b/app/src/components/SubmitRequest.jsx index 557f9ba..20debb4 100644 --- a/app/src/components/SubmitRequest.jsx +++ b/app/src/components/SubmitRequest.jsx @@ -169,7 +169,7 @@ const SubmitRequest = () => { What type of request is this? {errors.type && ( - + {errors.type} )} @@ -241,7 +241,7 @@ const SubmitRequest = () => { > Title * {errors.title && ( - {errors.title} + {errors.title} )} { > Description * {errors.description && ( - {errors.description} + {errors.description} )} { > Your Email (optional) {errors.email && ( - {errors.email} + {errors.email} )} 0 ? ` (${pendingCount} pending)` : ''}`} aria-label={`Sync status: ${label}`} > diff --git a/app/src/components/UploadData.jsx b/app/src/components/UploadData.jsx index 8e31c4c..209620d 100644 --- a/app/src/components/UploadData.jsx +++ b/app/src/components/UploadData.jsx @@ -96,7 +96,7 @@ const UploadData = () => { - Login Required + Login Required You need to be logged in to upload your own yield data. This allows you to customize the calculator with your specific experience. @@ -112,13 +112,13 @@ const UploadData = () => { - - + + Upload Yield Data @@ -159,7 +159,7 @@ const UploadData = () => { /> {file ? ( - + {file.name} {formatBytes(file.size)} @@ -173,7 +173,7 @@ const UploadData = () => { ) : ( - + {dragOver ? 'Drop to upload' : 'Drag & drop or click to select'} @@ -196,13 +196,13 @@ const UploadData = () => { {status.data.inserted > 0 && ( - {status.data.inserted} + {status.data.inserted} added )} {status.data.updated > 0 && ( - {status.data.updated} + {status.data.updated} updated )} @@ -223,7 +223,7 @@ const UploadData = () => { )} View your data diff --git a/app/src/index.css b/app/src/index.css index 42551ae..7807028 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -2,63 +2,131 @@ @tailwind components; @tailwind utilities; +/* + * Design tokens. + * Text pairs are checked against WCAG AA (4.5:1) and form-control edges against + * 3:1 by src/lib/__tests__/designTokens.test.js — change a value here and that + * test will tell you if it stops being readable in sunlight or a dim kitchen. + */ :root { + color-scheme: light; --color-surface: #f5f0eb; --color-surface-raised: #ffffff; --color-text-primary: #1a2e35; --color-text-secondary: #4a6572; - --color-text-muted: #7a9aaa; + --color-text-muted: #557080; --color-border: #d6ccc4; --color-border-subtle: #ede7e0; + --color-border-strong: #857a70; + --color-primary: #014457; + --color-primary-hover: #025a72; --color-accent: #014457; - --color-accent-warm: #CA5F40; + --color-link: #a34329; + --color-focus: #014457; + --color-success: #166534; + --color-danger: #b42318; } .dark { + color-scheme: dark; --color-surface: #0d1f26; --color-surface-raised: #132830; --color-text-primary: #e8ddd4; --color-text-secondary: #8fa8b2; - --color-text-muted: #5d7f8c; + --color-text-muted: #7f9aa6; --color-border: rgba(255, 255, 255, 0.12); --color-border-subtle: rgba(255, 255, 255, 0.06); - --color-accent: #025a72; - --color-accent-warm: #d97356; + --color-border-strong: #6f8b96; + --color-primary: #0b7a99; + --color-primary-hover: #096a86; + --color-accent: #7fd0e3; + --color-link: #ec8f72; + --color-focus: #f7c648; + --color-success: #4ade80; + --color-danger: #fca5a5; } @layer base { + html { + -webkit-text-size-adjust: 100%; + } + body { font-feature-settings: "cv11", "ss01"; -webkit-font-smoothing: antialiased; + -webkit-tap-highlight-color: transparent; + line-height: 1.6; + } + + /* One clear, high-contrast focus indicator for keyboard and switch users */ + :focus-visible { + outline: 3px solid var(--color-focus); + outline-offset: 2px; + } + + /* The navbar is teal in both themes, so its focus ring must be light */ + .focus-on-dark :focus-visible { + outline-color: #f7c648; + } + + /* ...but popovers rendered inside the navbar (sync details) sit on a normal surface */ + .focus-on-dark [role="dialog"] :focus-visible { + outline-color: var(--color-focus); + } + + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } } } @layer components { + /* Buttons: 48px tall so they're easy to hit with wet or gloved hands */ + .btn-primary, + .btn-secondary { + @apply inline-flex min-h-[3rem] items-center justify-center gap-2 rounded-lg px-5 py-3 text-base font-semibold + transition-colors active:translate-y-px disabled:cursor-not-allowed disabled:opacity-60; + } + .btn-primary { - @apply bg-brand-teal hover:bg-brand-teal-light text-white font-semibold py-3 px-5 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed; + @apply bg-primary text-white hover:bg-primary-hover; + } + + .btn-secondary { + @apply border-2 border-line-strong bg-surface-raised text-text-primary hover:border-accent hover:text-accent; + } + + .btn-ghost { + @apply inline-flex min-h-[2.75rem] items-center gap-2 rounded-lg px-3 text-sm font-semibold text-accent + transition-colors hover:underline; } .card { - @apply bg-surface-raised rounded-lg border border-line shadow-sm; + @apply rounded-xl border border-line bg-surface-raised shadow-sm; } .form-label { - @apply block text-sm font-medium mb-1.5 text-text-secondary; + @apply mb-1.5 block text-sm font-semibold text-text-primary; } - .form-input { - @apply w-full bg-surface border border-line rounded-md px-3 py-2.5 text-sm text-text-primary - placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-brand-teal focus:border-transparent - disabled:opacity-50 disabled:cursor-not-allowed; + /* 16px text stops iOS from zooming the page when a field is focused */ + .form-input, + .form-select { + @apply min-h-[3rem] w-full rounded-lg border border-line-strong bg-surface px-3.5 py-2.5 text-base text-text-primary + disabled:cursor-not-allowed disabled:opacity-60; } - .form-select { - @apply w-full bg-surface border border-line rounded-md px-3 py-2.5 text-sm text-text-primary - focus:outline-none focus:ring-2 focus:ring-brand-teal focus:border-transparent - disabled:opacity-50 disabled:cursor-not-allowed; + .form-input { + @apply placeholder-text-muted; } .section-divider { - @apply border-t border-line-subtle my-5; + @apply my-6 border-t border-line-subtle; } } diff --git a/app/src/lib/__tests__/designTokens.test.js b/app/src/lib/__tests__/designTokens.test.js new file mode 100644 index 0000000..d8d78e6 --- /dev/null +++ b/app/src/lib/__tests__/designTokens.test.js @@ -0,0 +1,78 @@ +/** + * Guards the design tokens in src/index.css against readability regressions. + * Users read this on a dock in glare or in a dim kitchen, so text must meet + * WCAG AA (4.5:1) and form-control edges / focus rings must meet 3:1. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import tailwindConfig from '../../../tailwind.config.js'; + +const css = readFileSync(fileURLToPath(new URL('../../index.css', import.meta.url)), 'utf8'); + +function readTokens(selector) { + const block = css.match(new RegExp(`${selector}\\s*\\{([^}]*)\\}`))?.[1] ?? ''; + const tokens = {}; + for (const [, name, value] of block.matchAll(/--color-([\w-]+):\s*(#[0-9a-fA-F]{6})\s*;/g)) { + tokens[name] = value; + } + return tokens; +} + +function luminance(hex) { + const [r, g, b] = [1, 3, 5] + .map((i) => parseInt(hex.slice(i, i + 2), 16) / 255) + .map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +function contrast(a, b) { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +const themes = { light: readTokens(':root'), dark: readTokens('\\.dark') }; + +describe.each(Object.entries(themes))('%s theme tokens', (_theme, t) => { + const backgrounds = ['surface', 'surface-raised']; + + it('defines every token the tests rely on', () => { + for (const name of [ + ...backgrounds, 'text-primary', 'text-secondary', 'text-muted', 'border-strong', + 'primary', 'primary-hover', 'accent', 'link', 'focus', 'success', 'danger', + ]) { + expect(t[name], `--color-${name}`).toMatch(/^#[0-9a-f]{6}$/i); + } + }); + + it.each(['text-primary', 'text-secondary', 'text-muted', 'accent', 'link', 'success', 'danger'])( + '%s text is at least 4.5:1 on both surfaces', + (name) => { + for (const bg of backgrounds) { + expect(contrast(t[name], t[bg]), `${name} on ${bg}`).toBeGreaterThanOrEqual(4.5); + } + }, + ); + + it.each(['border-strong', 'focus'])('%s is at least 3:1 on both surfaces', (name) => { + for (const bg of backgrounds) { + expect(contrast(t[name], t[bg]), `${name} on ${bg}`).toBeGreaterThanOrEqual(3); + } + }); + + it.each(['primary', 'primary-hover'])('white button text on %s is at least 4.5:1', (name) => { + expect(contrast('#ffffff', t[name])).toBeGreaterThanOrEqual(4.5); + }); +}); + +describe('fixed brand colors', () => { + const { brand } = tailwindConfig.theme.extend.colors; + + it.each(['cta', 'cta-hover'])('white text on brand.%s is at least 4.5:1', (name) => { + expect(contrast('#ffffff', brand[name])).toBeGreaterThanOrEqual(4.5); + }); + + it('keeps yellow readable on the teal navbar', () => { + expect(contrast(brand.yellow, brand.teal)).toBeGreaterThanOrEqual(4.5); + }); +}); diff --git a/app/tailwind.config.js b/app/tailwind.config.js index 8df9a47..6d1b76e 100644 --- a/app/tailwind.config.js +++ b/app/tailwind.config.js @@ -8,9 +8,10 @@ export default { theme: { extend: { fontFamily: { - sans: ['Inter', 'system-ui', 'sans-serif'], + sans: ['Inter', 'ui-sans-serif', 'system-ui', '-apple-system', 'Segoe UI', 'Roboto', 'sans-serif'], }, colors: { + // Theme-aware tokens (values live in index.css so light/dark stay in one place) surface: 'var(--color-surface)', 'surface-raised': 'var(--color-surface-raised)', 'text-primary': 'var(--color-text-primary)', @@ -18,11 +19,23 @@ export default { 'text-muted': 'var(--color-text-muted)', line: 'var(--color-border)', 'line-subtle': 'var(--color-border-subtle)', + 'line-strong': 'var(--color-border-strong)', // form-control edges (3:1 against the surface) + primary: { + DEFAULT: 'var(--color-primary)', + hover: 'var(--color-primary-hover)', + }, + accent: 'var(--color-accent)', // teal used as text/icons; lightens in dark mode + link: 'var(--color-link)', // terracotta used as text; AA-contrast in both themes + success: 'var(--color-success)', + danger: 'var(--color-danger)', + // Fixed brand colors, for fills/decoration (not for body-size text) brand: { teal: '#014457', 'teal-light': '#025a72', terracotta: '#CA5F40', 'terracotta-light': '#d97356', + cta: '#B04A2E', // terracotta that keeps white text at AA (5.4:1) + 'cta-hover': '#963D24', yellow: '#F7C648', }, }, diff --git a/app/vite.config.js b/app/vite.config.js index 8c1d702..c57632f 100644 --- a/app/vite.config.js +++ b/app/vite.config.js @@ -32,8 +32,8 @@ export default defineConfig({ name: 'Fish Cost Calculator', short_name: 'Fish Calc', description: 'Calculate true costs of fish processing with yield data from Alaska Sea Grant research', - theme_color: '#0A2540', - background_color: '#0A2540', + theme_color: '#014457', + background_color: '#014457', display: 'standalone', scope: '/', start_url: '/', diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md new file mode 100644 index 0000000..ddd7100 --- /dev/null +++ b/docs/DESIGN_SYSTEM.md @@ -0,0 +1,58 @@ +# Design system notes + +Supersedes the navy/rust direction in `docs/superpowers/specs/2026-03-27-branding-redesign-design.md` +(replaced by the teal/terracotta/yellow brand in July 2026). + +## Who this is for + +Fishers, chefs and fishmongers who use apps every day but are busy: on a dock in glare, in a +cold-room, in a kitchen mid-service, often on a phone, often with wet or gloved hands. So: + +- **Few steps, plain words.** Two numbered steps ("Your fish", "Your numbers"), one primary action, + no jargon in labels (help text explains the industry terms instead). +- **Big targets.** Buttons and form controls are at least 48px tall; other tappable things at least 44px. +- **Readable in any light.** Text is at least 4.5:1 and form-control edges at least 3:1, in both themes. +- **Nothing hover-only.** Help works on tap, keyboard focus and hover. + +## Color tokens + +Values live in `app/src/index.css` (`:root` = light, `.dark` = dark) and are exposed as Tailwind +colors in `app/tailwind.config.js`. + +| Use this | For | Not this | +|---|---|---| +| `text-text-primary` / `-secondary` / `-muted` | Body and helper text | `text-gray-*` | +| `text-accent` | Teal used as text or icons (headings, key numbers) | `text-brand-teal` (about 2:1 in dark mode) | +| `text-link` | Terracotta used as text (links, small accents) | `text-brand-terracotta` (under 4.5:1 on light) | +| `bg-primary` / `hover:bg-primary-hover` | Primary buttons (`.btn-primary`) | `bg-brand-teal` in dark mode | +| `border-line-strong` | Edges of inputs, selects, toggles | `border-line` (1.5:1, decoration only) | +| `text-success` / `text-danger` | Status text | `text-red-400`, `text-green-300` (fail on light) | +| `bg-brand-cta` | Terracotta fill with white text | `bg-brand-terracotta` + white text (4.0:1) | +| `bg-brand-*` (teal, yellow, terracotta) | Fixed brand fills and decoration | body-size text | + +Shared classes: `.btn-primary`, `.btn-secondary`, `.btn-ghost`, `.card`, `.form-label`, +`.form-input`, `.form-select`, `.section-divider`. Inputs are 16px text so iOS does not zoom on focus. + +## Guardrails + +- `app/src/lib/__tests__/designTokens.test.js` fails if a token pair drops below its contrast target. + Change a color, run `npm test`. +- Keyboard focus uses one 3px outline (`:focus-visible`), yellow on dark surfaces and in the navbar. +- `prefers-reduced-motion` is honored globally. + +## Known follow-ups + +- **Reference sites not analyzed.** The redesign request named Grace Communications, namanet.org, + the National Family Farm Coalition and localcatch.org, but this environment's network policy blocked + them, so nothing here is derived from those sites. Palette and typeface are unchanged from July 2026. + Tokens are centralized, so a palette or font pass is a change to `index.css`, `tailwind.config.js` + and the font link in `index.html`. +- **Dark-only status styling** (pale `text-red-300` / `text-green-300` on translucent dark boxes, or + `text-amber-*`) is unreadable in light mode. Move these to `text-danger` / `text-success` and + theme-aware backgrounds: `UploadData`, `SubmitRequest`, `ConflictResolutionModal`, `RecoveryModal`, + `DataManagement`, `ContributorProfile`, `CommunityData`, `FeaturesRoadmap`. +- **Unused components** `Footer.jsx` and `InstallPrompt.jsx` are not rendered anywhere and use tokens + that no longer exist (`bg-navy`, `text-teal`, `bg-rust`). Restyle before wiring them in, or delete. +- **Offline behavior.** `Calculator` seeds its data from `FISH_DATA_V3`, whose conversions have no + `from`/`to` fields (they are derived server-side by `/api/fish-data`). If that request fails, the + "What you have" list is empty and the calculator cannot be used, which matters on a boat with poor signal.
- Estimate processed cost per pound from raw input cost and yield data. + + Fish Cost Calculator + + See what your fish really costs per pound after cutting and trimming. -
+ See what your fish really costs per pound after cutting and trimming.
{scientificName}
{profile.description}
Edible portions: {profile.edible_portions}
+ Yield changes with fish size and cutting skill. Pick what fits your shop: +
- Reported range: {yieldRange[0]}–{yieldRange[1]}% -
+ Typical range: {yieldRange[0]}–{yieldRange[1]}% +
- {mode === 'cost' ? `Cost per lb — ${toState}` : `Required ${fromState} input`} -
- {mode === 'cost' ? `$${result.toFixed(2)}` : `${result.toFixed(1)} lbs`} -
- {mode === 'cost' - ? `At ${yieldPercent}% yield from ${fromState} to ${toState}` - : `${result.toFixed(1)} lbs ${fromState} needed to yield ${targetWeight} lbs of ${toState}` - } +
+ Choose a species, what you have, and what you're making to get started.
- Sign in to save calculations -
+ {mode === 'cost' ? `Your cost per lb of ${toState}` : `You need to buy (${fromState})`} +
+ {mode === 'cost' ? `$${result.toFixed(2)}` : `${result.toFixed(1)} lbs`} +
+ {mode === 'cost' + ? `At ${resultMeta?.yieldPercent}% yield from ${fromState} to ${toState}` + : `${result.toFixed(1)} lbs of ${fromState} makes ${resultMeta?.targetWeight} lbs of ${toState}` + } +
+ Want to keep this? Sign in to save your calculations. +
{calc.name || calc.species}
{calc.product} · {calc.yield}% yield
+
${parseFloat(calc.result).toFixed(2)}/lb
{new Date(calc.date).toLocaleDateString()}
- Sign in to save your own calculations +
+ Sign in to save your own calculations
@@ -75,7 +75,7 @@ const CommunityData = () => { Download CSV {downloadError && ( -
{downloadError}
Want to contribute?{' '} - Log in, + Log in, add yield data in My Data, then toggle the share icon.
You need to be logged in to create a contributor profile.
diff --git a/app/src/components/DataManagement.jsx b/app/src/components/DataManagement.jsx index 696d3c7..1acffbf 100644 --- a/app/src/components/DataManagement.jsx +++ b/app/src/components/DataManagement.jsx @@ -99,7 +99,7 @@ const DataManagement = () => {
You need to be logged in to manage your custom yield data.
Add, edit, or delete your custom yield data •{' '} - Edit Contributor Profile + Edit Contributor Profile
Publish a calculation to share it anonymously on the community feed.
{calc.name || `${calc.species} — ${calc.product}`} {calc.is_private === false ? ( - + Public ) : ( @@ -277,7 +277,7 @@ const DataManagement = () => { )}
- {calc.species} · {calc.product} → ${Number(calc.result).toFixed(2)}/lb + {calc.species} · {calc.product} → ${Number(calc.result).toFixed(2)}/lb
{item.species} {item.is_shared ? ( - Shared + Shared ) : null} {item.syncStatus === 'local' || item.syncStatus === 'pending-delete' ? ( @@ -339,7 +339,7 @@ const DataManagement = () => { ) : null}
- {item.product} → {item.yield}% + {item.product} → {item.yield}% {item.source && ({item.source})}
Transparency in data sourcing for accurate yield calculations
- Publication: {DATA_SOURCE.publication} + Publication: {DATA_SOURCE.publication}
Yields represent high quality, properly handled fresh fish and shellfish in good physiological condition. If fish condition is abnormal (post-spawning or starving state), actual yields may differ.
Ranges represent typical variations found within fish populations during the year. Many factors including handling, processing conditions, filleting skills, and refrigeration affect actual yields.
Smoked fish yields were calculated using an average 15% weight loss during salting/brining and 10% during the smoking process.
{definition}
@@ -129,7 +129,7 @@ const DataTransparency = () => {
{item.species}
- {item.field}: {item.note} + {item.field}: {item.note}
@@ -150,15 +150,15 @@ const DataTransparency = () => {
@@ -188,7 +188,7 @@ const DataTransparency = () => { {contributor.display_name} {contributor.organization && ( -
{contributor.organization}
{contributor.contribution_count}
diff --git a/app/src/components/Login.jsx b/app/src/components/Login.jsx index 7a63583..999293a 100644 --- a/app/src/components/Login.jsx +++ b/app/src/components/Login.jsx @@ -92,9 +92,9 @@ const Login = () => { return (
Your account was created but we couldn't send the verification email to {email}. Use the button below to try again.
{error}
You need to be logged in to upload your own yield data. This allows you to customize the calculator with your specific experience. @@ -112,13 +112,13 @@ const UploadData = () => {
{file.name}
{formatBytes(file.size)}
{dragOver ? 'Drop to upload' : 'Drag & drop or click to select'} @@ -196,13 +196,13 @@ const UploadData = () => {
{status.data.inserted}
added
{status.data.updated}
updated