commit 6455579cc2153688865be5c9869413aa78e4395e Author: Yashas Date: Thu Sep 10 16:50:48 2026 +0530 The Lead Desk, written for a phone A separate app from the desktop console, talking to the same platform with the same client. Not a breakpoint on the old one: the two answer different questions. The console answers "what is the state of the book?" across a wide grid; a phone answers "what needs me, and what happened to this one?" in a column you scroll with a thumb. The old console at 390px showed why. Tables scrolled sideways, nine queues stacked above the content ate the first screenful, and the action you came to perform sat below the fold. So every row of every table is a CARD — customer, stage, when it is due, who is holding it, and one line about what is happening. Everything else is one tap away. The queues live in a drawer. The action a lead is waiting on is pinned to the bottom of the screen, where a thumb already is. Shared with the console, because a divergence would be two apps disagreeing about the same lead: the whole api/ layer, ActivityForm, and Timeline — whose audit-row merging (one submission writes three rows) is hard-won and must not be reimplemented twice. Written fresh: the shell, the three screens, and the stylesheet. The colour rule is unchanged and is the product in four colours: blue the AI holds it, amber a person, teal the customer, red risk and nothing else. A PWA, so Add to Home Screen gives a full-screen app; the layout pads for the notch and the home indicator. Verified at 402x874: login, overview, drawer, a queue, and a lead, with no horizontal overflow on any of them. Co-Authored-By: Claude Opus 5 (1M context) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4f321e6 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +VITE_ZINO_API_URL=https://dev.getzino.in diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..01473fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.env +*.local diff --git a/README.md b/README.md new file mode 100644 index 0000000..48b325d --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# Zurich Kotak — Lead Desk (phone) + +The renewal desk on a phone. A separate app from the desktop console +(`zurich_kotak`), talking to the same platform with the same API client. + +## Why a second app rather than a breakpoint + +The two answer different questions. The desktop console answers *"what is the +state of the book?"* across a wide grid — six-column tables, a lead beside its +record, a rail of queues always visible. A phone answers *"what needs me, and +what happened to this one?"* in a column you scroll with a thumb. + +Squeezing the first into 390px produced what it always produces: tables that +scroll sideways, a menu that eats the first screenful, and a primary action +somewhere below the fold. So the screens are written for the phone, and only +the parts that must not diverge are shared. + +## What is shared, and what is not + +**Copied verbatim, because it is the contract with the platform** — a +divergence here would be two apps disagreeing about the same lead: + + src/api/client.js every call + src/api/config.js stages, actions, doc slots, the workflow mirror + src/api/permissions.js who may perform what + src/api/portfolio.jsx the one record-view call the queues share + src/api/holder.js who is holding a lead + src/api/lead.js how to read a date, name a field, group the record + src/components/ActivityForm.jsx, FileField.jsx, Timeline.jsx + +`Timeline.jsx` in particular is shared on purpose: its audit-row merging (one +submission writes three rows) is hard-won, and reimplementing it here would +mean two different accounts of the same history. + +**Written fresh for the phone:** every screen, the shell, and the stylesheet. + +## The colour rule + +Unchanged from the console, and it is the whole product in four colours: + + blue the AI is holding it + amber a person is holding it + teal the customer is holding it + red risk — and nothing else + +A lead that is amber on a laptop must be amber on a phone. + +## Run it + + npm install + npm run dev # http://localhost:5176 + +`VITE_ZINO_API_URL` in `.env` for local dev only. In a deployed build the API +host is read at RUNTIME from the `config.js` the server writes when it places +the build — never compiled in, so one artifact is promoted between +environments unchanged. + +## Installing it + +It is a PWA: `manifest.webmanifest`, a standalone display mode, and the navy +theme colour, so Add to Home Screen gives a full-screen app with no browser +chrome. The layout pads for the notch and the home indicator through +`env(safe-area-inset-*)`. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..ea36dd3 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,21 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + }, +]) diff --git a/index.html b/index.html new file mode 100644 index 0000000..685a075 --- /dev/null +++ b/index.html @@ -0,0 +1,21 @@ + + + + + + + + + + + + + Zurich Kotak — Lead Desk + + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..19b4ca5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2505 @@ +{ + "name": "zurich_kotak_pwa", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "zurich_kotak_pwa", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.2" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.1.0", + "eslint": "^10.9.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.4", + "globals": "^17.11.0", + "vite": "^8.2.2" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", + "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", + "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", + "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", + "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", + "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", + "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", + "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", + "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", + "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", + "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", + "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", + "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", + "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", + "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", + "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.426", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.426.tgz", + "integrity": "sha512-2Gcq6inCQs/AqfHP3f5ftCzk+pqeW2VlA1LgmPqEj2hfBO8HiZRspNYkD4HzFBe4u6lGqca4BspFr6Ix4Q400g==", + "dev": true, + "license": "ISC" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.10.0.tgz", + "integrity": "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.6.tgz", + "integrity": "sha512-uZnh24On2bk478AkaDAKcpRiYhS3qFSaSNvpXxV6/Ek/BBDICkWGG7MnBSfjnIlCVBei5vsLlIeQYxbF22+Udg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", + "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-router": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz", + "integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz", + "integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rolldown": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.149.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.8", + "@rolldown/binding-android-arm64": "1.2.8", + "@rolldown/binding-darwin-arm64": "1.2.8", + "@rolldown/binding-darwin-x64": "1.2.8", + "@rolldown/binding-freebsd-x64": "1.2.8", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", + "@rolldown/binding-linux-arm64-gnu": "1.2.8", + "@rolldown/binding-linux-arm64-musl": "1.2.8", + "@rolldown/binding-linux-ppc64-gnu": "1.2.8", + "@rolldown/binding-linux-s390x-gnu": "1.2.8", + "@rolldown/binding-linux-x64-gnu": "1.2.8", + "@rolldown/binding-linux-x64-musl": "1.2.8", + "@rolldown/binding-openharmony-arm64": "1.2.8", + "@rolldown/binding-win32-arm64-msvc": "1.2.8", + "@rolldown/binding-win32-x64-msvc": "1.2.8" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.1.tgz", + "integrity": "sha512-341aRWQsve0rvronKNTqZpjmzdbUDlFuzHaI/XLg/Ej82qffDJRRfBTCuv7+9q/rMjB6LSLyEBnW4InJeMtt/Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c3ff658 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "zurich_kotak_pwa", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.2" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.1.0", + "eslint": "^10.9.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.4", + "globals": "^17.11.0", + "vite": "^8.2.2" + } +} diff --git a/public/brand/zurich_logo.webp b/public/brand/zurich_logo.webp new file mode 100644 index 0000000..6f5c27e Binary files /dev/null and b/public/brand/zurich_logo.webp differ diff --git a/public/config.js b/public/config.js new file mode 100644 index 0000000..fd598f2 --- /dev/null +++ b/public/config.js @@ -0,0 +1,3 @@ +// Written by the server when it places a build. This copy is for local dev +// only; the deployed one names the environment's own API host. +window.__RUNTIME_CONFIG__ = { VITE_ZINO_API_URL: 'https://dev.getzino.in' } diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..da1f202 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,14 @@ +{ + "name": "Zurich Kotak — Lead Desk", + "short_name": "Lead Desk", + "description": "Motor and SME renewals, in your pocket.", + "start_url": "./", + "scope": "./", + "display": "standalone", + "orientation": "portrait", + "background_color": "#F2F4F8", + "theme_color": "#0B2A5B", + "icons": [ + { "src": "./brand/zurich_logo.webp", "sizes": "512x512", "type": "image/webp", "purpose": "any" } + ] +} diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..3c7b34f --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,39 @@ +import { Navigate, Route, Routes } from 'react-router-dom' +import { useZino } from './api/provider.jsx' +import { PortfolioProvider } from './api/portfolio.jsx' +import Login from './pages/Login.jsx' +import Shell from './layout/Shell.jsx' +import Overview from './screens/Overview.jsx' +import Leads from './screens/Leads.jsx' +import Lead from './screens/Lead.jsx' + +/** + * Three screens, which is the whole app on a phone: what needs me, the list, + * and one lead. Everything the desktop console shows in side panels is either + * folded into the lead page or left to the desktop, on purpose — a phone is + * for working a queue on the move, not for reading the whole book. + */ +export default function App() { + const { isAuthed } = useZino() + + if (!isAuthed) { + return ( + + } /> + } /> + + ) + } + + return ( + + } /> + }> + } /> + } /> + } /> + } /> + + + ) +} diff --git a/src/api/agents.js b/src/api/agents.js new file mode 100644 index 0000000..9972cb0 --- /dev/null +++ b/src/api/agents.js @@ -0,0 +1,108 @@ +/** + * The AI employees, as people the operator can recognise. + * + * Five agents carry this workflow and the console referred to them by their + * role slug or their full title, differently in each place — "ai_engage" in one + * view, "Engage" in another, "Engage AI" in a third. Someone watching a lead + * move could not tell that the thing which called the customer and the thing + * which wrote the quote were the same worker. + * + * So: one roster, one short name, one colour, one initial. Used wherever an + * agent is named. `does` is written in the present tense and in the operator's + * words, not the charter's — it appears on hover and in the roster strip, and + * it is what makes an unfamiliar name mean something the first time. + * + * `tools` and `knowledge` are a MIRROR of the employee config in + * aiemployee.tbl_ai_employees. The app frontend cannot read the agents schema, + * so this is hand-maintained the way STAGES and ACTIONS are, and carries the + * same hazard: a tool added or removed there and not here is described wrongly + * in the UI, silently. + * + * Worth the trade, because "what can this thing actually do?" is the first + * question anyone watching an agent asks — and the honest answer is short and + * reassuring: four of the five hold no tools at all and work from the policy + * wordings. The one tool that exists only reads a partner registry. Nothing + * here can move money, and since 89 nothing here can telephone anybody. + * + * Keyed by the ROLE, because that is what an audit row carries. + */ +export const AGENTS = { + ai_intake: { + icon: 'inspect', + short: 'Intake', + full: 'Intake & Attribution', + initials: 'IA', + tone: 'violet', + does: 'checks the lead is real, scores it, and works out whose it is', + wakes: 'Once, the moment a lead is filed.', + tools: [ + { + name: 'lookup_partner', + does: 'Reads the partner registry by partner code and reports whether that POSP, broker or corporate agent is active and empanelled for this product.', + }, + ], + knowledge: ['Products, UINs & Commission', 'SME Package (BSUS / BLUS)', 'Motor (Car Secure)'], + }, + + ai_engage: { + icon: 'chat', + short: 'Engage', + full: 'Engage', + initials: 'EN', + tone: 'blue', + does: 'talks to the customer, writes up the call, and asks for what is missing', + wakes: 'Most often of the five: after the call ends, after documents land, after the Advisor reports, when the premium falls due, and on every WhatsApp reply.', + tools: [], + knowledge: ['SME Package (BSUS / BLUS)', 'Motor (Car Secure)', 'Motor Renewal Sales Desk'], + }, + + ai_advisor: { + icon: 'shield', + short: 'Advisor', + full: 'Advisor', + initials: 'AD', + tone: 'teal', + does: 'reads the captured risk and recommends the cover and add-ons', + wakes: 'Once, as soon as the risk has been captured.', + tools: [], + knowledge: ['Products, UINs & Commission', 'SME Package (BSUS / BLUS)', 'Motor (Car Secure)', 'Motor Renewal Sales Desk'], + }, + + ai_kyc: { + icon: 'id', + short: 'KYC', + full: 'KYC & Evidence', + initials: 'KY', + tone: 'amber', + does: 'verifies identity and screens the risk for underwriting', + wakes: 'Twice: when the customer accepts, and again to run the underwriting screen.', + tools: [], + knowledge: ['Products, UINs & Commission', 'SME Package (BSUS / BLUS)', 'Motor (Car Secure)'], + }, + + ai_uw_referral: { + icon: 'scales', + short: 'Referral', + full: 'Underwriting Referral', + initials: 'UW', + tone: 'plum', + does: 'prepares a referred file so a human underwriter can decide', + wakes: 'Once, and only when the screen refers the risk.', + tools: [], + knowledge: ['Products, UINs & Commission', 'SME Package (BSUS / BLUS)', 'Motor (Car Secure)'], + }, +} + +/** The agent behind a set of roles, or null when a person did it. */ +export function agentFor(roles) { + if (!Array.isArray(roles)) return null + const key = roles.find((r) => AGENTS[r]) + return key ? { key, ...AGENTS[key] } : null +} + +/** Initials for a person, so a human entry reads as a name too. */ +export function initialsOf(name) { + const parts = String(name || '').trim().split(/\s+/).filter(Boolean) + if (!parts.length) return '—' + return (parts[0][0] + (parts.length > 1 ? parts[parts.length - 1][0] : '')).toUpperCase() +} diff --git a/src/api/client.js b/src/api/client.js new file mode 100644 index 0000000..8d456a0 --- /dev/null +++ b/src/api/client.js @@ -0,0 +1,267 @@ +import { APP_ID, WORKFLOW } from './config' + +const TOKEN_KEY = 'zk_lead_desk_token' +const USER_KEY = 'zk_lead_desk_user' + +/** + * HTTP client for the Zino gateway. + * + * Most routes are app-scoped (`/app/536/...`); login is not. The JWT persists + * in localStorage so a refresh does not bounce the operator back to the login + * screen mid-review. + */ +export class ZinoClient { + constructor(baseUrl, onAuthError) { + this.baseUrl = String(baseUrl).replace(/\/+$/, '') + this.token = null + this.onAuthError = onAuthError + if (typeof window !== 'undefined') this.token = localStorage.getItem(TOKEN_KEY) + } + + setAuthErrorHandler(fn) { this.onAuthError = fn } + + setToken(token) { + this.token = token + if (typeof window === 'undefined') return + if (token) localStorage.setItem(TOKEN_KEY, token) + else localStorage.removeItem(TOKEN_KEY) + } + + getToken() { return this.token } + + setStoredUser(user) { + if (typeof window === 'undefined') return + if (user) localStorage.setItem(USER_KEY, JSON.stringify(user)) + else localStorage.removeItem(USER_KEY) + } + + getStoredUser() { + if (typeof window === 'undefined') return null + try { return JSON.parse(localStorage.getItem(USER_KEY) || 'null') } catch { return null } + } + + async request(method, path, body) { + const headers = { 'Content-Type': 'application/json' } + if (this.token) headers['Authorization'] = `Bearer ${this.token}` + + const res = await fetch(`${this.baseUrl}${path}`, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + + if (res.status === 401) { + this.setToken(null) + this.setStoredUser(null) + this.onAuthError?.() + throw { status: 401, message: 'Session expired — sign in again' } + } + if (!res.ok) { + let message = res.statusText + try { + const j = await res.json() + message = j.error || j.message || message + } catch { /* non-JSON body */ } + throw { status: res.status, message } + } + if (res.status === 204) return undefined + return res.json() + } + + /* ── The support desk ──────────────────────────────────────────────────── + Not app-scoped: these are the platform's own agent routes, and the token + this console already holds is accepted on them as-is. A conversation is a + session; a question is a message on it. */ + + /** This operator's conversations with the desk, newest first. */ + deskSessions(agentId) { + return this.request('GET', `/api/agent-sessions?agent_id=${agentId}`) + } + + /** Open a new conversation. */ + deskStartSession(agentId) { + return this.request('POST', '/api/agent-sessions', { agent_id: agentId }) + } + + /** The transcript of one conversation. */ + deskMessages(sessionId) { + return this.request('GET', `/api/agent-sessions/${sessionId}/messages`) + } + + /** + * Ask. Answers with the rows the backend persisted for the turn — + * { user_message, call_api_message, agent_message } — not a bare string. + * + * These take twenty to forty seconds: the desk runs real queries against the + * book before it answers. The caller has to say so, or the panel looks hung. + */ + deskAsk(sessionId, content) { + return this.request('POST', `/api/agent-sessions/${sessionId}/messages`, { content }) + } + + /** + * org_id must be a STRING. The gateway rejects a number with + * "cannot unmarshal number into Go struct field LoginRequest.org_id". + */ + async login(email, password, orgId) { + const res = await this.request('POST', '/usr/login', { + email, + password, + org_id: String(orgId), + }) + if (res?.token) { + this.setToken(res.token) + this.setStoredUser(res.user || null) + } + return res + } + + logout() { + this.setToken(null) + this.setStoredUser(null) + } + + /** + * Paginated records for a record view, filtered server-side. + * + * The POST body is NOT the same shape as the GET query params: the view is + * named by `rv_template_uid` (not `rv_id`, which is the GET spelling), and + * paging/sort/filters all live INSIDE `search_query`. Sending them at the top + * level returns `400 Missing param: rv_template_uid (body)`. + */ + recordView(rvUid, params = {}) { + return this.request('POST', `/app/${APP_ID}/view/recordview`, { + rv_template_uid: rvUid, + search_query: { + page: params.page ?? 1, + limit: params.limit ?? 50, + sort_by: params.sort_by ?? '', + sort_dir: params.sort_dir ?? 'desc', + search: params.search ?? '', + filters: (params.filters ?? []).map((f) => ({ + field_key: f.field_key, + value: f.value, + value2: '', + data_type: f.data_type ?? 'string', + })), + }, + }) + } + + /** + * Rows only. The response key differs by source type — workflow views return + * `data`, rdbms views have been seen returning `records`. + */ + async rows(rvUid, params = {}) { + const r = await this.recordView(rvUid, params) + return r?.data ?? r?.records ?? r?.rows ?? [] + } + + detailView(dvUid, instanceId) { + return this.request( + 'GET', + `/app/${APP_ID}/view/detailview/${dvUid}?instance_id=${encodeURIComponent(String(instanceId))}`, + ) + } + + /** + * The LIVE definition of an activity's form: fields, types, select options, + * which are mandatory. Read rather than hardcoded so that adding a field in + * Studio and redeploying surfaces it here with no frontend change — the + * workflow stays the source of truth. + */ + formSchema(activityUid, instanceId) { + return this.request('POST', `/app/${APP_ID}/view/form-screens`, { + activity_id: activityUid, + device_type: 'desktop', + ...(instanceId ? { instance_id: instanceId } : {}), + }) + } + + /** Start a new instance via one of the three INIT activities. */ + start(activityUid, data) { + return this.request('POST', `/app/${APP_ID}/start`, { + workflow_uuid: WORKFLOW, + activity_id: activityUid, + data, + }) + } + + /** + * Perform an activity on an existing instance. The workflow refuses what the + * signed-in user may not do — a permission denial arrives as 403 here and as + * 400 on /start, both carrying "permission denied". + */ + activity(instanceId, activityUid, data) { + return this.request('POST', `/app/${APP_ID}/activity`, { + workflow_uuid: WORKFLOW, + instance_id: instanceId, + activity_id: activityUid, + data, + }) + } + + /** + * Upload a file and get back a reference — {uuid, blob_path, original_name, + * mime_type}. The field context matters: the backend resolves the field's + * own config (allowed types, size limit, ocr_config) server-side from it and + * ignores anything the client claims. + */ + async uploadFile(file, ctx) { + const form = new FormData() + form.append('file', file) + // workflow_uuid is REQUIRED and is fixed for this app, so it is taken from + // config rather than from ctx. Passing it optionally is how the first + // version shipped: the upload sent activity_id, field_id and instance_id + // and got back `Missing required params: workflow_uuid`. + form.append('workflow_uuid', WORKFLOW) + if (ctx.activityId) form.append('activity_id', ctx.activityId) + if (ctx.fieldId) form.append('field_id', ctx.fieldId) + if (ctx.instanceId) form.append('instance_id', String(ctx.instanceId)) + const headers = {} + if (this.token) headers['Authorization'] = `Bearer ${this.token}` + const res = await fetch(`${this.baseUrl}/app/${APP_ID}/upload`, { method: 'POST', headers, body: form }) + if (!res.ok) { + let message = res.statusText + try { const j = await res.json(); message = j.error || j.message || message } catch { /* non-JSON */ } + throw { status: res.status, message } + } + return res.json() + } + + /** + * Extract from an ALREADY-UPLOADED file. Sends a reference, not the bytes — + * the document crosses the wire once, survives a reload, and re-extracting + * costs no re-upload. The ocr_config (which fields to pull, where they map) + * is resolved server-side from the deployed workflow; anything the client + * sends is ignored. + */ + ocrExtract(fileRef, ctx) { + return this.request('POST', `/app/${APP_ID}/ocr-extract`, { + workflow_uuid: WORKFLOW, + activity_id: ctx.activityId, + field_id: ctx.fieldId, + instance_id: ctx.instanceId || undefined, + files: [fileRef], + }) + } + + instance(instanceId) { + return this.request('POST', `/app/${APP_ID}/instance`, { + workflow_uuid: WORKFLOW, + instance_id: instanceId, + }) + } + + /** + * The instance's audit trail: one entry per activity performed, with WHO + * performed it and the data they wrote. + * + * Must be the APP-SCOPED path. The bare `/view/audit` is not an API route at + * all — it falls through to the SPA and returns HTML with a 200, which + * parses as a JSON error rather than an HTTP one. + */ + audit(instanceId) { + return this.request('GET', `/app/${APP_ID}/view/audit?instance_id=${encodeURIComponent(String(instanceId))}`) + } +} diff --git a/src/api/config.js b/src/api/config.js new file mode 100644 index 0000000..65c75fa --- /dev/null +++ b/src/api/config.js @@ -0,0 +1,576 @@ +/** + * Environment-scoped identifiers for the Zurich Kotak "Lead to Policy" demo. + * + * Workflow config is seeded from sm2/custom-apps/zurich-kotak/ — org 84, + * app 536, workflow zk_wf_lead (110001 v1). + */ +export const ORG_ID = '84' +export const APP_ID = '536' +export const WORKFLOW = 'zk_wf_lead' + +/** + * The pipeline, in the order a lead actually moves. + * + * Mirrors workflow.tbl_wf_states by hand. It is duplicated rather than fetched + * because the sidebar has to render its ORDER, and the API returns states as a + * set with no canonical sequence. Keep in step with 53_v2_state_model.sql. + * + * `kind` answers "who is holding this lead", which is the question an operator + * actually has — not "where is it in the process": + * + * needs a person has to act, and nothing moves until they do + * customer waiting on someone outside the business + * auto an AI employee is carrying it; `doing` is what to say meanwhile + * waiting real, but not yet — the renewal is too far off to work + * end terminal + * + * `need` names the queue by what it wants done. A queue called "Upload + * documents" answers "is anything waiting on me?"; one called "Document + * Pending" describes where the lead sits and leaves the operator to work it out. + * + * `name` MUST match workflow.tbl_wf_states.name exactly — the queue filter, the + * sidebar tally and the action lookup all resolve a lead through this string + * against current_state_name. A workflow rename not mirrored here does NOT + * error: the queue returns nothing, correctly, for a name no lead is in. That + * is the failure mode this table has, and it is silent. + */ +/** + * The support desk this console can ask questions of. + * + * A Support App on the platform: an AI desk bound to app 536 that reads the + * deployed config and the live book through read-only tools and answers with + * evidence. It is org-scoped and lives outside this app, so its id is config + * here rather than something the console can derive. + * + * Access is granted per user on the platform (usersystem.tbl_org_user_agents) + * and the console additionally gates the panel to ops — see ASK_DESK_ROLES. + * Both have to agree; the platform's grant is the one that actually enforces. + */ +export const SUPPORT_AGENT_ID = 29648 +export const ASK_DESK_ROLES = ['ops_admin'] + +export const STAGES = [ + { uid: 'zk-state-new', name: 'New Lead', kind: 'auto', doing: 'Checking the lead', by: 'Intake AI' }, + { uid: 'zk-state-qualified', name: 'Awaiting Contact', kind: 'auto', doing: 'Calling the customer', by: 'the voice agent' }, + { uid: 'zk-state-contacted', name: 'Contacted', kind: 'auto', doing: 'Asking for the documents', by: 'Engage AI' }, + // The principal stall in the workflow, and the one that had no stage until + // 7 September: a lead waited for its three documents inside "Contacted", so + // the queue that most needed watching was the one that did not exist. + { uid: 'zk-state-docs', name: 'Document Pending', kind: 'needs', need: 'Document collection', by: 'the partner agent' }, + { uid: 'zk-state-quoted', name: 'Quote Presented', kind: 'customer', need: 'Customer decision', by: 'the customer', doing: 'Waiting for the customer to reply on WhatsApp' }, + // The one queue where the machine stops and a person decides. + { uid: 'zk-state-referred', name: 'Referred to Underwriting', kind: 'needs', need: 'Underwriting referral', by: 'an underwriter' }, + // `approval` marks the one queue that is a person's SIGN-OFF rather than a + // piece of work: confirming money has arrived. It is locked to ops_admin in + // the workflow — no agent and no other role can perform it — because an + // employee that can mark a premium received can put a customer on risk for a + // policy nobody paid for. It gets its own band on the overview so the role + // that owns it does not have to find it in a list of things mostly handled + // by somebody else. + // WHERE THE AI PUTS DOWN WHAT IT CANNOT HONESTLY FINISH. An AI employee has + // no way to raise a ticket and no way to send a message; the only move it + // has for "a person should take this" is Flag for Review, and it lands here + // with the reason it wrote. Nothing is running on these leads. + { uid: 'zk-state-review', name: 'Needs a Person', kind: 'needs', need: 'Flagged by the AI', by: 'operations' }, + { uid: 'zk-state-payment', name: 'Payment Pending', kind: 'needs', need: 'Premium confirmation', by: 'operations', approval: 'ops_admin', approvalLabel: 'Premium awaiting your confirmation', approvalNote: 'Cover cannot start until the premium is received — section 64VB. Only operations can confirm it.' }, + { uid: 'zk-state-issued', name: 'Policy Issued', kind: 'auto', doing: 'Closing the file', by: 'Engage AI' }, + // Nurture. There is no scheduler yet, so Resume Outreach is a button and it + // is the only thing that wakes a parked lead — do not present this as a + // stage with nothing to do. + { uid: 'zk-state-parked', name: 'Parked / Nurture', kind: 'waiting', need: 'Nurture', by: 'the renewal calendar' }, + { uid: 'zk-state-onboarded', name: 'Onboarded', kind: 'end' }, + { uid: 'zk-state-lost', name: 'Lost / Dropped', kind: 'end' }, + { uid: 'zk-state-declined', name: 'Declined', kind: 'end' }, +] + +/** + * How the sidebar groups those. Fourteen flat stages answer "what is the + * process?" — the question nobody signing in has. These answer "is anything + * waiting on me?". + */ +export const NAV_GROUPS = [ + // The sidebar answers "is anything waiting on me?", so a group is WHO holds + // the work, not where it sits in the process. A person's queues and the + // customer's queues both need a human to look, so they share one heading. + { label: 'Needs you', kinds: ['needs', 'customer'] }, + { label: 'Scheduled', kinds: ['waiting'] }, + { label: 'History', kinds: ['end'] }, +] + +/** Channel presentation. source_channel is data on the instance, never a branch. */ +export const CHANNELS = { + direct: { label: 'Direct', short: 'D' }, + bancassurance: { label: 'Bancassurance', short: 'B' }, + agency: { label: 'Agency', short: 'A' }, +} + +/** View source-uids. Seeded by sm2/custom-apps/zurich-kotak/04_views.sql. */ +export const RV_LEADS = 'zk-rv-leads' +export const DV_LEAD = 'zk-dv-lead' + +/** + * Which activities are runnable from which state, and who is expected to do it. + * + * Mirrors workflow.tbl_wf_state_allowed_activities by hand — the same reason + * STAGES is duplicated: the console needs the ORDER and the labels, and the API + * returns neither. + * + * `by` is presentational ONLY. Nothing here enforces anything: the workflow + * refuses server-side and the console reports what it said. Showing an action + * the signed-in user cannot perform is deliberate — the refusal is the demo. + * + * This map is hand-maintained against workflow.tbl_wf_state_allowed_activities. + * An activity added to the workflow but not added here is simply invisible: + * the platform allows it, the console never offers it, and nothing errors. + * Collect Documents shipped in that state for one round. + */ +/** + * What each activity IS at a stage, not merely who may press it. + * + * `do` the expected next step here. Usually one; Referred has two, + * because clear and decline are a decision pair rather than a + * step and an alternative to it. + * `again` a bounded loop — a retry, a reminder, a re-quote. Legitimate, + * never the thing to do next. + * `force` an AI employee's own job, offered to a person ONLY so a + * stalled lead can be pushed by hand. Presenting these as + * actions is what made the app read as a control panel: six + * equal buttons where five are recovery levers. + * `exit` Mark Lost. Always reachable, never a step. Four stages used + * to offer a partner agent this and nothing else, which told + * them their only option was to give up on a lead the system + * was actively working. + */ +const STATE_ACTIVITIES = { + 'zk-state-new': [ + { uid: 'zk-act-qualify', label: 'Qualify Lead', by: 'Intake AI', role: 'force' }, + ], + 'zk-state-qualified': [ + { uid: 'zk-act-contact', label: 'Log Contact', by: 'Engage AI', role: 'force' }, + { uid: 'zk-act-retry-call', label: 'Retry Call', by: 'Scheduled', role: 'force' }, + ], + 'zk-state-contacted': [ + { uid: 'zk-act-request-docs', label: 'Request Documents', by: 'Engage AI', role: 'force' }, + ], + // The one stage whose next step is a person's: three documents have to + // be found and uploaded. Everything else here is the AI's own chain. + 'zk-state-docs': [ + { uid: 'zk-act-collect-docs', label: 'Upload documents', by: 'you', role: 'do' }, + { uid: 'zk-act-doc-reminder', label: 'Send a reminder', by: 'Scheduled', role: 'force' }, + { uid: 'zk-act-capture-motor', label: 'Capture Motor Risk', by: 'Engage AI', role: 'force' }, + { uid: 'zk-act-capture-sme', label: 'Capture SME Risk', by: 'Engage AI', role: 'force' }, + { uid: 'zk-act-advise', label: 'AI Cover Recommendation', by: 'Advisor AI', role: 'force' }, + { uid: 'zk-act-quote', label: 'Generate Quote', by: 'Rating engine', role: 'force' }, + ], + 'zk-state-quoted': [ + // The customer accepts from their own phone: their WhatsApp reply is + // routed to Customer Reply, Engage reads it, and Engage performs this. + // Kept reachable — role 'force', folded away — because a customer who + // says yes on a CALL still needs somebody to record it, and because a + // reply Engage judged ambiguous has to be actionable by a person. + { uid: 'zk-act-accept', label: 'Record the acceptance by hand', by: 'you, on consent', role: 'force' }, + { uid: 'zk-act-quote', label: 'Re-quote', by: 'Engage AI', role: 'again' }, + { uid: 'zk-act-collect-docs', label: 'Add a document', by: 'you', role: 'again' }, + { uid: 'zk-act-answer-customer', label: 'Reply to the customer', by: 'you, on WhatsApp', role: 'again' }, + { uid: 'zk-act-kyc', label: 'Verify KYC', by: 'KYC AI', role: 'force' }, + { uid: 'zk-act-uw-screen', label: 'Underwriting Screen', by: 'KYC AI', role: 'force' }, + ], + 'zk-state-referred': [ + { uid: 'zk-act-uw-clear', label: 'Clear the referral', by: 'you', role: 'do' }, + { uid: 'zk-act-uw-decline', label: 'Decline the risk', by: 'you', role: 'do' }, + { uid: 'zk-act-uw-prepare', label: 'Prepare Referral', by: 'Referral AI', role: 'force' }, + ], + 'zk-state-review': [ + { uid: 'zk-act-review-resume', label: 'Send back to the workflow', by: 'you', role: 'do' }, + { uid: 'zk-act-review-refer', label: 'Send to underwriting', by: 'you', role: 'do' }, + ], + 'zk-state-payment': [ + { uid: 'zk-act-realise', label: 'Confirm premium received', by: 'you', role: 'do' }, + // Not offered: the chase is a SCHEDULED activity now (86), booked 24h + // out by Request Premium and re-booked at 48h to a cap of three. It + // carries zero roles so the scheduler can perform it at all, which means + // a button here would 403 for everyone. To actually reach the customer, + // use Reply to the customer — that sends something. + { uid: 'zk-act-payment', label: 'Request Premium', by: 'Engage AI', role: 'force' }, + ], + 'zk-state-issued': [ + { uid: 'zk-act-onboard', label: 'Complete Onboarding', by: 'Engage AI', role: 'force' }, + ], + 'zk-state-parked': [ + { uid: 'zk-act-resume', label: 'Resume outreach now', by: 'you', role: 'do' }, + ], +} + +/** + * Mark Lost is not tied to one state: a lead can be dropped from anywhere before + * the policy issues, and every role that files a lead may do it. Appended rather + * than written into every entry so there is one place to change it, and so a + * stage added above cannot silently lose it. + * + * It carries role 'exit' precisely so it is never rendered as a step. It is the + * way out, not the way on. + */ +const DROP = { uid: 'zk-act-drop', label: 'Mark Lost', by: 'whoever holds the lead', role: 'exit' } + +const DROPPABLE = [ + 'zk-state-new', 'zk-state-qualified', 'zk-state-contacted', 'zk-state-docs', + 'zk-state-quoted', 'zk-state-referred', 'zk-state-payment', 'zk-state-parked', +] + +export const ACTIONS = Object.fromEntries( + [...new Set([...Object.keys(STATE_ACTIVITIES), ...DROPPABLE])].map((uid) => [ + uid, + [...(STATE_ACTIVITIES[uid] ?? []), ...(DROPPABLE.includes(uid) ? [DROP] : [])], + ]), +) + +/** + * Entry points surfaced in the console. + * + * The workflow has THREE INIT activities — direct, bancassurance and agency — + * and all three still work over the API with their own permissions. Only the + * agency door is shown here, because this demo is about the individual agent + * who sources a lead and gets paid when it onboards. + * + * The other two are hidden, not removed: the bancassurance door is what makes + * the cross-channel duplicate story possible, and deleting it would take the + * skip-ahead behaviour with it. + */ +export const ENTRY = [ + { uid: 'zk-act-init-agent', label: 'Partner Agent Lead', channel: 'agency', + note: 'POSP or broker sourcing a lead. Everything after this happens without them.' }, +] + +/** Product lines, as stored on the instance and as they should be read. */ +export const PRODUCTS = { + motor: 'Motor', + sme_package: 'SME Package', +} + +/** + * Which upload slot belongs to which product line — and, for the two slots that + * are conditional, when the condition holds. + * + * The Collect Documents activity serves BOTH lines from one form: it returns all + * eleven slots to every lead, so a motor renewal was being asked for a Udyam + * certificate and a stock statement. Nothing on the platform decides otherwise — + * `field_rules` comes back as `[]` for this activity, so there is no server-side + * visibility to honour. + * + * This is therefore a MIRROR, in the same sense as STAGES and ACTIONS above, and + * carries the same hazard: a slot added to the activity but not added here is + * shown to every product, and one renamed here stops matching and reverts to the + * same. The durable home for this is `field_rules` on the activity; when those + * are seeded, ActivityForm should read them and this table should go. + * + * Every slot is optional in the workflow (`mandatory: false` on all eleven), so + * hiding one cannot make a submission fail validation. + */ +export const DOC_SLOTS = { + // Motor. + doc_rc: { line: 'motor' }, + doc_prev_policy: { line: 'motor' }, + // "Break-in only" per the policy. The flag DOES exist now — 49 derives + // break_in at filing on all three doors — so the slot can honour its own + // caveat instead of being shown to every motor renewal. + doc_vehicle_photos: { line: 'motor', when: (lead) => lead.break_in === 'yes' }, + + // SME. + doc_gst_cert: { line: 'sme' }, + doc_udyam_cert: { line: 'sme' }, + doc_premises_proof: { line: 'sme' }, + doc_stock_statement: { line: 'sme' }, + doc_premises_photos: { line: 'sme' }, + // Audited accounts are only read when business interruption is on the risk. + doc_financials: { line: 'sme', + when: (lead) => (lead.sme_sections || []).includes('business_interruption') }, + + // Both lines. + doc_pan: { line: 'both' }, + // Identity is only re-evidenced when KYC actually referred; asking up front + // collects an Aadhaar the file does not need. + doc_address_proof: { line: 'both', when: (lead) => lead.kyc_outcome === 'refer' }, +} + +/** + * The fields that belong to one product line but do not say so in their name. + * Everything else is classified by prefix: `motor_*` is motor, `sme_*` is SME. + * A prefix rule rather than a list, so a field added to the workflow tomorrow is + * classified without anyone remembering to come back here. + */ +export const LINE_FIELDS = { + gstin: 'sme', + udyam_no: 'sme', + // entity_name is labelled "Business Name" on the form. A company-owned + // vehicle does have an owner with a name, so this is not strictly an SME + // field — but asking a motor renewal for a business name puts an empty box + // on the shortest form in the app, and the answer is already carried by + // customer_name in every motor lead we have. It is optional, so hiding it + // cannot block a submission, and an SME lead still gets it. + entity_name: 'sme', +} + +/** + * Fields the form STAMPS rather than asks. + * + * Each is resolved by the prefill pipeline from the signed-in identity — + * Arjun IS Crestline Insurance Advisors, submitting through the agency door — + * so all three arrived filled and read-only-in-spirit, and the New lead form + * opened with three of its eleven boxes already answered by the system. That + * is three boxes of noise in front of the four that actually need a person. + * + * HIDDEN FROM THE FORM, STILL SENT. These are not decorative: source_channel + * is mandatory, and partner_code and rm_or_agent_id are what Intake attributes + * the lead on. So they are filtered at RENDER only — `fields`, which drives + * both validation and the payload, still holds them, and their prefilled + * values submit exactly as before. Filtering them out of `fields` instead + * would drop the attribution and 400 on the mandatory channel. + */ +export const STAMPED_FIELDS = new Set([ + 'source_channel', + 'partner_code', + 'rm_or_agent_id', +]) + +/** Whether a field is stamped by prefill and so should not be drawn. */ +export function fieldIsStamped(fieldId) { + return STAMPED_FIELDS.has(baseFieldId(fieldId)) +} + +/** + * Fields the workflow computes for itself, which a form must therefore not ask + * for. Matched on the base id, so every per-activity suffix is covered. + * + * `documents_status` is the one that matters. It gates the AND-join that wakes + * Engage after an upload (55), and it was rendered as a dropdown — asking the + * person who had just attached three files to tell the system what it could + * see. Left unset, the lead sits in Document Pending with everything attached + * and nothing happening, and it reads as the AI having stalled. It is derived + * in the trigger now (75), from the files themselves. + * + * `documents_notes` goes with it: the same script writes what is still missing, + * and a person overwriting that would be arguing with the file list. + */ +export const DERIVED_FIELDS = new Set([ + 'documents_status', + 'documents_notes', +]) + +/** + * Form field ids carry a per-form suffix — `doc_rc` arrives as `doc_rc_2`, `pan` + * as `pan_3` — so a field is matched on the id with that suffix removed. No + * underlying field id ends in a number, which is what makes this safe. + */ +export function baseFieldId(id) { + return String(id).replace(/_\d+$/, '') +} + +/** Which product line a field belongs to: 'motor', 'sme', or 'both'. */ +export function fieldLine(fieldId) { + const id = baseFieldId(fieldId) + if (DOC_SLOTS[id]) return DOC_SLOTS[id].line + if (LINE_FIELDS[id]) return LINE_FIELDS[id] + if (id.startsWith('motor_')) return 'motor' + if (id.startsWith('sme_')) return 'sme' + return 'both' +} + +/** + * The line a lead is on, in the vocabulary fieldLine answers in, or null when it + * cannot be told. + * + * Null matters: treating an unknown line as SME would quietly drop the RC and + * the expiring policy from Collect Documents on a lead whose product has not + * been stamped yet, with nothing on screen to say a slot was hidden. Not knowing + * means filtering nothing. + */ +export function leadLine(lead) { + if (lead?.product_line === 'motor') return 'motor' + if (lead?.product_line === 'sme_package') return 'sme' + return null +} + +/** + * Whether an activity field should be offered for this lead — its line has to + * match, and a conditional upload slot has to have its condition hold. + * + * Everything is shown when there is no instance yet: an INIT form has no product + * line to filter on. + */ +export function fieldApplies(fieldId, lead) { + // Computed by the workflow — never asked for, on any form, whether or not + // there is a lead behind it. + if (DERIVED_FIELDS.has(baseFieldId(fieldId))) return false + if (!lead) return true + const on = leadLine(lead) + if (!on) return true + const line = fieldLine(fieldId) + if (line !== 'both' && line !== on) return false + const slot = DOC_SLOTS[baseFieldId(fieldId)] + return slot?.when ? slot.when(lead) : true +} + +/** + * A STAGE IS NOT ALWAYS ONE SITUATION. + * + * Document Pending covers two that could not look more different to whoever is + * watching. Before the upload a person has to act and nothing moves until they + * do; after it the lead stays in the same state while Engage captures the risk, + * the Advisor recommends cover and the rating engine prices it — three AI steps, + * two to five minutes, no one to chase. + * + * Rendered from `current_state_name` alone both read as "waiting on the partner + * agent", so a lead that had just been served showed an Upload documents button + * and sat in the Action-required queue, and the AI work behind it was invisible. + * The state machine is right to hold one state here — nothing has been decided + * yet — so the distinction belongs in the read model, once, rather than in each + * screen's own guess. + * + * `documents_status` is what separates them. The workflow derives it from the + * files themselves (75), so it says what is actually attached rather than what + * anyone claimed. + */ +export function phaseOf(stage, lead) { + if (!stage || !lead) return stage + + // Quote Presented has the same shape as Document Pending: the state holds + // still while the situation underneath it changes completely. + // + // Before the customer answers, the lead is genuinely with them and nothing + // is running. After they accept, KYC and the underwriting screen run inside + // the SAME state — the lead does not move until underwriting clears — so a + // page saying "waiting for the customer to reply" sat directly above a panel + // saying "accepted by the customer, KYC and underwriting are running". Both + // were rendered from the same instant; only one was true. + if (stage.uid === 'zk-state-quoted' && lead.acceptance_ref && !lead.uw_outcome) { + return { + ...stage, + kind: 'auto', + doing: lead.kyc_outcome ? 'Screening the risk for underwriting' : 'Verifying KYC', + by: 'KYC & Evidence', + after: 'customer accepted', + } + } + + if (stage.uid === 'zk-state-docs' && lead.documents_status === 'complete') { + return { + ...stage, + kind: 'auto', + doing: 'Reading the documents and pricing the cover', + by: 'Engage AI, then the Advisor', + // Kept so a screen can say WHY this is not the queue it looks like. + after: 'documents received', + } + } + return stage +} + +/** + * Why an automated stage has stopped, when it has. + * + * The AI chain behind Document Pending is honest about refusing: the rating + * engine writes `quoted_breakup` = "Not priced: no insured value (IDV) + * captured…" rather than inventing a premium, and Engage then declines to + * raise a quote it would have to fabricate. Both are the right call. + * + * What was missing is that NOBODY WAS TOLD. The refusal lives in a field on a + * tab, the AI's task dies in a queue no operator can see, and the lead sits in + * Document Pending looking exactly like one whose documents never arrived — + * indefinitely, because nothing wakes the chain again. A workflow that stops + * for a good reason and a workflow that is broken must not look the same. + * + * Recovery is always the same shape: put the missing value in and let the + * chain re-run. Collect Documents carries the IDV field and re-performing it + * wakes Engage again, so the action is one the partner agent already holds. + */ +export function blockedOn(lead) { + if (!lead) return null + const breakup = String(lead.quoted_breakup || '') + if (breakup.startsWith('Not priced')) { + return { + what: breakup.replace(/^Not priced:\s*/, ''), + // Named rather than described: the operator has to find this field, and + // it is not where they would look for it. + fix: 'Re-open Collect Documents and enter the IDV, or attach the expiring policy again so it can be read off.', + via: 'zk-act-collect-docs', + } + } + return null +} + +/** + * WHEN AN AGENT STOPS, WHAT DOES A PERSON PRESS? + * + * Agents stall. Not often, but they do, and for reasons nothing in this app + * controls: the model provider slows to ninety seconds a call or returns a 502, + * a thinking budget runs out mid-sentence, a late webhook wakes the wrong + * employee. Watched from the console every one of those looks identical — + * a lead that simply stops — and the screen kept promising it would "complete + * within two minutes" indefinitely. + * + * There is no retry button in the platform, and none of these agents can be + * woken directly. But every one of them is woken BY AN ACTIVITY, so performing + * that activity again wakes it again. That is what this table holds: for a + * lead sitting in an automated step, the activity a person can perform to make + * the stalled agent run once more. + * + * `by` is who to expect to move afterwards, so the button says what will + * happen rather than just what it does. + * + * The choice within Document Pending depends on HOW FAR the chain got, because + * three agents work that state in sequence and the one to restart is the one + * that did not finish. + */ +export function nudgeFor(stage, lead) { + if (!stage || !lead) return null + + switch (stage.uid) { + case 'zk-state-new': + return { uid: 'zk-act-qualify', label: 'Run the check again', by: 'Intake' } + + case 'zk-state-qualified': + return { uid: 'zk-act-contact', label: 'Record the call outcome', by: 'Engage' } + + case 'zk-state-contacted': + return { uid: 'zk-act-request-docs', label: 'Ask for the documents again', by: 'Engage' } + + case 'zk-state-docs': { + // Engage captures, the Advisor recommends, the rating engine prices — + // in that order, each woken by the one before. + const line = lead.product_line === 'sme_package' ? 'zk-act-capture-sme' : 'zk-act-capture-motor' + if (!lead.motor_idv && !lead.sme_value_at_risk) { + return { uid: line, label: 'Capture the risk again', by: 'Engage' } + } + if (!lead.ai_recommended_cover) { + // The Advisor is the ONE step nobody may perform by hand — it is + // permitted to ai_advisor alone. Re-performing the activity that wakes + // it is the only way back, and it is the reason this table exists. + return { uid: line, label: 'Wake the Advisor', by: 'the Advisor' } + } + if (!lead.quoted_premium) { + return { uid: 'zk-act-quote', label: 'Build the quote again', by: 'the rating engine' } + } + return null + } + + case 'zk-state-quoted': { + // Only after an acceptance — before that nothing is running and there is + // nothing to wake; the lead is with the customer. + if (!lead.acceptance_ref) return null + if (!lead.kyc_outcome) return { uid: 'zk-act-kyc', label: 'Run KYC again', by: 'KYC & Evidence' } + if (!lead.uw_outcome) return { uid: 'zk-act-uw-screen', label: 'Run the underwriting screen', by: 'KYC & Evidence' } + return null + } + + case 'zk-state-issued': + return { uid: 'zk-act-onboard', label: 'Close the file', by: 'Engage' } + + default: + return null + } +} + +/** How long an automated step may sit before the console stops reassuring and + * starts offering the way out. Generous: an employee wake plus a slow model + * can legitimately take three or four minutes. */ +export const STALL_AFTER_MS = 5 * 60 * 1000 diff --git a/src/api/errors.js b/src/api/errors.js new file mode 100644 index 0000000..066c591 --- /dev/null +++ b/src/api/errors.js @@ -0,0 +1,130 @@ +/** + * The gateway's error shapes, turned into something an operator can act on. + * + * Five shapes matter, and they are told apart by status plus a phrase in the + * message — there is no error code to switch on. `kind` is for the caller to + * branch on ('stale' and 'gone' need the screen to do something); `title` and + * `detail` are what gets shown. + * + * Anything unrecognised keeps its own message: a wrong guess reads worse than + * the server's own words. + */ +export function describeError(err) { + const status = err?.status + const raw = String(err?.message ?? '') + const has = (...words) => words.every((w) => raw.toLowerCase().includes(w)) + + if (status === 404 && has('no record found')) { + return { + kind: 'gone', + title: 'This lead is no longer there.', + detail: 'It may have been removed since the queue was loaded. Taking you back to the list.', + } + } + + if (status === 403 && has('not allowed in state')) { + return { + kind: 'stale', + // Deliberately not "the lead moved": the gateway returns this both when a + // lead advanced under the screen AND when the console offered an activity + // the state never allowed. Only one of those is a move, and claiming the + // wrong one sends the operator looking for something that did not happen. + title: 'That activity is not available at this stage.', + detail: 'Either the lead moved on while this was open, or it never allowed this. Refreshed to show what it does allow.', + } + } + + if (status === 403) { + return { + kind: 'forbidden', + title: 'The workflow refused this.', + detail: 'This is the platform deciding, not the console: the signed-in role does not hold this activity.', + } + } + + // Field validation runs BEFORE the permission check, so a disallowed + // submission carrying bad data arrives here as a 400 and not a 403. + if (status === 400 && has('validation failed')) { + // "field(unknown), field_2(required)" is the unsuffixed-key mistake, and it + // is a bug in this console rather than anything the operator did. + if (has('unknown') && has('required')) { + return { + kind: 'bug', + title: 'The console sent a field name the workflow does not know.', + detail: raw, + } + } + if (has('enum')) { + return { + kind: 'invalid', + title: 'One of the choices is not one the workflow accepts.', + detail: raw, + } + } + return { kind: 'invalid', title: 'The workflow rejected this submission.', detail: raw } + } + + if (status === 401) { + return { kind: 'auth', title: 'The session has expired.', detail: 'Sign in again to continue.' } + } + + return { kind: 'unknown', title: raw || 'Something went wrong.', detail: null } +} + +/** + * Turns the workflow's field-validation reply into something a person can act + * on, using the form's own labels. + * + * The server answers with the machine keys and a bare reason: + * + * validation failed for activity zk-act-init-agent: product_line_4(required) + * + * Shown raw, that asks an operator to know what a field_s_id is, that `_4` is a + * disambiguating suffix rather than part of a name, and that "required" is a + * rule and not a value. All three are ours to know, not theirs. + * + * `fields` is the schema array, so the reply is rendered in the same words the + * form used a line above it — "Product Line", not product_line_4. A key the + * schema does not carry keeps its raw name: a wrong label is worse than an + * ugly one, because the operator goes looking for a field that is not there. + * + * Returns null when the message is not a field-validation reply, so callers + * can fall through to describeError. + */ +export function describeValidation(rawMessage, fields = []) { + const raw = String(rawMessage ?? '') + if (!/validation failed/i.test(raw)) return null + + const label = (key) => + fields.find((f) => f.id === key)?.name ?? + // Same suffix rule the prefill seeding uses: the activity key may carry a + // numeric tail the global name does not. + fields.find((f) => String(f.id).replace(/_\d+$/, '') === key)?.name ?? + key + + const reasons = { + required: (n) => `${n} is required`, + unknown: (n) => `${n} is not a field this form accepts`, + enum: (n) => `${n} is not one of the available choices`, + invalid: (n) => `${n} is not valid`, + type: (n) => `${n} is the wrong kind of value`, + } + + const items = [] + const re = /([A-Za-z0-9_.]+)\s*\(([^)]+)\)/g + let m + while ((m = re.exec(raw)) !== null) { + const name = label(m[1]) + const why = String(m[2]).toLowerCase().trim() + const key = Object.keys(reasons).find((k) => why.includes(k)) + items.push(key ? reasons[key](name) : `${name}: ${why}`) + } + + if (!items.length) return null + return { + kind: 'validation', + // One problem reads as a sentence; several read as a list. + title: items.length === 1 ? items[0] : 'Some details are missing or not accepted.', + items, + } +} diff --git a/src/api/holder.js b/src/api/holder.js new file mode 100644 index 0000000..3d1ed7f --- /dev/null +++ b/src/api/holder.js @@ -0,0 +1,36 @@ +import { phaseOf } from './config.js' + +/** + * Who is holding a lead right now, said the way the colour rule says it: + * blue = the AI, amber = a person, teal = the customer, grey = nobody (closed). + * + * Derived from phaseOf, so it agrees with the sidebar tallies and the lead + * header. `label` is the short form for a table cell: "AI · Intake", + * "Underwriter", "Customer", "Closed". + */ +const AI_NAMES = [ + ['intake', 'Intake'], ['engage', 'Engage'], ['advisor', 'Advisor'], + ['kyc', 'KYC'], ['voice', 'Meera'], ['rating', 'Rating'], +] + +function tidy(by) { + const s = String(by || '').replace(/^(the|an|a)\s+/i, '').trim() + return s ? s.charAt(0).toUpperCase() + s.slice(1) : '—' +} + +export function holderOf(stageDef, row) { + if (!stageDef) return { tone: 'grey', label: '—' } + const ph = phaseOf(stageDef, row) || stageDef + switch (ph.kind) { + case 'auto': { + const by = String(ph.by || '').toLowerCase() + const hit = AI_NAMES.find(([k]) => by.includes(k)) + return { tone: 'blue', label: 'AI · ' + (hit ? hit[1] : 'Engage') } + } + case 'customer': return { tone: 'teal', label: 'Customer' } + case 'needs': return { tone: 'amber', label: tidy(ph.by) } + case 'waiting': return { tone: 'grey', label: 'Calendar' } + case 'end': return { tone: 'grey', label: 'Closed' } + default: return { tone: 'grey', label: tidy(ph.by) } + } +} diff --git a/src/api/lead.js b/src/api/lead.js new file mode 100644 index 0000000..c3ee3a1 --- /dev/null +++ b/src/api/lead.js @@ -0,0 +1,163 @@ +import { PRODUCTS } from './config.js' + +/** + * The vocabulary of a lead: how to read a renewal date, how to name a field, + * which fields belong together, and what a blocker means in words. + * + * Lifted out of the desktop console's lead screen unchanged, so the phone and + * the desktop describe the same lead the same way. A divergence here would be + * two products wearing one brand. + */ +/** The countdown, not just the date. On a renewal book this is the number + * that decides whether anyone should act today. */ +export function expiryOf(dateStr) { + if (!dateStr) return null + const d = new Date(String(dateStr).substring(0, 10) + 'T00:00:00Z') + if (isNaN(d)) return null + const days = Math.round((d.getTime() - Date.parse(new Date().toISOString().substring(0, 10) + 'T00:00:00Z')) / 86400000) + return { + days, + tone: days < 0 ? 'lapsed' : days <= 7 ? 'urgent' : days <= 30 ? 'soon' : 'later', + label: days < 0 ? Math.abs(days) + ' days overdue' : days === 0 ? 'expires today' : 'in ' + days + ' days', + on: d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }), + } +} + +export function inrShort(v) { + const n = Number(v) + if (!Number.isFinite(n) || !n) return null + if (n >= 1e7) return '₹' + (n / 1e7).toFixed(2) + ' Cr' + if (n >= 1e5) return '₹' + (n / 1e5).toFixed(2) + ' L' + return '₹' + Math.round(n).toLocaleString('en-IN') +} + +export const MONEY = new Set(['quoted_premium','quoted_od_premium','quoted_tp_premium','quoted_addon_premium', + 'quoted_gst','commission_base','commission_amount','sme_building_si','sme_plant_si','sme_furniture_si', + 'sme_rawmaterial_si','sme_wip_si','sme_finished_si','sme_other_si','sme_value_at_risk','motor_idv', + 'sme_burglary_si','sme_ee_si','sme_bi_gross_profit','sme_claims_36m_amount','sme_stock_si']) + +/** Field groups, in the order the lead was actually worked. */ +/* What the AI said stopped it, turned into a sentence an operator can act on. + Keyed by the stored value so an unknown one degrades to the generic line. */ +export const BLOCKER = { + missing_evidence: 'It says the evidence it needed was not there.', + contradictory_data: 'It says the evidence it had contradicts itself.', + tool_failed: 'It says a lookup or a tool it depends on failed.', + outside_my_remit: 'It says the judgement is a person\u2019s to make, not its own.', + other: 'It could not complete its step.', +} + +export const GROUPS = [ + ['Source', ['lead_ref','product_line','source_channel','partner_code','partner_branch','rm_or_agent_id','consent_artefact']], + // First, because when a lead is flagged this is the only thing anyone opens + // it to read. Empty on every lead that was never flagged, and an empty group + // does not render. + ['Flagged for review', ['review_blocker','review_reason','review_return','review_note']], + ['Customer', ['customer_name','entity_name','mobile','email','pan','gstin','udyam_no']], + ['Intake', ['lead_score','attribution_status','attribution_reason','dedupe_match_ref','eligibility_outcome','eligibility_reason']], + ['Contact', ['contact_outcome','outreach_window','contact_notes','call_transcript']], + // The five OCR slots led this list and were absent from it, which is how a + // lead with an RC, an expiring policy and a PAN attached showed "Documents 2" + // — the status and the notes. They were also absent from the view itself + // until 76; adding them here without that would have changed nothing. + ['Documents', ['doc_rc','doc_prev_policy','doc_pan','doc_gst_cert','doc_udyam_cert', + 'doc_address_proof','doc_premises_proof','doc_stock_statement','doc_premises_photos', + 'doc_vehicle_photos','doc_financials','documents_status','documents_notes']], + ['SME risk', ['sme_product_variant','sme_occupancy','sme_location_address','sme_building_si','sme_plant_si','sme_furniture_si','sme_rawmaterial_si','sme_wip_si','sme_finished_si','sme_stock_si','sme_other_si','sme_value_at_risk','sme_floor','sme_num_floors','sme_floor_material','sme_walls','sme_roof','sme_building_age_band','sme_unit_age_years','sme_fire_protection','sme_fire_amc','sme_fire_brigade_km','sme_claims_36m_count','sme_claims_36m_amount','sme_sections','sme_bi_gross_profit','sme_bi_indemnity_months','sme_burglary_si','sme_ee_si']], + ['Motor risk', ['motor_reg_no','motor_make_model','motor_mfg_year','motor_cc','motor_fuel','motor_idv','motor_ncb_pct','motor_addons','motor_prev_insurer','motor_prev_policy_no','motor_prev_expiry','motor_prev_claim']], + ['AI advice', ['ai_recommended_cover','ai_recommended_addons','ai_recommendation_rationale','ai_recommendation_confidence']], + ['Quote', ['product_code','quoted_od_premium','quoted_tp_premium','quoted_addon_premium','quoted_section_premiums','quoted_gst','quoted_premium','quoted_breakup','quote_valid_till']], + ['Conversation', ['customer_reply','customer_reply_from','customer_answer','answer_count']], + ['Proposal', ['acceptance_ref','accepted_at']], + ['KYC', ['kyc_mode','kyc_ref','kyc_outcome','kyc_mismatch_notes']], + ['Underwriting', ['uw_outcome','uw_survey_required','referral_analysis','uw_referral_reason','uw_decision_notes']], + ['Policy', ['payment_link','payment_ref','premium_realised','realised_at','policy_no','policy_issued_at']], + ['Commission', ['commission_rate_pct','commission_base','commission_amount','payout_status','payout_ref','payout_at']], + ['Outcome', ['lost_reason','welcome_sent','renewal_due']], +] + +/** Past this, a value is prose and gets folded rather than printed in full. */ +export const LONG = 150 + +/** + * WHAT THE PAGE SHOWS WITHOUT BEING ASKED. + * + * Three things are always true of a lead — who the customer is, what is being + * insured, and what has been attached — so those are always here. The fourth + * section depends on where the lead has got to: a lead in Quoted needs the + * cover and the quote's expiry; the same lead in Onboarded needs the policy + * number and when it renews. Everything else is in the full file. + * + * THIS IS A MIRROR, in the same sense as STAGES, ACTIONS and DOC_SLOTS, and it + * carries the same hazard: a stage not listed here simply gets no fourth + * section, and a field renamed in the workflow quietly stops appearing. That is + * the safe direction to fail — the full file is generated from the data and + * still holds everything — but it is a mirror, not a source. + * + * The metrics strip at the top of the page already carries premium, commission, + * product, renewal date, lead age and AI confidence. Nothing is repeated here. + */ +export const GLANCE_STAGE = { + 'zk-state-new': ['lead_score', 'eligibility_outcome'], + 'zk-state-qualified': ['lead_score', 'eligibility_outcome', 'outreach_window'], + 'zk-state-contacted': ['contact_outcome', 'outreach_window'], + 'zk-state-docs': ['documents_status', 'motor_prev_expiry'], + 'zk-state-quoted': ['ai_recommended_cover', 'quote_valid_till', 'acceptance_ref'], + 'zk-state-payment': ['payment_ref', 'quote_valid_till'], + 'zk-state-issued': ['policy_no', 'policy_issued_at', 'payment_ref'], + 'zk-state-onboarded': ['policy_no', 'policy_issued_at', 'renewal_due', 'payout_status'], + 'zk-state-referred': ['uw_outcome', 'uw_referral_reason'], + 'zk-state-parked': ['contact_outcome', 'outreach_window'], + 'zk-state-lost': ['lost_reason', 'contact_outcome'], +} + +/** The risk, in whichever line's vocabulary this lead is written. */ +export const GLANCE_RISK = { + motor: ['motor_reg_no', 'motor_make_model', 'motor_mfg_year', 'motor_idv', 'motor_ncb_pct'], + sme: ['sme_product_variant', 'sme_occupancy', 'sme_value_at_risk'], +} + +/** + * Field ids are snake_case and several carry acronyms, which sentence-casing + * turns into "Pan" and "Gstin". Only the words that need it are listed; every + * other word passes through. + * + * This is the FALLBACK. The detail view ships an output_label for every field + * and that is preferred — this covers a field the config does not describe. + */ +export const WORDS = { + pan: 'PAN', gstin: 'GSTIN', kyc: 'KYC', ai: 'AI', sme: 'SME', uw: 'UW', + od: 'OD', tp: 'TP', gst: 'GST', idv: 'IDV', ncb: 'NCB', rm: 'RM', si: 'SI', + cc: 'CC', id: 'ID', no: 'no.', pct: '%', wip: 'WIP', bi: 'BI', ee: 'EE', + amc: 'AMC', km: 'km', posp: 'POSP', +} + +export function label(k) { + const words = k.split('_').map((w) => WORDS[w] ?? w).join(' ') + return words.charAt(0).toUpperCase() + words.slice(1) +} + +/** The uploaded files on a field value, if that is what it holds. */ +export function filesOf(v) { + return Array.isArray(v) ? v.filter((f) => f && typeof f === 'object' && f.uuid) : [] +} + +export function fmt(k, v) { + if (v === null || v === undefined || v === '') return null + if (k === 'product_line') return PRODUCTS[v] ?? String(v) + if (Array.isArray(v)) { + // A file field holds an array of upload references — {uuid, original_name, + // blob_path, …} — so joining it raw prints [object Object]. + return v + .map((x) => (x && typeof x === 'object' ? (x.original_name || x.file_name || x.uuid || '') : x)) + .filter((x) => x !== '' && x !== null && x !== undefined) + .join(', ') || null + } + // No field carries a bare object today, but one arriving as JSON should not + // print as [object Object]. + if (typeof v === 'object') return JSON.stringify(v) + if (MONEY.has(k)) { const n = Number(v); return Number.isFinite(n) ? '₹' + n.toLocaleString('en-IN') : String(v) } + return String(v) +} + + diff --git a/src/api/permissions.js b/src/api/permissions.js new file mode 100644 index 0000000..dc6c31a --- /dev/null +++ b/src/api/permissions.js @@ -0,0 +1,159 @@ +import { ACTIONS, STAGES } from './config.js' + +/** + * Who may perform what. + * + * A MIRROR of workflow.tbl_wf_activity_permissions on app 536, in the same + * sense as STAGES and ACTIONS: the console needs it to decide what to draw, + * and the API returns a refusal rather than a capability list. + * + * ── This is presentation, never enforcement ────────────────────────────── + * The workflow refuses server-side on every submission, and that refusal is + * the real control. Nothing here can grant anything; a role added here that + * the platform does not recognise gets a 403 the moment it submits. What this + * buys is that an operator is not shown four buttons they will be refused for + * pressing — which on a demo reads as a broken app rather than as a fence. + * + * Keep in step with the permissions table. A role REMOVED there but left here + * shows a button that 403s: recoverable and visible. A role ADDED there but + * missing here hides a button that would have worked: invisible, and the + * failure mode worth watching for. + */ +export const ACTIVITY_ROLES = { + 'zk-act-init-agent': ['ops_admin', 'partner_agent'], + 'zk-act-init-bank': ['ops_admin', 'bank_rm'], + 'zk-act-init-direct': ['ops_admin', 'csr_direct'], + + 'zk-act-qualify': ['ops_admin', 'ai_intake'], + 'zk-act-contact': ['ops_admin', 'ai_engage'], + 'zk-act-request-docs': ['ops_admin', 'ai_engage'], + 'zk-act-collect-docs': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'], + 'zk-act-capture-motor':['ops_admin', 'ai_engage'], + 'zk-act-capture-sme': ['ops_admin', 'ai_engage'], + 'zk-act-advise': ['ai_advisor'], + 'zk-act-quote': ['ops_admin', 'ai_engage'], + 'zk-act-accept': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'], + // Ops only. The customer's questions are normally Engage's, and this is + // the way a person answers one Engage declined — through the same WhatsApp + // thread, so the reply lands on the lead file instead of somewhere nobody + // can find it later. + 'zk-act-answer-customer': ['ops_admin'], + 'zk-act-kyc': ['ops_admin', 'ai_kyc'], + 'zk-act-uw-screen': ['ops_admin', 'ai_kyc'], + 'zk-act-uw-prepare': ['ai_uw_referral'], + + // The only exclusive permission in the application. Ops runs everything + // else and cannot touch these two — a referral is cleared by an underwriter + // or it is not cleared. + 'zk-act-uw-clear': ['sme_underwriter'], + 'zk-act-uw-decline': ['sme_underwriter'], + + 'zk-act-payment': ['ops_admin', 'ai_engage'], + // Zero roles in the workflow since 86 — the scheduler performs it, and a + // scheduled activity with any role list fails silently. Left here as an + // empty list so the mirror still records that the activity exists. + 'zk-act-nudge': [], + 'zk-act-realise': ['ops_admin'], + 'zk-act-onboard': ['ops_admin', 'ai_engage'], + 'zk-act-resume': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'], + + // The AI's one way of saying "a person should take this". Every employee + // holds it, and so does ops — a person can park a lead for the same reasons. + 'zk-act-flag-review': ['ops_admin', 'ai_intake', 'ai_engage', 'ai_kyc', 'ai_advisor', 'ai_uw_referral'], + // And the two ways back out of Needs a Person. The underwriter is here too: + // a flag they are sent is theirs to route, not only ops'. + 'zk-act-review-resume':['ops_admin', 'sme_underwriter'], + 'zk-act-review-refer': ['ops_admin', 'sme_underwriter'], + 'zk-act-drop': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'], + + // ── The one place this mirror deliberately DISAGREES with the workflow ── + // + // Both of these carry NO roles in tbl_wf_activity_permissions, which the + // platform reads as "open to anyone". That is not generosity: the scheduler + // performs them as the synthetic `system` actor, and the trigger's own + // perform step ignores allow_system_perform, so zero roles is the only + // configuration under which a scheduled activity can run at all. See + // 63_scheduled_activities_rbac.sql. + // + // Open to the SYSTEM is not the same as open to everyone signing in. Left + // literal, an underwriter is offered "Send Document Reminder" on a queue + // they have no business in. So the UI narrows them to the roles that would + // plausibly chase by hand. The workflow still accepts either from anyone — + // this hides a button, it does not close a door. + 'zk-act-doc-reminder': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'], + 'zk-act-retry-call': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'], +} + +/** Roles that belong to an AI employee. They never sign in. */ +export const AI_ROLES = new Set([ + 'ai_intake', 'ai_engage', 'ai_advisor', 'ai_kyc', 'ai_uw_referral', +]) + +export function rolesOf(user) { + const r = user?.roles + if (Array.isArray(r)) return r.filter(Boolean).map(String) + if (Array.isArray(user?.role_assignments)) { + return user.role_assignments.map((x) => x?.role_id).filter(Boolean).map(String) + } + return [] +} + +/** + * An empty role list means the activity is open to any signed-in user — that + * is what the platform's RBAC does with no roles attached, so mirroring it + * here keeps the two from disagreeing. + */ +export function canPerform(roles, activityUid) { + const allowed = ACTIVITY_ROLES[activityUid] + if (!allowed) return true // unknown activity: show it, let the API decide + if (allowed.length === 0) return true + return roles.some((r) => allowed.includes(r)) +} + +/** The activities this user may actually run from a given stage. */ +export function actionsFor(roles, stageUid) { + return (ACTIONS[stageUid] || []).filter((a) => canPerform(roles, a.uid)) +} + +/** + * Activities that are available almost everywhere, or that no person runs. + * + * They must not count towards "does this role have business in this queue". + * Mark Lost is offered in eight stages to every agent role, and the two + * scheduled activities carry no roles at all — which the platform reads as + * open. Left in the test, between them they make every queue look relevant + * to everybody, which is the whole thing this is trying to avoid. + */ +const AMBIENT = new Set(['zk-act-drop', 'zk-act-doc-reminder', 'zk-act-retry-call']) + +/** + * Which queues a role has any business WORKING IN. + * + * Terminal stages stay visible to everyone — closed business is reporting, + * not work, and hiding it would leave an underwriter unable to see what + * became of a file they declined. Everything else appears only where the + * role can do something substantive. + * + * This gates the SIDEBAR, not the data. The overview still counts the whole + * book for every role, because an agent tracking leads they filed is a + * reasonable thing to want and a queue they cannot act in is not. + */ +export function visibleStages(roles) { + // Operations runs the desk. They cannot clear a referral — that stays + // exclusive to the underwriter — but the underwriting queue is theirs to + // watch, and an ops console that hides a queue because it holds one + // activity they may not press is hiding the wrong thing. + if (roles.includes('ops_admin')) return STAGES + + return STAGES.filter((s) => { + if (s.kind === 'end') return true + return (ACTIONS[s.uid] || []) + .filter((a) => !AMBIENT.has(a.uid)) + .some((a) => canPerform(roles, a.uid)) + }) +} + +/** Whether this user can file a lead at all, and through which doors. */ +export function entryDoorsFor(roles, entries) { + return entries.filter((e) => canPerform(roles, e.uid)) +} diff --git a/src/api/portfolio.jsx b/src/api/portfolio.jsx new file mode 100644 index 0000000..4d46450 --- /dev/null +++ b/src/api/portfolio.jsx @@ -0,0 +1,109 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import { useZino } from './provider.jsx' +import { RV_LEADS, STAGES, phaseOf } from './config.js' + +/** + * The whole book, fetched ONCE and shared. + * + * The sidebar used to carry no counts, on the reasoning that a tally could only + * come from a second full list call that would then disagree with the queue's + * own total. That reasoning was right about the cost and wrong about the + * conclusion: a sidebar with no numbers means the only way to learn whether + * anything is waiting on you is to click all nine queues, which is the one + * question a console's navigation exists to answer. + * + * So there is no second call. The overview already fetched the whole book on a + * timer; that fetch moves here and both surfaces read it, which is one call + * fewer than before and makes the two agree by construction. + * + * Bounded at 200 rows, as the overview always was. Beyond that the honest + * answer is an aggregate endpoint rather than a bigger limit, and the counts + * would need to say they are partial — worth knowing before this app meets a + * real book. + */ + +const PortfolioContext = createContext(null) +const EMPTY = [] + +export function PortfolioProvider({ children }) { + const { client, user } = useZino() + // Identity, not just presence: switching user must not show the previous + // one's book while the new fetch is in flight. + const who = user?.id ?? user?.email ?? null + // Seeded from `who` rather than set inside the effect. Nothing here writes + // state synchronously during a render pass — a signed-out shell is 'ready' + // and empty from the first frame, and the first fetch only ever moves it + // forwards, so there is no loading flash and no cascading render. + const [state, setState] = useState(() => ({ + status: who ? 'loading' : 'ready', rows: EMPTY, at: null, error: null, + })) + const cancelled = useRef(false) + + const load = useCallback(() => { + if (!who) return Promise.resolve() + return client.recordView(RV_LEADS, { limit: 200 }) + .then((res) => { + if (cancelled.current) return + setState({ status: 'ready', rows: res?.data ?? res?.rows ?? res?.records ?? EMPTY, at: new Date(), error: null }) + }) + .catch((err) => { + if (cancelled.current) return + // A failed poll leaves a good book on screen. Only a first load is an error. + setState((p) => (p.status === 'ready' ? p : { status: 'error', rows: EMPTY, at: null, error: err })) + }) + }, [client, who]) + + useEffect(() => { + cancelled.current = false + load() + const id = setInterval(() => { if (document.visibilityState === 'visible') load() }, 30000) + return () => { cancelled.current = true; clearInterval(id) } + }, [load]) + + const value = useMemo(() => ({ ...state, reload: load }), [state, load]) + return {children} +} + +export function usePortfolio() { + const ctx = useContext(PortfolioContext) + if (!ctx) throw new Error('usePortfolio must be used inside PortfolioProvider') + return ctx +} + +const DAY = 86400000 +const daysToExpiry = (v) => { + if (!v) return null + const d = Date.parse(String(v).substring(0, 10) + 'T00:00:00Z') + return isNaN(d) ? null : Math.round((d - Date.parse(new Date().toISOString().substring(0, 10) + 'T00:00:00Z')) / DAY) +} + +/** + * Per-stage tallies for the sidebar. + * + * `waiting` is the number that goes on the badge: leads whose PHASE still needs + * a person. `working` is the rest — in the state, carried by an agent, not + * anyone's task. Badging the state total told an operator four leads needed + * them when two did, which is how a queue stops being believed. + * + * `urgent` marks a queue holding a renewal that has lapsed or expires within a + * week. It is the only reason to look at one queue before another, and it was + * invisible until you opened each one. + */ +export function useStageCounts() { + const { rows, status } = usePortfolio() + return useMemo(() => { + const out = {} + for (const s of STAGES) out[s.uid] = { total: 0, waiting: 0, working: 0, urgent: 0 } + for (const r of rows) { + const s = STAGES.find((x) => x.name === r.current_state_name) + if (!s) continue + const t = out[s.uid] + t.total += 1 + if (s.kind !== 'end' && phaseOf(s, r).kind === 'auto') t.working += 1 + else if (s.kind !== 'end') t.waiting += 1 + const d = daysToExpiry(r.renewal_due_date) + if (s.kind !== 'end' && d !== null && d <= 7) t.urgent += 1 + } + return { counts: out, ready: status === 'ready' } + }, [rows, status]) +} diff --git a/src/api/provider.jsx b/src/api/provider.jsx new file mode 100644 index 0000000..9adcfca --- /dev/null +++ b/src/api/provider.jsx @@ -0,0 +1,51 @@ +import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react' +import { ZinoClient } from './client' + +const ZinoContext = createContext(null) + +/** + * Holds the client and the session. + * + * The stored user is restored on boot so a refresh does not flash the login + * screen. It restores an IDENTITY, never a permission set: every permission + * decision is the workflow's, made server-side on each submission. Nothing here + * hides a button to enforce a rule — the platform refuses, and the console + * reports what it said. + */ +export function ZinoProvider({ baseUrl, children }) { + const clientRef = useRef(null) + if (!clientRef.current) clientRef.current = new ZinoClient(baseUrl) + const client = clientRef.current + + const [user, setUser] = useState(() => (client.getToken() ? client.getStoredUser() : null)) + + const signOut = useCallback(() => { + client.logout() + setUser(null) + }, [client]) + + client.setAuthErrorHandler(() => setUser(null)) + + const signIn = useCallback( + async (email, password, orgId) => { + const res = await client.login(email, password, orgId) + const u = res?.user || { email } + setUser(u) + return u + }, + [client], + ) + + const value = useMemo( + () => ({ client, user, signIn, signOut, isAuthed: Boolean(user && client.getToken()) }), + [client, user, signIn, signOut], + ) + + return {children} +} + +export function useZino() { + const ctx = useContext(ZinoContext) + if (!ctx) throw new Error('useZino must be used inside ') + return ctx +} diff --git a/src/api/thread.js b/src/api/thread.js new file mode 100644 index 0000000..0ac01f8 --- /dev/null +++ b/src/api/thread.js @@ -0,0 +1,216 @@ +import { AGENTS } from './agents.js' +import { baseFieldId } from './config.js' + +/** + * THE WHATSAPP THREAD, BUILT ONCE. + * + * The conversation was being reconstructed twice — the timeline read it per + * audit row, the dialog read it per field — and the two disagreed. The trail + * printed one entry per turn, so a four-message exchange took more of the page + * than the entire underwriting chain, and a message the workflow recorded from + * inside a trigger (the payment request, the issuance note) appeared in the + * dialog and nowhere in the trail. One builder, two readers, one answer. + * + * WHY A THREAD IS NOT A LIST OF ROWS. The audit trail records activities; the + * conversation is a thing that persists ACROSS them. Three facts make it awkward + * and they are all handled here rather than in each screen's own guess: + * + * 1. The customer's turns and ours are separate activities — `customer-reply` + * is performed by the channel, `answer-customer` by Engage — so a turn is + * a FIELD on a row, not a row. + * 2. Two of our sends are not chat activities at all. Request Premium and the + * issuance step compose their message inside a trigger and write it to + * `customer_answer` alongside their own work (see 98/99/100 in the seed + * SQL). They belong in the thread and stay on their own step in the trail. + * 3. One submission is recorded up to three times — the trigger commit, its + * repeat, and the settle — so the same sentence arrives three times. + * + * `episodes` is what makes this renderable as a timeline entry. The thread is + * one conversation, but it happens in bursts: the customer argues about the + * quote, then KYC and underwriting run for twenty minutes, then the premium + * request reopens the same thread. An episode is one burst. Collapsing the + * whole thread into a single entry would date the payment exchange to the + * moment of the quote objection and destroy the chronology the trail exists + * for; leaving every turn as its own entry is what it did before. A burst is + * the unit that is both honest and short. + */ + +/** The two activities that ARE the thread — one turn each, either direction. */ +export const CHAT_ACTS = new Set(['zk-act-customer-reply', 'zk-act-answer-customer']) + +const INBOUND = 'customer_reply' +const OUTBOUND = 'customer_answer' + +/** + * The fields the thread OWNS. A step that writes one is not asked to print it + * as well — the message belongs to the conversation, and quoting it on the step + * too is how the payment request came to say the same sentence twice. + */ +export const THREAD_FIELDS = new Set([INBOUND, OUTBOUND]) + +/** Bookkeeping rows. They interrupt nothing: the platform writing a field is + * not a step that broke off a conversation. */ +const SYSTEM_ACT = 'DATA_UPDATE' + +/** + * Last resort for a step whose activity_name did not come back — without it the + * trail prints a raw slug like "zk-act-doc-reminder" in the middle of an + * otherwise readable story. A guess, but one that reads as English. + */ +export function prettyUid(uid) { + if (!uid) return 'Step' + return String(uid) + .replace(/^zk-act-/, '') + .split('-') + .filter(Boolean) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' ') +} + +/** What to call a step. The server's own name first; the slug, tidied, after. */ +export function stepName(row) { + return row?.activity_name || prettyUid(row?.activity_id) +} + +/** + * `fields[]` is the platform's typed rendering of a submission; `data` is the + * raw fallback for a row the workflow could not resolve. + */ +function fieldsOf(row) { + return Array.isArray(row.fields) && row.fields.length + ? row.fields + : Object.entries(row.data || {}).map(([k, v]) => ({ field_id: k, value: v })) +} + +/** Who sent an outbound turn. Usually Engage; sometimes a person answering by + * hand, which must not be attributed to the machine. */ +function senderOf(row) { + const key = (row.user_roles || []).find((r) => AGENTS[r]) || null + return { agentKey: key, by: key ? AGENTS[key].short : (row.user_name || 'Zurich Kotak') } +} + +/** The turns carried by one audit row, in field order. */ +function turnsInRow(row) { + const out = [] + for (const f of fieldsOf(row)) { + const base = baseFieldId(f.field_id) + if (!THREAD_FIELDS.has(base)) continue + const text = typeof f.value === 'string' ? f.value.trim() : '' + if (!text) continue + out.push({ side: base === INBOUND ? 'them' : 'us', text }) + } + return out +} + +/** + * The thread, its bursts, and which audit row carries what. + * + * Returns: + * turns every message, oldest first + * episodes contiguous bursts, each with the steps that preceded it + * byRow row id -> { episodeId, sent, received }, so a step that sent a + * message can link into the thread instead of quoting it + */ +export function buildThread(rows) { + const empty = { turns: [], episodes: [], byRow: new Map() } + if (!Array.isArray(rows) || !rows.length) return empty + + // Sorted on (timestamp, id). One submission writes three rows in the same + // second, so a timestamp alone leaves their order to the sort's stability and + // puts a reply before the message it answers whenever the two land together. + const ordered = [...rows].sort((a, b) => { + const t = String(a.created_at).localeCompare(String(b.created_at)) + return t !== 0 ? t : (Number(a.id) || 0) - (Number(b.id) || 0) + }) + + const turns = [] + const episodes = [] + const byRow = new Map() + let current = null + // The steps seen since the last message, and whether any of them broke off + // the conversation. `pending` accumulates even before the first message so + // that the first episode can drop it: there is nothing to bridge yet. + let pending = [] + let broke = false + + for (const row of ordered) { + const mine = turnsInRow(row) + + if (!mine.length) { + // A chat activity that recorded no text is not a step that interrupted + // anything — it is an empty row. Bookkeeping is not a step either. + if (row.activity_id === SYSTEM_ACT || CHAT_ACTS.has(row.activity_id)) continue + if (current) broke = true + const name = stepName(row) + if (name && !pending.includes(name)) pending.push(name) + continue + } + + const who = senderOf(row) + for (const t of mine) { + const prev = turns[turns.length - 1] + // De-duped against the PREVIOUS turn only. A customer who asks the same + // thing twice because the first went unanswered has said it twice, and a + // thread that showed it once would hide exactly the impatience an + // operator needs to see. Only an immediate repeat is the platform + // recording one submission three times. + if (prev && prev.side === t.side && prev.text === t.text) continue + + if (!current || broke) { + current = { + id: `ep${episodes.length + 1}`, + turns: [], + // What ran between this burst and the one before it. Empty on the + // first, which begins the conversation rather than resuming it. + after: episodes.length ? pending : [], + anchorRowId: row.id, + anchorIsChat: CHAT_ACTS.has(row.activity_id), + } + episodes.push(current) + broke = false + } + pending = [] + + const turn = { + key: `${row.id}-${t.side}-${turns.length}`, + side: t.side, + text: t.text, + at: row.created_at, + rowId: row.id, + episodeId: current.id, + agentKey: t.side === 'us' ? who.agentKey : null, + by: t.side === 'us' ? who.by : 'the customer', + } + current.turns.push(turn) + turns.push(turn) + + const tally = byRow.get(row.id) || { episodeId: current.id, sent: 0, received: 0 } + if (t.side === 'us') tally.sent += 1 + else tally.received += 1 + byRow.set(row.id, tally) + } + } + + for (const ep of episodes) { + ep.count = ep.turns.length + ep.from = ep.turns[0].at + ep.to = ep.turns[ep.turns.length - 1].at + // The last exchange, which is where the conversation GOT to — what an + // operator scanning the trail is actually looking for. + ep.preview = ep.turns.slice(-2) + } + + return { turns, episodes, byRow } +} + +/** + * "Verify KYC and Underwriting Screen", "A, B +2 more" — a bridge, not a list. + * Used for the steps that interrupted a conversation and for the voices in it. + */ +export function listOf(names, max = 2) { + if (!names || !names.length) return '' + if (names.length <= max) { + return names.length === 1 ? names[0] : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}` + } + return `${names.slice(0, max).join(', ')} +${names.length - max} more` +} diff --git a/src/assets/brand/zurich_logo.webp b/src/assets/brand/zurich_logo.webp new file mode 100644 index 0000000..6f5c27e Binary files /dev/null and b/src/assets/brand/zurich_logo.webp differ diff --git a/src/assets/fonts/source-sans-3-latin-400-normal.woff2 b/src/assets/fonts/source-sans-3-latin-400-normal.woff2 new file mode 100644 index 0000000..0c4242e Binary files /dev/null and b/src/assets/fonts/source-sans-3-latin-400-normal.woff2 differ diff --git a/src/assets/fonts/source-sans-3-latin-500-normal.woff2 b/src/assets/fonts/source-sans-3-latin-500-normal.woff2 new file mode 100644 index 0000000..5918916 Binary files /dev/null and b/src/assets/fonts/source-sans-3-latin-500-normal.woff2 differ diff --git a/src/assets/fonts/source-sans-3-latin-600-normal.woff2 b/src/assets/fonts/source-sans-3-latin-600-normal.woff2 new file mode 100644 index 0000000..9351177 Binary files /dev/null and b/src/assets/fonts/source-sans-3-latin-600-normal.woff2 differ diff --git a/src/assets/fonts/source-sans-3-latin-700-normal.woff2 b/src/assets/fonts/source-sans-3-latin-700-normal.woff2 new file mode 100644 index 0000000..3cb944c Binary files /dev/null and b/src/assets/fonts/source-sans-3-latin-700-normal.woff2 differ diff --git a/src/assets/fonts/source-sans-3-latin-ext-400-normal.woff2 b/src/assets/fonts/source-sans-3-latin-ext-400-normal.woff2 new file mode 100644 index 0000000..73c9edc Binary files /dev/null and b/src/assets/fonts/source-sans-3-latin-ext-400-normal.woff2 differ diff --git a/src/assets/fonts/source-sans-3-latin-ext-500-normal.woff2 b/src/assets/fonts/source-sans-3-latin-ext-500-normal.woff2 new file mode 100644 index 0000000..dcaff79 Binary files /dev/null and b/src/assets/fonts/source-sans-3-latin-ext-500-normal.woff2 differ diff --git a/src/assets/fonts/source-sans-3-latin-ext-600-normal.woff2 b/src/assets/fonts/source-sans-3-latin-ext-600-normal.woff2 new file mode 100644 index 0000000..4ebc744 Binary files /dev/null and b/src/assets/fonts/source-sans-3-latin-ext-600-normal.woff2 differ diff --git a/src/assets/fonts/source-sans-3-latin-ext-700-normal.woff2 b/src/assets/fonts/source-sans-3-latin-ext-700-normal.woff2 new file mode 100644 index 0000000..2b448cb Binary files /dev/null and b/src/assets/fonts/source-sans-3-latin-ext-700-normal.woff2 differ diff --git a/src/components/ActivityForm.css b/src/components/ActivityForm.css new file mode 100644 index 0000000..4cacf91 --- /dev/null +++ b/src/components/ActivityForm.css @@ -0,0 +1,398 @@ +.af { + display: flex; + flex-direction: column; + gap: 20px; +} + +.af__loading { + color: var(--zk-muted); + font-size: var(--fs-xs); +} + +.af__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(248px, 1fr)); + gap: 16px 20px; +} + +.af__field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.af__field--wide { + grid-column: 1 / -1; +} + +.af__field > span { + font-size: var(--fs-2xs); + font-weight: 500; + letter-spacing: 0.03em; + color: var(--zk-muted); + display: flex; + align-items: center; + gap: 8px; +} + +.af__req { + font-style: normal; + font-size: var(--fs-3xs); + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--zk-blue); + background: var(--zk-tint-blue); + padding: 2px 7px; + border-radius: var(--r-pill); +} + +.af input, +.af select, +.af textarea { + font: inherit; + font-size: var(--fs-xs); + padding: 10px 12px; + border-radius: var(--r-md); + border: 1px solid var(--zk-line); + background: var(--zk-white); + color: var(--zk-ink); + width: 100%; + transition: border-color var(--t-fast), box-shadow var(--t-fast), background var(--t-fast); +} + +.af input:hover, +.af select:hover, +.af textarea:hover { + border-color: var(--zk-blue-light); +} + +.af input:focus, +.af select:focus, +.af textarea:focus { + outline: none; + border-color: var(--zk-blue); + box-shadow: var(--ring); +} + +.af select { + appearance: none; + padding-right: 34px; + /* The caret is drawn rather than loaded: an inline data URI keeps the select + consistent across platforms without another asset to serve. */ + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M2.5 4.5 6 8l3.5-3.5' fill='none' stroke='%235d6162' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + background-size: 12px; +} + +.af textarea { + resize: vertical; + min-height: 84px; + line-height: 1.55; +} + +.af__multi { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.af__chip { + font: inherit; + font-size: var(--fs-2xs); + padding: 6px 13px; + border-radius: var(--r-pill); + cursor: pointer; + border: 1px solid var(--zk-line); + background: var(--zk-white); + color: var(--zk-muted); + transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast), box-shadow var(--t-fast); +} + +.af__chip:hover { + border-color: var(--zk-blue-light); + background: var(--zk-tint); + color: var(--zk-ink); +} + +.af__chip.is-on { + background: var(--grad-blue); + border-color: var(--zk-blue); + color: var(--zk-white); + box-shadow: var(--sh-xs); +} + +.af__actions { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 10px; + padding-top: 4px; +} + +.af__submit { + font: inherit; + font-size: var(--fs-xs); + font-weight: 500; + padding: 10px 26px; + border-radius: var(--r-md); + cursor: pointer; + border: 0; + background: var(--grad-blue); + color: var(--zk-white); + box-shadow: var(--sh-sm); + transition: background var(--t), box-shadow var(--t), transform var(--t-fast), opacity var(--t-fast); +} + +.af__submit:hover:not(:disabled) { + background: var(--grad-blue-hover); + box-shadow: var(--sh-md); + transform: translateY(-1px); +} + +.af__submit:active:not(:disabled) { + transform: none; + box-shadow: var(--sh-xs); +} + +.af__submit:disabled { + opacity: 0.5; + cursor: default; + box-shadow: none; +} + +.af__ghost { + font: inherit; + font-size: var(--fs-xs); + padding: 10px 20px; + border-radius: var(--r-md); + cursor: pointer; + border: 1px solid var(--zk-line); + background: var(--zk-white); + color: var(--zk-muted); + transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast); +} + +.af__ghost:hover { + border-color: var(--zk-grey); + background: var(--zk-tint); + color: var(--zk-ink); +} + +.af__err { + background: var(--zk-danger-tint); + border: 1px solid var(--zk-danger-line); + border-left: 3px solid var(--zk-danger); + border-radius: var(--r-xs) var(--r-md) var(--r-md) var(--r-xs); + padding: 12px 16px; + font-size: var(--fs-xs); + color: var(--zk-ink); + animation: zk-rise 0.2s var(--ease) both; +} + +.af__err strong { + font-family: var(--font-mono); + font-size: 0.85em; + margin-right: 8px; + padding: 1px 7px; + border-radius: var(--r-xs); + background: rgba(211, 47, 47, 0.1); + color: var(--zk-danger-ink); +} + +.af__err p { + margin: 7px 0 0; + font-size: var(--fs-2xs); + color: var(--zk-muted); + line-height: 1.55; +} + +.af__auto { + font-size: var(--fs-xs); + padding: 10px 12px; + border-radius: var(--r-md); + min-height: 41px; + border: 1px dashed var(--zk-line); + background: var(--zk-tint); + color: var(--zk-muted); + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.af__auto span { + font-size: var(--fs-3xs); + font-weight: 500; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--zk-grey); + border: 1px solid var(--zk-line); + border-radius: var(--r-pill); + padding: 2px 8px; + background: var(--zk-white); +} + +/* ---- file / ocr ---- */ +.ff { + display: flex; + flex-direction: column; + gap: 8px; +} + +.ff__input { + display: none; +} + +.ff__drop { + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + width: 100%; + font: inherit; + padding: 16px 12px; + border-radius: var(--r-md); + cursor: pointer; + border: 1px dashed var(--zk-blue-light); + background: var(--zk-tint-blue); + color: var(--zk-blue-dark); + text-align: center; + transition: background var(--t-fast), border-color var(--t-fast), box-shadow var(--t-fast); +} +.ff__drop svg { width: 20px; height: 20px; } +.ff__drop strong { font-size: var(--fs-2xs); font-weight: 500; } +.ff__drop span { font-size: var(--fs-3xs); color: var(--zk-grey); } + +.ff__drop:hover:not(:disabled) { + background: var(--zk-white); + border-style: solid; + border-color: var(--zk-blue); +} +.ff__drop:focus-visible { outline: none; box-shadow: var(--ring); } +.ff__drop:disabled { opacity: 0.65; cursor: default; border-style: solid; } + +/* Uploaded: the file as a fact, not a call to action. */ +.ff__file { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 9px 9px 11px; + border: 1px solid var(--zk-line); + border-radius: var(--r-md); + background: var(--zk-white); +} +.ff__file > svg { width: 15px; height: 15px; flex: none; color: var(--zk-blue-dark); } + +.ff__name { + flex: 1; + min-width: 0; + font-size: var(--fs-2xs); + color: var(--zk-ink); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ff__replace { + flex: none; + font: inherit; + font-size: var(--fs-3xs); + font-weight: 500; + padding: 4px 10px; + border: 1px solid var(--zk-line); + border-radius: var(--r-pill); + background: var(--zk-white); + color: var(--zk-blue-dark); + cursor: pointer; + transition: background var(--t-fast), border-color var(--t-fast); +} +.ff__replace:hover { background: var(--zk-tint-blue); border-color: var(--zk-blue-mid); } +.ff__replace:focus-visible { outline: none; box-shadow: var(--ring); } + +.ff__read { + border: 1px solid var(--zk-blue-light); + border-left-width: 3px; + background: var(--zk-tint); + border-radius: var(--r-xs) var(--r-md) var(--r-md) var(--r-xs); + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 3px; + animation: zk-rise 0.22s var(--ease) both; +} + +.ff__readlabel { + font-size: var(--fs-3xs); + font-weight: 500; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--zk-blue-dark); + margin-bottom: 3px; +} + +.ff__read div { + font-size: var(--fs-2xs); + color: var(--zk-ink); +} + +.ff__read em { + font-style: normal; + color: var(--zk-grey); + text-transform: capitalize; + margin-right: 6px; + font-size: var(--fs-2xs); +} + +.ff__err { + font-size: var(--fs-2xs); + color: var(--zk-danger-ink); + background: var(--zk-danger-tint); + border: 1px solid var(--zk-danger-line); + border-radius: var(--r-md); + padding: 8px 11px; +} + +/* A field the form is waiting on. Marked on the label rather than the input so + the name is highlighted too — an operator scanning a wide form is looking for + the LABEL they missed, not for a red box. */ +.af__field.is-missing > span { color: var(--zk-danger-ink); } +.af__field.is-missing input, +.af__field.is-missing select, +.af__field.is-missing textarea { + border-color: var(--zk-danger-line); + background: var(--zk-danger-tint); +} +.af__field.is-missing input:focus, +.af__field.is-missing select:focus, +.af__field.is-missing textarea:focus { + border-color: var(--zk-danger); + outline-color: var(--zk-danger); +} + +/* The form talking about itself, or relaying a field-level refusal. Distinct + from af__err, which is for a failure the operator cannot fix by typing. */ +.af__err--soft { + background: var(--zk-danger-tint); + border: 1px solid var(--zk-danger-line); + color: var(--zk-danger-ink); +} +.af__err--soft ul { margin: 6px 0 0; padding-left: 18px; } +.af__err--soft li { margin: 2px 0; } + +/* ---- form sections ---- */ +.af__sec + .af__sec { margin-top: 22px; } + +.af__sech { + margin: 0 0 10px; + font-size: var(--fs-3xs); + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--zk-grey); +} + +/* Uploads size to their content and never stretch to the tallest card in the + row, so a document that has been read does not pull its neighbours down. */ +.af__grid--docs { align-items: start; } diff --git a/src/components/ActivityForm.jsx b/src/components/ActivityForm.jsx new file mode 100644 index 0000000..8ee1ab3 --- /dev/null +++ b/src/components/ActivityForm.jsx @@ -0,0 +1,402 @@ +import { useEffect, useRef, useState } from 'react' +import { useZino } from '../api/provider.jsx' +import { baseFieldId, fieldApplies, fieldIsStamped } from '../api/config.js' +import { describeError, describeValidation } from '../api/errors.js' +import FileField from './FileField.jsx' +import './ActivityForm.css' + +/** + * The HTML input each workflow data_type maps to. `phone` and `email` were both + * falling through to plain text, which costs the keyboard on a phone and the + * browser's own validation everywhere. + */ +const INPUT_TYPES = { + number: 'number', + date: 'date', + phone: 'tel', + email: 'email', +} + +/** + * Renders whatever /view/form-screens returns for an activity — labels, types, + * select options and which fields are mandatory — and submits it straight back. + * + * Nothing about this form is defined in the frontend. Add a field to the + * activity in Studio, redeploy, and it appears here with no code change. That + * is the point: the workflow is the source of truth, and a hardcoded form would + * quietly drift from it. + * + * The ONE thing filtered here is which upload slots apply to the lead's product + * line. Collect Documents serves motor and SME from a single form and the + * activity carries no `field_rules`, so without this a motor renewal is asked + * for a Udyam certificate. See DOC_SLOTS in api/config.js — including why that + * table should stop existing once the rules are seeded on the activity. + */ +export default function ActivityForm({ activityUid, instanceId, lead, onDone, onCancel, onStale }) { + const { client } = useZino() + const [schema, setSchema] = useState(null) + const [values, setValues] = useState({}) + const [error, setError] = useState(null) + // Which required fields were empty on the last attempt. Kept separate from + // `error`, because this is the form talking about itself rather than the + // server refusing something. + const [missing, setMissing] = useState([]) + // Field ids filled from the lead record rather than typed. OCR may overwrite + // these — the document is more authoritative than a copy of the record — but + // must never overwrite something a person typed. + const seededRef = useRef(new Set()) + const [busy, setBusy] = useState(false) + + useEffect(() => { + let dead = false + setSchema(null); setError(null); setValues({}) + client.formSchema(activityUid, instanceId) + .then((s) => { + if (dead) return + setSchema(s) + // The server resolved a prefill pipeline for this activity — it stamps + // the channel from the door and the agent from the signed-in user, so + // the form never asks for either. Seed the inputs with what it sent. + // + // The two sides key differently, and matching only on f.id is why this + // silently did nothing. A form field is the ACTIVITY key, which carries + // a numeric suffix because a workflow version may use one global on + // several activities — partner_code_3, rm_or_agent_id_3, + // source_channel_4. The pipeline's fieldMapping names the GLOBAL — + // partner_code, rm_or_agent_id, source_channel. Neither is wrong; they + // are different names for the same field, and nothing between them + // reconciles it. + // + // So: exact id first, then the field's uid, then the global name with + // the suffix stripped. Accepting all three means this keeps working + // whichever convention a pipeline is authored in, rather than breaking + // again the next time one is written the other way. + const pre = s.prefill_data || s.prefillData || s.field_defaults || {} + const seed = {} + if (pre && typeof pre === 'object') { + for (const f of s.fields) { + const base = baseFieldId(f.id ?? '') + const v = pre[f.id] ?? pre[f.uid] ?? (base ? pre[base] : undefined) + if (v !== undefined && v !== null && v !== '') seed[f.id] = v + } + } + + // THE LEAD ALREADY KNOWS MOST OF THIS. The document form mirrors + // fourteen fields the record carries — registration, make and model, + // previous insurer, expiry, policy number, PAN — so the OCR can write + // into them. Rendered blank, they read as fourteen more things to type. + // Seed each from the lead by its base key, so an agent uploading for + // KA01MF6618 sees KA01MF6618 already there. + // + // Server prefill wins, then anything already typed. Files and generated + // ids are never seeded — a file reference is not a value to copy, and an + // id_gen is the platform's to issue. Only on an existing lead: an INIT + // form has no record behind it. + const seededFromLead = new Set() + if (lead && instanceId) { + for (const f of s.fields) { + if (seed[f.id] !== undefined) continue + if (['file', 'ocr', 'id_gen'].includes(f.data_type)) continue + const v = lead[baseFieldId(f.id ?? '')] + if (v !== undefined && v !== null && v !== '' && typeof v !== 'object') { + seed[f.id] = v + seededFromLead.add(f.id) + } + } + } + seededRef.current = seededFromLead + + if (Object.keys(seed).length) setValues(seed) + }) + .catch((e) => { if (!dead) setError(e) }) + return () => { dead = true } + // `lead` is READ here but deliberately not a dependency. It is the parent's + // polled record object, so its identity changes on every refresh; listing it + // would re-run this effect, re-fetch the schema and call setValues(seed) — + // discarding whatever the operator had typed, every few seconds, mid-form. + // The seed is a one-time starting point, not a subscription. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [client, activityUid, instanceId]) + + function set(id, v) { + // Typed by hand: from here on it outranks any document. + seededRef.current.delete(id) + setValues((p) => ({ ...p, [id]: v })) + // Clear the complaint as soon as the field is filled. Leaving a red field + // marked after it has been corrected teaches people to ignore the marking. + setMissing((p) => (p.includes(id) ? p.filter((x) => x !== id) : p)) + } + + // Computed before the early returns below use it, and before submit: a field + // that was never offered must never be sent. + // + // If the lead's line would hide EVERY field, the filter is not removing noise + // any more — it is removing the activity. Capture Motor Risk is twelve motor + // fields and is runnable from Contacted whatever the product is, so on an SME + // lead this would otherwise render a form with nothing in it and a live Submit + // button. Show the activity whole and let the operator see what it is asking. + // + // THE LINE CAN BE CHOSEN ON THE FORM ITSELF. On an INIT form there is no + // lead yet, so `fieldApplies` had nothing to filter on and every entry form + // asked a motor renewal for a business name. But the product line IS on that + // form, three boxes up — the person has already said "Motor" by the time the + // question is drawn. So visibility reads the live answer first and the saved + // lead second, and the form narrows as it is filled. + const effLead = (() => { + const live = schema?.fields.find((f) => baseFieldId(f.id) === 'product_line') + const chosen = live ? values[live.id] : undefined + if (!chosen) return lead + return { ...(lead || {}), product_line: chosen } + })() + + const applicable = schema ? schema.fields.filter((f) => fieldApplies(f.id, effLead)) : [] + // Filtering must never hide a field the workflow requires: `submit` sends only + // what is rendered, so a hidden mandatory field becomes a 400 naming something + // that is not on screen and cannot be filled. Hiding everything is the same + // failure in the large — it removes the activity rather than its noise, and + // Capture Motor Risk is twelve motor fields that stay runnable on an SME lead. + const hidesMandatory = schema ? schema.fields.some((f) => f.mandatory && !fieldApplies(f.id, effLead)) : false + const fields = applicable.length && !hidesMandatory ? applicable : (schema?.fields ?? []) + + /** + * Turn an /ocr-extract response into form values. + * + * THE ENDPOINT DOES NOT SPEAK THE FORM'S LANGUAGE. It answers keyed by the + * ocr_config's `extraction_fields[].key` — `reg_no`, `engine_cc`, `fuel` — + * because that is what the vision prompt was asked to produce. The form's + * fields are `motor_reg_no_6`, `motor_cc_3`, `motor_fuel_3`. Written straight + * in, as they were, every extracted value landed on a key no field renders: + * the read succeeded, the panel said so, and nothing filled. + * + * The bridge is already on the field. `ocr_config.field_mappings` maps + * extraction_key -> target_field (the GLOBAL name), and the form field is that + * global plus a per-form suffix. So: key -> target -> the field whose base id + * matches. Same suffix rule as prefill and the same trap, in a third place. + */ + function applyExtraction(ocrField, extracted) { + const maps = ocrField?.properties?.ocr_config?.field_mappings + ?? ocrField?.ocr_config?.field_mappings ?? [] + const all = schema?.fields ?? [] + + // extraction_key -> form field id + const target = {} + for (const m of maps) { + if (!m?.extraction_key || !m?.target_field) continue + const hit = all.find((f) => f.id === m.target_field) + ?? all.find((f) => baseFieldId(f.id) === m.target_field) + if (hit) target[m.extraction_key] = hit.id + } + + setValues((prev) => { + const next = { ...prev } + for (const [key, val] of Object.entries(extracted)) { + if (val === null || val === undefined || String(val) === '') continue + // An unmapped key may still name a field directly on forms whose + // extraction keys ARE the field names. + const id = target[key] + ?? all.find((f) => f.id === key)?.id + ?? all.find((f) => baseFieldId(f.id) === key)?.id + if (!id) continue + // Never overwrite something a person typed. A value we copied off the + // lead is fair game — the document is the better source for it. + const isBlank = next[id] === undefined || next[id] === '' + if (isBlank || seededRef.current.has(id)) { + next[id] = val + seededRef.current.delete(id) + } + } + return next + }) + } + + function isEmpty(v) { + return v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0) + } + + async function submit(e) { + e.preventDefault() + + // Check here rather than letting the workflow do it. The server's answer is + // correct and unreadable — "product_line_4(required)" — and it costs a round + // trip to be told something this form already knew. id_gen is issued + // server-side and is never the operator's to fill. + const gaps = fields.filter((f) => f.mandatory && f.data_type !== 'id_gen' && isEmpty(values[f.id])) + if (gaps.length) { + setMissing(gaps.map((f) => f.id)) + setError(null) + // Put the first offender on screen. On a form this wide the empty field + // is often above the fold and the message below it. Scrolling to the + // LABEL rather than focusing an input works for every field type, + // including the file and OCR widgets that render no input at all. + requestAnimationFrame(() => { + document + .querySelector('.af__field.is-missing') + ?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + }) + return + } + + setMissing([]) + setBusy(true); setError(null) + // Send only fields the activity defines. A submission is schema-validated + // and an unknown field is fatal, so empties are dropped rather than sent. + const payload = {} + for (const f of fields) { + // id_gen is issued server-side and stripped from the submission. Sending + // it would be forging a reference the platform owns. + if (f.data_type === 'id_gen') continue + const v = values[f.id] + if (v === undefined || v === '' || (Array.isArray(v) && v.length === 0)) continue + payload[f.id] = f.data_type === 'number' ? Number(v) : v + } + try { + const res = instanceId + ? await client.activity(instanceId, activityUid, payload) + : await client.start(activityUid, payload) + onDone?.(res) + } catch (err) { + setError(err) + // The lead moved under the form. Nothing the operator can fix by reading — + // tell the page to re-fetch so the actions on offer are the real ones. + if (describeError(err).kind === 'stale') onStale?.() + } finally { + setBusy(false) + } + } + + if (error && !schema) return
Could not load the form — {describeError(error).title}
+ if (!schema) return

Loading the form…

+ + // Stamped fields are in `fields` — they validate and they submit — but they + // are not drawn. See STAMPED_FIELDS. A stamped field that came back EMPTY is + // drawn anyway: source_channel is mandatory, so a prefill that did not + // resolve would otherwise fail validation against a box that is not on the + // screen and cannot be filled. + const visible = fields.filter((f) => !fieldIsStamped(f.id) || isEmpty(values[f.id])) + + /* DOCUMENTS GET THEIR OWN ROW. In one flat auto-fit grid the uploads sat + beside plain inputs, and an upload that has read a document is several + times taller than a text box — so a row carried three tall OCR cards and + three short fields, and the form went ragged with the inputs stranded up + beside the buttons. Split in two, each grid is internally one height. */ + const isDoc = (f) => f.data_type === 'file' || f.data_type === 'ocr' + const docFields = visible.filter(isDoc) + const dataFields = visible.filter((f) => !isDoc(f)) + + const renderField = (f) => { + const opts = f.properties?.options || [] + const v = values[f.id] ?? (f.data_type === 'multiselect' ? [] : '') + return ( +