diff --git a/.changeset/stats-hero-and-ui-polish.md b/.changeset/stats-hero-and-ui-polish.md new file mode 100644 index 0000000..728afa1 --- /dev/null +++ b/.changeset/stats-hero-and-ui-polish.md @@ -0,0 +1,5 @@ +--- +"mpesa2csv": major +--- + +Success screen now shows a financial snapshot — date range covered, total money in, total money out, and charges — all derived from the parsed statement before you export. Window height reduced from 850 → 650px, footer simplified to a single line, and dead-zone layout issues resolved. diff --git a/.changeset/ux-success-state-simplification.md b/.changeset/ux-success-state-simplification.md new file mode 100644 index 0000000..8b6eebc --- /dev/null +++ b/.changeset/ux-success-state-simplification.md @@ -0,0 +1,5 @@ +--- +"mpesa2csv": patch +--- + +improved success state ui diff --git a/.github/workflows/main-release.yml b/.github/workflows/main-release.yml index c32e7d0..0eb2a9c 100644 --- a/.github/workflows/main-release.yml +++ b/.github/workflows/main-release.yml @@ -83,9 +83,15 @@ jobs: VALIDATION_PASSED="true" VALIDATION_ERRORS="" - if ! pnpm audit --audit-level moderate; then - VALIDATION_ERRORS="${VALIDATION_ERRORS}Security vulnerabilities found. " - VALIDATION_PASSED="false" + AUDIT_EXIT=0 + AUDIT_OUT=$(pnpm audit --audit-level moderate 2>&1) || AUDIT_EXIT=$? + if [ $AUDIT_EXIT -ne 0 ]; then + if echo "$AUDIT_OUT" | grep -qE "410|ERR_PNPM_AUDIT_BAD_RESPONSE|endpoint is being retired"; then + echo "⚠️ npm audit endpoint unavailable (retired July 2026). Skipping security check." + else + VALIDATION_ERRORS="${VALIDATION_ERRORS}Security vulnerabilities found. " + VALIDATION_PASSED="false" + fi fi VERSION=$(node -p "require('./package.json').version") diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 1516791..5e81424 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -190,12 +190,21 @@ jobs: if: matrix.test_type == 'security' run: | echo "🔒 Running security audit..." - pnpm audit --audit-level moderate || { - echo "⚠️ Security vulnerabilities found. Please review and fix." - echo "Run 'pnpm audit --fix' to automatically fix issues where possible." - exit 1 - } - echo "✅ No security vulnerabilities found" + AUDIT_EXIT=0 + AUDIT_OUT=$(pnpm audit --audit-level moderate 2>&1) || AUDIT_EXIT=$? + echo "$AUDIT_OUT" + if [ $AUDIT_EXIT -ne 0 ]; then + if echo "$AUDIT_OUT" | grep -qE "410|ERR_PNPM_AUDIT_BAD_RESPONSE|endpoint is being retired"; then + echo "⚠️ npm audit endpoint unavailable (retired July 2026). Skipping." + echo "💡 Dependabot alerts remain active for ongoing security coverage." + else + echo "⚠️ Security vulnerabilities found. Please review and fix." + echo "Run 'pnpm audit --fix' to automatically fix issues where possible." + exit 1 + fi + else + echo "✅ No security vulnerabilities found" + fi release-branch-validation: name: Release Branch Validation diff --git a/package.json b/package.json index 25566d8..d005bd6 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@base-ui/react": "^1.2.0", + "@number-flow/react": "^0.6.1", "@tailwindcss/vite": "^4.1.18", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2095b54..a794a58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: '@base-ui/react': specifier: ^1.2.0 version: 1.2.0(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@number-flow/react': + specifier: ^0.6.1 + version: 0.6.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@7.3.6(@types/node@24.5.2)(jiti@2.6.1)(lightningcss@1.30.2)) @@ -528,6 +531,12 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@number-flow/react@0.6.1': + resolution: {integrity: sha512-JsvB8FrmtRmn251jbJnFD7Tr6Sv/eQMLUAlbshGo4CfAPk0qgNYTio9HuFoETqtuflcJHQezeGsSTPbUn1oplw==} + peerDependencies: + react: ^18 || ^19 + react-dom: ^18 || ^19 + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -1074,6 +1083,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} @@ -1427,6 +1439,9 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + number-flow@0.6.1: + resolution: {integrity: sha512-5NUCwZA6J/kZeEzqApOyq7KOf1Wu/oxjpp/95HB0NBNBFlpK/Pp7i4jH8h0LIXyRDKSTewDAUaYHL7IBrk19Ew==} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -2250,6 +2265,13 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 + '@number-flow/react@0.6.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + esm-env: 1.2.2 + number-flow: 0.6.1 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.62.2': @@ -2743,6 +2765,8 @@ snapshots: escalade@3.2.0: {} + esm-env@1.2.2: {} + esprima@4.0.1: {} exceljs@4.4.0: @@ -3044,6 +3068,10 @@ snapshots: normalize-path@3.0.0: {} + number-flow@0.6.1: + dependencies: + esm-env: 1.2.2 + once@1.4.0: dependencies: wrappy: 1.0.2 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index acbb44e..ed1ff26 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2141,7 +2141,7 @@ dependencies = [ [[package]] name = "mpesa2csv" -version = "0.13.0" +version = "0.14.0" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2b58191..13376c1 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -13,8 +13,8 @@ "windows": [ { "title": "mpesa2csv - Convert M-PESA Statements to CSV/Excel", - "width": 750, - "height": 850, + "width": 600, + "height": 550, "minWidth": 500, "minHeight": 400, "resizable": true @@ -68,8 +68,8 @@ "y": 170 }, "windowSize": { - "width": 750, - "height": 850 + "width": 600, + "height": 550 } } }, diff --git a/src/App.tsx b/src/App.tsx index d074078..fa58543 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,9 +7,10 @@ import { ExternalLink, MessageSquare, ChevronDown, - ChevronUp, Table2, X, + CheckCircle2, + Settings2, } from "lucide-react"; import { openUrl } from "@tauri-apps/plugin-opener"; @@ -29,6 +30,8 @@ import { ThemeToggle } from "./components/theme-toggle"; import { Button } from "./components/ui/button"; import { formatDateForFilename } from "./utils/helpers"; import { TIMEOUTS, URLS } from "./constants"; +import { cn } from "@/lib/utils"; +import NumberFlow from "@number-flow/react"; function App() { const [files, setFiles] = useState([]); @@ -40,6 +43,7 @@ function App() { const cancelRequested = useRef(false); // Controls the transaction-preview panel. const [previewExpanded, setPreviewExpanded] = useState(false); + const [optionsExpanded, setOptionsExpanded] = useState(false); const [exportFormat, setExportFormat] = useState( ExportFormat.XLSX ); @@ -115,6 +119,7 @@ function App() { setStatements([]); setCurrentFileIndex(0); setPreviewExpanded(false); + setOptionsExpanded(false); try { const result = await processFiles(selectedFiles); @@ -341,6 +346,7 @@ function App() { setSavedFilePath(""); setExportFileName(""); setPreviewExpanded(false); + setOptionsExpanded(false); }; const handleCancel = async () => { @@ -487,12 +493,21 @@ function App() {
-
-
+
+
{status === FileStatus.IDLE || status === FileStatus.LOADING || status === FileStatus.ERROR ? ( -
+
) : status === FileStatus.SUCCESS && statements.length > 0 ? ( -
-
-

- ✅ Your Data is Ready! -

-

- Processed {statements[0].transactions.length} transactions - from {files.length} statement{files.length > 1 ? "s" : ""} • - Choose your export options below -

-

- Total Charges: KES{" "} - {(statements[0].totalCharges).toLocaleString("en-US", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} -

-
+
+ + {/* ── Stats hero ──────────────────────────────────── */} + {(() => { + const txs = statements.flatMap(s => s.transactions); + const dates = txs + .map(t => new Date(t.completionTime)) + .filter(d => !isNaN(d.getTime())); + const minDate = dates.length ? new Date(Math.min(...dates.map(d => d.getTime()))) : null; + const maxDate = dates.length ? new Date(Math.max(...dates.map(d => d.getTime()))) : null; + const fmt = (d: Date) => d.toLocaleDateString("en-US", { month: "short", year: "numeric" }); + const dateRange = minDate && maxDate + ? fmt(minDate) === fmt(maxDate) ? fmt(minDate) : `${fmt(minDate)} – ${fmt(maxDate)}` + : null; + + const totalIn = txs.reduce((s, t) => s + (t.paidIn ?? 0), 0); + const totalOut = txs.reduce((s, t) => s + (t.withdrawn ?? 0), 0); + const totalCharges = statements.reduce((s, st) => s + st.totalCharges, 0); + + const abbr = (n: number) => { + if (n >= 1_000_000) return `${(n / 1_000_000).toLocaleString("en-US", { maximumFractionDigits: 2 })}M`; + if (n >= 1_000) return `${(n / 1_000).toLocaleString("en-US", { maximumFractionDigits: 1 })}K`; + return n.toLocaleString("en-US", { maximumFractionDigits: 0 }); + }; + + return ( +
+ +

+ transaction{statements[0].transactions.length !== 1 ? "s" : ""} extracted + {files.length > 1 ? ` from ${files.length} statements` : ""} +

+ {dateRange && ( +

{dateRange}

+ )} + {(totalIn > 0 || totalOut > 0) && ( +
+ {totalIn > 0 && ( + + ↑ KES {abbr(totalIn)} + + )} + {totalIn > 0 && totalOut > 0 && ( + · + )} + {totalOut > 0 && ( + + ↓ KES {abbr(totalOut)} + + )} +
+ )} + {totalCharges > 0 && ( +

+ KES {totalCharges.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} in charges +

+ )} +
+ ); + })()} + + {/* ── Primary export action ───────────────────────── */} + + + {/* ── Post-save confirmation chip ─────────────────── */} + {downloadSuccess && savedFilePath && ( +
+ + + {savedFilePath} + + +
+ )} + + {/* ── Error ───────────────────────────────────────── */} + {error && ( +
+

{error}

+
+ )} - {/* ── Transaction Preview ────────────────────────── */} -
+ {/* ── Transaction Preview ─────────────────────────── */} +
- {previewExpanded && ( -
- - - - - - - - - - - - {statements[0].transactions.slice(0, 10).map((tx, i) => ( - - - - - - +
+
+
+
DateDetailsPaid InWithdrawnBalance
{tx.completionTime}{tx.details} - {tx.paidIn ? tx.paidIn.toLocaleString() : ""} - - {tx.withdrawn ? tx.withdrawn.toLocaleString() : ""} - {tx.balance.toLocaleString()}
+ + + + + + + - ))} - -
DateDetailsPaid InWithdrawnBalance
- {statements[0].transactions.length > 10 && ( -

- Showing 10 of {statements[0].transactions.length} — export to see all -

- )} + + + {statements[0].transactions.slice(0, 10).map((tx, i) => ( + + {tx.completionTime} + {tx.details} + + {tx.paidIn ? tx.paidIn.toLocaleString() : ""} + + + {tx.withdrawn ? tx.withdrawn.toLocaleString() : ""} + + {tx.balance.toLocaleString()} + + ))} + + + {statements[0].transactions.length > 10 && ( +

+ Showing 10 of {statements[0].transactions.length} — export to see all +

+ )} +
- )} +
- {/* ── Export Options ──────────────────────────────── */} -
- + {/* ── Customize export (collapsible, closed by default) */} +
+ + +
+
+
+ +
+
+
- {/* ── Action Buttons ──────────────────────────────── */} -
- {/* Export is always visible so users can re-export after changing format/options */} - - - {/* Open File appears only after a successful save */} - {downloadSuccess && savedFilePath && ( - - )} - - + + + Start over +
-
-

- File: {exportFileName} -

- {downloadSuccess && savedFilePath && ( -

- Saved to: {savedFilePath} -

- )} - - {/* Feedback Link */} -
- -
-
) : null}
-