diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87e803d..05f9794 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: react-dom: specifier: 19.2.7 version: 19.2.7(react@19.2.7) + xlsx: + specifier: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz + version: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz devDependencies: '@types/react': specifier: ^19.2.0 @@ -1759,6 +1762,12 @@ packages: utf-8-validate: optional: true + xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: + resolution: {integrity: sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==, tarball: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz} + version: 0.20.3 + engines: {node: '>=0.8'} + hasBin: true + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -3353,6 +3362,8 @@ snapshots: ws@8.21.0: {} + xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: {} + yallist@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/web/index.html b/web/index.html index 07e24dd..404887e 100644 --- a/web/index.html +++ b/web/index.html @@ -14,12 +14,18 @@ content="The whole pipeline in your browser: scrub, rinse, soak, steep. Cleaning runs locally; the AI phases are bring-your-own-key." /> - + + + + + + + diff --git a/web/package.json b/web/package.json index 6396f1d..9efa864 100644 --- a/web/package.json +++ b/web/package.json @@ -12,7 +12,8 @@ "@anthropic-ai/sdk": "^0.39.0", "datasink": "workspace:*", "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" }, "devDependencies": { "@types/react": "^19.2.0", diff --git a/web/public/og.png b/web/public/og.png new file mode 100644 index 0000000..e94218c Binary files /dev/null and b/web/public/og.png differ diff --git a/web/src/components/DropZone.tsx b/web/src/components/DropZone.tsx index c8a387e..e3ca1ab 100644 --- a/web/src/components/DropZone.tsx +++ b/web/src/components/DropZone.tsx @@ -17,6 +17,7 @@ export function DropZone({ const [urlValue, setUrlValue] = useState('') const [spotValue, setSpotValue] = useState('') const [urlError, setUrlError] = useState(null) + const [fileError, setFileError] = useState(null) const [fetching, setFetching] = useState(false) const fileInput = useRef(null) @@ -31,9 +32,26 @@ export function DropZone({ const readFile = useCallback( (file: File) => { + setFileError(null) const reader = new FileReader() - reader.onload = () => submitCsv(String(reader.result ?? '')) - reader.readAsText(file) + if (/\.xlsx?$/i.test(file.name)) { + // Excel — parse the first sheet to CSV, then run the normal pipeline. + reader.onload = async () => { + try { + const XLSX = await import('xlsx') + const wb = XLSX.read(reader.result, { type: 'array' }) + const sheet = wb.Sheets[wb.SheetNames[0]] + if (!sheet) throw new Error('empty workbook') + submitCsv(XLSX.utils.sheet_to_csv(sheet)) + } catch { + setFileError('Couldn’t read that spreadsheet — try exporting it as CSV.') + } + } + reader.readAsArrayBuffer(file) + } else { + reader.onload = () => submitCsv(String(reader.result ?? '')) + reader.readAsText(file) + } }, [submitCsv], ) @@ -99,7 +117,7 @@ export function DropZone({ { const file = e.target.files?.[0] @@ -108,9 +126,10 @@ export function DropZone({ />
-

Drop your contacts CSV here

+

Drop your contacts CSV or Excel file

click to browse, or paste straight from your spreadsheet

{error &&

{error}

} + {fileError &&

{fileError}

}
e.stopPropagation()}>